Skip to content

Commit

Permalink
WIP Add a swapin manager that uses musig2/taproot
Browse files Browse the repository at this point in the history
  • Loading branch information
sstone committed Oct 29, 2023
1 parent 73ba730 commit 336ee57
Show file tree
Hide file tree
Showing 3 changed files with 139 additions and 1 deletion.
115 changes: 115 additions & 0 deletions src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package fr.acinq.lightning.crypto
import fr.acinq.bitcoin.*
import fr.acinq.bitcoin.DeterministicWallet.hardened
import fr.acinq.bitcoin.io.ByteArrayInput
import fr.acinq.bitcoin.musig2.Musig2
import fr.acinq.lightning.DefaultSwapInParams
import fr.acinq.lightning.NodeParams
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
Expand Down Expand Up @@ -33,6 +34,8 @@ interface KeyManager {

val swapInOnChainWallet: SwapInOnChainKeys

val swapInOnChainWalletV2: SwapInOnChainKeysV2

/**
* Keys used for the node. They are used to generate the node id, to secure communication with other peers, and
* to sign network-wide public announcements.
Expand Down Expand Up @@ -205,4 +208,116 @@ interface KeyManager {
}
}

/**
* We use a specific kind of swap-in where users send funds to a 2-of-2 multisig with a timelock refund.
* Once confirmed, the swap-in utxos can be spent by one of two paths:
* - with a signature from both [userPublicKey] and [remoteServerPublicKey]
* - with a signature from [userPublicKey] after the [refundDelay]
* The keys used are static across swaps to make recovery easier.
*/
data class SwapInOnChainKeysV2(
private val chain: NodeParams.Chain,
private val master: DeterministicWallet.ExtendedPrivateKey,
val remoteServerPublicKey: PublicKey,
val refundDelay: Int = DefaultSwapInParams.RefundDelay
) {
private val userExtendedPrivateKey: DeterministicWallet.ExtendedPrivateKey = DeterministicWallet.derivePrivateKey(master, swapInUserKeyPath(chain))
val userPrivateKey: PrivateKey = userExtendedPrivateKey.privateKey
val userPublicKey: PublicKey = userPrivateKey.publicKey()

private val localServerExtendedPrivateKey: DeterministicWallet.ExtendedPrivateKey = DeterministicWallet.derivePrivateKey(master, swapInLocalServerKeyPath(chain))
fun localServerPrivateKey(remoteNodeId: PublicKey): PrivateKey = DeterministicWallet.derivePrivateKey(localServerExtendedPrivateKey, perUserPath(remoteNodeId)).privateKey

// the redeem script is just the refund script. it is generated from this policy: and_v(v:pk(user),older(refundDelay))
val redeemScript = listOf(OP_PUSHDATA(userPublicKey.xOnly()), OP_CHECKSIGVERIFY, OP_PUSHDATA(Script.encodeNumber(refundDelay)), OP_CHECKSEQUENCEVERIFY)
val scriptTree = ScriptTree.Leaf(ScriptLeaf(0, Script.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(userPublicKey, remoteServerPublicKey)).Q.xOnly()
val commonPubKeyAndParity = internalPubKey.outputKey(Crypto.TaprootTweak.ScriptTweak(merkleRoot))

val pubkeyScript: List<ScriptElt> = Script.pay2tr(commonPubKeyAndParity.first)
val address: String = Bitcoin.addressFromPublicKeyScript(chain.chainHash, pubkeyScript).result!!

/**
* The output script descriptor matching our swap-in addresses.
* That descriptor can be imported in bitcoind to recover funds after the refund delay.
*/
val descriptor = run {
// Since child public keys cannot be derived from a master xpub when hardened derivation is used,
// we need to provide the fingerprint of the master xpub and the hardened derivation path.
// This lets wallets that have access to the master xpriv derive the corresponding private and public keys.
val masterFingerprint = ByteVector(Crypto.hash160(DeterministicWallet.publicKey(master).publickeybytes).take(4).toByteArray())
val encodedChildKey = DeterministicWallet.encode(DeterministicWallet.publicKey(userExtendedPrivateKey), testnet = chain != NodeParams.Chain.Mainnet)
val userKey = "[${masterFingerprint.toHex()}/${encodedSwapInUserKeyPath(chain)}]$encodedChildKey"
"wsh(and_v(v:pk($userKey),or_d(pk(${remoteServerPublicKey.toHex()}),older($refundDelay))))"
}

/**
* Create a recovery transaction that spends a swap-in transaction after the refund delay has passed
* @param swapInTx swap-in transaction
* @param address address to send funds to
* @param feeRate fee rate for the refund transaction
* @return a signed transaction that spends our swap-in transaction. It cannot be published until `swapInTx` has enough confirmations
*/
fun createRecoveryTransaction(swapInTx: Transaction, address: String, feeRate: FeeratePerKw): Transaction? {
val utxos = swapInTx.txOut.filter { it.publicKeyScript.contentEquals(Script.write(pubkeyScript)) }
return if (utxos.isEmpty()) {
null
} else {
val pubKeyScript = Bitcoin.addressToPublicKeyScript(chain.chainHash, address).result
pubKeyScript?.let { script ->
val ourOutput = TxOut(utxos.map { it.amount }.sum(), script)
val unsignedTx = Transaction(
version = 2,
txIn = utxos.map { TxIn(OutPoint(swapInTx, swapInTx.txOut.indexOf(it).toLong()), sequence = refundDelay.toLong()) },
txOut = listOf(ourOutput),
lockTime = 0
)
val controlBlock = byteArrayOf((Script.TAPROOT_LEAF_TAPSCRIPT + (if (commonPubKeyAndParity.second) 1 else 0)).toByte()) + internalPubKey.value.toByteArray()
val execData = Script.ExecutionData(annex = null, tapleafHash = merkleRoot)

fun signTx(unsignedTx: Transaction): Transaction = utxos.foldIndexed(unsignedTx) { index, tx, _ ->
val txHash = Transaction.hashForSigningSchnorr(tx, index, utxos, SigHash.SIGHASH_DEFAULT, SigVersion.SIGVERSION_TAPSCRIPT, execData)
val sig = Crypto.signSchnorr(txHash, userPrivateKey, Crypto.SchnorrTweak.NoTweak)
tx.updateWitness(index, ScriptWitness.empty.push(sig).push(redeemScript).push(controlBlock))
}

val fees = run {
val recoveryTx = signTx(unsignedTx)
Transactions.weight2fee(feeRate, recoveryTx.weight())
}
val unsignedTx1 = unsignedTx.copy(txOut = listOf(ourOutput.copy(amount = ourOutput.amount - fees)))
val recoveryTx = signTx(unsignedTx1)
// this tx is signed but cannot be published until swapInTx has `refundDelay` confirmations
recoveryTx
}
}
}

companion object {
private fun swapInKeyBasePath(chain: NodeParams.Chain) = when (chain) {
NodeParams.Chain.Regtest, NodeParams.Chain.Testnet -> KeyPath.empty / hardened(51) / hardened(0)
NodeParams.Chain.Mainnet -> KeyPath.empty / hardened(52) / hardened(0)
}

fun swapInUserKeyPath(chain: NodeParams.Chain) = swapInKeyBasePath(chain) / hardened(0)

fun swapInLocalServerKeyPath(chain: NodeParams.Chain) = swapInKeyBasePath(chain) / hardened(1)

fun encodedSwapInUserKeyPath(chain: NodeParams.Chain) = when (chain) {
NodeParams.Chain.Regtest, NodeParams.Chain.Testnet -> "51h/0h/0h"
NodeParams.Chain.Mainnet -> "52h/0h/0h"
}

/** Swap-in servers use a different swap-in key for different users. */
fun perUserPath(remoteNodeId: PublicKey): KeyPath {
// We hash the remote node_id and break it into 2-byte values to get non-hardened path indices.
val h = ByteArrayInput(Crypto.sha256(remoteNodeId.value))
return KeyPath((0 until 16).map { _ -> LightningCodecs.u16(h).toLong() })
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,16 @@ data class LocalKeyManager(val seed: ByteVector, val chain: Chain, val remoteSwa
val remoteSwapInPublicKey = DeterministicWallet.derivePublicKey(xpub, KeyManager.SwapInOnChainKeys.perUserPath(nodeKeys.nodeKey.publicKey)).publicKey
KeyManager.SwapInOnChainKeys(chain, master, remoteSwapInPublicKey)
}

override val swapInOnChainWalletV2: KeyManager.SwapInOnChainKeysV2 = run {
val (prefix, xpub) = DeterministicWallet.ExtendedPublicKey.decode(remoteSwapInExtendedPublicKey)
val expectedPrefix = when (chain) {
Chain.Mainnet -> DeterministicWallet.xpub
else -> DeterministicWallet.tpub
}
require(prefix == expectedPrefix) { "unexpected swap-in xpub prefix $prefix (expected $expectedPrefix)" }
val remoteSwapInPublicKey = DeterministicWallet.derivePublicKey(xpub, KeyManager.SwapInOnChainKeys.perUserPath(nodeKeys.nodeKey.publicKey)).publicKey
KeyManager.SwapInOnChainKeysV2(chain, master, remoteSwapInPublicKey)
}
private val channelKeyBasePath: KeyPath = channelKeyBasePath(chain)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,20 @@ class LocalKeyManagerTestsCommon : LightningTestSuite() {
Transaction.correctlySpends(recoveryTx, swapInTx, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
}

@Test
fun `spend swap-in v2 transactions`() {
val swapInTx = Transaction(version = 2,
txIn = listOf(),
txOut = listOf(
TxOut(Satoshi(100000), Bitcoin.addressToPublicKeyScript(Block.RegtestGenesisBlock.hash, TestConstants.Alice.keyManager.swapInOnChainWalletV2.address).result!!),
TxOut(Satoshi(150000), Bitcoin.addressToPublicKeyScript(Block.RegtestGenesisBlock.hash, TestConstants.Alice.keyManager.swapInOnChainWalletV2.address).result!!)
),
lockTime = 0)
val recoveryTx = TestConstants.Alice.keyManager.swapInOnChainWalletV2.createRecoveryTransaction(swapInTx, TestConstants.Alice.keyManager.finalOnChainWallet.address(0), FeeratePerKw(FeeratePerByte(Satoshi(5))))!!
assertEquals(swapInTx.txOut.size, recoveryTx.txIn.size)
Transaction.correctlySpends(recoveryTx, swapInTx, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
}

companion object {
val dummyExtendedPubkey = DeterministicWallet.publicKey(DeterministicWallet.generate(ByteVector("deadbeef")))
}
Expand Down

0 comments on commit 336ee57

Please sign in to comment.