diff --git a/src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt b/src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt index ab0dab9fe..d47827c35 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt @@ -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 @@ -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. @@ -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 = 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() }) + } + } + } + } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/crypto/LocalKeyManager.kt b/src/commonMain/kotlin/fr/acinq/lightning/crypto/LocalKeyManager.kt index 87adcfded..29c177363 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/crypto/LocalKeyManager.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/crypto/LocalKeyManager.kt @@ -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) /** diff --git a/src/commonTest/kotlin/fr/acinq/lightning/crypto/LocalKeyManagerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/crypto/LocalKeyManagerTestsCommon.kt index 9bd0811a5..0e5ad9fdb 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/crypto/LocalKeyManagerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/crypto/LocalKeyManagerTestsCommon.kt @@ -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"))) }