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:
+12
@@ -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()
|
||||
|
||||
|
||||
+2
@@ -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)
|
||||
}
|
||||
|
||||
+13
@@ -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(
|
||||
|
||||
+5
@@ -101,4 +101,9 @@ class NostrSignerInternal(
|
||||
runWrapErrors {
|
||||
signerSync.deriveKey(nonce)
|
||||
}
|
||||
|
||||
override suspend fun signPsbt(psbtHex: String): String =
|
||||
runWrapErrors {
|
||||
signerSync.signPsbt(psbtHex)
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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()
|
||||
|
||||
|
||||
+6
@@ -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)
|
||||
}
|
||||
|
||||
+10
@@ -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(
|
||||
|
||||
+2
@@ -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>> {
|
||||
|
||||
+183
@@ -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)
|
||||
+240
@@ -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)
|
||||
}
|
||||
+62
@@ -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 }
|
||||
}
|
||||
+105
@@ -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)
|
||||
+167
@@ -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())
|
||||
}
|
||||
}
|
||||
+30
@@ -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
|
||||
}
|
||||
|
||||
+120
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+112
@@ -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)))
|
||||
}
|
||||
}
|
||||
+93
@@ -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)
|
||||
}
|
||||
|
||||
+2
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user