Skip to content

Commit

Permalink
Add an example of swapin transaction that uses musig2 and taproot
Browse files Browse the repository at this point in the history
Add a simple test that uses how to modify the swap-in-potentiam protocol to use musig2 and taproot:
- taproot key path is used for the mutual user key + server key use case, which sends to a single musig2 aggregated key
- tapscript path is used for the refund case (user key + delay)

Add another example with taproot but not musig2 that uses 2 differents scripts (mutual case and refund case)
  • Loading branch information
sstone committed Oct 24, 2023
1 parent 648b7bb commit 14eface
Show file tree
Hide file tree
Showing 2 changed files with 148 additions and 1 deletion.
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ kotlin {

val commonMain by sourceSets.getting {
dependencies {
api("fr.acinq.bitcoin:bitcoin-kmp:0.14.0") // when upgrading, keep secp256k1-kmp-jni-jvm in sync below
api("fr.acinq.bitcoin:bitcoin-kmp:0.15.0-MUSIG2-SNAPSHOT") // when upgrading, keep secp256k1-kmp-jni-jvm in sync below
api("org.kodein.log:canard:0.18.0")
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion")
api("org.jetbrains.kotlinx:kotlinx-serialization-core:$serializationVersion")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import fr.acinq.bitcoin.Script.pay2wpkh
import fr.acinq.bitcoin.Script.pay2wsh
import fr.acinq.bitcoin.Script.write
import fr.acinq.bitcoin.crypto.Pack
import fr.acinq.bitcoin.musig2.Musig2
import fr.acinq.bitcoin.musig2.PublicNonce
import fr.acinq.bitcoin.musig2.SecretNonce
import fr.acinq.bitcoin.musig2.SessionCtx
import fr.acinq.lightning.CltvExpiry
import fr.acinq.lightning.CltvExpiryDelta
import fr.acinq.lightning.Lightning.randomBytes
Expand Down Expand Up @@ -480,6 +484,149 @@ class TransactionsTestsCommon : LightningTestSuite() {
}
}

@Test
fun `spend 2-of-2 swap-in taproot without musig2 version`() {
val userPrivateKey = PrivateKey(ByteArray(32) { 1 })
val serverPrivateKey = PrivateKey(ByteArray(32) { 2 })

// mutual agreement script is generated from this policy: and_v(v:pk(A),pk(B))
val mutualScript = listOf(OP_PUSHDATA(userPrivateKey.xOnlyPublicKey()), OP_CHECKSIGVERIFY, OP_PUSHDATA(serverPrivateKey.xOnlyPublicKey()), OP_CHECKSIG)

// the refund script is generated from this policy: and_v(v:pk(user),older(refundDelay))
val refundDelay = 144
val refundScript = listOf(OP_PUSHDATA(userPrivateKey.xOnlyPublicKey()), OP_CHECKSIGVERIFY, OP_PUSHDATA(Script.encodeNumber(refundDelay)), OP_CHECKSEQUENCEVERIFY)

// we have a simple script tree with 2 leaves
val scriptTree = ScriptTree.Branch(
ScriptTree.Leaf(ScriptLeaf(0, write(mutualScript).byteVector(), Script.TAPROOT_LEAF_TAPSCRIPT)),
ScriptTree.Leaf(ScriptLeaf(1, write(refundScript).byteVector(), Script.TAPROOT_LEAF_TAPSCRIPT))
)
val merkleRoot = ScriptTree.hash(scriptTree)

// we choose a pubkey that does not have a corresponding private key: our swap-in tx can only be spent through the script path, not the key path
val internalPubkey = XonlyPublicKey(PublicKey.fromHex("0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"))
val (tweakedKey, parity) = internalPubkey.outputKey(Crypto.TaprootTweak.ScriptTweak(merkleRoot))

val swapInTx = Transaction(
version = 2,
txIn = listOf(),
txOut = listOf(TxOut(Satoshi(10000), listOf(OP_1, OP_PUSHDATA(tweakedKey)))),
lockTime = 0
)

// The transaction can be spent if the user and the server produce a signature.
run {
val tx = Transaction(
version = 2,
txIn = listOf(TxIn(OutPoint(swapInTx, 0), sequence = TxIn.SEQUENCE_FINAL)),
txOut = listOf(TxOut(Satoshi(10000), pay2wpkh(PrivateKey(randomBytes32()).publicKey()))),
lockTime = 0
)
// we want to spend the left leave of the tree, so we provide the hash of the right leave (to be able to recompute the merkle root of the tree)
val controlBlock = byteArrayOf((Script.TAPROOT_LEAF_TAPSCRIPT + (if (parity) 1 else 0)).toByte()) +
internalPubkey.value.toByteArray() +
ScriptTree.hash(scriptTree.right).toByteArray()

val txHash = Transaction.hashForSigningSchnorr(tx, 0, listOf(swapInTx.txOut[0]), SigHash.SIGHASH_DEFAULT, SigVersion.SIGVERSION_TAPSCRIPT, Script.ExecutionData(null, ScriptTree.hash(scriptTree.left)))
val userSig = Crypto.signSchnorr(txHash, userPrivateKey, Crypto.SchnorrTweak.NoTweak)
val serverSig = Crypto.signSchnorr(txHash, serverPrivateKey, Crypto.SchnorrTweak.NoTweak)

val signedTx = tx.updateWitness(0, ScriptWitness.empty.push(serverSig).push(userSig).push(mutualScript).push(controlBlock))
Transaction.correctlySpends(signedTx, swapInTx, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
}

// Or it can be spent with only the user's signature, after a delay.
run {
val tx = Transaction(
version = 2,
txIn = listOf(TxIn(OutPoint(swapInTx, 0), sequence = refundDelay.toLong())),
txOut = listOf(TxOut(Satoshi(10000), pay2wpkh(PrivateKey(randomBytes32()).publicKey()))),
lockTime = 0
)
val controlBlock = byteArrayOf((Script.TAPROOT_LEAF_TAPSCRIPT + (if (parity) 1 else 0)).toByte()) +
internalPubkey.value.toByteArray() +
ScriptTree.hash(scriptTree.left).toByteArray()
val txHash = Transaction.hashForSigningSchnorr(tx, 0, listOf(swapInTx.txOut[0]), SigHash.SIGHASH_DEFAULT, SigVersion.SIGVERSION_TAPSCRIPT, Script.ExecutionData(null, ScriptTree.hash(scriptTree.right)))
val userSig = Crypto.signSchnorr(txHash, userPrivateKey, Crypto.SchnorrTweak.NoTweak)
val signedTx = tx.updateWitness(0, ScriptWitness.empty.push(userSig).push(refundScript).push(controlBlock))
Transaction.correctlySpends(signedTx, swapInTx, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
}
}

@Test
fun `spend 2-of-2 swap-in taproot-musig2 version`() {
val userPrivateKey = PrivateKey(ByteArray(32) { 1 })
val serverPrivateKey = PrivateKey(ByteArray(32) { 2 })

// the redeem script is just the refund script. it is generated from this policy: and_v(v:pk(user),older(refundDelay))
val refundDelay = 144
val redeemScript = listOf(OP_PUSHDATA(userPrivateKey.publicKey().xOnly()), OP_CHECKSIGVERIFY, OP_PUSHDATA(Script.encodeNumber(refundDelay)), OP_CHECKSEQUENCEVERIFY)
val scriptTree = ScriptTree.Leaf(ScriptLeaf(0, write(redeemScript).byteVector(), Script.TAPROOT_LEAF_TAPSCRIPT))
val merkleRoot = ScriptTree.hash(scriptTree)

// User and Server exchange public keys and agree on a common aggregated key
val internalPubKey = Musig2.keyAgg(listOf(userPrivateKey.publicKey(), serverPrivateKey.publicKey())).Q.xOnly()
val (commonPubKey, parity) = internalPubKey.outputKey(Crypto.TaprootTweak.ScriptTweak(merkleRoot))
val swapInTx = Transaction(
version = 2,
txIn = listOf(),
txOut = listOf(TxOut(Satoshi(10000), listOf(OP_1, OP_PUSHDATA(commonPubKey)))),
lockTime = 0
)

// The transaction can be spent if the user and the server produce a signature.
run {
val tx = Transaction(
version = 2,
txIn = listOf(TxIn(OutPoint(swapInTx, 0), sequence = TxIn.SEQUENCE_FINAL)),
txOut = listOf(TxOut(Satoshi(10000), pay2wpkh(PrivateKey(randomBytes32()).publicKey()))),
lockTime = 0
)
// this is the beginning of an interactive musig2 signing session. if user and server are disconnected before they have exchanged partial
// signatures they will have to start again with fresh nonces
val userNonce = SecretNonce.generate(userPrivateKey, userPrivateKey.publicKey(), commonPubKey, null, null, randomBytes32())
val serverNonce = SecretNonce.generate(serverPrivateKey, serverPrivateKey.publicKey(), commonPubKey, null, null, randomBytes32())

val txHash = Transaction.hashForSigningSchnorr(tx, 0, listOf(swapInTx.txOut[0]), SigHash.SIGHASH_DEFAULT, SigVersion.SIGVERSION_TAPROOT)

val ctx = SessionCtx(
PublicNonce.aggregate(listOf(userNonce.publicNonce(), serverNonce.publicNonce())),
listOf(userPrivateKey.publicKey(), serverPrivateKey.publicKey()),
listOf(Pair(internalPubKey.tweak(Crypto.TaprootTweak.ScriptTweak(merkleRoot)), true)),
txHash
)
val userSig = ctx.sign(userNonce, userPrivateKey)
val severSig = ctx.sign(serverNonce, serverPrivateKey)
val commonSig = ctx.partialSigAgg(listOf(userSig, severSig))
// end of the musig2 signing session

val signedTx = tx.updateWitness(0, ScriptWitness(listOf(commonSig)))
Transaction.correctlySpends(signedTx, swapInTx, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
}

// Or it can be spent with only the user's signature, after a delay.
run {
val tx = Transaction(
version = 2,
txIn = listOf(TxIn(OutPoint(swapInTx, 0), sequence = refundDelay.toLong())),
txOut = listOf(TxOut(Satoshi(10000), pay2wpkh(PrivateKey(randomBytes32()).publicKey()))),
lockTime = 0
)
val txHash = Transaction.hashForSigningSchnorr(
tx,
0,
listOf(swapInTx.txOut[0]),
SigHash.SIGHASH_DEFAULT,
SigVersion.SIGVERSION_TAPSCRIPT,
Script.ExecutionData(annex = null, tapleafHash = merkleRoot)
)
val sig = Crypto.signSchnorr(txHash, userPrivateKey, Crypto.SchnorrTweak.NoTweak)
val controlBlock = byteArrayOf((Script.TAPROOT_LEAF_TAPSCRIPT + (if (parity) 1 else 0)).toByte()) + internalPubKey.value.toByteArray()
val signedTx = tx.updateWitness(0, ScriptWitness.empty.push(sig).push(redeemScript).push(controlBlock))
Transaction.correctlySpends(signedTx, swapInTx, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
}
}

@Test
fun `swap-in input weight`() {
val pubkey = randomKey().publicKey()
Expand Down

0 comments on commit 14eface

Please sign in to comment.