diff --git a/src/main/scala/fr/acinq/bitcoin/scalacompat/Musig2.scala b/src/main/scala/fr/acinq/bitcoin/scalacompat/Musig2.scala new file mode 100644 index 00000000..53a8f030 --- /dev/null +++ b/src/main/scala/fr/acinq/bitcoin/scalacompat/Musig2.scala @@ -0,0 +1,60 @@ +package fr.acinq.bitcoin.scalacompat + +import fr.acinq.bitcoin.ScriptTree +import fr.acinq.bitcoin.musig2.{IndividualNonce, SecretNonce} +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey, XonlyPublicKey} +import fr.acinq.bitcoin.scalacompat.KotlinUtils._ +import scodec.bits.ByteVector + +import scala.jdk.CollectionConverters.SeqHasAsJava +import scala.util.Random + +object Musig2 { + + /** Aggregate the public keys of a musig2 session into a single public key. */ + def aggregateKeys(pubkeys: Seq[PublicKey]): PublicKey = fr.acinq.bitcoin.musig2.Musig2.keyAgg(pubkeys.map(scala2kmp).asJava) + + /** + * @param aggregatePublicKey aggregate public key of all participants of the musig2 session. + */ + def generateNonce(privateKey: PrivateKey, aggregatePublicKey: XonlyPublicKey): SecretNonce = { + fr.acinq.bitcoin.musig2.SecretNonce.generate(privateKey, aggregatePublicKey.pub, ByteVector32(ByteVector(Random.nextBytes(32)))) + } + + /** + * @param pubkeys public keys of all participants of the musig2 session. + */ + def generateNonce(privateKey: PrivateKey, pubkeys: Seq[PublicKey]): SecretNonce = generateNonce(privateKey, aggregateKeys(pubkeys).xOnly) + + /** + * Create a partial musig2 signature for the given taproot input key path. + * + * @param privateKey private key of the signing participant. + * @param tx transaction spending the target taproot input. + * @param inputIndex index of the taproot input to spend. + * @param inputs all inputs of the spending transaction. + * @param pubkeys public keys of all participants of the musig2 session. + * @param secretNonce secret nonce of the signing participant. + * @param publicNonces public nonces of all participants of the musig2 session. + * @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], pubkeys: Seq[PublicKey], secretNonce: SecretNonce, publicNonces: Seq[IndividualNonce], scriptTree_opt: Option[ScriptTree]): Option[ByteVector32] = { + Option(fr.acinq.bitcoin.musig2.Musig2.signTaprootInput(privateKey, tx, inputIndex, inputs.map(scala2kmp).asJava, pubkeys.map(scala2kmp).asJava, secretNonce, publicNonces.asJava, scriptTree_opt.orNull)) + } + + /** + * Aggregate partial musig2 signatures into a valid schnorr signature for the given taproot input key path. + * + * @param partialSigs partial musig2 signatures of all participants of the musig2 session. + * @param tx transaction spending the target taproot input. + * @param inputIndex index of the taproot input to spend. + * @param inputs all inputs of the spending transaction. + * @param pubkeys public keys of all participants of the musig2 session. + * @param publicNonces public nonces of all participants of the musig2 session. + * @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], pubkeys: Seq[PublicKey], publicNonces: Seq[IndividualNonce], scriptTree_opt: Option[ScriptTree]): Option[ByteVector64] = { + Option(fr.acinq.bitcoin.musig2.Musig2.aggregateTaprootSignatures(partialSigs.map(scala2kmp).asJava, tx, inputIndex, inputs.map(scala2kmp).asJava, pubkeys.map(scala2kmp).asJava, publicNonces.asJava, scriptTree_opt.orNull)) + } + +} diff --git a/src/test/scala/fr/acinq/bitcoin/scalacompat/Musig2Spec.scala b/src/test/scala/fr/acinq/bitcoin/scalacompat/Musig2Spec.scala new file mode 100644 index 00000000..c4ed826e --- /dev/null +++ b/src/test/scala/fr/acinq/bitcoin/scalacompat/Musig2Spec.scala @@ -0,0 +1,108 @@ +package fr.acinq.bitcoin.scalacompat + +import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey +import fr.acinq.bitcoin.{ScriptFlags, ScriptTree, SigHash} +import org.scalatest.FunSuite +import scodec.bits.{ByteVector, HexStringSyntax} + +import scala.jdk.CollectionConverters.SeqHasAsJava + +class Musig2Spec extends FunSuite { + + test("use musig2 to replace multisig 2-of-2") { + val alicePrivKey = PrivateKey(hex"0101010101010101010101010101010101010101010101010101010101010101") + val alicePubKey = alicePrivKey.publicKey + val bobPrivKey = PrivateKey(hex"0202020202020202020202020202020202020202020202020202020202020202") + val bobPubKey = bobPrivKey.publicKey + + // Alice and Bob exchange public keys and agree on a common aggregated key. + val aggregatedKey = Musig2.aggregateKeys(Seq(alicePubKey, bobPubKey)).xOnly + // This tx sends to a taproot script that doesn't contain any script path. + val tx = Transaction(2, Nil, Seq(TxOut(10_000 sat, Script.pay2tr(aggregatedKey, scripts_opt = None))), 0) + + // this is how Alice and Bob would spend that tx + val spendingTx = Transaction(2, Seq(TxIn(OutPoint(tx, 0), ByteVector.empty, 0)), Seq(TxOut(10_000 sat, Script.pay2wpkh(alicePubKey))), 0) + val sig = { + // 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 aliceNonce = Musig2.generateNonce(alicePrivKey, aggregatedKey) + val bobNonce = Musig2.generateNonce(bobPrivKey, Seq(alicePubKey, bobPubKey)) + + // Once they have each other's public nonce, they can produce partial signatures. + val publicNonces = Seq(aliceNonce.publicNonce(), bobNonce.publicNonce()) + val aliceSig = Musig2.signTaprootInput(alicePrivKey, spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), aliceNonce, publicNonces, scriptTree_opt = None).get + val bobSig = Musig2.signTaprootInput(bobPrivKey, spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), bobNonce, publicNonces, scriptTree_opt = None).get + + // Once they have each other's partial signature, they can aggregate them into a valid signature. + Musig2.aggregateTaprootSignatures(Seq(aliceSig, bobSig), spendingTx, 0, tx.txOut, Seq(alicePubKey, bobPubKey), publicNonces, scriptTree_opt = None).get + } + + // This tx looks like any other tx that spends a p2tr output, with a single signature. + val signedSpendingTx = spendingTx.updateWitness(0, Script.witnessKeyPathPay2tr(sig)) + Transaction.correctlySpends(signedSpendingTx, Seq(tx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + } + + test("swap-in-potentiam example with musig2 and taproot") { + val userPrivateKey = PrivateKey(hex"0101010101010101010101010101010101010101010101010101010101010101") + val userPublicKey = userPrivateKey.publicKey + val serverPrivateKey = PrivateKey(hex"0202020202020202020202020202020202020202020202020202020202020202") + val serverPublicKey = serverPrivateKey.publicKey + val userRefundPrivateKey = PrivateKey(hex"0303030303030303030303030303030303030303030303030303030303030303") + val refundDelay = 25920 + + // 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(0, redeemScript.map(KotlinUtils.scala2kmp).asJava) + + // 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)).xOnly + val pubkeyScript = Script.pay2tr(aggregatedKey, Some(scriptTree)) + + val swapInTx = Transaction( + version = 2, + txIn = Nil, + txOut = Seq(TxOut(10_000 sat, pubkeyScript)), + lockTime = 0 + ) + + // The transaction can be spent if the user and the server produce a signature. + { + val tx = Transaction( + version = 2, + txIn = Seq(TxIn(OutPoint(swapInTx, 0), ByteVector.empty, 0xFFFFFFFD)), + txOut = Seq(TxOut(10_000 sat, Script.pay2wpkh(userPublicKey))), + lockTime = 0 + ) + // 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 userNonce = Musig2.generateNonce(userPrivateKey, Seq(userPublicKey, serverPublicKey)) + val serverNonce = Musig2.generateNonce(serverPrivateKey, Seq(userPublicKey, serverPublicKey)) + + // Once they have each other's public nonce, they can produce partial signatures. + val publicNonces = Seq(userNonce.publicNonce(), serverNonce.publicNonce()) + val userSig = Musig2.signTaprootInput(userPrivateKey, tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), userNonce, publicNonces, Some(scriptTree)).get + val serverSig = Musig2.signTaprootInput(serverPrivateKey, tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), serverNonce, publicNonces, Some(scriptTree)).get + + // Once they have each other's partial signature, they can aggregate them into a valid signature. + val sig = Musig2.aggregateTaprootSignatures(Seq(userSig, serverSig), tx, 0, swapInTx.txOut, Seq(userPublicKey, serverPublicKey), publicNonces, Some(scriptTree)).get + val signedTx = tx.updateWitness(0, Script.witnessKeyPathPay2tr(sig)) + Transaction.correctlySpends(signedTx, Seq(swapInTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + } + + // Or it can be spent with only the user's signature, after a delay. + { + val tx = Transaction( + version = 2, + txIn = Seq(TxIn(OutPoint(swapInTx, 0), ByteVector.empty, refundDelay)), + txOut = Seq(TxOut(10_000 sat, Script.pay2wpkh(userPublicKey))), + lockTime = 0 + ) + val sig = Crypto.signTaprootScriptPath(userRefundPrivateKey, tx, 0, swapInTx.txOut, SigHash.SIGHASH_DEFAULT, KotlinUtils.kmp2scala(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) + } + } + +}