From a42b1e0f3243c5c9114adeee259184635b745c1b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 11:29:47 +0000 Subject: [PATCH] feat(quartz): NIP-BC onchain zap send-side foundation (Phase A.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the send half of the onchain zaps plan: a minimal, fund-safe BIP-174 PSBT pipeline plus the OnchainZapBuilder and `NostrSigner.signPsbt`. Every cryptographic step is validated against the BIP-341 wallet test vectors. nipBCOnchainZaps/psbt/: - BitcoinIO: little-endian Bitcoin consensus byte reader/writer (u16/u32/u64, compact-size varint, var-bytes). - BitcoinTransaction: OutPoint / TxIn / TxOut / transaction model with legacy and BIP-144 segwit serialization, segwit-aware parsing, and witness-stripped txid. Validated against the genesis coinbase tx and the BIP-341 9-input unsigned tx. - TaprootSigHash: BIP-341 SigMsg + TapSighash tagged hash for key-path spends. All six base sighash types plus both ANYONECANPAY variants verified against the seven BIP-341 keyPathSpending vectors. - Psbt: BIP-174 container subset (global unsigned-tx, per-input witness-utxo / tap-internal-key / tap-key-sig / sighash-type, per-output tap-internal-key). Unknown records round-trip verbatim. Typed accessor extensions. - PsbtSigner: signs the key-path P2TR inputs a given private key controls — derives the BIP-341 tweaked key, computes the all-inputs sighash, produces a BIP-340 Schnorr signature. Leaves inputs the key does not control untouched. - PsbtFinalizer: moves tap-key-sigs into the witness stack, yields a broadcastable transaction. nipBCOnchainZaps/build/: - OnchainZapBuilder: largest-first coin selection + unsigned-PSBT assembly for a NIP-BC zap. Sender spends from / changes back to the single Taproot address derived from their Nostr pubkey; recipient output pays the recipient's derived address. Dust-aware change handling, InsufficientFundsException, self-zap and dust-amount guards. nipBCOnchainZaps/taproot/: - TaprootAddress.tweakSecretKey: BIP-341 taproot_tweak_seckey (key-path-only), including the odd-y internal-key negation. Validated against the BIP-341 vector's tweakedPrivkey. Secp256k1Instance: new `privKeyNegate` primitive across commonMain expect + jvm / android / native actuals. Signer hierarchy — new `NostrSigner.signPsbt(psbtHex): String`: - NostrSignerSync / NostrSignerInternal: real implementation. - NostrSignerWithClientTag: delegates to the wrapped signer. - NostrSignerRemote (NIP-46) and NostrSignerExternal (NIP-55): throw SignerExceptions.UnsupportedMethodException — the bunker command and the Android Intent contract are Phase B, and depend on signer-app support. Tests: 31 new tests across the psbt/build/signers packages, anchored on the BIP-341 wallet-test-vectors so the tweak, sighash, signature, and finalized transaction are all checked against an authoritative source. Still pending: Phase B (NIP-55 sign_psbt Intent) and Phase D (send UI + broadcast + publish kind 8333). --- .../commons/viewmodels/NestViewModelTest.kt | 2 + .../client/NostrSignerExternal.kt | 12 + .../quartz/utils/Secp256k1Instance.android.kt | 2 + .../quartz/nip01Core/signers/NostrSigner.kt | 13 + .../nip01Core/signers/NostrSignerInternal.kt | 5 + .../nip01Core/signers/NostrSignerSync.kt | 13 + .../nip01Core/signers/SignerExceptions.kt | 6 + .../signer/NostrSignerRemote.kt | 10 + .../clientTag/NostrSignerWithClientTag.kt | 2 + .../quartz/nipBCOnchainZaps/psbt/BitcoinIO.kt | 183 +++++++++++++ .../psbt/BitcoinTransaction.kt | 240 +++++++++++++++++ .../quartz/nipBCOnchainZaps/psbt/Psbt.kt | 242 ++++++++++++++++++ .../nipBCOnchainZaps/psbt/PsbtFinalizer.kt | 62 +++++ .../nipBCOnchainZaps/psbt/PsbtSigner.kt | 105 ++++++++ .../nipBCOnchainZaps/psbt/TaprootSigHash.kt | 167 ++++++++++++ .../taproot/TaprootAddress.kt | 30 +++ .../quartz/utils/Secp256k1Instance.kt | 8 + .../nip01Core/signers/NostrSignerPsbtTest.kt | 120 +++++++++ .../psbt/BitcoinTransactionTest.kt | 106 ++++++++ .../nipBCOnchainZaps/psbt/PsbtSignerTest.kt | 182 +++++++++++++ .../quartz/nipBCOnchainZaps/psbt/PsbtTest.kt | 112 ++++++++ .../psbt/TaprootSigHashTest.kt | 93 +++++++ .../quartz/utils/Secp256k1Instance.jvm.kt | 2 + .../quartz/utils/Secp256k1Instance.native.kt | 2 + 24 files changed, 1719 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinIO.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransaction.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/Psbt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtFinalizer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSigner.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHash.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerPsbtTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransactionTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignerTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHashTest.kt diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt index 59b7a91ca..ceff41e3f 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt @@ -589,6 +589,8 @@ class NestViewModelTest { override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = error("not used") override suspend fun deriveKey(nonce: String): String = error("not used") + + override suspend fun signPsbt(psbtHex: String): String = error("not used") } companion object { diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt index 64551b128..681e521ee 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt @@ -174,6 +174,18 @@ class NostrSignerExternal( throw convertExceptions("Could not decrypt private zap", result) } + /** + * NIP-BC `sign_psbt` over NIP-55. The Android external-signer Intent + * contract for PSBT signing is not implemented yet (Phase B); Amber and + * other signer apps must also ship support before this can work. Until + * then, callers should fall back to other signer kinds or surface an + * "update your signer" message. + */ + override suspend fun signPsbt(psbtHex: String): String = + throw SignerExceptions.UnsupportedMethodException( + "This external signer does not support sign_psbt yet", + ) + // always ready override fun hasForegroundSupport() = hasForegroundActivity() diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt index f142e506b..efc5276da 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.android.kt @@ -72,4 +72,6 @@ actual object Secp256k1Instance { val full = if (pubKey.size == 32) h02 + pubKey else pubKey return secp256k1.pubKeyCompress(secp256k1.pubKeyTweakAdd(full, tweak)) } + + actual fun privKeyNegate(privKey: ByteArray): ByteArray = secp256k1.privKeyNegate(privKey) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt index 6b44ea6f8..fd5fd2e0d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt @@ -64,6 +64,19 @@ abstract class NostrSigner( abstract suspend fun deriveKey(nonce: HexKey): HexKey + /** + * NIP-BC `sign_psbt`: sign the key-path P2TR inputs of [psbtHex] that this + * signer's key controls and return the updated PSBT as lowercase hex. + * + * The signer adds `PSBT_IN_TAP_KEY_SIG` records; it does NOT finalize the + * PSBT — finalization and broadcast are the client's responsibility. + * + * Throws [SignerExceptions.UnsupportedMethodException] for signer kinds + * that have not implemented the method yet (remote NIP-46 bunkers, NIP-55 + * external signers that predate `sign_psbt` support). + */ + abstract suspend fun signPsbt(psbtHex: String): String + abstract fun hasForegroundSupport(): Boolean suspend fun decrypt( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt index ea1b167c8..b308afbd0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt @@ -101,4 +101,9 @@ class NostrSignerInternal( runWrapErrors { signerSync.deriveKey(nonce) } + + override suspend fun signPsbt(psbtHex: String): String = + runWrapErrors { + signerSync.signPsbt(psbtHex) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt index dc626ee9b..9ca2935bf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt @@ -33,6 +33,8 @@ import com.vitorpamplona.quartz.nip44Encryption.Nip44 import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.PrivateZapRequestBuilder +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtSigner class NostrSignerSync( val keyPair: KeyPair = KeyPair(), @@ -129,6 +131,17 @@ class NostrSignerSync( fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = PrivateZapRequestBuilder().decryptZapEvent(event, this) + /** + * NIP-BC `sign_psbt`: sign the key-path P2TR inputs of [psbtHex] this key + * controls and return the updated (not finalized) PSBT as lowercase hex. + */ + fun signPsbt(psbtHex: String): String { + val privKey = keyPair.privKey ?: throw SignerExceptions.ReadOnlyException() + val psbt = Psbt.parse(psbtHex) + PsbtSigner.signKeyPathInputs(psbt, privKey) + return psbt.toHex() + } + fun deriveKey(nonce: HexKey): HexKey { if (keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt index 1e71ccd92..f3d14320f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt @@ -59,4 +59,10 @@ sealed class SignerExceptions( msg: String, cause: Throwable? = null, ) : SignerExceptions(msg) + + /** The signer cannot perform the requested method (e.g. a bunker that has not shipped `sign_psbt`). */ + class UnsupportedMethodException( + msg: String, + cause: Throwable? = null, + ) : SignerExceptions(msg, cause) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt index 2163b42e8..7c36acbb8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt @@ -278,6 +278,16 @@ class NostrSignerRemote( TODO("Not yet implemented") } + /** + * NIP-BC `sign_psbt` over NIP-46. The bunker-side command is not yet + * standardized/shipped, so this is intentionally unsupported until the + * remote-signer ecosystem catches up. + */ + override suspend fun signPsbt(psbtHex: String): String = + throw SignerExceptions.UnsupportedMethodException( + "Remote (NIP-46) signers do not support sign_psbt yet", + ) + override fun hasForegroundSupport(): Boolean = true fun convertExceptions( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt index 5856a0ebf..2da2479da 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt @@ -98,6 +98,8 @@ class NostrSignerWithClientTag( override suspend fun deriveKey(nonce: HexKey): HexKey = inner.deriveKey(nonce) + override suspend fun signPsbt(psbtHex: String): String = inner.signPsbt(psbtHex) + override fun hasForegroundSupport(): Boolean = inner.hasForegroundSupport() private fun appendClientTag(tags: Array>): Array> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinIO.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinIO.kt new file mode 100644 index 000000000..3e4c3d277 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinIO.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.utils.ByteArrayOutputStream + +/** + * Little-endian byte writer for Bitcoin consensus serialization (transactions, + * PSBT maps). All multi-byte integers are written little-endian, matching the + * Bitcoin wire format. + */ +class BitcoinWriter( + initialSize: Int = 256, +) { + private val out = ByteArrayOutputStream(initialSize) + + fun writeByte(value: Int): BitcoinWriter { + out.write((value and 0xFF).toByte()) + return this + } + + fun writeBytes(bytes: ByteArray): BitcoinWriter { + out.write(bytes) + return this + } + + fun writeUInt16LE(value: Int): BitcoinWriter { + out.write((value and 0xFF).toByte()) + out.write(((value ushr 8) and 0xFF).toByte()) + return this + } + + fun writeUInt32LE(value: Long): BitcoinWriter { + out.write((value and 0xFF).toByte()) + out.write(((value ushr 8) and 0xFF).toByte()) + out.write(((value ushr 16) and 0xFF).toByte()) + out.write(((value ushr 24) and 0xFF).toByte()) + return this + } + + fun writeUInt64LE(value: Long): BitcoinWriter { + var v = value + for (i in 0 until 8) { + out.write((v and 0xFF).toByte()) + v = v ushr 8 + } + return this + } + + /** Bitcoin compact-size (varint) encoding. */ + fun writeVarInt(value: Long): BitcoinWriter { + when { + value < 0xFD -> { + writeByte(value.toInt()) + } + + value <= 0xFFFF -> { + writeByte(0xFD) + writeUInt16LE(value.toInt()) + } + + value <= 0xFFFFFFFFL -> { + writeByte(0xFE) + writeUInt32LE(value) + } + + else -> { + writeByte(0xFF) + writeUInt64LE(value) + } + } + return this + } + + /** Length-prefixed (varint) byte string. */ + fun writeVarBytes(bytes: ByteArray): BitcoinWriter { + writeVarInt(bytes.size.toLong()) + writeBytes(bytes) + return this + } + + fun toByteArray(): ByteArray = out.toByteArray() +} + +/** + * Little-endian byte reader, the inverse of [BitcoinWriter]. Throws + * [PsbtParseException] on truncated input. + */ +class BitcoinReader( + private val data: ByteArray, + private var pos: Int = 0, +) { + val remaining: Int get() = data.size - pos + + val isAtEnd: Boolean get() = pos >= data.size + + private fun require(n: Int) { + if (remaining < n) { + throw PsbtParseException("Unexpected end of data: needed $n, have $remaining") + } + } + + fun readByte(): Int { + require(1) + return data[pos++].toInt() and 0xFF + } + + fun readBytes(n: Int): ByteArray { + require(n) + val slice = data.copyOfRange(pos, pos + n) + pos += n + return slice + } + + fun readUInt16LE(): Int { + require(2) + val v = (data[pos].toInt() and 0xFF) or ((data[pos + 1].toInt() and 0xFF) shl 8) + pos += 2 + return v + } + + fun readUInt32LE(): Long { + require(4) + var v = 0L + for (i in 0 until 4) { + v = v or ((data[pos + i].toLong() and 0xFF) shl (8 * i)) + } + pos += 4 + return v + } + + fun readUInt64LE(): Long { + require(8) + var v = 0L + for (i in 0 until 8) { + v = v or ((data[pos + i].toLong() and 0xFF) shl (8 * i)) + } + pos += 8 + return v + } + + fun readVarInt(): Long { + val first = readByte() + return when (first) { + 0xFD -> readUInt16LE().toLong() + 0xFE -> readUInt32LE() + 0xFF -> readUInt64LE() + else -> first.toLong() + } + } + + fun readVarBytes(): ByteArray { + val len = readVarInt() + if (len > Int.MAX_VALUE.toLong()) { + throw PsbtParseException("var-bytes length too large: $len") + } + return readBytes(len.toInt()) + } +} + +/** Thrown when PSBT or transaction bytes cannot be parsed. */ +class PsbtParseException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransaction.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransaction.kt new file mode 100644 index 000000000..3e54203cf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransaction.kt @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** Double SHA-256, the Bitcoin hash. */ +internal fun hash256(data: ByteArray): ByteArray = sha256(sha256(data)) + +/** + * A reference to a specific output of a previous transaction. + * + * @property txid Transaction id in display byte order (64-char lowercase hex). + * @property vout Output index within that transaction. + */ +@Immutable +data class OutPoint( + val txid: String, + val vout: Long, +) { + fun write(writer: BitcoinWriter) { + // On the wire the txid is stored in internal (reversed) byte order. + writer.writeBytes(txid.hexToByteArray().reversedArray()) + writer.writeUInt32LE(vout) + } + + companion object { + fun read(reader: BitcoinReader): OutPoint { + val txidInternal = reader.readBytes(32) + val txid = txidInternal.reversedArray().toHexKey() + val vout = reader.readUInt32LE() + return OutPoint(txid, vout) + } + } +} + +/** + * A transaction input. + * + * @property outPoint The previous output being spent. + * @property scriptSig The unlocking script. Empty for segwit inputs. + * @property sequence nSequence value. + * @property witness Witness stack items. Empty for a pre-signing or legacy input. + */ +@Immutable +data class TxIn( + val outPoint: OutPoint, + val scriptSig: ByteArray = ByteArray(0), + val sequence: Long = 0xFFFFFFFFL, + val witness: List = emptyList(), +) { + fun writeWithoutWitness(writer: BitcoinWriter) { + outPoint.write(writer) + writer.writeVarBytes(scriptSig) + writer.writeUInt32LE(sequence) + } + + fun writeWitness(writer: BitcoinWriter) { + writer.writeVarInt(witness.size.toLong()) + witness.forEach { writer.writeVarBytes(it) } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TxIn) return false + return outPoint == other.outPoint && + scriptSig.contentEquals(other.scriptSig) && + sequence == other.sequence && + witness.size == other.witness.size && + witness.indices.all { witness[it].contentEquals(other.witness[it]) } + } + + override fun hashCode(): Int { + var result = outPoint.hashCode() + result = 31 * result + scriptSig.contentHashCode() + result = 31 * result + sequence.hashCode() + result = 31 * result + witness.sumOf { it.contentHashCode() } + return result + } + + companion object { + fun readWithoutWitness(reader: BitcoinReader): TxIn { + val outPoint = OutPoint.read(reader) + val scriptSig = reader.readVarBytes() + val sequence = reader.readUInt32LE() + return TxIn(outPoint, scriptSig, sequence) + } + } +} + +/** + * A transaction output. + * + * @property valueSats Output value in satoshis. + * @property scriptPubKey The locking script. + */ +@Immutable +data class TxOut( + val valueSats: Long, + val scriptPubKey: ByteArray, +) { + fun write(writer: BitcoinWriter) { + writer.writeUInt64LE(valueSats) + writer.writeVarBytes(scriptPubKey) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TxOut) return false + return valueSats == other.valueSats && scriptPubKey.contentEquals(other.scriptPubKey) + } + + override fun hashCode(): Int = 31 * valueSats.hashCode() + scriptPubKey.contentHashCode() + + companion object { + fun read(reader: BitcoinReader): TxOut = TxOut(reader.readUInt64LE(), reader.readVarBytes()) + } +} + +/** + * A Bitcoin transaction. + * + * Supports both legacy and BIP-144 segwit serialization. [txid] is computed + * over the legacy serialization (witness-stripped), per consensus rules. + */ +@Immutable +data class BitcoinTransaction( + val version: Long, + val inputs: List, + val outputs: List, + val lockTime: Long, +) { + val hasWitness: Boolean get() = inputs.any { it.witness.isNotEmpty() } + + /** Legacy (witness-stripped) serialization — the bytes that [txid] hashes. */ + fun serializeForId(): ByteArray { + val w = BitcoinWriter() + w.writeUInt32LE(version) + w.writeVarInt(inputs.size.toLong()) + inputs.forEach { it.writeWithoutWitness(w) } + w.writeVarInt(outputs.size.toLong()) + outputs.forEach { it.write(w) } + w.writeUInt32LE(lockTime) + return w.toByteArray() + } + + /** Full serialization. Uses BIP-144 segwit format when any input carries witness data. */ + fun serialize(): ByteArray { + if (!hasWitness) return serializeForId() + val w = BitcoinWriter() + w.writeUInt32LE(version) + w.writeByte(0x00) // segwit marker + w.writeByte(0x01) // segwit flag + w.writeVarInt(inputs.size.toLong()) + inputs.forEach { it.writeWithoutWitness(w) } + w.writeVarInt(outputs.size.toLong()) + outputs.forEach { it.write(w) } + inputs.forEach { it.writeWitness(w) } + w.writeUInt32LE(lockTime) + return w.toByteArray() + } + + /** Transaction id in display byte order (reversed double-SHA256 of the legacy bytes). */ + fun txid(): String = hash256(serializeForId()).reversedArray().toHexKey() + + companion object { + fun parse(rawHex: String): BitcoinTransaction = parse(rawHex.hexToByteArray()) + + fun parse(bytes: ByteArray): BitcoinTransaction { + val reader = BitcoinReader(bytes) + val version = reader.readUInt32LE() + + // Detect the BIP-144 segwit marker+flag (0x00 0x01). + var isSegwit = false + val firstByte = reader.readByte() + val inputCount: Long + if (firstByte == 0x00) { + val flag = reader.readByte() + if (flag != 0x01) throw PsbtParseException("Invalid segwit flag $flag") + isSegwit = true + inputCount = reader.readVarInt() + } else { + // firstByte was actually the start of the input-count varint. + inputCount = + when (firstByte) { + 0xFD -> reader.readUInt16LE().toLong() + 0xFE -> reader.readUInt32LE() + 0xFF -> reader.readUInt64LE() + else -> firstByte.toLong() + } + } + + val inputs = ArrayList(inputCount.toInt()) + for (i in 0 until inputCount) { + inputs.add(TxIn.readWithoutWitness(reader)) + } + + val outputCount = reader.readVarInt() + val outputs = ArrayList(outputCount.toInt()) + for (i in 0 until outputCount) { + outputs.add(TxOut.read(reader)) + } + + if (isSegwit) { + for (i in inputs.indices) { + val itemCount = reader.readVarInt() + val items = ArrayList(itemCount.toInt()) + for (j in 0 until itemCount) { + items.add(reader.readVarBytes()) + } + inputs[i] = inputs[i].copy(witness = items) + } + } + + val lockTime = reader.readUInt32LE() + return BitcoinTransaction(version, inputs, outputs, lockTime) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/Psbt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/Psbt.kt new file mode 100644 index 000000000..5875b2c5d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/Psbt.kt @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * One key-value record inside a PSBT map. [keyType] is the BIP-174 keytype + * compact-size; [keyData] is whatever follows it (empty for all the field + * types NIP-BC uses). + */ +class PsbtRecord( + val keyType: Int, + val keyData: ByteArray, + val value: ByteArray, +) { + val hasEmptyKeyData: Boolean get() = keyData.isEmpty() +} + +/** + * An ordered PSBT key-value map (global, per-input, or per-output). Unknown + * records are preserved verbatim so serialization round-trips. + */ +class PsbtMap( + val records: MutableList = mutableListOf(), +) { + /** First value for [keyType] with empty key data, or null. */ + fun get(keyType: Int): ByteArray? = records.firstOrNull { it.keyType == keyType && it.hasEmptyKeyData }?.value + + /** Insert or replace the (empty-key-data) record for [keyType]. */ + fun put( + keyType: Int, + value: ByteArray, + ) { + val idx = records.indexOfFirst { it.keyType == keyType && it.hasEmptyKeyData } + val record = PsbtRecord(keyType, ByteArray(0), value) + if (idx >= 0) records[idx] = record else records.add(record) + } + + /** Remove the (empty-key-data) record for [keyType], if present. */ + fun remove(keyType: Int) { + records.removeAll { it.keyType == keyType && it.hasEmptyKeyData } + } + + fun write(writer: BitcoinWriter) { + for (record in records) { + val key = BitcoinWriter() + key.writeVarInt(record.keyType.toLong()) + key.writeBytes(record.keyData) + writer.writeVarBytes(key.toByteArray()) + writer.writeVarBytes(record.value) + } + writer.writeByte(0x00) // map separator + } + + companion object { + fun read(reader: BitcoinReader): PsbtMap { + val map = PsbtMap() + while (true) { + val keyLen = reader.readVarInt() + if (keyLen == 0L) break // separator + val keyBytes = reader.readBytes(keyLen.toInt()) + val keyReader = BitcoinReader(keyBytes) + val keyType = keyReader.readVarInt().toInt() + val keyData = keyReader.readBytes(keyReader.remaining) + val value = reader.readVarBytes() + map.records.add(PsbtRecord(keyType, keyData, value)) + } + return map + } + } +} + +/** + * A Partially Signed Bitcoin Transaction ([BIP-174](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki)). + * + * This is a deliberately small subset — enough to construct, sign, and + * finalize the single-key-path P2TR spends NIP-BC needs. Unknown records are + * preserved verbatim so the container round-trips even when fields aren't + * modeled. + */ +class Psbt( + val global: PsbtMap, + val inputs: MutableList, + val outputs: MutableList, +) { + /** The unsigned transaction from `PSBT_GLOBAL_UNSIGNED_TX`. */ + val unsignedTx: BitcoinTransaction by lazy { + val raw = + global.get(PSBT_GLOBAL_UNSIGNED_TX) + ?: throw PsbtParseException("PSBT has no unsigned transaction") + BitcoinTransaction.parse(raw) + } + + fun serialize(): ByteArray { + val w = BitcoinWriter() + w.writeBytes(MAGIC) + global.write(w) + inputs.forEach { it.write(w) } + outputs.forEach { it.write(w) } + return w.toByteArray() + } + + fun toHex(): String = serialize().toHexKey() + + companion object { + val MAGIC = byteArrayOf(0x70, 0x73, 0x62, 0x74, 0xFF.toByte()) + + // Global keytypes. + const val PSBT_GLOBAL_UNSIGNED_TX = 0x00 + const val PSBT_GLOBAL_VERSION = 0xFB + + // Per-input keytypes. + const val PSBT_IN_NON_WITNESS_UTXO = 0x00 + const val PSBT_IN_WITNESS_UTXO = 0x01 + const val PSBT_IN_SIGHASH_TYPE = 0x03 + const val PSBT_IN_TAP_KEY_SIG = 0x13 + const val PSBT_IN_TAP_INTERNAL_KEY = 0x17 + + // Per-output keytypes. + const val PSBT_OUT_TAP_INTERNAL_KEY = 0x05 + + fun parse(hex: String): Psbt = parse(hex.hexToByteArray()) + + fun parse(bytes: ByteArray): Psbt { + val reader = BitcoinReader(bytes) + val magic = reader.readBytes(5) + if (!magic.contentEquals(MAGIC)) { + throw PsbtParseException("Not a PSBT: bad magic ${magic.toHexKey()}") + } + + val global = PsbtMap.read(reader) + val rawTx = + global.get(PSBT_GLOBAL_UNSIGNED_TX) + ?: throw PsbtParseException("PSBT global map has no unsigned transaction") + val tx = BitcoinTransaction.parse(rawTx) + + val inputs = ArrayList(tx.inputs.size) + for (i in tx.inputs.indices) { + inputs.add(PsbtMap.read(reader)) + } + + val outputs = ArrayList(tx.outputs.size) + for (i in tx.outputs.indices) { + outputs.add(PsbtMap.read(reader)) + } + + return Psbt(global, inputs, outputs) + } + + /** Build an unsigned PSBT shell from a transaction with empty input/output maps. */ + fun fromUnsignedTx(tx: BitcoinTransaction): Psbt { + require(!tx.hasWitness) { "unsigned tx must not carry witness data" } + val global = PsbtMap() + global.put(PSBT_GLOBAL_UNSIGNED_TX, tx.serializeForId()) + val inputs = MutableList(tx.inputs.size) { PsbtMap() } + val outputs = MutableList(tx.outputs.size) { PsbtMap() } + return Psbt(global, inputs, outputs) + } + } +} + +// --------------------------------------------------------------------------- +// Typed accessors for the fields NIP-BC's key-path P2TR flow touches. +// --------------------------------------------------------------------------- + +/** The witness UTXO (`PSBT_IN_WITNESS_UTXO`) being spent by input [index]. */ +fun Psbt.inputWitnessUtxo(index: Int): TxOut? = inputs[index].get(Psbt.PSBT_IN_WITNESS_UTXO)?.let { TxOut.read(BitcoinReader(it)) } + +fun Psbt.setInputWitnessUtxo( + index: Int, + output: TxOut, +) { + val w = BitcoinWriter() + output.write(w) + inputs[index].put(Psbt.PSBT_IN_WITNESS_UTXO, w.toByteArray()) +} + +/** The 32-byte x-only taproot internal key for input [index]. */ +fun Psbt.inputTapInternalKey(index: Int): ByteArray? = inputs[index].get(Psbt.PSBT_IN_TAP_INTERNAL_KEY) + +fun Psbt.setInputTapInternalKey( + index: Int, + xOnlyPubKey: ByteArray, +) { + require(xOnlyPubKey.size == 32) { "tap internal key must be 32 bytes" } + inputs[index].put(Psbt.PSBT_IN_TAP_INTERNAL_KEY, xOnlyPubKey) +} + +/** The 64- or 65-byte schnorr key-path signature for input [index]. */ +fun Psbt.inputTapKeySig(index: Int): ByteArray? = inputs[index].get(Psbt.PSBT_IN_TAP_KEY_SIG) + +fun Psbt.setInputTapKeySig( + index: Int, + signature: ByteArray, +) { + require(signature.size == 64 || signature.size == 65) { + "taproot key-path signature must be 64 or 65 bytes, got ${signature.size}" + } + inputs[index].put(Psbt.PSBT_IN_TAP_KEY_SIG, signature) +} + +/** The optional BIP-174 sighash type for input [index] (4-byte LE), or null. */ +fun Psbt.inputSighashType(index: Int): Int? = inputs[index].get(Psbt.PSBT_IN_SIGHASH_TYPE)?.let { BitcoinReader(it).readUInt32LE().toInt() } + +fun Psbt.setInputSighashType( + index: Int, + sighashType: Int, +) { + inputs[index].put( + Psbt.PSBT_IN_SIGHASH_TYPE, + BitcoinWriter().writeUInt32LE(sighashType.toLong()).toByteArray(), + ) +} + +/** Optional taproot internal key on a change output. */ +fun Psbt.setOutputTapInternalKey( + index: Int, + xOnlyPubKey: ByteArray, +) { + require(xOnlyPubKey.size == 32) { "tap internal key must be 32 bytes" } + outputs[index].put(Psbt.PSBT_OUT_TAP_INTERNAL_KEY, xOnlyPubKey) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtFinalizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtFinalizer.kt new file mode 100644 index 000000000..a64cd4afa --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtFinalizer.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * Turns a fully signed key-path P2TR [Psbt] into a broadcastable transaction. + * + * For a key-path taproot spend the witness is just the single Schnorr + * signature and the scriptSig stays empty, so finalization is purely + * mechanical: move each input's `PSBT_IN_TAP_KEY_SIG` into the transaction's + * witness stack. + */ +object PsbtFinalizer { + /** + * Build the final signed transaction from [psbt]. + * + * @throws PsbtSigningException if any input is still missing its key-path + * signature. + */ + fun finalize(psbt: Psbt): BitcoinTransaction { + val tx = psbt.unsignedTx + val signedInputs = + tx.inputs.mapIndexed { index, input -> + val sig = + psbt.inputTapKeySig(index) + ?: throw PsbtSigningException("input $index is not signed") + input.copy( + scriptSig = ByteArray(0), + witness = listOf(sig), + ) + } + return BitcoinTransaction(tx.version, signedInputs, tx.outputs, tx.lockTime) + } + + /** Convenience: [finalize] then serialize to a broadcast-ready lowercase hex string. */ + fun finalizeToHex(psbt: Psbt): String = finalize(psbt).serialize().toHexKey() + + /** True when every input of [psbt] carries a key-path signature. */ + fun isFullySigned(psbt: Psbt): Boolean = + psbt.unsignedTx.inputs.indices + .all { psbt.inputTapKeySig(it) != null } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSigner.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSigner.kt new file mode 100644 index 000000000..6ae4cb490 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSigner.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.utils.Secp256k1Instance + +/** + * Signs the key-path P2TR inputs of a [Psbt] with a single private key. + * + * This is the core of `NostrSigner.signPsbt` — pure protocol logic, no UI or + * signer-app dependency. The signer: + * 1. derives the caller's x-only public key, + * 2. for each input whose `PSBT_IN_TAP_INTERNAL_KEY` matches that key and + * which is not already signed, + * 3. computes the BIP-341 sighash over all inputs (so every input must carry + * a `PSBT_IN_WITNESS_UTXO`), + * 4. applies the BIP-341 key-path tweak to the private key, and + * 5. produces a BIP-340 Schnorr signature, stored as `PSBT_IN_TAP_KEY_SIG`. + * + * Inputs the key does not control are left untouched. Finalization (moving the + * signature into the transaction witness) is [PsbtFinalizer]'s job. + */ +object PsbtSigner { + /** + * Sign every key-path input of [psbt] that [privKey] controls, in place. + * + * @return the number of inputs signed by this call. + * @throws PsbtSigningException if a controllable input is missing the + * witness-UTXO data needed to compute its sighash. + */ + fun signKeyPathInputs( + psbt: Psbt, + privKey: ByteArray, + ): Int { + require(privKey.size == 32) { "private key must be 32 bytes" } + + val ourXOnlyPubKey = Secp256k1Instance.compressedPubKeyFor(privKey).copyOfRange(1, 33) + val tx = psbt.unsignedTx + + // Lazily materialized: every input's spent output, needed for the + // all-inputs commitment in the BIP-341 sighash. + var spentOutputs: List? = null + + var signed = 0 + for (index in tx.inputs.indices) { + val internalKey = psbt.inputTapInternalKey(index) ?: continue + if (!internalKey.contentEquals(ourXOnlyPubKey)) continue + if (psbt.inputTapKeySig(index) != null) continue // already signed + + if (spentOutputs == null) { + spentOutputs = + tx.inputs.indices.map { i -> + psbt.inputWitnessUtxo(i) + ?: throw PsbtSigningException( + "input $i has no witness UTXO; cannot compute sighash", + ) + } + } + + val sighashType = psbt.inputSighashType(index) ?: TaprootSigHash.SIGHASH_DEFAULT + val sigHash = TaprootSigHash.compute(tx, index, spentOutputs, sighashType) + + val tweakedSecKey = TaprootAddress.tweakSecretKey(privKey) + val signature64 = Secp256k1Instance.signSchnorr(sigHash, tweakedSecKey) + + // SIGHASH_DEFAULT → bare 64-byte signature. Any other type → append + // the sighash byte for a 65-byte signature, per BIP-341. + val signature = + if (sighashType == TaprootSigHash.SIGHASH_DEFAULT) { + signature64 + } else { + signature64 + byteArrayOf(sighashType.toByte()) + } + + psbt.setInputTapKeySig(index, signature) + signed++ + } + return signed + } +} + +/** Thrown when a PSBT cannot be signed (e.g. missing witness-UTXO data). */ +class PsbtSigningException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHash.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHash.kt new file mode 100644 index 000000000..2bc1b2679 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHash.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * BIP-341 taproot signature hash (`SigMsg` + `TapSighash` tagged hash). + * + * Supports key-path spends (`ext_flag = 0`) with all six base sighash types, + * with and without an annex. Script-path spends (`ext_flag = 1`) are out of + * scope for NIP-BC, which only ever spends key-path P2TR outputs. + * + * Reference: + */ +object TaprootSigHash { + const val SIGHASH_DEFAULT = 0x00 + const val SIGHASH_ALL = 0x01 + const val SIGHASH_NONE = 0x02 + const val SIGHASH_SINGLE = 0x03 + const val SIGHASH_ANYONECANPAY = 0x80 + + private val tapSighashTag: ByteArray by lazy { sha256("TapSighash".encodeToByteArray()) } + + /** + * Compute the BIP-341 signature hash for a key-path P2TR spend. + * + * @param tx The transaction being signed. + * @param inputIndex The index of the input whose signature is being produced. + * @param spentOutputs The previous outputs being spent, one per input of [tx], + * in the same order as `tx.inputs`. + * @param hashType A BIP-341 sighash type byte (default `SIGHASH_DEFAULT`). + * @param annex Optional annex bytes (including the `0x50` prefix), or null. + */ + fun compute( + tx: BitcoinTransaction, + inputIndex: Int, + spentOutputs: List, + hashType: Int = SIGHASH_DEFAULT, + annex: ByteArray? = null, + ): ByteArray { + require(spentOutputs.size == tx.inputs.size) { + "spentOutputs (${spentOutputs.size}) must match inputs (${tx.inputs.size})" + } + require(inputIndex in tx.inputs.indices) { "inputIndex $inputIndex out of range" } + + val anyoneCanPay = (hashType and SIGHASH_ANYONECANPAY) != 0 + val outputType = hashType and 0x03 + require( + hashType == SIGHASH_DEFAULT || + outputType == SIGHASH_ALL || + outputType == SIGHASH_NONE || + outputType == SIGHASH_SINGLE, + ) { "invalid sighash type $hashType" } + + val ss = BitcoinWriter() + + // Sighash epoch. + ss.writeByte(0x00) + + // Common signature message fields. + ss.writeByte(hashType) + ss.writeUInt32LE(tx.version) + ss.writeUInt32LE(tx.lockTime) + + if (!anyoneCanPay) { + ss.writeBytes(shaPrevouts(tx)) + ss.writeBytes(shaAmounts(spentOutputs)) + ss.writeBytes(shaScriptPubKeys(spentOutputs)) + ss.writeBytes(shaSequences(tx)) + } + + if (outputType != SIGHASH_NONE && outputType != SIGHASH_SINGLE) { + ss.writeBytes(shaOutputs(tx)) + } + + // spend_type = ext_flag * 2 + annex_present. ext_flag = 0 for key-path. + val annexPresent = if (annex != null) 1 else 0 + ss.writeByte(annexPresent) + + if (anyoneCanPay) { + tx.inputs[inputIndex].outPoint.write(ss) + ss.writeUInt64LE(spentOutputs[inputIndex].valueSats) + ss.writeVarBytes(spentOutputs[inputIndex].scriptPubKey) + ss.writeUInt32LE(tx.inputs[inputIndex].sequence) + } else { + ss.writeUInt32LE(inputIndex.toLong()) + } + + if (annex != null) { + val a = BitcoinWriter() + a.writeVarBytes(annex) + ss.writeBytes(sha256(a.toByteArray())) + } + + if (outputType == SIGHASH_SINGLE) { + require(inputIndex < tx.outputs.size) { + "SIGHASH_SINGLE with no matching output at index $inputIndex" + } + val o = BitcoinWriter() + tx.outputs[inputIndex].write(o) + ss.writeBytes(sha256(o.toByteArray())) + } + + return taggedHash(tapSighashTag, ss.toByteArray()) + } + + /** BIP-340 tagged hash: `SHA256(SHA256(tag) || SHA256(tag) || msg)`. */ + private fun taggedHash( + tagHash: ByteArray, + msg: ByteArray, + ): ByteArray { + val buf = ByteArray(tagHash.size * 2 + msg.size) + tagHash.copyInto(buf, 0) + tagHash.copyInto(buf, tagHash.size) + msg.copyInto(buf, tagHash.size * 2) + return sha256(buf) + } + + private fun shaPrevouts(tx: BitcoinTransaction): ByteArray { + val w = BitcoinWriter() + tx.inputs.forEach { it.outPoint.write(w) } + return sha256(w.toByteArray()) + } + + private fun shaAmounts(spentOutputs: List): ByteArray { + val w = BitcoinWriter() + spentOutputs.forEach { w.writeUInt64LE(it.valueSats) } + return sha256(w.toByteArray()) + } + + private fun shaScriptPubKeys(spentOutputs: List): ByteArray { + val w = BitcoinWriter() + spentOutputs.forEach { w.writeVarBytes(it.scriptPubKey) } + return sha256(w.toByteArray()) + } + + private fun shaSequences(tx: BitcoinTransaction): ByteArray { + val w = BitcoinWriter() + tx.inputs.forEach { w.writeUInt32LE(it.sequence) } + return sha256(w.toByteArray()) + } + + private fun shaOutputs(tx: BitcoinTransaction): ByteArray { + val w = BitcoinWriter() + tx.outputs.forEach { it.write(w) } + return sha256(w.toByteArray()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt index 1f0cbffe1..ac5f04618 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/taproot/TaprootAddress.kt @@ -78,6 +78,36 @@ object TaprootAddress { return tweaked.copyOfRange(1, 33) } + /** + * BIP-341 `taproot_tweak_seckey` for a key-path-only spend (no script tree). + * + * Produces the private key that controls the P2TR output derived from + * [internalSecKey]'s public key. The internal key is first normalized to + * the even-y representation BIP-341 hashes over, then the TapTweak scalar + * is added. + * + * @param internalSecKey 32-byte internal private key. + * @return 32-byte tweaked private key suitable for a BIP-340 Schnorr + * signature over a [TaprootSigHash]-style key-path sighash. + */ + fun tweakSecretKey(internalSecKey: ByteArray): ByteArray { + require(internalSecKey.size == 32) { + "internal secret key must be 32 bytes (got ${internalSecKey.size})" + } + // P = internalSecKey · G, as a 33-byte compressed point. + val compressedPubKey = Secp256k1Instance.compressedPubKeyFor(internalSecKey) + val xOnly = compressedPubKey.copyOfRange(1, 33) + + // If P has odd y, BIP-341 negates the secret key so it corresponds to + // the even-y lift used when computing the TapTweak hash. + val evenYParity = compressedPubKey[0].toInt() == 0x02 + val normalizedSecKey = + if (evenYParity) internalSecKey else Secp256k1Instance.privKeyNegate(internalSecKey) + + val tweak = tapTweakHash(xOnly) + return Secp256k1Instance.privateKeyAdd(normalizedSecKey, tweak) + } + /** * Derive the Bitcoin mainnet taproot address (`bc1p...`) for a Nostr * public key. The pubkey is used directly as the BIP-341 internal key. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt index d3d537804..e993a83ee 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.kt @@ -73,4 +73,12 @@ expect object Secp256k1Instance { pubKey: ByteArray, tweak: ByteArray, ): ByteArray + + /** + * Negate a private key: returns `(n - d) mod n` as 32 bytes. + * + * Used by the BIP-341 `taproot_tweak_seckey` algorithm when the internal + * key's public point has odd y. + */ + fun privKeyNegate(privKey: ByteArray): ByteArray } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerPsbtTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerPsbtTest.kt new file mode 100644 index 000000000..0a1aa8a16 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerPsbtTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.signers + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nipBCOnchainZaps.build.OnchainZapBuilder +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtFinalizer +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.TaprootSigHash +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.TxOut +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.inputTapKeySig +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** Tests that `NostrSigner.signPsbt` is wired correctly across the hierarchy. */ +class NostrSignerPsbtTest { + private val senderPriv = "0000000000000000000000000000000000000000000000000000000000000007".hexToByteArray() + private val recipientPriv = "000000000000000000000000000000000000000000000000000000000000000d".hexToByteArray() + + private fun xOnly(priv: ByteArray) = Secp256k1Instance.compressedPubKeyFor(priv).copyOfRange(1, 33).toHexKey() + + @Test + fun internalSignerSignsAndFinalizes() = + runTest { + val signer = NostrSignerInternal(KeyPair(senderPriv)) + val senderPubKey = xOnly(senderPriv) + val recipientPubKey = xOnly(recipientPriv) + + val built = + OnchainZapBuilder.build( + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = 50_000L, + feeRateSatPerVByte = 4.0, + availableUtxos = listOf(Utxo("1".repeat(64), 0, 250_000L, 6)), + ) + + val signedHex = signer.signPsbt(built.psbt.toHex()) + val signedPsbt = Psbt.parse(signedHex) + + assertTrue(PsbtFinalizer.isFullySigned(signedPsbt)) + + val finalTx = PsbtFinalizer.finalize(signedPsbt) + val senderScript = TaprootAddress.scriptPubKeyForRecipient(senderPubKey) + val senderOutputKey = TaprootAddress.tweakOutputKey(senderPubKey.hexToByteArray()) + val spent = built.selectedUtxos.map { TxOut(it.valueSats, senderScript) } + + finalTx.inputs.forEachIndexed { i, input -> + val sigHash = TaprootSigHash.compute(finalTx, i, spent, TaprootSigHash.SIGHASH_DEFAULT) + assertTrue( + Secp256k1Instance.verifySchnorr(input.witness[0], sigHash, senderOutputKey), + "input $i must verify after NostrSigner.signPsbt", + ) + } + } + + @Test + fun signPsbtIsIdempotentlySafeOnAlreadySignedInputs() = + runTest { + val signer = NostrSignerInternal(KeyPair(senderPriv)) + val built = + OnchainZapBuilder.build( + xOnly(senderPriv), + xOnly(recipientPriv), + 20_000L, + 3.0, + listOf(Utxo("2".repeat(64), 1, 100_000L, 3)), + ) + val once = signer.signPsbt(built.psbt.toHex()) + val twice = signer.signPsbt(once) + // Re-signing must not clobber or duplicate the existing signature. + assertEquals(once, twice) + assertEquals(64, Psbt.parse(twice).inputTapKeySig(0)!!.size) + } + + @Test + fun readOnlySignerCannotSignPsbt() = + runTest { + // A key-less (watch-only) keypair. + val pubOnly = Secp256k1Instance.compressedPubKeyFor(senderPriv).copyOfRange(1, 33) + val signer = NostrSignerInternal(KeyPair(pubKey = pubOnly)) + val built = + OnchainZapBuilder.build( + xOnly(senderPriv), + xOnly(recipientPriv), + 20_000L, + 3.0, + listOf(Utxo("3".repeat(64), 0, 100_000L, 3)), + ) + assertFailsWith { + signer.signPsbt(built.psbt.toHex()) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransactionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransactionTest.kt new file mode 100644 index 000000000..7cc925207 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/BitcoinTransactionTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BitcoinTransactionTest { + // The Bitcoin genesis block coinbase transaction — a well-known legacy tx. + private val genesisCoinbaseHex = + "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff" + + "4d04ffff001d0104455468652054696d65732030332f4a616e2f32303039204368616e63656c6c6f72" + + "206f6e206272696e6b206f66207365636f6e64206261696c6f757420666f722062616e6b73ffffffff" + + "0100f2052a01000000434104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f" + + "61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac00000000" + private val genesisCoinbaseTxid = + "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b" + + // BIP-341 wallet-test-vectors keyPathSpending: the raw unsigned transaction. + private val bip341UnsignedTxHex = + "02000000097de20cbff686da83a54981d2b9bab3586f4ca7e48f57f5b55963115f3b334e9c0100000000" + + "00000000d7b7cab57b1393ace2d064f4d4a2cb8af6def61273e127517d44759b6dafdd9900000000" + + "00fffffffff8e1f583384333689228c5d28eac13366be082dc57441760d957275419a41842000000" + + "0000fffffffff0689180aa63b30cb162a73c6d2a38b7eeda2a83ece74310fda0843ad604853b0100" + + "000000feffffffaa5202bdf6d8ccd2ee0f0202afbbb7461d9264a25e5bfd3c5a52ee1239e0ba6c00" + + "00000000feffffff956149bdc66faa968eb2be2d2faa29718acbfe3941215893a2a3446d32acd050" + + "000000000000000000e664b9773b88c09c32cb70a2a3e4da0ced63b7ba3b22f848531bbb1d5d5f4c" + + "94010000000000000000e9aa6b8e6c9de67619e6a3924ae25696bb7b694bb677a632a74ef7eadfd4" + + "eabf0000000000ffffffffa778eb6a263dc090464cd125c466b5a99667720b1c110468831d058aa1" + + "b82af10100000000ffffffff0200ca9a3b000000001976a91406afd46bcdfd22ef94ac122aa11f24" + + "1244a37ecc88ac807840cb0000000020ac9a87f5594be208f8532db38cff670c450ed2fea8fcdefc" + + "c9a663f78bab962b0065cd1d" + + @Test + fun computesGenesisCoinbaseTxid() { + val tx = BitcoinTransaction.parse(genesisCoinbaseHex) + assertEquals(genesisCoinbaseTxid, tx.txid()) + } + + @Test + fun genesisCoinbaseRoundTrips() { + val tx = BitcoinTransaction.parse(genesisCoinbaseHex) + assertEquals(genesisCoinbaseHex, tx.serialize().toHexKey()) + assertEquals(1, tx.inputs.size) + assertEquals(1, tx.outputs.size) + assertEquals(5_000_000_000L, tx.outputs[0].valueSats) + } + + @Test + fun parsesBip341UnsignedTransaction() { + val tx = BitcoinTransaction.parse(bip341UnsignedTxHex) + assertEquals(2L, tx.version) + assertEquals(9, tx.inputs.size) + assertEquals(2, tx.outputs.size) + assertEquals(1_000_000_000L, tx.outputs[0].valueSats) + assertEquals(3_410_000_000L, tx.outputs[1].valueSats) + // No witness on the unsigned tx → legacy serialization round-trips exactly. + assertEquals(bip341UnsignedTxHex, tx.serialize().toHexKey()) + } + + @Test + fun segwitSerializationAddsMarkerFlagAndWitness() { + val base = BitcoinTransaction.parse(bip341UnsignedTxHex) + val withWitness = + base.copy( + inputs = + base.inputs.mapIndexed { i, input -> + if (i == 0) input.copy(witness = listOf(ByteArray(64) { 0x11 })) else input + }, + ) + val serialized = withWitness.serialize().toHexKey() + // version(4 bytes = 8 hex) then segwit marker+flag 0001 + assertTrue(serialized.substring(8, 12) == "0001", "expected segwit marker+flag") + // txid is witness-stripped, so it is unchanged by adding a witness. + assertEquals(base.txid(), withWitness.txid()) + } + + @Test + fun varIntRoundTrips() { + val values = listOf(0L, 1L, 0xFCL, 0xFDL, 0xFFFFL, 0x10000L, 0xFFFFFFFFL, 0x100000000L) + for (v in values) { + val bytes = BitcoinWriter().writeVarInt(v).toByteArray() + assertEquals(v, BitcoinReader(bytes).readVarInt(), "varint round-trip failed for $v") + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignerTest.kt new file mode 100644 index 000000000..45c26ca88 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignerTest.kt @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * End-to-end signing tests for the PSBT pipeline. Anchored on the BIP-341 + * `keyPathSpending` test vector (input 0) so the BIP-341 tweak, the BIP-341 + * sighash, and the BIP-340 signature are all validated against an authoritative + * source. + */ +class PsbtSignerTest { + private val unsignedTxHex = + "02000000097de20cbff686da83a54981d2b9bab3586f4ca7e48f57f5b55963115f3b334e9c0100000000" + + "00000000d7b7cab57b1393ace2d064f4d4a2cb8af6def61273e127517d44759b6dafdd9900000000" + + "00fffffffff8e1f583384333689228c5d28eac13366be082dc57441760d957275419a41842000000" + + "0000fffffffff0689180aa63b30cb162a73c6d2a38b7eeda2a83ece74310fda0843ad604853b0100" + + "000000feffffffaa5202bdf6d8ccd2ee0f0202afbbb7461d9264a25e5bfd3c5a52ee1239e0ba6c00" + + "00000000feffffff956149bdc66faa968eb2be2d2faa29718acbfe3941215893a2a3446d32acd050" + + "000000000000000000e664b9773b88c09c32cb70a2a3e4da0ced63b7ba3b22f848531bbb1d5d5f4c" + + "94010000000000000000e9aa6b8e6c9de67619e6a3924ae25696bb7b694bb677a632a74ef7eadfd4" + + "eabf0000000000ffffffffa778eb6a263dc090464cd125c466b5a99667720b1c110468831d058aa1" + + "b82af10100000000ffffffff0200ca9a3b000000001976a91406afd46bcdfd22ef94ac122aa11f24" + + "1244a37ecc88ac807840cb0000000020ac9a87f5594be208f8532db38cff670c450ed2fea8fcdefc" + + "c9a663f78bab962b0065cd1d" + + private val spentOutputs = + listOf( + TxOut(420_000_000L, "512053a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343".hexToByteArray()), + TxOut(462_000_000L, "5120147c9c57132f6e7ecddba9800bb0c4449251c92a1e60371ee77557b6620f3ea3".hexToByteArray()), + TxOut(294_000_000L, "76a914751e76e8199196d454941c45d1b3a323f1433bd688ac".hexToByteArray()), + TxOut(504_000_000L, "5120e4d810fd50586274face62b8a807eb9719cef49c04177cc6b76a9a4251d5450e".hexToByteArray()), + TxOut(630_000_000L, "512091b64d5324723a985170e4dc5a0f84c041804f2cd12660fa5dec09fc21783605".hexToByteArray()), + TxOut(378_000_000L, "00147dd65592d0ab2fe0d0257d571abf032cd9db93dc".hexToByteArray()), + TxOut(672_000_000L, "512075169f4001aa68f15bbed28b218df1d0a62cbbcf1188c6665110c293c907b831".hexToByteArray()), + TxOut(546_000_000L, "5120712447206d7a5238acc7ff53fbe94a3b64539ad291c7cdbc490b7577e4b17df5".hexToByteArray()), + TxOut(588_000_000L, "512077e30a5522dd9f894c3f8b8bd4c4b2cf82ca7da8a3ea6a239655c39c050ab220".hexToByteArray()), + ) + + // BIP-341 keyPathSpending input 0. + private val internalPrivKey0 = "6b973d88838f27366ed61c9ad6367663045cb456e28335c109e30717ae0c6baa" + private val internalPubKey0 = "d6889cb081036e0faefa3a35157ad71086b123b2b144b649798b494c300a961d" + private val tweakedPrivKey0 = "2405b971772ad26915c8dcdf10f238753a9b837e5f8e6a86fd7c0cce5b7296d9" + private val outputKey0 = "53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343" + private val sigHashSingle0 = "2514a6272f85cfa0f45eb907fcb0d121b808ed37c6ea160a5a9046ed5526d555" + + private fun psbtForVectorTx(): Psbt { + val psbt = Psbt.fromUnsignedTx(BitcoinTransaction.parse(unsignedTxHex)) + spentOutputs.forEachIndexed { i, utxo -> psbt.setInputWitnessUtxo(i, utxo) } + psbt.setInputTapInternalKey(0, internalPubKey0.hexToByteArray()) + return psbt + } + + @Test + fun tweakSecretKeyMatchesBip341Vector() { + val tweaked = TaprootAddress.tweakSecretKey(internalPrivKey0.hexToByteArray()) + assertEquals(tweakedPrivKey0, tweaked.toHexKey()) + } + + @Test + fun signsAndProducesVerifiableSignature_sighashSingle() { + val psbt = psbtForVectorTx() + psbt.setInputSighashType(0, TaprootSigHash.SIGHASH_SINGLE) + + val count = PsbtSigner.signKeyPathInputs(psbt, internalPrivKey0.hexToByteArray()) + assertEquals(1, count, "exactly input 0 should be signed") + + val sig = psbt.inputTapKeySig(0)!! + // Non-default sighash → 65-byte signature with the type byte appended. + assertEquals(65, sig.size) + assertEquals(TaprootSigHash.SIGHASH_SINGLE, sig[64].toInt()) + + // The 64-byte BIP-340 signature must verify against the vector's exact + // SIGHASH_SINGLE sighash and the input's taproot output key. + val ok = + Secp256k1Instance.verifySchnorr( + sig.copyOfRange(0, 64), + sigHashSingle0.hexToByteArray(), + outputKey0.hexToByteArray(), + ) + assertTrue(ok, "signature must verify against the BIP-341 vector sighash + output key") + } + + @Test + fun signsDefaultSighashHappyPath() { + // The path NIP-BC actually uses: SIGHASH_DEFAULT, bare 64-byte signature. + val psbt = psbtForVectorTx() + PsbtSigner.signKeyPathInputs(psbt, internalPrivKey0.hexToByteArray()) + + val sig = psbt.inputTapKeySig(0)!! + assertEquals(64, sig.size, "SIGHASH_DEFAULT → bare 64-byte signature") + + val expectedSigHash = + TaprootSigHash.compute(psbt.unsignedTx, 0, spentOutputs, TaprootSigHash.SIGHASH_DEFAULT) + val ok = Secp256k1Instance.verifySchnorr(sig, expectedSigHash, outputKey0.hexToByteArray()) + assertTrue(ok, "default-sighash signature must verify against the output key") + } + + @Test + fun skipsInputsTheKeyDoesNotControl() { + // A wrong internal key on input 0 → nothing to sign. + val psbt = Psbt.fromUnsignedTx(BitcoinTransaction.parse(unsignedTxHex)) + spentOutputs.forEachIndexed { i, utxo -> psbt.setInputWitnessUtxo(i, utxo) } + psbt.setInputTapInternalKey(0, "ab".repeat(32).hexToByteArray()) + + val count = PsbtSigner.signKeyPathInputs(psbt, internalPrivKey0.hexToByteArray()) + assertEquals(0, count) + } + + @Test + fun failsWhenWitnessUtxoMissing() { + val psbt = Psbt.fromUnsignedTx(BitcoinTransaction.parse(unsignedTxHex)) + psbt.setInputTapInternalKey(0, internalPubKey0.hexToByteArray()) + // No witness UTXOs set → sighash cannot be computed. + assertFailsWith { + PsbtSigner.signKeyPathInputs(psbt, internalPrivKey0.hexToByteArray()) + } + } + + @Test + fun finalizeMovesSignatureIntoWitness() { + // A minimal self-contained 1-in / 1-out taproot spend. + val privKey = "0000000000000000000000000000000000000000000000000000000000000003".hexToByteArray() + val xOnly = Secp256k1Instance.compressedPubKeyFor(privKey).copyOfRange(1, 33) + val outputKey = TaprootAddress.tweakOutputKey(xOnly) + val prevScript = TaprootAddress.outputKeyToScriptPubKey(outputKey) + + val tx = + BitcoinTransaction( + version = 2L, + inputs = listOf(TxIn(OutPoint("a".repeat(64), 0L), sequence = 0xFFFFFFFFL)), + outputs = listOf(TxOut(90_000L, prevScript)), + lockTime = 0L, + ) + val psbt = Psbt.fromUnsignedTx(tx) + psbt.setInputWitnessUtxo(0, TxOut(100_000L, prevScript)) + psbt.setInputTapInternalKey(0, xOnly) + + assertTrue(!PsbtFinalizer.isFullySigned(psbt)) + PsbtSigner.signKeyPathInputs(psbt, privKey) + assertTrue(PsbtFinalizer.isFullySigned(psbt)) + + val finalTx = PsbtFinalizer.finalize(psbt) + assertEquals(1, finalTx.inputs[0].witness.size) + assertEquals(64, finalTx.inputs[0].witness[0].size) + assertTrue(finalTx.hasWitness) + // txid is witness-stripped — unchanged by finalization. + assertEquals(tx.txid(), finalTx.txid()) + + // The signature in the witness must verify. + val sigHash = TaprootSigHash.compute(tx, 0, listOf(TxOut(100_000L, prevScript)), TaprootSigHash.SIGHASH_DEFAULT) + assertTrue( + Secp256k1Instance.verifySchnorr(finalTx.inputs[0].witness[0], sigHash, outputKey), + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtTest.kt new file mode 100644 index 000000000..7fcb8026a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PsbtTest { + private fun sampleTx() = + BitcoinTransaction( + version = 2L, + inputs = + listOf( + TxIn(OutPoint("a".repeat(64), 0L), sequence = 0xFFFFFFFFL), + TxIn(OutPoint("b".repeat(64), 3L), sequence = 0xFFFFFFFFL), + ), + outputs = + listOf( + TxOut(25_000L, "5120${"c".repeat(64)}".hexToByteArray()), + TxOut(99_000L, "5120${"d".repeat(64)}".hexToByteArray()), + ), + lockTime = 0L, + ) + + @Test + fun emptyPsbtSerializesToExpectedBytes() { + // version 2, 0 inputs, 0 outputs, locktime 0 → 10-byte tx. + val tx = BitcoinTransaction(2L, emptyList(), emptyList(), 0L) + val psbt = Psbt.fromUnsignedTx(tx) + // magic | keylen 01 | keytype 00 | valuelen 0a | <10-byte tx> | global separator 00 + assertEquals("70736274ff01000a0200000000000000000000", psbt.toHex()) + } + + @Test + fun rejectsBadMagic() { + assertFailsWith { + Psbt.parse("00112233445566778899") + } + } + + @Test + fun roundTripsWithTypedFields() { + val tx = sampleTx() + val psbt = Psbt.fromUnsignedTx(tx) + + // Input 0: witness utxo + tap internal key + sighash type. + psbt.setInputWitnessUtxo(0, TxOut(120_000L, "5120${"e".repeat(64)}".hexToByteArray())) + psbt.setInputTapInternalKey(0, "f".repeat(64).hexToByteArray()) + psbt.setInputSighashType(0, TaprootSigHash.SIGHASH_DEFAULT) + + // Input 1: a different witness utxo. + psbt.setInputWitnessUtxo(1, TxOut(80_000L, "0014${"1".repeat(40)}".hexToByteArray())) + + // Output 0: change-style tap internal key. + psbt.setOutputTapInternalKey(0, "2".repeat(64).hexToByteArray()) + + val reparsed = Psbt.parse(psbt.serialize()) + + assertEquals(tx.txid(), reparsed.unsignedTx.txid()) + assertEquals(120_000L, reparsed.inputWitnessUtxo(0)!!.valueSats) + assertEquals("f".repeat(64), reparsed.inputTapInternalKey(0)!!.toHexKey()) + assertEquals(TaprootSigHash.SIGHASH_DEFAULT, reparsed.inputSighashType(0)) + assertEquals(80_000L, reparsed.inputWitnessUtxo(1)!!.valueSats) + assertNull(reparsed.inputTapInternalKey(1)) + assertNull(reparsed.inputTapKeySig(0)) + // Exact byte round-trip. + assertEquals(psbt.toHex(), reparsed.toHex()) + } + + @Test + fun keySigAccessorEnforcesLength() { + val psbt = Psbt.fromUnsignedTx(sampleTx()) + assertFailsWith { + psbt.setInputTapKeySig(0, ByteArray(32)) + } + psbt.setInputTapKeySig(0, ByteArray(64) { 0x07 }) + assertTrue(psbt.inputTapKeySig(0)!!.size == 64) + } + + @Test + fun preservesUnknownRecords() { + val psbt = Psbt.fromUnsignedTx(sampleTx()) + // An unrecognized global keytype must survive a round-trip untouched. + psbt.global.records.add(PsbtRecord(0x7E, ByteArray(0), byteArrayOf(0x01, 0x02, 0x03))) + val reparsed = Psbt.parse(psbt.serialize()) + val unknown = reparsed.global.records.firstOrNull { it.keyType == 0x7E } + assertTrue(unknown != null && unknown.value.contentEquals(byteArrayOf(0x01, 0x02, 0x03))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHashTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHashTest.kt new file mode 100644 index 000000000..5e55edc11 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/TaprootSigHashTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Validates [TaprootSigHash] against the BIP-341 `wallet-test-vectors.json` + * `keyPathSpending` cases — all seven spendable inputs, covering every base + * sighash type and both ANYONECANPAY variants. + */ +class TaprootSigHashTest { + private val unsignedTxHex = + "02000000097de20cbff686da83a54981d2b9bab3586f4ca7e48f57f5b55963115f3b334e9c0100000000" + + "00000000d7b7cab57b1393ace2d064f4d4a2cb8af6def61273e127517d44759b6dafdd9900000000" + + "00fffffffff8e1f583384333689228c5d28eac13366be082dc57441760d957275419a41842000000" + + "0000fffffffff0689180aa63b30cb162a73c6d2a38b7eeda2a83ece74310fda0843ad604853b0100" + + "000000feffffffaa5202bdf6d8ccd2ee0f0202afbbb7461d9264a25e5bfd3c5a52ee1239e0ba6c00" + + "00000000feffffff956149bdc66faa968eb2be2d2faa29718acbfe3941215893a2a3446d32acd050" + + "000000000000000000e664b9773b88c09c32cb70a2a3e4da0ced63b7ba3b22f848531bbb1d5d5f4c" + + "94010000000000000000e9aa6b8e6c9de67619e6a3924ae25696bb7b694bb677a632a74ef7eadfd4" + + "eabf0000000000ffffffffa778eb6a263dc090464cd125c466b5a99667720b1c110468831d058aa1" + + "b82af10100000000ffffffff0200ca9a3b000000001976a91406afd46bcdfd22ef94ac122aa11f24" + + "1244a37ecc88ac807840cb0000000020ac9a87f5594be208f8532db38cff670c450ed2fea8fcdefc" + + "c9a663f78bab962b0065cd1d" + + // utxosSpent from the vector, in input order. + private val spentOutputs = + listOf( + TxOut(420_000_000L, "512053a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343".hexToByteArray()), + TxOut(462_000_000L, "5120147c9c57132f6e7ecddba9800bb0c4449251c92a1e60371ee77557b6620f3ea3".hexToByteArray()), + TxOut(294_000_000L, "76a914751e76e8199196d454941c45d1b3a323f1433bd688ac".hexToByteArray()), + TxOut(504_000_000L, "5120e4d810fd50586274face62b8a807eb9719cef49c04177cc6b76a9a4251d5450e".hexToByteArray()), + TxOut(630_000_000L, "512091b64d5324723a985170e4dc5a0f84c041804f2cd12660fa5dec09fc21783605".hexToByteArray()), + TxOut(378_000_000L, "00147dd65592d0ab2fe0d0257d571abf032cd9db93dc".hexToByteArray()), + TxOut(672_000_000L, "512075169f4001aa68f15bbed28b218df1d0a62cbbcf1188c6665110c293c907b831".hexToByteArray()), + TxOut(546_000_000L, "5120712447206d7a5238acc7ff53fbe94a3b64539ad291c7cdbc490b7577e4b17df5".hexToByteArray()), + TxOut(588_000_000L, "512077e30a5522dd9f894c3f8b8bd4c4b2cf82ca7da8a3ea6a239655c39c050ab220".hexToByteArray()), + ) + + private val tx = BitcoinTransaction.parse(unsignedTxHex) + + private fun check( + inputIndex: Int, + hashType: Int, + expected: String, + ) { + val sigHash = TaprootSigHash.compute(tx, inputIndex, spentOutputs, hashType) + assertEquals(expected, sigHash.toHexKey(), "sighash mismatch for input $inputIndex hashType $hashType") + } + + @Test + fun input4_sighashDefault() = check(4, 0x00, "4f900a0bae3f1446fd48490c2958b5a023228f01661cda3496a11da502a7f7ef") + + @Test + fun input3_sighashAll() = check(3, 0x01, "bf013ea93474aa67815b1b6cc441d23b64fa310911d991e713cd34c7f5d46669") + + @Test + fun input6_sighashNone() = check(6, 0x02, "15f25c298eb5cdc7eb1d638dd2d45c97c4c59dcaec6679cfc16ad84f30876b85") + + @Test + fun input0_sighashSingle() = check(0, 0x03, "2514a6272f85cfa0f45eb907fcb0d121b808ed37c6ea160a5a9046ed5526d555") + + @Test + fun input8_sighashAllAnyoneCanPay() = check(8, 0x81, "cccb739eca6c13a8a89e6e5cd317ffe55669bbda23f2fd37b0f18755e008edd2") + + @Test + fun input7_sighashNoneAnyoneCanPay() = check(7, 0x82, "cd292de50313804dabe4685e83f923d2969577191a3e1d2882220dca88cbeb10") + + @Test + fun input1_sighashSingleAnyoneCanPay() = check(1, 0x83, "325a644af47e8a5a2591cda0ab0723978537318f10e6a63d4eed783b96a71a4d") +} diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt index f142e506b..efc5276da 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.jvm.kt @@ -72,4 +72,6 @@ actual object Secp256k1Instance { val full = if (pubKey.size == 32) h02 + pubKey else pubKey return secp256k1.pubKeyCompress(secp256k1.pubKeyTweakAdd(full, tweak)) } + + actual fun privKeyNegate(privKey: ByteArray): ByteArray = secp256k1.privKeyNegate(privKey) } diff --git a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt index bf61c5877..7a3473a47 100644 --- a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt +++ b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/Secp256k1Instance.native.kt @@ -71,4 +71,6 @@ actual object Secp256k1Instance { val full = if (pubKey.size == 32) h02 + pubKey else pubKey return secp256k1Ref.pubKeyCompress(secp256k1Ref.pubKeyTweakAdd(full, tweak)) } + + actual fun privKeyNegate(privKey: ByteArray): ByteArray = secp256k1Ref.privKeyNegate(privKey) }