feat(quartz): NIP-BC onchain zap send-side foundation (Phase A.2)

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).
This commit is contained in:
Claude
2026-05-14 11:29:47 +00:00
parent bb5613ca8b
commit a42b1e0f32
24 changed files with 1719 additions and 0 deletions
@@ -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()
@@ -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)
}
@@ -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(
@@ -101,4 +101,9 @@ class NostrSignerInternal(
runWrapErrors {
signerSync.deriveKey(nonce)
}
override suspend fun signPsbt(psbtHex: String): String =
runWrapErrors {
signerSync.signPsbt(psbtHex)
}
}
@@ -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()
@@ -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)
}
@@ -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(
@@ -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<String>>): Array<Array<String>> {
@@ -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)
@@ -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<ByteArray> = 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<TxIn>,
val outputs: List<TxOut>,
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<TxIn>(inputCount.toInt())
for (i in 0 until inputCount) {
inputs.add(TxIn.readWithoutWitness(reader))
}
val outputCount = reader.readVarInt()
val outputs = ArrayList<TxOut>(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<ByteArray>(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)
}
}
}
@@ -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<PsbtRecord> = 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<PsbtMap>,
val outputs: MutableList<PsbtMap>,
) {
/** 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<PsbtMap>(tx.inputs.size)
for (i in tx.inputs.indices) {
inputs.add(PsbtMap.read(reader))
}
val outputs = ArrayList<PsbtMap>(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)
}
@@ -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 }
}
@@ -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<TxOut>? = 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)
@@ -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: <https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki>
*/
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<TxOut>,
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<TxOut>): ByteArray {
val w = BitcoinWriter()
spentOutputs.forEach { w.writeUInt64LE(it.valueSats) }
return sha256(w.toByteArray())
}
private fun shaScriptPubKeys(spentOutputs: List<TxOut>): 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())
}
}
@@ -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.
@@ -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
}
@@ -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<SignerExceptions> {
signer.signPsbt(built.psbt.toHex())
}
}
}
@@ -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")
}
}
}
@@ -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<PsbtSigningException> {
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),
)
}
}
@@ -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<PsbtParseException> {
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<IllegalArgumentException> {
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)))
}
}
@@ -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")
}
@@ -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)
}
@@ -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)
}