diff --git a/README.md b/README.md index 8ba7bd95d..a32422458 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ Read [this article](https://medium.com/@ACINQ/when-ios-cdf798d5f8ef) for more de See instructions [here](https://github.com/ACINQ/lightning-kmp/blob/master/BUILD.md) to build and test the library. +## Recovering on-chain funds + +See instructions [here](./RECOVERY.md) to recover on-chain funds. + ## Contributing We use GitHub for bug tracking. Search the existing issues for your bug and create a new one if needed. diff --git a/RECOVERY.md b/RECOVERY.md new file mode 100644 index 000000000..081a05fe0 --- /dev/null +++ b/RECOVERY.md @@ -0,0 +1,188 @@ +# Funds recovery + +The following steps lets you recover on-chain funds managed by `lightning-kmp`. + +## Closed channels + +When channels are closed, funds are sent to an address derived from your seed using BIP39 and BIP84. +You can use any on-chain wallet that supports these two standards to recover those funds. + +For example, when using [electrum](https://electrum.org/): + +- Create a new standard wallet +- Select "I already have a seed" +- Enter your 12 words, click on "Options" and check "BIP39 seed" +- Select "native segwit (p2wpkh)" +- Wait for the funds to show up + +## Pending swap-in transactions + +When swapping funds to a `lightning-kmp` wallet, the following steps are performed: + +- funds are sent to a swap-in address via a swap transaction +- we wait for that transaction to have enough confirmations +- then, if the fees don't exceed the user's liquidity policy, these funds are moved into a lightning channel + +The swap transaction's output can be spent using either: + +1. A signature from the user's wallet and a signature from the remote node +2. A signature from the user's wallet after a refund delay + +Funds can be recovered using the second option and [Bitcoin Core](https://github.com/bitcoin/bitcoin). +This process needs at least Bitcoin Core 25.0. + +This process will become simpler once popular on-chain wallets (such as [electrum](https://electrum.org/)) add supports for output script descriptors. + +### Extract master keys + +We don't directly export your extended master private key for security reasons, so you will need to manually insert it in the descriptor. +You can obtain your extended master private key in [electrum](https://electrum.org/). After restoring your seed, type `wallet.keystore.xprv` in the console to obtain your master `xprv`. + +### Create recovery wallet + +Create a wallet to recover your funds using the following command: + +```sh +bitcoin-cli createwallet recovery +``` + +### Import descriptor into the recovery wallet + +`lightning-kmp` provides the public descriptor for your swap-in address, which uses the following template: + +```txt +wsh(and_v(v:pk([/]),or_d(pk(),older()))) +``` + +For example, it will look like this: + +```txt +wsh(and_v(v:pk([14620948/51h/0h/0h]tpubDCvYeHUZisCMV3h1zPevPWQmNPfA3g3vnu7gDqskXVCbJB1VKk2F7LApV6TTdm1sCyGout8ma27CCHvYTuMZxpwrcHnLwL4kaXW8z2KfFcW),or_d(pk(0256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fd),older(25920)))) +``` + +Replace the `extended_public_key` and the `derivation_path` with the extended private key obtained in the [first step](#extract-master-keys). +In our example, the extended private key matching our seed is `tprv8ZgxMBicQKsPdKRFLVct6VDpfmCxk6aC7iAF8tb6roQ7hv1zFCyGwDLBUUxMVJ95dTiQS5VvCbQ6J7CcGqguw5SbnDpNjbjpfVwcMwUtmjS`, so we create the following private descriptor: + +```txt +wsh(and_v(v:pk(tprv8ZgxMBicQKsPdKRFLVct6VDpfmCxk6aC7iAF8tb6roQ7hv1zFCyGwDLBUUxMVJ95dTiQS5VvCbQ6J7CcGqguw5SbnDpNjbjpfVwcMwUtmjS/51h/0h/0h),or_d(pk(0256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fd),older(25920)))) +``` + +We need to obtain a checksum for this descriptor, which is provided by Bitcoin Core: + +```sh +bitcoin-cli getdescriptorinfo "wsh(and_v(v:pk(tprv8ZgxMBicQKsPdKRFLVct6VDpfmCxk6aC7iAF8tb6roQ7hv1zFCyGwDLBUUxMVJ95dTiQS5VvCbQ6J7CcGqguw5SbnDpNjbjpfVwcMwUtmjS/51h/0h/0h),or_d(pk(0256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fd),older(25920))))" + +{ + "descriptor": "wsh(and_v(v:pk(tpubD6NzVbkrYhZ4WnT3E9HUVtswEnituRm6h1m2RQdQH5CWYQGksbns7hx3ediWHpFEkEQC4vPssnQN2gQpzkodRDuMA7nQtWiQ5EDzkGpGVNw/51'/0'/0'),or_d(pk(0256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fd),older(25920))))#m8v4e6vu", + "checksum": "dlcgkrnc", + "isrange": false, + "issolvable": true, + "hasprivatekeys": true +} +``` + +We can the append this checksum to our private descriptor and import it into our recovery wallet: + +```sh +bitcoin-cli -rpcwallet=recovery importdescriptors '[{ "desc": "wsh(and_v(v:pk(tprv8ZgxMBicQKsPdKRFLVct6VDpfmCxk6aC7iAF8tb6roQ7hv1zFCyGwDLBUUxMVJ95dTiQS5VvCbQ6J7CcGqguw5SbnDpNjbjpfVwcMwUtmjS/51h/0h/0h),or_d(pk(0256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fd),older(25920))))#dlcgkrnc", "timestamp": 0 }]' + +[ + { + "success": true, + "warnings": [ + "Not all private keys provided. Some wallet functionality may return unexpected errors" + ] + } +] +``` + +Bitcoin Core will then scan the blockchain to find funds that were sent to a matching address. +This is a slow process, which can be sped up by setting the `timestamp` field to a value slightly before the first usage of `lightning-kmp`. + +Once Bitcoin Core is done with the scanning process, the `getwalletinfo` command will return `"scanning": false`: + +```sh +bitcoin-cli -rpcwallet=recovery getwalletinfo + +{ + "walletname": "recovery", + "walletversion": 169900, + "format": "sqlite", + "balance": 1.50000000, + "unconfirmed_balance": 0.00000000, + "immature_balance": 0.00000000, + "txcount": 1, + "keypoolsize": 4000, + "keypoolsize_hd_internal": 4000, + "paytxfee": 0.00000000, + "private_keys_enabled": true, + "avoid_reuse": false, + "scanning": false, + "descriptors": true, + "external_signer": false +} +``` + +You can then find available funds matching the descriptor we imported: + +```sh +bitcoin-cli -rpcwallet=recovery listtransactions + +[ + { + "address": "bcrt1qw78cdcsn55vwsvmwe9qgwnx0fwffzqej7keuqfjnwj5xm0f5u6js2hp66f", + "parent_descs": [ + "wsh(and_v(v:pk(tpubD6NzVbkrYhZ4WnT3E9HUVtswEnituRm6h1m2RQdQH5CWYQGksbns7hx3ediWHpFEkEQC4vPssnQN2gQpzkodRDuMA7nQtWiQ5EDzkGpGVNw/51'/0'/0'),or_d(pk(0256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fd),older(25920))))#m8v4e6vu" + ], + "category": "receive", + "amount": 1.50000000, + "label": "", + "vout": 1, + "confirmations": 5, + "blockhash": "6e1048a8d7829d36a766188b499ddcc2e497193427678d115fd341b2b452c0bd", + "blockheight": 151, + "blockindex": 1, + "blocktime": 1687759025, + "txid": "d9940b7eb709ff8eaec307bdd6d20633e30a6eb1627d9ef8c8e03dfd28298c75", + "wtxid": "261492e5f930b82f65f269bb3006db9c3ef14423e5f52f2a185ace18704bb7b0", + "walletconflicts": [ + ], + "time": 1687759025, + "timereceived": 1687759181, + "bip125-replaceable": "no" + } +] +``` + +### Send funds to a different address + +Once those funds have been recovered and the refund delay has expired (the `confirmations` field of the previous command exceeds `25920`), you can send them to your normal on-chain wallet. +Compute the total amount received (in our example, 1.5 BTC), choose the address to send to (for example, `bcrt1q9ez7rt33wynwpah582lnqlj3u0tpzsrkj2flas`) and create a transaction using all of the received funds: + +```sh +bitcoin-cli -rpcwallet=recovery walletcreatefundedpsbt '[{"txid":"d9940b7eb709ff8eaec307bdd6d20633e30a6eb1627d9ef8c8e03dfd28298c75","vout":1,"sequence":25920}]' '[{"bcrt1q9ez7rt33wynwpah582lnqlj3u0tpzsrkj2flas":1.5}]' 0 '{"subtractFeeFromOutputs":[0]}' + +{ + "psbt": "cHNidP8BAFICAAAAAXWMKSj9PeDI+J59YrFuCuMzBtLWvQfDro7/Cbd+C5TZAQAAAABAZQAAAQzI8AgAAAAAFgAULkXhrjFxJuD29Dq/MH5R49YRQHYAAAAAAAEAiQIAAAABsDXoV21bcbM8ii+Nyo4r8ZWmMEJIiaYqYg6pKaXiOiMAAAAAAP3///8CnBMVIQEAAAAiUSA520UqAgN8jz9APGIBbNHksiweuAEnMZvjgpMKiRUKkoDR8AgAAAAAIgAgd4+G4hOlGOgzbslAh0zPS5KRAzL1s8AmU3Sobb005qWWAAAAAQErgNHwCAAAAAAiACB3j4biE6UY6DNuyUCHTM9LkpEDMvWzwCZTdKhtvTTmpQEFTSED1ZiIPcIwgcWSbaso29B11ULE+6VERxkh27lqMde8SRmtIQJW6UgYDzPwZyRnEKQWVghPwkW5ftoIHv4eSIshV31g/axzZAJAZbJoIgYCVulIGA8z8GckZxCkFlYIT8JFuX7aCB7+HkiLIVd9YP0EsxuziiIGA9WYiD3CMIHFkm2rKNvQddVCxPulREcZIdu5ajHXvEkZEBRiCUgzAACAAAAAgAAAAIAAAA==", + "fee": 0.00002420, + "changepos": -1 +} + +bitcoin-cli -rpcwallet=recovery walletprocesspsbt "cHNidP8BAFICAAAAAXWMKSj9PeDI+J59YrFuCuMzBtLWvQfDro7/Cbd+C5TZAQAAAABAZQAAAQzI8AgAAAAAFgAULkXhrjFxJuD29Dq/MH5R49YRQHYAAAAAAAEAiQIAAAABsDXoV21bcbM8ii+Nyo4r8ZWmMEJIiaYqYg6pKaXiOiMAAAAAAP3///8CnBMVIQEAAAAiUSA520UqAgN8jz9APGIBbNHksiweuAEnMZvjgpMKiRUKkoDR8AgAAAAAIgAgd4+G4hOlGOgzbslAh0zPS5KRAzL1s8AmU3Sobb005qWWAAAAAQErgNHwCAAAAAAiACB3j4biE6UY6DNuyUCHTM9LkpEDMvWzwCZTdKhtvTTmpQEFTSED1ZiIPcIwgcWSbaso29B11ULE+6VERxkh27lqMde8SRmtIQJW6UgYDzPwZyRnEKQWVghPwkW5ftoIHv4eSIshV31g/axzZAJAZbJoIgYCVulIGA8z8GckZxCkFlYIT8JFuX7aCB7+HkiLIVd9YP0EsxuziiIGA9WYiD3CMIHFkm2rKNvQddVCxPulREcZIdu5ajHXvEkZEBRiCUgzAACAAAAAgAAAAIAAAA==" + +{ + "psbt": "cHNidP8BAFICAAAAAXWMKSj9PeDI+J59YrFuCuMzBtLWvQfDro7/Cbd+C5TZAQAAAABAZQAAAQzI8AgAAAAAFgAULkXhrjFxJuD29Dq/MH5R49YRQHYAAAAAAAEAiQIAAAABsDXoV21bcbM8ii+Nyo4r8ZWmMEJIiaYqYg6pKaXiOiMAAAAAAP3///8CnBMVIQEAAAAiUSA520UqAgN8jz9APGIBbNHksiweuAEnMZvjgpMKiRUKkoDR8AgAAAAAIgAgd4+G4hOlGOgzbslAh0zPS5KRAzL1s8AmU3Sobb005qWWAAAAAQErgNHwCAAAAAAiACB3j4biE6UY6DNuyUCHTM9LkpEDMvWzwCZTdKhtvTTmpQEImAMARzBEAiBNe5Y/fGWNfCIh2oBoZZHh5Em1kR3GFumpa0bgn9WRCQIgTDKGl/F59wpGRhdJ/jLlOTHqszmHonQTD4qgVNNJIc4BTSED1ZiIPcIwgcWSbaso29B11ULE+6VERxkh27lqMde8SRmtIQJW6UgYDzPwZyRnEKQWVghPwkW5ftoIHv4eSIshV31g/axzZAJAZbJoAAA=", + "complete": true +} + +bitcoin-cli -rpcwallet=recovery finalizepsbt "cHNidP8BAFICAAAAAXWMKSj9PeDI+J59YrFuCuMzBtLWvQfDro7/Cbd+C5TZAQAAAABAZQAAAQzI8AgAAAAAFgAULkXhrjFxJuD29Dq/MH5R49YRQHYAAAAAAAEAiQIAAAABsDXoV21bcbM8ii+Nyo4r8ZWmMEJIiaYqYg6pKaXiOiMAAAAAAP3///8CnBMVIQEAAAAiUSA520UqAgN8jz9APGIBbNHksiweuAEnMZvjgpMKiRUKkoDR8AgAAAAAIgAgd4+G4hOlGOgzbslAh0zPS5KRAzL1s8AmU3Sobb005qWWAAAAAQErgNHwCAAAAAAiACB3j4biE6UY6DNuyUCHTM9LkpEDMvWzwCZTdKhtvTTmpQEImAMARzBEAiBNe5Y/fGWNfCIh2oBoZZHh5Em1kR3GFumpa0bgn9WRCQIgTDKGl/F59wpGRhdJ/jLlOTHqszmHonQTD4qgVNNJIc4BTSED1ZiIPcIwgcWSbaso29B11ULE+6VERxkh27lqMde8SRmtIQJW6UgYDzPwZyRnEKQWVghPwkW5ftoIHv4eSIshV31g/axzZAJAZbJoAAA=" + +{ + "hex": "02000000000101758c2928fd3de0c8f89e7d62b16e0ae33306d2d6bd07c3ae8eff09b77e0b94d9010000000040650000010cc8f008000000001600142e45e1ae317126e0f6f43abf307e51e3d6114076030047304402204d7b963f7c658d7c2221da80686591e1e449b5911dc616e9a96b46e09fd5910902204c328697f179f70a46461749fe32e53931eab33987a274130f8aa054d34921ce014d2103d598883dc23081c5926dab28dbd075d542c4fba544471921dbb96a31d7bc4919ad210256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fdac7364024065b26800000000", + "complete": true +} + +bitcoin-cli -rpcwallet=recovery sendrawtransaction 02000000000101758c2928fd3de0c8f89e7d62b16e0ae33306d2d6bd07c3ae8eff09b77e0b94d9010000000040650000010cc8f008000000001600142e45e1ae317126e0f6f43abf307e51e3d6114076030047304402204d7b963f7c658d7c2221da80686591e1e449b5911dc616e9a96b46e09fd5910902204c328697f179f70a46461749fe32e53931eab33987a274130f8aa054d34921ce014d2103d598883dc23081c5926dab28dbd075d542c4fba544471921dbb96a31d7bc4919ad210256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fdac7364024065b26800000000 +``` + +Wait for that transaction to confirm, and your funds will have been successfully recovered! diff --git a/src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt b/src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt index 909744ab0..0ee993227 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt @@ -113,25 +113,49 @@ interface KeyManager { val remoteServerPublicKey: PublicKey, val refundDelay: Int = SwapInRefundDelay ) { - val userPrivateKey: PrivateKey = DeterministicWallet.derivePrivateKey(master, swapInKeyBasePath(chain) / hardened(0)).privateKey + 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, swapInKeyBasePath(chain) / hardened(1)) + private val localServerExtendedPrivateKey: DeterministicWallet.ExtendedPrivateKey = DeterministicWallet.derivePrivateKey(master, swapInLocalServerKeyPath(chain)) fun localServerPrivateKey(remoteNodeId: PublicKey): PrivateKey = DeterministicWallet.derivePrivateKey(localServerExtendedPrivateKey, perUserPath(remoteNodeId)).privateKey val redeemScript: List = Scripts.swapIn2of2(userPublicKey, remoteServerPublicKey, refundDelay) val pubkeyScript: List = Script.pay2wsh(redeemScript) val address: String = Bitcoin.addressFromPublicKeyScript(chain.chainHash, pubkeyScript)!! + /** + * 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))))" + } + companion object { /** When doing a swap-in, the user's funds are locked in a 2-of-2: they can claim them unilaterally after that delay. */ const val SwapInRefundDelay = 144 * 30 * 6 // ~6 months - fun swapInKeyBasePath(chain: NodeParams.Chain) = when (chain) { + 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. diff --git a/src/commonTest/kotlin/fr/acinq/lightning/crypto/LocalKeyManagerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/crypto/LocalKeyManagerTestsCommon.kt index 7c9c48970..4dbd21125 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/crypto/LocalKeyManagerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/crypto/LocalKeyManagerTestsCommon.kt @@ -176,6 +176,16 @@ class LocalKeyManagerTestsCommon : LightningTestSuite() { assertEquals(PublicKey.fromHex("020bc21d9cb5ecc60fc2002195429d55ff4dfd0888e377a1b3226b4dc1ee7cedf3"), TestConstants.Bob.keyManager.swapInOnChainWallet.userPublicKey) assertEquals(TestConstants.Alice.keyManager.swapInOnChainWallet.remoteServerPublicKey, TestConstants.Bob.keyManager.swapInOnChainWallet.localServerPrivateKey(TestConstants.Alice.nodeParams.nodeId).publicKey()) assertEquals(TestConstants.Bob.keyManager.swapInOnChainWallet.remoteServerPublicKey, TestConstants.Alice.keyManager.swapInOnChainWallet.localServerPrivateKey(TestConstants.Bob.nodeParams.nodeId).publicKey()) + assertEquals(TestConstants.Alice.keyManager.swapInOnChainWallet.remoteServerPublicKey, PublicKey.fromHex("0256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fd")) + assertEquals(TestConstants.Bob.keyManager.swapInOnChainWallet.remoteServerPublicKey, PublicKey.fromHex("02d8c2f4fe8a017ff3a30eb2a4477f3ebe64ae930f67f907270712a70b18cb8951")) + assertEquals( + "wsh(and_v(v:pk([14620948/51h/0h/0h]tpubDCvYeHUZisCMV3h1zPevPWQmNPfA3g3vnu7gDqskXVCbJB1VKk2F7LApV6TTdm1sCyGout8ma27CCHvYTuMZxpwrcHnLwL4kaXW8z2KfFcW),or_d(pk(0256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fd),older(25920))))", + TestConstants.Alice.keyManager.swapInOnChainWallet.descriptor + ) + assertEquals( + "wsh(and_v(v:pk([85185511/51h/0h/0h]tpubDDt5vQap1awkteTeYioVGLQvj75xrFvjuW6WjNumsedvckEHAMUACubuKtmjmXViDPYMvtnEQt6EGj3eeMVSGRKxRZqCme37j5jAUMhkX5L),or_d(pk(02d8c2f4fe8a017ff3a30eb2a4477f3ebe64ae930f67f907270712a70b18cb8951),older(25920))))", + TestConstants.Bob.keyManager.swapInOnChainWallet.descriptor + ) } companion object {