Skip to content

Commit

Permalink
Rework recovery procedure
Browse files Browse the repository at this point in the history
The current recovery process uses Electrum to get the master private key from the seed, which afaict does not work: electrum returns
the wallet master key (i.e derived with 84'/0'/0' for example).
Here we use https://iancoleman.io/bip39/ instead.

There 2 descriptor methods: one to get the private swap-in wallet descriptor, which can be used as-is, and the other to get the
public swap-in wallet descriptor, which can be used to create a watch-only wallet to monitor swap-in funds.

Both descriptor use the refund master key, and not the master key itself because we use hardened paths to derive the refund key, which means that it is not
possible to compute the refund master public key from the master public: importing the descriptor would fail.
  • Loading branch information
sstone committed Feb 8, 2024
1 parent f963c21 commit 689949a
Show file tree
Hide file tree
Showing 4 changed files with 122 additions and 76 deletions.
70 changes: 45 additions & 25 deletions RECOVERY.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,62 +38,82 @@ This process needs at least Bitcoin Core 26.0.

This process will become simpler once popular on-chain wallets (such as [electrum](https://electrum.org/)) add supports for output script descriptors.

### Get your wallet descriptor

lightning-kmp provides both a public descriptor and private descriptor for your swap-in wallet.
The public descriptor can be used to create a watch-only wallet for your swap-in funds.
The private descriptor can be used to recover your swap-in funds, after the refund delay has passed.
:warning: Do not share this private descriptor with anyone !

### Create recovery wallet

Create a wallet to recover your funds using the following command:
#### Compute your refund master private key

```sh
bitcoin-cli createwallet recovery
```
Use the [mnemonic code converter](https://iancoleman.io/bip39/) to compute your refund master key from your seed word (we
strongly recommend that you use this tool in "offline" mode, see the "Offline usage section for more information)"
1. Enter your 12-words seed in the `BIP39 Mnemonic` field
2. In the `Derivation Path` section, select the `BIP32` tab and enter `m/52'/0'/2'/0` in the `BIP32 Derivation Path` field

Your refund master private key is displayed in the `BIP32 Extended Private Key` field.
In the following examples, we assume that your refund master private key is `tprv8hWm2EfcAbMerYoXeHA9w6faUqXdiQeWfSxxWpzh3Yc1FAjB2vv1sbBNY1dX3HraotvBAEeY2hzz1X4vc3SC516K1ebBvLYrkA6LstQdbNX`

### Import descriptor into the recovery wallet
#### Create your refund wallet descriptor

`lightning-kmp` provides a public and private descriptor for your swap-in wallet, which both use the following template:
Copy the descriptor from the `SWAP_IN WALLET` section in the `Wallet Info` menu on your Phoenix wallet. It should look like this:

```txt
tr(<extended_public_key>,and_v(v:pk(<master_key>/<derivation_path>),older(<refund_delay>)))
tr(<extended_public_key>,and_v(v:pk(<refund_master_public_key>/<derivation_path>),older(<refund_delay>)))
```

For example, your public descriptor will look like this:
For example:
```txt
tr(1fc559d9c96c5953895d3150e64ebf3dd696a0b08e758650b48ff6251d7e60d1,and_v(v:pk(tpubDECoAehrJy3Kk1qKXvpkLWKh3s3ZsjqREkZjoM2zTpQQ5eywfKjc45oEi8GMq1mpWxM2kg79Lp5DzznQKGRE15btY327vgLcLbfZLrgAWrv/*),older(2590)))#xmhrglc6
```

Replace the `refund master public key` with your refund master private key. For example:
```txt
tr(1fc559d9c96c5953895d3150e64ebf3dd696a0b08e758650b48ff6251d7e60d1,and_v(v:pk(tpubDDqzCA42sbCmnGBcuuiAeLGqB9XHU5Gy1n68omeKf4pwFKe2padzkdXAPsDMWMdee879oPYrGrTS8sioqyjv8b6TztunE526eo4Au9kTef3/*),older(25920)))#z6mq2a3u
tr(1fc559d9c96c5953895d3150e64ebf3dd696a0b08e758650b48ff6251d7e60d1,and_v(v:pk(tprv8hWm2EfcAbMerYoXeHA9w6faUqXdiQeWfSxxWpzh3Yc1FAjB2vv1sbBNY1dX3HraotvBAEeY2hzz1X4vc3SC516K1ebBvLYrkA6LstQdbNX/*),older(2590)))#xmhrglc6
```

And your private descriptor will look like this:
### Create a bitcoin core recovery wallet

```
tr(1fc559d9c96c5953895d3150e64ebf3dd696a0b08e758650b48ff6251d7e60d1,and_v(v:pk(tprv8h9x3k1njDX6to9q2G3aEvcic81MJk64SUVMXFc2Eo2YQqPGCBpQa8uJDkTz3DMHVXEmvhuwf4ShjLQ7YaVr34x9DFT3y43cPzVKGB94r1n/*),older(25920)))#7dne06j5
Create an empty wallet to recover your funds using the following command:

```shell
bitcoin-cli -named createwallet wallet_name=recovery blank=true
```

We can import our private descriptor into our recovery wallet:
### Import your descriptor into the recovery wallet

```sh
bitcoin-cli -rpcwallet=recovery importdescriptors '[{ "desc": "tr(1fc559d9c96c5953895d3150e64ebf3dd696a0b08e758650b48ff6251d7e60d1,and_v(v:pk(tprv8ZgxMBicQKsPdKRFLVct6VDpfmCxk6aC7iAF8tb6roQ7hv1zFCyGwDLBUUxMVJ95dTiQS5VvCbQ6J7CcGqguw5SbnDpNjbjpfVwcMwUtmjS/51h/0h/0h/*),older(25920)))#rn7cy7yr", "timestamp": 0 }]'
We can import our private descriptor into our recovery wallet. Since you replaced you refund master public key with your refund master private key, the descriptor checksum is no long valid, but bitcoin core will give you the correct checksum:

```shell
bitcoin-cli -rpcwallet=recovery importdescriptors '[{ "desc": "tr(1fc559d9c96c5953895d3150e64ebf3dd696a0b08e758650b48ff6251d7e60d1,and_v(v:pk(tprv8hWm2EfcAbMerYoXeHA9w6faUqXdiQeWfSxxWpzh3Yc1FAjB2vv1sbBNY1dX3HraotvBAEeY2hzz1X4vc3SC516K1ebBvLYrkA6LstQdbNX/*),older(2590)))#xmhrglc6", "timestamp":0}]'
[
{
"success": false,
"error": {
"code": -5,
"message": "Provided checksum 'xmhrglc6' does not match computed checksum '90ftphf9'"
}
}
]
```

Update the checksum and try again:
```shell
bitcoin-cli -rpcwallet=recovery importdescriptors '[{ "desc": "tr(1fc559d9c96c5953895d3150e64ebf3dd696a0b08e758650b48ff6251d7e60d1,and_v(v:pk(tprv8hWm2EfcAbMerYoXeHA9w6faUqXdiQeWfSxxWpzh3Yc1FAjB2vv1sbBNY1dX3HraotvBAEeY2hzz1X4vc3SC516K1ebBvLYrkA6LstQdbNX/*),older(2590)))#90ftphf9", "timestamp":0}]'
[
{
"success": true,
"warnings": [
"Range not given, using default keypool range",
"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
```shell
bitcoin-cli -rpcwallet=recovery getwalletinfo

{
Expand All @@ -117,7 +137,7 @@ bitcoin-cli -rpcwallet=recovery getwalletinfo

You can then find available funds matching the descriptor we imported:

```sh
```shell
bitcoin-cli -rpcwallet=recovery listtransactions

[
Expand Down Expand Up @@ -151,7 +171,7 @@ bitcoin-cli -rpcwallet=recovery listtransactions
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
```shell
bitcoin-cli -rpcwallet=recovery walletcreatefundedpsbt '[{"txid":"4c3236b1fa1f3ed124ab83b1667be95f855952e68729eae54a9f511c8c8cb993", "vout":0, "sequence":144}]' '[{"bcrt1qzy4h8dux6pjl8ys979632uynqffd53vjkzffjl":0.09}]'
{
"psbt": "cHNidP8BAHECAAAAAZO5jIwcUZ9K5eoph+ZSWYVf6XtmsYOrJNE+H/qxNjJMAAAAAACQAAAAAkBUiQAAAAAAFgAUEStzt4bQZfOSBfF1FXCTAlLaRZIEOA8AAAAAABYAFEx1yJgBL6kfpf2sybIL0WajM0rXAAAAAAABASuAlpgAAAAAACJRIBC8PjbhAQmhNnxw0RaGeV3xZIp431XBmRfmNKaKpRZmIhXBH8VZ2clsWVOJXTFQ5k6/PdaWoLCOdYZQtI/2JR1+YNEnIG0cAcgg4JAO3Y5ZtLOD5zp/WFAHJFWAT5z/6Z+k+FQbrQKQALLAIRYfxVnZyWxZU4ldMVDmTr891pagsI51hlC0j/YlHX5g0QUA0EkObyEWbRwByCDgkA7djlm0s4PnOn9YUAckVYBPnP/pn6T4VBspAWR9Jdbf5zHI25Gs69RTMJILBCLUX82cmJj59Bk4SZKgTHUGgQAAAAABFyAfxVnZyWxZU4ldMVDmTr891pagsI51hlC0j/YlHX5g0QEYIGR9Jdbf5zHI25Gs69RTMJILBCLUX82cmJj59Bk4SZKgAAAiAgMhzD3XSvW4p+oRyBAvB6rUHaOCIyjVxJV9tEin3sUiqxjpcZ0vVAAAgAEAAIAAAACAAQAAAAEAAAAA",
Expand Down
12 changes: 10 additions & 2 deletions src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,19 @@ interface KeyManager {
fun localServerPrivateKey(remoteNodeId: PublicKey): PrivateKey = DeterministicWallet.derivePrivateKey(localServerExtendedPrivateKey, perUserPath(remoteNodeId)).privateKey

val swapInProtocol = SwapInProtocol(userPublicKey, remoteServerPublicKey, userRefundPublicKey, refundDelay)
val descriptor = swapInProtocol.descriptor(chain, userRefundExtendedPrivateKey)

// this is a private descriptor that can be used as-is to recover swap-in funds once the refund delay has passed
// it is compatible with address rotation as long as refund keys are derived directly from userRefundExtendedPrivateKey
// README: it includes the user's master refund private key and is not safe to share !!
val privateDescriptor = SwapInProtocol.privateDescriptor(chain, userPublicKey, remoteServerPublicKey, refundDelay, userRefundExtendedPrivateKey)

// this is the public version of the above descriptor. It can be used to monitor a user's swap-in transaction
// README: it cannot be used to derive private keys, but it can be used to derive swap-in addresses
val publicDescriptor = SwapInProtocol.publicDescriptor(chain, userPublicKey, remoteServerPublicKey, refundDelay, DeterministicWallet.publicKey(userRefundExtendedPrivateKey))

// legacy p2wsh-based swap-in protocol, with a fixed on-chain address
val legacySwapInProtocol = SwapInProtocolLegacy(userPublicKey, remoteServerPublicKey, refundDelay)
val legacyDescriptor = legacySwapInProtocol.descriptor(chain, master, userExtendedPrivateKey)
val legacyDescriptor = SwapInProtocolLegacy.descriptor(chain, DeterministicWallet.publicKey(master), DeterministicWallet.publicKey(userExtendedPrivateKey), remoteServerPublicKey, refundDelay)

fun signSwapInputUserLegacy(fundingTx: Transaction, index: Int, parentTxOuts: List<TxOut>): ByteVector64 {
return legacySwapInProtocol.signSwapInputUser(fundingTx, index, parentTxOuts[fundingTx.txIn[index].outPoint.index.toInt()], userPrivateKey)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import fr.acinq.bitcoin.crypto.musig2.Musig2
import fr.acinq.bitcoin.crypto.musig2.SecretNonce
import fr.acinq.bitcoin.utils.Either
import fr.acinq.lightning.NodeParams
import fr.acinq.lightning.crypto.KeyManager

/**
* new swap-in protocol based on musig2 and taproot: (user key + server key) OR (user refund key + delay)
Expand All @@ -18,6 +17,7 @@ import fr.acinq.lightning.crypto.KeyManager
data class SwapInProtocol(val userPublicKey: PublicKey, val serverPublicKey: PublicKey, val userRefundKey: PublicKey, val refundDelay: Int) {
// The key path uses musig2 with the user and server keys.
private val internalPublicKey = Musig2.aggregateKeys(listOf(userPublicKey, serverPublicKey))

// The script path contains a refund script, 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.
private val refundScript = listOf(OP_PUSHDATA(userRefundKey.xOnly()), OP_CHECKSIGVERIFY, OP_PUSHDATA(Script.encodeNumber(refundDelay)), OP_CHECKSEQUENCEVERIFY)
Expand Down Expand Up @@ -55,38 +55,30 @@ data class SwapInProtocol(val userPublicKey: PublicKey, val serverPublicKey: Pub
return Musig2.signTaprootInput(serverPrivateKey, fundingTx, index, parentTxOuts, publicKeys, privateNonce, publicNonces, scriptTree)
}

/**
* @param chain chain we're on.
* @param masterRefundKey master private key for the refund keys: we assume that there is a single level of derivation to compute the refund keys.
* @return a taproot descriptor that can be imported in bitcoin core (from version 26 on) to recover user funds once the funding delay has passed.
*/
fun descriptor(chain: NodeParams.Chain, masterRefundKey: DeterministicWallet.ExtendedPrivateKey): String {
val prefix = when (chain) {
NodeParams.Chain.Mainnet -> DeterministicWallet.xprv
else -> DeterministicWallet.tprv
companion object {
fun privateDescriptor(chain: NodeParams.Chain, userPublicKey: PublicKey, serverPublicKey: PublicKey, refundDelay: Int, masterRefundKey: DeterministicWallet.ExtendedPrivateKey): String {
val internalPubKey = Musig2.aggregateKeys(listOf(userPublicKey, serverPublicKey))
val prefix = when (chain) {
NodeParams.Chain.Mainnet -> DeterministicWallet.xprv
else -> DeterministicWallet.tprv
}
val xpriv = DeterministicWallet.encode(masterRefundKey, prefix)
val desc = "tr(${internalPubKey.value},and_v(v:pk($xpriv/*),older($refundDelay)))"
val checksum = Descriptor.checksum(desc)
return "$desc#$checksum"
}
val xpriv = DeterministicWallet.encode(masterRefundKey, prefix)
val desc = "tr(${internalPublicKey.value},and_v(v:pk($xpriv/*),older($refundDelay)))"
val checksum = Descriptor.checksum(desc)
return "$desc#$checksum"
}

/**
* @param chain chain we're on.
* @param masterRefundKey master public key for the refund keys: we assume that there is a single level of derivation to compute the refund keys.
* @return a taproot descriptor that can be imported in bitcoin core (from version 26 on) to create a watch-only wallet for your swap-in transactions.
*/
fun descriptor(chain: NodeParams.Chain, masterRefundKey: DeterministicWallet.ExtendedPublicKey): String {
// 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 prefix = when (chain) {
NodeParams.Chain.Mainnet -> DeterministicWallet.xpub
else -> DeterministicWallet.tpub
fun publicDescriptor(chain: NodeParams.Chain, userPublicKey: PublicKey, serverPublicKey: PublicKey, refundDelay: Int, masterRefundKey: DeterministicWallet.ExtendedPublicKey): String {
val internalPubKey = Musig2.aggregateKeys(listOf(userPublicKey, serverPublicKey))
val prefix = when (chain) {
NodeParams.Chain.Mainnet -> DeterministicWallet.xpub
else -> DeterministicWallet.tpub
}
val xpub = DeterministicWallet.encode(masterRefundKey, prefix)
val desc = "tr(${internalPubKey.value},and_v(v:pk($xpub/*),older($refundDelay)))"
val checksum = Descriptor.checksum(desc)
return "$desc#$checksum"
}
val xpub = DeterministicWallet.encode(masterRefundKey, prefix)
val path = masterRefundKey.path.toString().replace('\'', 'h').removePrefix("m")
val desc = "tr(${internalPublicKey.value},and_v(v:pk($xpub$path/*),older($refundDelay)))"
val checksum = Descriptor.checksum(desc)
return "$desc#$checksum"
}
}

Expand Down Expand Up @@ -126,22 +118,26 @@ data class SwapInProtocolLegacy(val userPublicKey: PublicKey, val serverPublicKe
return Transactions.sign(fundingTx, index, Script.write(redeemScript), parentTxOut.amount, serverKey)
}

/**
* The output script descriptor matching our legacy swap-in addresses.
* That descriptor can be imported in bitcoind to recover funds after the refund delay.
*
* @param chain chain we're on.
* @param masterRefundKey master private key for the swap-in wallet.
* @param userPrivateKey user refund private key, derived from the master private key.
* @return a p2wsh descriptor that can be imported in bitcoin core (from version 24 on) to recover user funds once the funding delay has passed.
*/
fun descriptor(chain: NodeParams.Chain, masterRefundKey: DeterministicWallet.ExtendedPrivateKey, userPrivateKey: DeterministicWallet.ExtendedPrivateKey): String {
// 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(masterRefundKey).publickeybytes).take(4).toByteArray())
val encodedChildKey = DeterministicWallet.encode(DeterministicWallet.publicKey(userPrivateKey), testnet = chain != NodeParams.Chain.Mainnet)
val userKey = "[${masterFingerprint.toHex()}/${KeyManager.SwapInOnChainKeys.encodedSwapInUserKeyPath(chain)}]$encodedChildKey"
return "wsh(and_v(v:pk($userKey),or_d(pk(${serverPublicKey.toHex()}),older($refundDelay))))"
companion object {
/**
* The output script descriptor matching our legacy swap-in addresses.
* That descriptor can be imported in bitcoind to recover funds after the refund delay.
*
* @param chain chain we're on.
* @param masterPublicKey master public key for the swap-in wallet.
* @param userExtendedPublicKey user public key, derived from the master private key.
* @param remoteServerPublicKey server public key
* @param refundDelay refund delay
* @return a p2wsh descriptor that can be imported in bitcoin core (from version 24 on) to recover user funds once the funding delay has passed.
*/
fun descriptor(chain: NodeParams.Chain, masterPublicKey: DeterministicWallet.ExtendedPublicKey, userExtendedPublicKey: DeterministicWallet.ExtendedPublicKey, remoteServerPublicKey: PublicKey, refundDelay: Int): String {
// 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(masterPublicKey.publickeybytes).take(4).toByteArray())
val encodedChildKey = DeterministicWallet.encode(userExtendedPublicKey, testnet = chain != NodeParams.Chain.Mainnet)
val userKey = "[${masterFingerprint.toHex()}/${userExtendedPublicKey.path.asString('h').removePrefix("m/")}]$encodedChildKey"
return "wsh(and_v(v:pk($userKey),or_d(pk(${remoteServerPublicKey.toHex()}),older($refundDelay))))"
}
}
}
Loading

0 comments on commit 689949a

Please sign in to comment.