From d2da3fdbdf29fa29f59f7ab0991bc68ebcaa8368 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 25 Sep 2025 12:24:27 +0200 Subject: [PATCH 1/6] Add wrapper classes for Musig2 nonces --- .../fr/acinq/bitcoin/scalacompat/Musig2.scala | 49 +++++++++++++------ .../bitcoin/scalacompat/Musig2Spec.scala | 34 ++++++------- 2 files changed, 51 insertions(+), 32 deletions(-) diff --git a/src/main/scala/fr/acinq/bitcoin/scalacompat/Musig2.scala b/src/main/scala/fr/acinq/bitcoin/scalacompat/Musig2.scala index 3d7e4823..e71f61fd 100644 --- a/src/main/scala/fr/acinq/bitcoin/scalacompat/Musig2.scala +++ b/src/main/scala/fr/acinq/bitcoin/scalacompat/Musig2.scala @@ -1,14 +1,33 @@ package fr.acinq.bitcoin.scalacompat import fr.acinq.bitcoin.ScriptTree -import fr.acinq.bitcoin.crypto.musig2.{IndividualNonce, SecretNonce} +import fr.acinq.bitcoin.crypto.musig2 import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey, XonlyPublicKey} import fr.acinq.bitcoin.scalacompat.KotlinUtils._ +import fr.acinq.secp256k1.Secp256k1 +import scodec.bits.ByteVector import scala.jdk.CollectionConverters.SeqHasAsJava object Musig2 { + /** + * Musig2 secret nonce, that should be treated as a private opaque blob. + * This nonce must never be persisted or reused across signing sessions. + */ + case class SecretNonce(inner: musig2.SecretNonce) + + /** + * Musig2 public nonce, that must be shared with other participants in the signing session. + * It contains two elliptic curve points, but should be treated as an opaque blob. + */ + case class IndividualNonce(data: ByteVector) { + require(data.size == Secp256k1.MUSIG2_PUBLIC_NONCE_SIZE, "invalid musig2 public nonce size") + } + + /** A locally-generated nonce, for which both the secret and public parts are known. */ + case class LocalNonce(secret: SecretNonce, public: IndividualNonce) + /** * Aggregate the public keys of a musig2 session into a single public key. * Note that this function doesn't apply any tweak: when used for taproot, it computes the internal public key, not @@ -16,18 +35,18 @@ object Musig2 { * * @param publicKeys public keys of all participants: callers must verify that all public keys are valid. */ - def aggregateKeys(publicKeys: Seq[PublicKey]): XonlyPublicKey = XonlyPublicKey(fr.acinq.bitcoin.crypto.musig2.Musig2.aggregateKeys(publicKeys.map(scala2kmp).asJava)) + def aggregateKeys(publicKeys: Seq[PublicKey]): XonlyPublicKey = XonlyPublicKey(musig2.Musig2.aggregateKeys(publicKeys.map(scala2kmp).asJava)) /** - * @param sessionId a random, unique session ID. - * @param signingKey either the signer's private key or public key - * @param publicKeys public keys of all participants: callers must verify that all public keys are valid. - * @param message_opt (optional) message that will be signed, if already known. + * @param sessionId a random, unique session ID. + * @param signingKey either the signer's private key or public key + * @param publicKeys public keys of all participants: callers must verify that all public keys are valid. + * @param message_opt (optional) message that will be signed, if already known. * @param extraInput_opt (optional) additional random data. */ - def generateNonce(sessionId: ByteVector32, signingKey: Either[PrivateKey, PublicKey], publicKeys: Seq[PublicKey], message_opt: Option[ByteVector32], extraInput_opt: Option[ByteVector32]): (SecretNonce, IndividualNonce) = { - val nonce = fr.acinq.bitcoin.crypto.musig2.Musig2.generateNonce(sessionId, either2keitherkmp(signingKey.map(scala2kmp).left.map(scala2kmp)), publicKeys.map(scala2kmp).asJava, message_opt.map(scala2kmp).orNull, extraInput_opt.map(scala2kmp).orNull) - (nonce.getFirst, nonce.getSecond) + def generateNonce(sessionId: ByteVector32, signingKey: Either[PrivateKey, PublicKey], publicKeys: Seq[PublicKey], message_opt: Option[ByteVector32], extraInput_opt: Option[ByteVector32]): LocalNonce = { + val nonce = musig2.Musig2.generateNonce(sessionId, either2keitherkmp(signingKey.map(scala2kmp).left.map(scala2kmp)), publicKeys.map(scala2kmp).asJava, message_opt.map(scala2kmp).orNull, extraInput_opt.map(scala2kmp).orNull) + LocalNonce(SecretNonce(nonce.getFirst), IndividualNonce(nonce.getSecond.getData)) } /** @@ -37,9 +56,9 @@ object Musig2 { * @param message_opt (optional) message that will be signed, if already known. * @param extraInput_opt (optional) additional random data. */ - def generateNonceWithCounter(nonRepeatingCounter: Long, privateKey: PrivateKey, publicKeys: Seq[PublicKey], message_opt: Option[ByteVector32], extraInput_opt: Option[ByteVector32]): (SecretNonce, IndividualNonce) = { - val nonce = fr.acinq.bitcoin.crypto.musig2.Musig2.generateNonceWithCounter(nonRepeatingCounter, privateKey, publicKeys.map(scala2kmp).asJava, message_opt.map(scala2kmp).orNull, extraInput_opt.map(scala2kmp).orNull) - (nonce.getFirst, nonce.getSecond) + def generateNonceWithCounter(nonRepeatingCounter: Long, privateKey: PrivateKey, publicKeys: Seq[PublicKey], message_opt: Option[ByteVector32], extraInput_opt: Option[ByteVector32]): LocalNonce = { + val nonce = musig2.Musig2.generateNonceWithCounter(nonRepeatingCounter, privateKey, publicKeys.map(scala2kmp).asJava, message_opt.map(scala2kmp).orNull, extraInput_opt.map(scala2kmp).orNull) + LocalNonce(SecretNonce(nonce.getFirst), IndividualNonce(nonce.getSecond.getData)) } /** @@ -55,7 +74,7 @@ object Musig2 { * @param scriptTree_opt tapscript tree of the taproot input, if it has script paths. */ def signTaprootInput(privateKey: PrivateKey, tx: Transaction, inputIndex: Int, inputs: Seq[TxOut], publicKeys: Seq[PublicKey], secretNonce: SecretNonce, publicNonces: Seq[IndividualNonce], scriptTree_opt: Option[ScriptTree]): Either[Throwable, ByteVector32] = { - fr.acinq.bitcoin.crypto.musig2.Musig2.signTaprootInput(privateKey, tx, inputIndex, inputs.map(scala2kmp).asJava, publicKeys.map(scala2kmp).asJava, secretNonce, publicNonces.asJava, scriptTree_opt.orNull).map(kmp2scala) + musig2.Musig2.signTaprootInput(privateKey, tx, inputIndex, inputs.map(scala2kmp).asJava, publicKeys.map(scala2kmp).asJava, secretNonce.inner, publicNonces.map(n => new musig2.IndividualNonce(n.data.toArray)).asJava, scriptTree_opt.orNull).map(kmp2scala) } /** @@ -73,7 +92,7 @@ object Musig2 { * @return true if the partial signature is valid. */ def verifyTaprootSignature(partialSig: ByteVector32, nonce: IndividualNonce, publicKey: PublicKey, tx: Transaction, inputIndex: Int, inputs: Seq[TxOut], publicKeys: Seq[PublicKey], publicNonces: Seq[IndividualNonce], scriptTree_opt: Option[ScriptTree]): Boolean = { - fr.acinq.bitcoin.crypto.musig2.Musig2.verify(partialSig, nonce, publicKey, tx, inputIndex, inputs.map(scala2kmp).asJava, publicKeys.map(scala2kmp).asJava, publicNonces.asJava, scriptTree_opt.orNull) + musig2.Musig2.verify(partialSig, new musig2.IndividualNonce(nonce.data.toArray), publicKey, tx, inputIndex, inputs.map(scala2kmp).asJava, publicKeys.map(scala2kmp).asJava, publicNonces.map(n => new musig2.IndividualNonce(n.data.toArray)).asJava, scriptTree_opt.orNull) } /** @@ -88,7 +107,7 @@ object Musig2 { * @param scriptTree_opt tapscript tree of the taproot input, if it has script paths. */ def aggregateTaprootSignatures(partialSigs: Seq[ByteVector32], tx: Transaction, inputIndex: Int, inputs: Seq[TxOut], publicKeys: Seq[PublicKey], publicNonces: Seq[IndividualNonce], scriptTree_opt: Option[ScriptTree]): Either[Throwable, ByteVector64] = { - fr.acinq.bitcoin.crypto.musig2.Musig2.aggregateTaprootSignatures(partialSigs.map(scala2kmp).asJava, tx, inputIndex, inputs.map(scala2kmp).asJava, publicKeys.map(scala2kmp).asJava, publicNonces.asJava, scriptTree_opt.orNull).map(kmp2scala) + musig2.Musig2.aggregateTaprootSignatures(partialSigs.map(scala2kmp).asJava, tx, inputIndex, inputs.map(scala2kmp).asJava, publicKeys.map(scala2kmp).asJava, publicNonces.map(n => new musig2.IndividualNonce(n.data.toArray)).asJava, scriptTree_opt.orNull).map(kmp2scala) } } diff --git a/src/test/scala/fr/acinq/bitcoin/scalacompat/Musig2Spec.scala b/src/test/scala/fr/acinq/bitcoin/scalacompat/Musig2Spec.scala index 9311bd19..ce7d4747 100644 --- a/src/test/scala/fr/acinq/bitcoin/scalacompat/Musig2Spec.scala +++ b/src/test/scala/fr/acinq/bitcoin/scalacompat/Musig2Spec.scala @@ -2,7 +2,6 @@ package fr.acinq.bitcoin.scalacompat import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey import fr.acinq.bitcoin.{ScriptFlags, ScriptTree, SigHash} -import fr.acinq.secp256k1.Hex import org.scalatest.FunSuite import scodec.bits.{ByteVector, HexStringSyntax} @@ -27,15 +26,15 @@ class Musig2Spec extends FunSuite { // The first step of a musig2 signing session is to exchange nonces. // If participants are disconnected before the end of the signing session, they must start again with fresh nonces. - val (aliceSecretNonce, alicePublicNonce) = Musig2.generateNonce(ByteVector32(ByteVector(Random.nextBytes(32))), Left(alicePrivKey), Seq(alicePubKey, bobPubKey), None, None) - val (bobSecretNonce, bobPublicNonce) = Musig2.generateNonce(ByteVector32(ByteVector(Random.nextBytes(32))), Right(bobPrivKey.publicKey), Seq(alicePubKey, bobPubKey), None, None) + val aliceNonce = Musig2.generateNonce(ByteVector32(ByteVector(Random.nextBytes(32))), Left(alicePrivKey), Seq(alicePubKey, bobPubKey), None, None) + val bobNonce = Musig2.generateNonce(ByteVector32(ByteVector(Random.nextBytes(32))), Right(bobPrivKey.publicKey), Seq(alicePubKey, bobPubKey), None, None) // Once they have each other's public nonce, they can produce partial signatures. - val publicNonces = Seq(alicePublicNonce, bobPublicNonce) - val Right(aliceSig) = Musig2.signTaprootInput(alicePrivKey, spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), aliceSecretNonce, publicNonces, scriptTree_opt = None) - assert(Musig2.verifyTaprootSignature(aliceSig, alicePublicNonce, alicePubKey, spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), publicNonces, scriptTree_opt = None)) - val Right(bobSig) = Musig2.signTaprootInput(bobPrivKey, spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), bobSecretNonce, publicNonces, scriptTree_opt = None) - assert(Musig2.verifyTaprootSignature(bobSig, bobPublicNonce, bobPubKey, spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), publicNonces, scriptTree_opt = None)) + val publicNonces = Seq(aliceNonce, bobNonce).map(_.public) + val Right(aliceSig) = Musig2.signTaprootInput(alicePrivKey, spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), aliceNonce.secret, publicNonces, scriptTree_opt = None) + assert(Musig2.verifyTaprootSignature(aliceSig, aliceNonce.public, alicePubKey, spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), publicNonces, scriptTree_opt = None)) + val Right(bobSig) = Musig2.signTaprootInput(bobPrivKey, spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), bobNonce.secret, publicNonces, scriptTree_opt = None) + assert(Musig2.verifyTaprootSignature(bobSig, bobNonce.public, bobPubKey, spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), publicNonces, scriptTree_opt = None)) // Once they have each other's partial signature, they can aggregate them into a valid signature. val Right(aggregateSig) = Musig2.aggregateTaprootSignatures(Seq(aliceSig, bobSig), spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), publicNonces, scriptTree_opt = None) @@ -80,15 +79,15 @@ class Musig2Spec extends FunSuite { ) // The first step of a musig2 signing session is to exchange nonces. // If participants are disconnected before the end of the signing session, they must start again with fresh nonces. - val (userSecretNonce, userPublicNonce) = Musig2.generateNonce(ByteVector32(ByteVector(Random.nextBytes(32))), Left(userPrivateKey), Seq(userPublicKey, serverPublicKey), None, None) - val (serverSecretNonce, serverPublicNonce) = Musig2.generateNonce(ByteVector32(ByteVector(Random.nextBytes(32))), Right(serverPrivateKey.publicKey), Seq(userPublicKey, serverPublicKey), None, None) + val userNonce = Musig2.generateNonce(ByteVector32(ByteVector(Random.nextBytes(32))), Left(userPrivateKey), Seq(userPublicKey, serverPublicKey), None, None) + val serverNonce = Musig2.generateNonce(ByteVector32(ByteVector(Random.nextBytes(32))), Right(serverPrivateKey.publicKey), Seq(userPublicKey, serverPublicKey), None, None) // Once they have each other's public nonce, they can produce partial signatures. - val publicNonces = Seq(userPublicNonce, serverPublicNonce) - val Right(userSig) = Musig2.signTaprootInput(userPrivateKey, tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), userSecretNonce, publicNonces, Some(scriptTree)) - assert(Musig2.verifyTaprootSignature(userSig, userPublicNonce, userPublicKey, tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), publicNonces, Some(scriptTree))) - val Right(serverSig) = Musig2.signTaprootInput(serverPrivateKey, tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), serverSecretNonce, publicNonces, Some(scriptTree)) - assert(Musig2.verifyTaprootSignature(serverSig, serverPublicNonce, serverPublicKey, tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), publicNonces, Some(scriptTree))) + val publicNonces = Seq(userNonce, serverNonce).map(_.public) + val Right(userSig) = Musig2.signTaprootInput(userPrivateKey, tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), userNonce.secret, publicNonces, Some(scriptTree)) + assert(Musig2.verifyTaprootSignature(userSig, userNonce.public, userPublicKey, tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), publicNonces, Some(scriptTree))) + val Right(serverSig) = Musig2.signTaprootInput(serverPrivateKey, tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), serverNonce.secret, publicNonces, Some(scriptTree)) + assert(Musig2.verifyTaprootSignature(serverSig, serverNonce.public, serverPublicKey, tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), publicNonces, Some(scriptTree))) // Once they have each other's partial signature, they can aggregate them into a valid signature. val Right(sig) = Musig2.aggregateTaprootSignatures(Seq(userSig, serverSig), tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), publicNonces, Some(scriptTree)) @@ -113,7 +112,8 @@ class Musig2Spec extends FunSuite { test("generate nonce with counter") { val sk = PrivateKey(ByteVector.fromValidHex("EEC1CB7D1B7254C5CAB0D9C61AB02E643D464A59FE6C96A7EFE871F07C5AEF54")) - val (_, pubnonce) = Musig2.generateNonceWithCounter(0, sk, Seq(sk.publicKey), None, None) - assert(pubnonce.getData.contentEquals(Hex.decode("0271efb262c0535e921efacacd30146fa93f193689e4974d5348fa9d909d90000702a049680ef3f6acfb12320297df31d3a634214491cbeebacef5acdf13f8f61cc2"))) + val nonce = Musig2.generateNonceWithCounter(0, sk, Seq(sk.publicKey), None, None) + assert(nonce.public.data == hex"0271efb262c0535e921efacacd30146fa93f193689e4974d5348fa9d909d90000702a049680ef3f6acfb12320297df31d3a634214491cbeebacef5acdf13f8f61cc2") } + } From 5ce45dd28cd566a9e2a05c6b4f4a8be5c91901d6 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 25 Sep 2025 14:55:59 +0200 Subject: [PATCH 2/6] Add wrapper classes for taproot `ScriptTree` --- .../fr/acinq/bitcoin/scalacompat/Crypto.scala | 2 +- .../bitcoin/scalacompat/KotlinUtils.scala | 21 +++++++ .../fr/acinq/bitcoin/scalacompat/Musig2.scala | 7 +-- .../fr/acinq/bitcoin/scalacompat/Script.scala | 4 +- .../bitcoin/scalacompat/ScriptTree.scala | 56 +++++++++++++++++++ .../bitcoin/scalacompat/Transaction.scala | 6 +- .../bitcoin/scalacompat/Musig2Spec.scala | 7 +-- 7 files changed, 89 insertions(+), 14 deletions(-) create mode 100644 src/main/scala/fr/acinq/bitcoin/scalacompat/ScriptTree.scala diff --git a/src/main/scala/fr/acinq/bitcoin/scalacompat/Crypto.scala b/src/main/scala/fr/acinq/bitcoin/scalacompat/Crypto.scala index 660b8a57..3dbe9ac7 100644 --- a/src/main/scala/fr/acinq/bitcoin/scalacompat/Crypto.scala +++ b/src/main/scala/fr/acinq/bitcoin/scalacompat/Crypto.scala @@ -138,7 +138,7 @@ object Crypto { } /** Tweak this key with the merkle root of the given script tree. */ - def outputKey(scriptTree: bitcoin.ScriptTree): (XonlyPublicKey, Boolean) = outputKey(new bitcoin.Crypto.TaprootTweak.ScriptTweak(scriptTree)) + def outputKey(scriptTree: ScriptTree): (XonlyPublicKey, Boolean) = outputKey(new bitcoin.Crypto.TaprootTweak.ScriptTweak(scriptTree)) /** Tweak this key with the merkle root provided. */ def outputKey(merkleRoot: ByteVector32): (XonlyPublicKey, Boolean) = outputKey(new bitcoin.Crypto.TaprootTweak.ScriptTweak(merkleRoot)) diff --git a/src/main/scala/fr/acinq/bitcoin/scalacompat/KotlinUtils.scala b/src/main/scala/fr/acinq/bitcoin/scalacompat/KotlinUtils.scala index 9917cb9a..35e40e34 100644 --- a/src/main/scala/fr/acinq/bitcoin/scalacompat/KotlinUtils.scala +++ b/src/main/scala/fr/acinq/bitcoin/scalacompat/KotlinUtils.scala @@ -8,6 +8,7 @@ import java.io.{InputStream, OutputStream} import scala.jdk.CollectionConverters.{ListHasAsScala, SeqHasAsJava} object KotlinUtils { + implicit def kmp2scala(input: bitcoin.ByteVector32): ByteVector32 = ByteVector32(ByteVector(input.toByteArray)) implicit def scala2kmp(input: ByteVector32): bitcoin.ByteVector32 = new bitcoin.ByteVector32(input.toArray) @@ -44,6 +45,25 @@ object KotlinUtils { implicit def scala2kmp(input: ScriptWitness): bitcoin.ScriptWitness = new bitcoin.ScriptWitness(input.stack.map(scala2kmp).asJava) + implicit def kmp2scala(input: bitcoin.ScriptTree.Leaf): ScriptTree.Leaf = ScriptTree.Leaf(kmp2scala(input.getScript), input.getLeafVersion) + + implicit def scala2kmp(input: ScriptTree.Leaf): bitcoin.ScriptTree.Leaf = new bitcoin.ScriptTree.Leaf(scala2kmp(input.script), input.leafVersion) + + implicit def kmp2scala(input: bitcoin.ScriptTree.Branch): ScriptTree.Branch = ScriptTree.Branch(kmp2scala(input.getLeft), kmp2scala(input.getRight)) + + implicit def scala2kmp(input: ScriptTree.Branch): bitcoin.ScriptTree.Branch = new bitcoin.ScriptTree.Branch(scala2kmp(input.left), scala2kmp(input.right)) + + implicit def kmp2scala(input: bitcoin.ScriptTree): ScriptTree = input match { + case branch: bitcoin.ScriptTree.Branch => kmp2scala(branch) + case leaf: bitcoin.ScriptTree.Leaf => kmp2scala(leaf) + case _ => ??? // this cannot happen, but the compiler cannot know that there aren't other cases + } + + implicit def scala2kmp(input: ScriptTree): bitcoin.ScriptTree = input match { + case leaf: ScriptTree.Leaf => scala2kmp(leaf) + case branch: ScriptTree.Branch => scala2kmp(branch) + } + implicit def kmp2scala(input: bitcoin.TxIn): TxIn = TxIn(input.outPoint, input.signatureScript, input.sequence, input.witness) implicit def scala2kmp(input: Satoshi): bitcoin.Satoshi = new bitcoin.Satoshi(input.toLong) @@ -229,5 +249,6 @@ object KotlinUtils { OP_INVALIDOPCODE -> bitcoin.OP_INVALIDOPCODE.INSTANCE) private val scriptEltMapKmp2Scala2Map: Map[bitcoin.ScriptElt, ScriptElt] = scriptEltMapScala2Kmp.map { case (k, v) => v -> k } + } diff --git a/src/main/scala/fr/acinq/bitcoin/scalacompat/Musig2.scala b/src/main/scala/fr/acinq/bitcoin/scalacompat/Musig2.scala index e71f61fd..280838c1 100644 --- a/src/main/scala/fr/acinq/bitcoin/scalacompat/Musig2.scala +++ b/src/main/scala/fr/acinq/bitcoin/scalacompat/Musig2.scala @@ -1,6 +1,5 @@ package fr.acinq.bitcoin.scalacompat -import fr.acinq.bitcoin.ScriptTree import fr.acinq.bitcoin.crypto.musig2 import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey, XonlyPublicKey} import fr.acinq.bitcoin.scalacompat.KotlinUtils._ @@ -74,7 +73,7 @@ object Musig2 { * @param scriptTree_opt tapscript tree of the taproot input, if it has script paths. */ def signTaprootInput(privateKey: PrivateKey, tx: Transaction, inputIndex: Int, inputs: Seq[TxOut], publicKeys: Seq[PublicKey], secretNonce: SecretNonce, publicNonces: Seq[IndividualNonce], scriptTree_opt: Option[ScriptTree]): Either[Throwable, ByteVector32] = { - musig2.Musig2.signTaprootInput(privateKey, tx, inputIndex, inputs.map(scala2kmp).asJava, publicKeys.map(scala2kmp).asJava, secretNonce.inner, publicNonces.map(n => new musig2.IndividualNonce(n.data.toArray)).asJava, scriptTree_opt.orNull).map(kmp2scala) + musig2.Musig2.signTaprootInput(privateKey, tx, inputIndex, inputs.map(scala2kmp).asJava, publicKeys.map(scala2kmp).asJava, secretNonce.inner, publicNonces.map(n => new musig2.IndividualNonce(n.data.toArray)).asJava, scriptTree_opt.map(scala2kmp).orNull).map(kmp2scala) } /** @@ -92,7 +91,7 @@ object Musig2 { * @return true if the partial signature is valid. */ def verifyTaprootSignature(partialSig: ByteVector32, nonce: IndividualNonce, publicKey: PublicKey, tx: Transaction, inputIndex: Int, inputs: Seq[TxOut], publicKeys: Seq[PublicKey], publicNonces: Seq[IndividualNonce], scriptTree_opt: Option[ScriptTree]): Boolean = { - musig2.Musig2.verify(partialSig, new musig2.IndividualNonce(nonce.data.toArray), publicKey, tx, inputIndex, inputs.map(scala2kmp).asJava, publicKeys.map(scala2kmp).asJava, publicNonces.map(n => new musig2.IndividualNonce(n.data.toArray)).asJava, scriptTree_opt.orNull) + musig2.Musig2.verify(partialSig, new musig2.IndividualNonce(nonce.data.toArray), publicKey, tx, inputIndex, inputs.map(scala2kmp).asJava, publicKeys.map(scala2kmp).asJava, publicNonces.map(n => new musig2.IndividualNonce(n.data.toArray)).asJava, scriptTree_opt.map(scala2kmp).orNull) } /** @@ -107,7 +106,7 @@ object Musig2 { * @param scriptTree_opt tapscript tree of the taproot input, if it has script paths. */ def aggregateTaprootSignatures(partialSigs: Seq[ByteVector32], tx: Transaction, inputIndex: Int, inputs: Seq[TxOut], publicKeys: Seq[PublicKey], publicNonces: Seq[IndividualNonce], scriptTree_opt: Option[ScriptTree]): Either[Throwable, ByteVector64] = { - musig2.Musig2.aggregateTaprootSignatures(partialSigs.map(scala2kmp).asJava, tx, inputIndex, inputs.map(scala2kmp).asJava, publicKeys.map(scala2kmp).asJava, publicNonces.map(n => new musig2.IndividualNonce(n.data.toArray)).asJava, scriptTree_opt.orNull).map(kmp2scala) + musig2.Musig2.aggregateTaprootSignatures(partialSigs.map(scala2kmp).asJava, tx, inputIndex, inputs.map(scala2kmp).asJava, publicKeys.map(scala2kmp).asJava, publicNonces.map(n => new musig2.IndividualNonce(n.data.toArray)).asJava, scriptTree_opt.map(scala2kmp).orNull).map(kmp2scala) } } diff --git a/src/main/scala/fr/acinq/bitcoin/scalacompat/Script.scala b/src/main/scala/fr/acinq/bitcoin/scalacompat/Script.scala index ab914ef0..808b5fd4 100644 --- a/src/main/scala/fr/acinq/bitcoin/scalacompat/Script.scala +++ b/src/main/scala/fr/acinq/bitcoin/scalacompat/Script.scala @@ -175,7 +175,7 @@ object Script { * @param internalKey internal public key that will be tweaked with the [scripts] provided. * @param scripts_opt optional spending scripts that can be used instead of key-path spending. */ - def pay2tr(internalKey: XonlyPublicKey, scripts_opt: Option[bitcoin.ScriptTree]): Seq[ScriptElt] = bitcoin.Script.pay2tr(internalKey.pub, scripts_opt.orNull).asScala.map(kmp2scala).toList + def pay2tr(internalKey: XonlyPublicKey, scripts_opt: Option[ScriptTree]): Seq[ScriptElt] = bitcoin.Script.pay2tr(internalKey.pub, scripts_opt.map(scala2kmp).orNull).asScala.map(kmp2scala).toList def isPay2tr(script: Seq[ScriptElt]): Boolean = bitcoin.Script.isPay2tr(script.map(scala2kmp).asJava) @@ -188,6 +188,6 @@ object Script { * @param witness witness for the spent [script]. * @param scriptTree tapscript tree. */ - def witnessScriptPathPay2tr(internalKey: XonlyPublicKey, script: bitcoin.ScriptTree.Leaf, witness: ScriptWitness, scriptTree: bitcoin.ScriptTree): ScriptWitness = bitcoin.Script.witnessScriptPathPay2tr(internalKey.pub, script, witness, scriptTree) + def witnessScriptPathPay2tr(internalKey: XonlyPublicKey, script: ScriptTree.Leaf, witness: ScriptWitness, scriptTree: ScriptTree): ScriptWitness = bitcoin.Script.witnessScriptPathPay2tr(internalKey.pub, scala2kmp(script), witness, scala2kmp(scriptTree)) } diff --git a/src/main/scala/fr/acinq/bitcoin/scalacompat/ScriptTree.scala b/src/main/scala/fr/acinq/bitcoin/scalacompat/ScriptTree.scala new file mode 100644 index 00000000..ebc6e7d8 --- /dev/null +++ b/src/main/scala/fr/acinq/bitcoin/scalacompat/ScriptTree.scala @@ -0,0 +1,56 @@ +package fr.acinq.bitcoin.scalacompat + +import scodec.bits.ByteVector + +/** Simple binary tree structure containing taproot spending scripts. */ +sealed trait ScriptTree { + + /** Compute the merkle root of the script tree. */ + def hash(): ByteVector32 = KotlinUtils.kmp2scala(KotlinUtils.scala2kmp(this).hash()) + + /** Return the first leaf with a matching script, if any. */ + def findScript(script: ByteVector): Option[ScriptTree.Leaf] = this match { + case leaf: ScriptTree.Leaf if leaf.script == script => Some(leaf) + case _: ScriptTree.Leaf => None + case branch: ScriptTree.Branch => branch.left.findScript(script).orElse(branch.right.findScript(script)) + } + + /** Return the first leaf with a matching leaf hash, if any. */ + def findScript(leafHash: ByteVector32): Option[ScriptTree.Leaf] = this match { + case leaf: ScriptTree.Leaf if leaf.hash() == leafHash => Some(leaf) + case _: ScriptTree.Leaf => None + case branch: ScriptTree.Branch => branch.left.findScript(leafHash).orElse(branch.right.findScript(leafHash)) + } + + /** + * Compute a merkle proof for the given script leaf. + * This merkle proof is encoded for creating control blocks in taproot script path witnesses. + * If the leaf doesn't belong to the script tree, this function will return None. + */ + def merkleProof(leafHash: ByteVector32): Option[ByteVector] = { + val proof_opt = KotlinUtils.scala2kmp(this).merkleProof(KotlinUtils.scala2kmp(leafHash)) + if (proof_opt == null) None else Some(ByteVector(proof_opt)) + } + +} + +object ScriptTree { + /** + * Multiple spending scripts can be placed in the leaves of a taproot tree. When using one of those scripts to spend + * funds, we only need to reveal that specific script and a merkle proof that it is a leaf of the tree. + * + * @param script serialized spending script. + * @param leafVersion tapscript version. + */ + case class Leaf(script: ByteVector, leafVersion: Int) extends ScriptTree + + object Leaf { + // @formatter:off + def apply(script: ByteVector): Leaf = Leaf(script, fr.acinq.bitcoin.Script.TAPROOT_LEAF_TAPSCRIPT) + def apply(script: Seq[ScriptElt]): Leaf = Leaf(script, fr.acinq.bitcoin.Script.TAPROOT_LEAF_TAPSCRIPT) + def apply(script: Seq[ScriptElt], leafVersion: Int): Leaf = Leaf(Script.write(script), leafVersion) + // @formatter:on + } + + case class Branch(left: ScriptTree, right: ScriptTree) extends ScriptTree +} diff --git a/src/main/scala/fr/acinq/bitcoin/scalacompat/Transaction.scala b/src/main/scala/fr/acinq/bitcoin/scalacompat/Transaction.scala index 5490ab8a..a105853e 100644 --- a/src/main/scala/fr/acinq/bitcoin/scalacompat/Transaction.scala +++ b/src/main/scala/fr/acinq/bitcoin/scalacompat/Transaction.scala @@ -312,7 +312,7 @@ object Transaction extends BtcSerializer[Transaction] { * @param scriptTree_opt tapscript tree of the signed input, if it has script paths. * @return the schnorr signature of this tx for this specific tx input. */ - def signInputTaprootKeyPath(privateKey: PrivateKey, tx: Transaction, inputIndex: Int, inputs: Seq[TxOut], sighashType: Int, scriptTree_opt: Option[bitcoin.ScriptTree], annex_opt: Option[ByteVector] = None, auxrand32: Option[ByteVector32] = None): ByteVector64 = { + def signInputTaprootKeyPath(privateKey: PrivateKey, tx: Transaction, inputIndex: Int, inputs: Seq[TxOut], sighashType: Int, scriptTree_opt: Option[ScriptTree], annex_opt: Option[ByteVector] = None, auxrand32: Option[ByteVector32] = None): ByteVector64 = { tx.signInputTaprootKeyPath(privateKey, inputIndex, inputs, sighashType, scriptTree_opt, annex_opt, auxrand32) } @@ -521,8 +521,8 @@ case class Transaction(version: Long, txIn: Seq[TxIn], txOut: Seq[TxOut], lockTi * @param scriptTree_opt tapscript tree of the signed input, if it has script paths. * @return the schnorr signature of this tx for this specific tx input. */ - def signInputTaprootKeyPath(privateKey: PrivateKey, inputIndex: Int, inputs: Seq[TxOut], sighashType: Int, scriptTree_opt: Option[bitcoin.ScriptTree], annex_opt: Option[ByteVector] = None, auxrand32: Option[ByteVector32] = None): ByteVector64 = { - scala2kmp(this).signInputTaprootKeyPath(privateKey, inputIndex, inputs.map(scala2kmp).asJava, sighashType, scriptTree_opt.orNull, annex_opt.map(scala2kmp).orNull, auxrand32.map(scala2kmp).orNull) + def signInputTaprootKeyPath(privateKey: PrivateKey, inputIndex: Int, inputs: Seq[TxOut], sighashType: Int, scriptTree_opt: Option[ScriptTree], annex_opt: Option[ByteVector] = None, auxrand32: Option[ByteVector32] = None): ByteVector64 = { + scala2kmp(this).signInputTaprootKeyPath(privateKey, inputIndex, inputs.map(scala2kmp).asJava, sighashType, scriptTree_opt.map(scala2kmp).orNull, annex_opt.map(scala2kmp).orNull, auxrand32.map(scala2kmp).orNull) } /** diff --git a/src/test/scala/fr/acinq/bitcoin/scalacompat/Musig2Spec.scala b/src/test/scala/fr/acinq/bitcoin/scalacompat/Musig2Spec.scala index ce7d4747..b60baab2 100644 --- a/src/test/scala/fr/acinq/bitcoin/scalacompat/Musig2Spec.scala +++ b/src/test/scala/fr/acinq/bitcoin/scalacompat/Musig2Spec.scala @@ -1,11 +1,10 @@ package fr.acinq.bitcoin.scalacompat import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey -import fr.acinq.bitcoin.{ScriptFlags, ScriptTree, SigHash} +import fr.acinq.bitcoin.{ScriptFlags, SigHash} import org.scalatest.FunSuite import scodec.bits.{ByteVector, HexStringSyntax} -import scala.jdk.CollectionConverters.SeqHasAsJava import scala.util.Random class Musig2Spec extends FunSuite { @@ -55,7 +54,7 @@ class Musig2Spec extends FunSuite { // The redeem script is just the refund script. it is generated from this policy: and_v(v:pk(user),older(refundDelay)). // It does not depend upon the user's or server's key, just the user's refund key and the refund delay. val redeemScript = Seq(OP_PUSHDATA(userRefundPrivateKey.xOnlyPublicKey()), OP_CHECKSIGVERIFY, OP_PUSHDATA(Script.encodeNumber(refundDelay)), OP_CHECKSEQUENCEVERIFY) - val scriptTree = new ScriptTree.Leaf(redeemScript.map(KotlinUtils.scala2kmp).asJava) + val scriptTree = ScriptTree.Leaf(redeemScript) // The internal pubkey is the musig2 aggregation of the user's and server's public keys: it does not depend upon the user's refund's key. val aggregatedKey = Musig2.aggregateKeys(Seq(userPublicKey, serverPublicKey)) @@ -103,7 +102,7 @@ class Musig2Spec extends FunSuite { txOut = Seq(TxOut(10_000 sat, Script.pay2wpkh(userPublicKey))), lockTime = 0 ) - val sig = Transaction.signInputTaprootScriptPath(userRefundPrivateKey, tx, 0, swapInTx.txOut, SigHash.SIGHASH_DEFAULT, KotlinUtils.kmp2scala(scriptTree.hash())) + val sig = Transaction.signInputTaprootScriptPath(userRefundPrivateKey, tx, 0, swapInTx.txOut, SigHash.SIGHASH_DEFAULT, scriptTree.hash()) val witness = Script.witnessScriptPathPay2tr(aggregatedKey, scriptTree, ScriptWitness(Seq(sig)), scriptTree) val signedTx = tx.updateWitness(0, witness) Transaction.correctlySpends(signedTx, Seq(swapInTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) From 75f9f1238f55e97cd3ddb1e338a5c94b98eae30a Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 25 Sep 2025 15:18:54 +0200 Subject: [PATCH 3/6] Add wrapper classes for schnorr tweaks --- .../fr/acinq/bitcoin/scalacompat/Crypto.scala | 35 +++++++++++++++---- .../bitcoin/scalacompat/KotlinUtils.scala | 24 ++++++++++++- .../bitcoin/scalacompat/TaprootSpec.scala | 26 ++++++-------- 3 files changed, 62 insertions(+), 23 deletions(-) diff --git a/src/main/scala/fr/acinq/bitcoin/scalacompat/Crypto.scala b/src/main/scala/fr/acinq/bitcoin/scalacompat/Crypto.scala index 3dbe9ac7..35db3acb 100644 --- a/src/main/scala/fr/acinq/bitcoin/scalacompat/Crypto.scala +++ b/src/main/scala/fr/acinq/bitcoin/scalacompat/Crypto.scala @@ -5,6 +5,27 @@ import fr.acinq.bitcoin.scalacompat.KotlinUtils._ import scodec.bits.ByteVector object Crypto { + + // @formatter:off + /** Specify how private keys are tweaked when creating Schnorr signatures. */ + sealed trait SchnorrTweak + object SchnorrTweak { + /** The private key is directly used, without any tweaks. */ + case object NoTweak extends SchnorrTweak + } + + sealed trait TaprootTweak extends SchnorrTweak + object TaprootTweak { + /** The private key is tweaked with H_TapTweak(public key) (this is used for key path spending when there is no script tree). */ + case object NoScriptTweak extends TaprootTweak + /** The private key is tweaked with H_TapTweak(public key || merkle_root) (this is used for key path spending when a script tree exists). */ + case class ScriptTweak(merkleRoot: ByteVector32) extends TaprootTweak + object ScriptTweak { + def apply(scriptTree: ScriptTree): ScriptTweak = ScriptTweak(scriptTree.hash()) + } + } + // @formatter:on + /** * A bitcoin private key. * A private key is valid if it is not 0 and less than the secp256k1 curve order when interpreted as an integer (most significant byte first). @@ -124,7 +145,7 @@ object Crypto { case class XonlyPublicKey(pub: bitcoin.XonlyPublicKey) { val publicKey: PublicKey = PublicKey(pub.getPublicKey) - def tweak(tapTweak: bitcoin.Crypto.TaprootTweak): ByteVector32 = pub.tweak(tapTweak) + def tweak(tapTweak: TaprootTweak): ByteVector32 = pub.tweak(scala2kmp(tapTweak)) /** * "tweaks" this key with an optional merkle root @@ -132,16 +153,16 @@ object Crypto { * @param tapTweak taproot tweak * @return an (x-only pubkey, parity) pair */ - def outputKey(tapTweak: bitcoin.Crypto.TaprootTweak): (XonlyPublicKey, Boolean) = { - val p = pub.outputKey(tapTweak) + def outputKey(tapTweak: TaprootTweak): (XonlyPublicKey, Boolean) = { + val p = pub.outputKey(scala2kmp(tapTweak)) (XonlyPublicKey(p.getFirst), p.getSecond) } /** Tweak this key with the merkle root of the given script tree. */ - def outputKey(scriptTree: ScriptTree): (XonlyPublicKey, Boolean) = outputKey(new bitcoin.Crypto.TaprootTweak.ScriptTweak(scriptTree)) + def outputKey(scriptTree: ScriptTree): (XonlyPublicKey, Boolean) = outputKey(TaprootTweak.ScriptTweak(scriptTree)) /** Tweak this key with the merkle root provided. */ - def outputKey(merkleRoot: ByteVector32): (XonlyPublicKey, Boolean) = outputKey(new bitcoin.Crypto.TaprootTweak.ScriptTweak(merkleRoot)) + def outputKey(merkleRoot: ByteVector32): (XonlyPublicKey, Boolean) = outputKey(TaprootTweak.ScriptTweak(merkleRoot)) /** * add a public key to this x-only key @@ -274,8 +295,8 @@ object Crypto { * the key (there is an extra "1" appended to the key) * @return a signature in compact format (64 bytes) */ - def signSchnorr(data: ByteVector32, privateKey: PrivateKey, schnorrTweak: bitcoin.Crypto.SchnorrTweak = bitcoin.Crypto.SchnorrTweak.NoTweak.INSTANCE, auxrand32: Option[ByteVector32] = None): ByteVector64 = { - bitcoin.Crypto.signSchnorr(data, privateKey, schnorrTweak, auxrand32.map(scala2kmp).orNull) + def signSchnorr(data: ByteVector32, privateKey: PrivateKey, schnorrTweak: SchnorrTweak = SchnorrTweak.NoTweak, auxrand32: Option[ByteVector32] = None): ByteVector64 = { + bitcoin.Crypto.signSchnorr(data, privateKey, scala2kmp(schnorrTweak), auxrand32.map(scala2kmp).orNull) } /** diff --git a/src/main/scala/fr/acinq/bitcoin/scalacompat/KotlinUtils.scala b/src/main/scala/fr/acinq/bitcoin/scalacompat/KotlinUtils.scala index 35e40e34..880ccc67 100644 --- a/src/main/scala/fr/acinq/bitcoin/scalacompat/KotlinUtils.scala +++ b/src/main/scala/fr/acinq/bitcoin/scalacompat/KotlinUtils.scala @@ -1,7 +1,7 @@ package fr.acinq.bitcoin.scalacompat import fr.acinq.bitcoin -import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey, XonlyPublicKey} +import fr.acinq.bitcoin.scalacompat.Crypto._ import scodec.bits.ByteVector import java.io.{InputStream, OutputStream} @@ -64,6 +64,28 @@ object KotlinUtils { case branch: ScriptTree.Branch => scala2kmp(branch) } + implicit def kmp2scala(input: bitcoin.Crypto.TaprootTweak): TaprootTweak = input match { + case bitcoin.Crypto.TaprootTweak.NoScriptTweak.INSTANCE => TaprootTweak.NoScriptTweak + case tweak: bitcoin.Crypto.TaprootTweak.ScriptTweak => TaprootTweak.ScriptTweak(kmp2scala(tweak.getMerkleRoot)) + case _ => ??? // this cannot happen, but the compiler cannot know that there aren't other cases + } + + implicit def scala2kmp(input: TaprootTweak): bitcoin.Crypto.TaprootTweak = input match { + case TaprootTweak.NoScriptTweak => bitcoin.Crypto.TaprootTweak.NoScriptTweak.INSTANCE + case tweak: TaprootTweak.ScriptTweak => new bitcoin.Crypto.TaprootTweak.ScriptTweak(scala2kmp(tweak.merkleRoot)) + } + + implicit def kmp2scala(input: bitcoin.Crypto.SchnorrTweak): SchnorrTweak = input match { + case bitcoin.Crypto.SchnorrTweak.NoTweak.INSTANCE => SchnorrTweak.NoTweak + case tweak: bitcoin.Crypto.TaprootTweak => kmp2scala(tweak) + case _ => ??? // this cannot happen, but the compiler cannot know that there aren't other cases + } + + implicit def scala2kmp(input: SchnorrTweak): bitcoin.Crypto.SchnorrTweak = input match { + case SchnorrTweak.NoTweak => bitcoin.Crypto.SchnorrTweak.NoTweak.INSTANCE + case tweak: TaprootTweak => scala2kmp(tweak) + } + implicit def kmp2scala(input: bitcoin.TxIn): TxIn = TxIn(input.outPoint, input.signatureScript, input.sequence, input.witness) implicit def scala2kmp(input: Satoshi): bitcoin.Satoshi = new bitcoin.Satoshi(input.toLong) diff --git a/src/test/scala/fr/acinq/bitcoin/scalacompat/TaprootSpec.scala b/src/test/scala/fr/acinq/bitcoin/scalacompat/TaprootSpec.scala index ae70957d..e5b5552b 100644 --- a/src/test/scala/fr/acinq/bitcoin/scalacompat/TaprootSpec.scala +++ b/src/test/scala/fr/acinq/bitcoin/scalacompat/TaprootSpec.scala @@ -1,16 +1,12 @@ package fr.acinq.bitcoin.scalacompat -import fr.acinq.bitcoin.Crypto.TaprootTweak -import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey, TaprootTweak} import fr.acinq.bitcoin.scalacompat.KotlinUtils._ -import fr.acinq.bitcoin.scalacompat.Transaction.hashForSigningSchnorr -import fr.acinq.bitcoin.{Bech32, ScriptFlags, ScriptTree, SigHash, SigVersion} +import fr.acinq.bitcoin.{Bech32, ScriptFlags, SigHash, SigVersion} import fr.acinq.secp256k1.Secp256k1 import org.scalatest.FunSuite import scodec.bits.ByteVector -import scala.jdk.CollectionConverters.SeqHasAsJava - class TaprootSpec extends FunSuite { test("check taproot signatures") { @@ -19,7 +15,7 @@ class TaprootSpec extends FunSuite { val key = DeterministicWallet.derivePrivateKey(master, "86'/1'/0'/0/1") val internalKey = key.publicKey.xOnly val script = Script.pay2tr(internalKey, scripts_opt = None) - val (outputKey, _) = internalKey.outputKey(TaprootTweak.NoScriptTweak.INSTANCE) + val (outputKey, _) = internalKey.outputKey(TaprootTweak.NoScriptTweak) assert("tb1phlhs7afhqzkgv0n537xs939s687826vn8l24ldkrckvwsnlj3d7qj6u57c" == Bech32.encodeWitnessAddress("tb", 1, outputKey.pub.value.toByteArray)) assert(script == Script.pay2tr(outputKey)) @@ -45,23 +41,23 @@ class TaprootSpec extends FunSuite { assert(Crypto.verifySignatureSchnorr(hash, sig, outputKey)) // re-create signature - val ourSig = Crypto.signSchnorr(hash, key.privateKey, TaprootTweak.NoScriptTweak.INSTANCE) + val ourSig = Crypto.signSchnorr(hash, key.privateKey, TaprootTweak.NoScriptTweak) assert(Crypto.verifySignatureSchnorr(hash, ourSig, outputKey)) assert(Secp256k1.get().verifySchnorr(ourSig.toArray, hash.toArray, outputKey.pub.value.toByteArray)) // setting auxiliary random data to all-zero yields the same result as not setting any auxiliary random data - val ourSig1 = Crypto.signSchnorr(hash, key.privateKey, TaprootTweak.NoScriptTweak.INSTANCE, Some(ByteVector32.Zeroes)) + val ourSig1 = Crypto.signSchnorr(hash, key.privateKey, TaprootTweak.NoScriptTweak, Some(ByteVector32.Zeroes)) assert(ourSig == ourSig1) // setting auxiliary random data to a non-zero value yields a different result - val ourSig2 = Crypto.signSchnorr(hash, key.privateKey, TaprootTweak.NoScriptTweak.INSTANCE, Some(ByteVector32.One)) + val ourSig2 = Crypto.signSchnorr(hash, key.privateKey, TaprootTweak.NoScriptTweak, Some(ByteVector32.One)) assert(ourSig != ourSig2) } test("send to and spend from taproot addresses") { val privateKey = PrivateKey(ByteVector32.fromValidHex("0101010101010101010101010101010101010101010101010101010101010101")) val internalKey = privateKey.publicKey.xOnly - val (outputKey, _) = internalKey.outputKey(TaprootTweak.NoScriptTweak.INSTANCE) + val (outputKey, _) = internalKey.outputKey(TaprootTweak.NoScriptTweak) val address = Bech32.encodeWitnessAddress("tb", 1, outputKey.pub.value.toByteArray) assert("tb1p33wm0auhr9kkahzd6l0kqj85af4cswn276hsxg6zpz85xe2r0y8snwrkwy" == address) @@ -156,7 +152,7 @@ class TaprootSpec extends FunSuite { ) // simple script tree with a single element - val scriptTree = new ScriptTree.Leaf(script.map(scala2kmp).asJava) + val scriptTree = ScriptTree.Leaf(script) // we choose a pubkey that does not have a corresponding private key: our funding tx can only be spent through the script path, not the key path val internalPubkey = PublicKey.fromBin(ByteVector.fromValidHex("0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0")).xOnly @@ -201,13 +197,13 @@ class TaprootSpec extends FunSuite { PrivateKey(ByteVector32.fromValidHex("0101010101010101010101010101010101010101010101010101010101010103")) ) val scripts: Seq[Seq[ScriptElt]] = privs.map { p => Seq(OP_PUSHDATA(p.xOnlyPublicKey()), OP_CHECKSIG) } - val leaves = scripts.map { script => new ScriptTree.Leaf(script.map(scala2kmp).asJava) } + val leaves = scripts.map(ScriptTree.Leaf(_)) // root // / \ // / \ #3 // #1 #2 - val scriptTree = new ScriptTree.Branch( - new ScriptTree.Branch(leaves(0), leaves(1)), + val scriptTree = ScriptTree.Branch( + ScriptTree.Branch(leaves(0), leaves(1)), leaves(2) ) val blockchain = Block.SignetGenesisBlock.hash From 044f62ca9c115d91909dfb65925f8fdce7479716 Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 25 Sep 2025 15:19:08 +0200 Subject: [PATCH 4/6] Set minor version --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index c564eb6d..b742cc41 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ fr.acinq bitcoin-lib_2.13 jar - 0.43 + 0.43.1 Simple Scala Bitcoin library https://github.com/ACINQ/bitcoin-lib bitcoin-lib From 77e62f46e34801efecb5ccd9e76374e3205a4349 Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 29 Sep 2025 11:50:15 +0200 Subject: [PATCH 5/6] nit: rename `LocalNonce` fields --- .../fr/acinq/bitcoin/scalacompat/Musig2.scala | 2 +- .../bitcoin/scalacompat/Musig2Spec.scala | 22 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/main/scala/fr/acinq/bitcoin/scalacompat/Musig2.scala b/src/main/scala/fr/acinq/bitcoin/scalacompat/Musig2.scala index 280838c1..2c601435 100644 --- a/src/main/scala/fr/acinq/bitcoin/scalacompat/Musig2.scala +++ b/src/main/scala/fr/acinq/bitcoin/scalacompat/Musig2.scala @@ -25,7 +25,7 @@ object Musig2 { } /** A locally-generated nonce, for which both the secret and public parts are known. */ - case class LocalNonce(secret: SecretNonce, public: IndividualNonce) + case class LocalNonce(secretNonce: SecretNonce, publicNonce: IndividualNonce) /** * Aggregate the public keys of a musig2 session into a single public key. diff --git a/src/test/scala/fr/acinq/bitcoin/scalacompat/Musig2Spec.scala b/src/test/scala/fr/acinq/bitcoin/scalacompat/Musig2Spec.scala index b60baab2..55f24e1f 100644 --- a/src/test/scala/fr/acinq/bitcoin/scalacompat/Musig2Spec.scala +++ b/src/test/scala/fr/acinq/bitcoin/scalacompat/Musig2Spec.scala @@ -29,11 +29,11 @@ class Musig2Spec extends FunSuite { val bobNonce = Musig2.generateNonce(ByteVector32(ByteVector(Random.nextBytes(32))), Right(bobPrivKey.publicKey), Seq(alicePubKey, bobPubKey), None, None) // Once they have each other's public nonce, they can produce partial signatures. - val publicNonces = Seq(aliceNonce, bobNonce).map(_.public) - val Right(aliceSig) = Musig2.signTaprootInput(alicePrivKey, spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), aliceNonce.secret, publicNonces, scriptTree_opt = None) - assert(Musig2.verifyTaprootSignature(aliceSig, aliceNonce.public, alicePubKey, spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), publicNonces, scriptTree_opt = None)) - val Right(bobSig) = Musig2.signTaprootInput(bobPrivKey, spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), bobNonce.secret, publicNonces, scriptTree_opt = None) - assert(Musig2.verifyTaprootSignature(bobSig, bobNonce.public, bobPubKey, spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), publicNonces, scriptTree_opt = None)) + val publicNonces = Seq(aliceNonce, bobNonce).map(_.publicNonce) + val Right(aliceSig) = Musig2.signTaprootInput(alicePrivKey, spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), aliceNonce.secretNonce, publicNonces, scriptTree_opt = None) + assert(Musig2.verifyTaprootSignature(aliceSig, aliceNonce.publicNonce, alicePubKey, spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), publicNonces, scriptTree_opt = None)) + val Right(bobSig) = Musig2.signTaprootInput(bobPrivKey, spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), bobNonce.secretNonce, publicNonces, scriptTree_opt = None) + assert(Musig2.verifyTaprootSignature(bobSig, bobNonce.publicNonce, bobPubKey, spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), publicNonces, scriptTree_opt = None)) // Once they have each other's partial signature, they can aggregate them into a valid signature. val Right(aggregateSig) = Musig2.aggregateTaprootSignatures(Seq(aliceSig, bobSig), spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), publicNonces, scriptTree_opt = None) @@ -82,11 +82,11 @@ class Musig2Spec extends FunSuite { val serverNonce = Musig2.generateNonce(ByteVector32(ByteVector(Random.nextBytes(32))), Right(serverPrivateKey.publicKey), Seq(userPublicKey, serverPublicKey), None, None) // Once they have each other's public nonce, they can produce partial signatures. - val publicNonces = Seq(userNonce, serverNonce).map(_.public) - val Right(userSig) = Musig2.signTaprootInput(userPrivateKey, tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), userNonce.secret, publicNonces, Some(scriptTree)) - assert(Musig2.verifyTaprootSignature(userSig, userNonce.public, userPublicKey, tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), publicNonces, Some(scriptTree))) - val Right(serverSig) = Musig2.signTaprootInput(serverPrivateKey, tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), serverNonce.secret, publicNonces, Some(scriptTree)) - assert(Musig2.verifyTaprootSignature(serverSig, serverNonce.public, serverPublicKey, tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), publicNonces, Some(scriptTree))) + val publicNonces = Seq(userNonce, serverNonce).map(_.publicNonce) + val Right(userSig) = Musig2.signTaprootInput(userPrivateKey, tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), userNonce.secretNonce, publicNonces, Some(scriptTree)) + assert(Musig2.verifyTaprootSignature(userSig, userNonce.publicNonce, userPublicKey, tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), publicNonces, Some(scriptTree))) + val Right(serverSig) = Musig2.signTaprootInput(serverPrivateKey, tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), serverNonce.secretNonce, publicNonces, Some(scriptTree)) + assert(Musig2.verifyTaprootSignature(serverSig, serverNonce.publicNonce, serverPublicKey, tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), publicNonces, Some(scriptTree))) // Once they have each other's partial signature, they can aggregate them into a valid signature. val Right(sig) = Musig2.aggregateTaprootSignatures(Seq(userSig, serverSig), tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), publicNonces, Some(scriptTree)) @@ -112,7 +112,7 @@ class Musig2Spec extends FunSuite { test("generate nonce with counter") { val sk = PrivateKey(ByteVector.fromValidHex("EEC1CB7D1B7254C5CAB0D9C61AB02E643D464A59FE6C96A7EFE871F07C5AEF54")) val nonce = Musig2.generateNonceWithCounter(0, sk, Seq(sk.publicKey), None, None) - assert(nonce.public.data == hex"0271efb262c0535e921efacacd30146fa93f193689e4974d5348fa9d909d90000702a049680ef3f6acfb12320297df31d3a634214491cbeebacef5acdf13f8f61cc2") + assert(nonce.publicNonce.data == hex"0271efb262c0535e921efacacd30146fa93f193689e4974d5348fa9d909d90000702a049680ef3f6acfb12320297df31d3a634214491cbeebacef5acdf13f8f61cc2") } } From 1d87e43b6ee55726531775c986f2aa54f86e467c Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 29 Sep 2025 12:18:21 +0200 Subject: [PATCH 6/6] Expose `weight()` on `TxIn` and `TxOut` --- src/main/scala/fr/acinq/bitcoin/scalacompat/Transaction.scala | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/scala/fr/acinq/bitcoin/scalacompat/Transaction.scala b/src/main/scala/fr/acinq/bitcoin/scalacompat/Transaction.scala index a105853e..e01ad5ca 100644 --- a/src/main/scala/fr/acinq/bitcoin/scalacompat/Transaction.scala +++ b/src/main/scala/fr/acinq/bitcoin/scalacompat/Transaction.scala @@ -100,8 +100,8 @@ object TxIn extends BtcSerializer[TxIn] { */ case class TxIn(outPoint: OutPoint, signatureScript: ByteVector, sequence: Long, witness: ScriptWitness = ScriptWitness.empty) extends BtcSerializable[TxIn] { def isFinal: Boolean = sequence == bitcoin.TxIn.SEQUENCE_FINAL - def hasWitness: Boolean = witness.isNotNull + def weight(): Int = scala2kmp(this).weight() override def serializer: BtcSerializer[TxIn] = TxIn } @@ -128,6 +128,8 @@ object TxOut extends BtcSerializer[TxOut] { * @param publicKeyScript public key script which sets the conditions for spending this output */ case class TxOut(amount: Satoshi, publicKeyScript: ByteVector) extends BtcSerializable[TxOut] { + def weight(): Int = scala2kmp(this).weight() + override def serializer: BtcSerializer[TxOut] = TxOut }