diff --git a/RECOVERY.md b/RECOVERY.md index 081a05fe0..e414446fe 100644 --- a/RECOVERY.md +++ b/RECOVERY.md @@ -19,24 +19,29 @@ For example, when using [electrum](https://electrum.org/): 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 +- 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 +We use musig2 to aggregate user keys (user being the wallet) and server keys (server being the LSP: the ACINQ node): swap-in addresses are standard p2tr addresses, and +swap-in transactions to your wallet are indistinguishable from other p2tr transactions. + 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 +1. A aggregated musig2 signature built from a partial signature from the user's wallet and a partial 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 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. -### Extract master keys +### Get your wallet descriptor -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`. +lighting-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 @@ -48,43 +53,28 @@ 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: +`lightning-kmp` provides a public and private descriptor for your swap-in wallet, which both use the following template: ```txt -wsh(and_v(v:pk([/]),or_d(pk(),older()))) +tr(,and_v(v:pk(/),older())) ``` -For example, it will look like this: +For example, your public descriptor will look like this: ```txt -wsh(and_v(v:pk([14620948/51h/0h/0h]tpubDCvYeHUZisCMV3h1zPevPWQmNPfA3g3vnu7gDqskXVCbJB1VKk2F7LApV6TTdm1sCyGout8ma27CCHvYTuMZxpwrcHnLwL4kaXW8z2KfFcW),or_d(pk(0256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fd),older(25920)))) +tr(1fc559d9c96c5953895d3150e64ebf3dd696a0b08e758650b48ff6251d7e60d1,and_v(v:pk(tprv8h9x3k1njDX6to9q2G3aEvcic81MJk64SUVMXFc2Eo2YQqPGCBpQa8uJDkTz3DMHVXEmvhuwf4ShjLQ7YaVr34x9DFT3y43cPzVKGB94r1n/*),older(25920)))#7dne06j5 ``` -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: +And your private descriptor will look like this: -```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 -} +tr(1fc559d9c96c5953895d3150e64ebf3dd696a0b08e758650b48ff6251d7e60d1,and_v(v:pk(tpubDDqzCA42sbCmnGBcuuiAeLGqB9XHU5Gy1n68omeKf4pwFKe2padzkdXAPsDMWMdee879oPYrGrTS8sioqyjv8b6TztunE526eo4Au9kTef3/*),older(25920)))#z6mq2a3u ``` -We can the append this checksum to our private descriptor and import it into our recovery wallet: +We can import our private descriptor 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 }]' +bitcoin-cli -rpcwallet=recovery importdescriptors '[{ "desc": "tr(1fc559d9c96c5953895d3150e64ebf3dd696a0b08e758650b48ff6251d7e60d1,and_v(v:pk(tprv8ZgxMBicQKsPdKRFLVct6VDpfmCxk6aC7iAF8tb6roQ7hv1zFCyGwDLBUUxMVJ95dTiQS5VvCbQ6J7CcGqguw5SbnDpNjbjpfVwcMwUtmjS/51h/0h/0h/*),older(25920)))#rn7cy7yr", "timestamp": 0 }]' [ { @@ -130,25 +120,25 @@ bitcoin-cli -rpcwallet=recovery listtransactions [ { - "address": "bcrt1qw78cdcsn55vwsvmwe9qgwnx0fwffzqej7keuqfjnwj5xm0f5u6js2hp66f", + "address": "bcrt1pzz7rudhpqyy6zdnuwrg3dpnethckfzncma2urxghuc62dz49zenqv0p0q6", "parent_descs": [ - "wsh(and_v(v:pk(tpubD6NzVbkrYhZ4WnT3E9HUVtswEnituRm6h1m2RQdQH5CWYQGksbns7hx3ediWHpFEkEQC4vPssnQN2gQpzkodRDuMA7nQtWiQ5EDzkGpGVNw/51'/0'/0'),or_d(pk(0256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fd),older(25920))))#m8v4e6vu" + "tr(1fc559d9c96c5953895d3150e64ebf3dd696a0b08e758650b48ff6251d7e60d1,and_v(v:pk(tpubDDqzCA42sbCmnGBcuuiAeLGqB9XHU5Gy1n68omeKf4pwFKe2padzkdXAPsDMWMdee879oPYrGrTS8sioqyjv8b6TztunE526eo4Au9kTef3/*),older(144)))#zqam8e56" ], "category": "receive", - "amount": 1.50000000, - "label": "", - "vout": 1, - "confirmations": 5, - "blockhash": "6e1048a8d7829d36a766188b499ddcc2e497193427678d115fd341b2b452c0bd", - "blockheight": 151, + "amount": 0.10000000, + "vout": 0, + "abandoned": false, + "confirmations": 1, + "blockhash": "06361beb06e7d24bea80fc6800f4b5f374f09542a07fae77a7f8c26a9f7544b2", + "blockheight": 146, "blockindex": 1, - "blocktime": 1687759025, - "txid": "d9940b7eb709ff8eaec307bdd6d20633e30a6eb1627d9ef8c8e03dfd28298c75", - "wtxid": "261492e5f930b82f65f269bb3006db9c3ef14423e5f52f2a185ace18704bb7b0", + "blocktime": 1700670588, + "txid": "4c3236b1fa1f3ed124ab83b1667be95f855952e68729eae54a9f511c8c8cb993", + "wtxid": "16ab0b31f680e5bd4f149527148b542e16de96ce2d14db9c41552752f3d8e655", "walletconflicts": [ ], - "time": 1687759025, - "timereceived": 1687759181, + "time": 1700670571, + "timereceived": 1700670571, "bip125-replaceable": "no" } ] @@ -160,29 +150,22 @@ Once those funds have been recovered and the refund delay has expired (the `conf 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]}' - +bitcoin-cli -rpcwallet=recovery walletcreatefundedpsbt '[{"txid":"4c3236b1fa1f3ed124ab83b1667be95f855952e68729eae54a9f511c8c8cb993", "vout":0, "sequence":144}]' '[{"bcrt1qzy4h8dux6pjl8ys979632uynqffd53vjkzffjl":0.09}]' { - "psbt": "cHNidP8BAFICAAAAAXWMKSj9PeDI+J59YrFuCuMzBtLWvQfDro7/Cbd+C5TZAQAAAABAZQAAAQzI8AgAAAAAFgAULkXhrjFxJuD29Dq/MH5R49YRQHYAAAAAAAEAiQIAAAABsDXoV21bcbM8ii+Nyo4r8ZWmMEJIiaYqYg6pKaXiOiMAAAAAAP3///8CnBMVIQEAAAAiUSA520UqAgN8jz9APGIBbNHksiweuAEnMZvjgpMKiRUKkoDR8AgAAAAAIgAgd4+G4hOlGOgzbslAh0zPS5KRAzL1s8AmU3Sobb005qWWAAAAAQErgNHwCAAAAAAiACB3j4biE6UY6DNuyUCHTM9LkpEDMvWzwCZTdKhtvTTmpQEFTSED1ZiIPcIwgcWSbaso29B11ULE+6VERxkh27lqMde8SRmtIQJW6UgYDzPwZyRnEKQWVghPwkW5ftoIHv4eSIshV31g/axzZAJAZbJoIgYCVulIGA8z8GckZxCkFlYIT8JFuX7aCB7+HkiLIVd9YP0EsxuziiIGA9WYiD3CMIHFkm2rKNvQddVCxPulREcZIdu5ajHXvEkZEBRiCUgzAACAAAAAgAAAAIAAAA==", - "fee": 0.00002420, - "changepos": -1 + "psbt": "cHNidP8BAHECAAAAAZO5jIwcUZ9K5eoph+ZSWYVf6XtmsYOrJNE+H/qxNjJMAAAAAACQAAAAAkBUiQAAAAAAFgAUEStzt4bQZfOSBfF1FXCTAlLaRZIEOA8AAAAAABYAFEx1yJgBL6kfpf2sybIL0WajM0rXAAAAAAABASuAlpgAAAAAACJRIBC8PjbhAQmhNnxw0RaGeV3xZIp431XBmRfmNKaKpRZmIhXBH8VZ2clsWVOJXTFQ5k6/PdaWoLCOdYZQtI/2JR1+YNEnIG0cAcgg4JAO3Y5ZtLOD5zp/WFAHJFWAT5z/6Z+k+FQbrQKQALLAIRYfxVnZyWxZU4ldMVDmTr891pagsI51hlC0j/YlHX5g0QUA0EkObyEWbRwByCDgkA7djlm0s4PnOn9YUAckVYBPnP/pn6T4VBspAWR9Jdbf5zHI25Gs69RTMJILBCLUX82cmJj59Bk4SZKgTHUGgQAAAAABFyAfxVnZyWxZU4ldMVDmTr891pagsI51hlC0j/YlHX5g0QEYIGR9Jdbf5zHI25Gs69RTMJILBCLUX82cmJj59Bk4SZKgAAAiAgMhzD3XSvW4p+oRyBAvB6rUHaOCIyjVxJV9tEin3sUiqxjpcZ0vVAAAgAEAAIAAAACAAQAAAAEAAAAA", + "fee": 0.00002620, + "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=" - +bitcoin-cli -rpcwallet=recovery walletprocesspsbt "cHNidP8BAHECAAAAAZO5jIwcUZ9K5eoph+ZSWYVf6XtmsYOrJNE+H/qxNjJMAAAAAACQAAAAAkBUiQAAAAAAFgAUEStzt4bQZfOSBfF1FXCTAlLaRZIEOA8AAAAAABYAFEx1yJgBL6kfpf2sybIL0WajM0rXAAAAAAABASuAlpgAAAAAACJRIBC8PjbhAQmhNnxw0RaGeV3xZIp431XBmRfmNKaKpRZmIhXBH8VZ2clsWVOJXTFQ5k6/PdaWoLCOdYZQtI/2JR1+YNEnIG0cAcgg4JAO3Y5ZtLOD5zp/WFAHJFWAT5z/6Z+k+FQbrQKQALLAIRYfxVnZyWxZU4ldMVDmTr891pagsI51hlC0j/YlHX5g0QUA0EkObyEWbRwByCDgkA7djlm0s4PnOn9YUAckVYBPnP/pn6T4VBspAWR9Jdbf5zHI25Gs69RTMJILBCLUX82cmJj59Bk4SZKgTHUGgQAAAAABFyAfxVnZyWxZU4ldMVDmTr891pagsI51hlC0j/YlHX5g0QEYIGR9Jdbf5zHI25Gs69RTMJILBCLUX82cmJj59Bk4SZKgAAAiAgMhzD3XSvW4p+oRyBAvB6rUHaOCIyjVxJV9tEin3sUiqxjpcZ0vVAAAgAEAAIAAAACAAQAAAAEAAAAA" { - "hex": "02000000000101758c2928fd3de0c8f89e7d62b16e0ae33306d2d6bd07c3ae8eff09b77e0b94d9010000000040650000010cc8f008000000001600142e45e1ae317126e0f6f43abf307e51e3d6114076030047304402204d7b963f7c658d7c2221da80686591e1e449b5911dc616e9a96b46e09fd5910902204c328697f179f70a46461749fe32e53931eab33987a274130f8aa054d34921ce014d2103d598883dc23081c5926dab28dbd075d542c4fba544471921dbb96a31d7bc4919ad210256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fdac7364024065b26800000000", - "complete": true + "psbt": "cHNidP8BAHECAAAAAZO5jIwcUZ9K5eoph+ZSWYVf6XtmsYOrJNE+H/qxNjJMAAAAAACQAAAAAkBUiQAAAAAAFgAUEStzt4bQZfOSBfF1FXCTAlLaRZIEOA8AAAAAABYAFEx1yJgBL6kfpf2sybIL0WajM0rXAAAAAAABASuAlpgAAAAAACJRIBC8PjbhAQmhNnxw0RaGeV3xZIp431XBmRfmNKaKpRZmAQiLA0D59zl6TLlwXk2oCio3Ffff8dpRQmpYWs7MaY+cUk1Zfl03hzxj1vwIAHBQQbyh33PCX7JoDrlXxlo/Le86jMjQJiBtHAHIIOCQDt2OWbSzg+c6f1hQByRVgE+c/+mfpPhUG60CkACyIcEfxVnZyWxZU4ldMVDmTr891pagsI51hlC0j/YlHX5g0QAAIgIDIcw910r1uKfqEcgQLweq1B2jgiMo1cSVfbRIp97FIqsY6XGdL1QAAIABAACAAAAAgAEAAAABAAAAAA==", + "complete": true, + "hex": "0200000000010193b98c8c1c519f4ae5ea2987e65259855fe97b66b183ab24d13e1ffab136324c000000000090000000024054890000000000160014112b73b786d065f39205f1751570930252da459204380f00000000001600144c75c898012fa91fa5fdacc9b20bd166a3334ad70340f9f7397a4cb9705e4da80a2a3715f7dff1da51426a585acecc698f9c524d597e5d37873c63d6fc0800705041bca1df73c25fb2680eb957c65a3f2def3a8cc8d026206d1c01c820e0900edd8e59b4b383e73a7f5850072455804f9cffe99fa4f8541bad029000b221c11fc559d9c96c5953895d3150e64ebf3dd696a0b08e758650b48ff6251d7e60d100000000" } -bitcoin-cli -rpcwallet=recovery sendrawtransaction 02000000000101758c2928fd3de0c8f89e7d62b16e0ae33306d2d6bd07c3ae8eff09b77e0b94d9010000000040650000010cc8f008000000001600142e45e1ae317126e0f6f43abf307e51e3d6114076030047304402204d7b963f7c658d7c2221da80686591e1e449b5911dc616e9a96b46e09fd5910902204c328697f179f70a46461749fe32e53931eab33987a274130f8aa054d34921ce014d2103d598883dc23081c5926dab28dbd075d542c4fba544471921dbb96a31d7bc4919ad210256e948180f33f067246710a41656084fc245b97eda081efe1e488b21577d60fdac7364024065b26800000000 +bitcoin-cli sendrawtransaction 0200000000010193b98c8c1c519f4ae5ea2987e65259855fe97b66b183ab24d13e1ffab136324c000000000090000000024054890000000000160014112b73b786d065f39205f1751570930252da459204380f00000000001600144c75c898012fa91fa5fdacc9b20bd166a3334ad70340f9f7397a4cb9705e4da80a2a3715f7dff1da51426a585acecc698f9c524d597e5d37873c63d6fc0800705041bca1df73c25fb2680eb957c65a3f2def3a8cc8d026206d1c01c820e0900edd8e59b4b383e73a7f5850072455804f9cffe99fa4f8541bad029000b221c11fc559d9c96c5953895d3150e64ebf3dd696a0b08e758650b48ff6251d7e60d100000000 +09efe025805b2db8ae845a94639e5ad415756fb0d010aad54bf3f74ae71e015d ``` Wait for that transaction to confirm, and your funds will have been successfully recovered! diff --git a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt index 8b174fb02..d344d983e 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/channel/InteractiveTx.kt @@ -124,6 +124,14 @@ sealed class InteractiveTxInput { override val outPoint: OutPoint = OutPoint(previousTx, previousTxOutput) } + data class LocalMusig2SwapIn( + override val serialId: Long, + override val previousTx: Transaction, + override val previousTxOutput: Long, + override val sequence: UInt, + val swapInParams: TxAddInputTlv.SwapInParamsMusig2) : Local() { + override val outPoint: OutPoint = OutPoint(previousTx, previousTxOutput) + } /** * A remote input that funds the interactive transaction. * We only keep the data we need from our peer's TxAddInput to avoid storing potentially large messages in our DB. @@ -141,6 +149,13 @@ sealed class InteractiveTxInput { override val sequence: UInt, val swapInParams: TxAddInputTlv.SwapInParams) : Remote() + data class RemoteSwapInMusig2( + override val serialId: Long, + override val outPoint: OutPoint, + override val txOut: TxOut, + override val sequence: UInt, + val swapInParams: TxAddInputTlv.SwapInParamsMusig2) : Remote() + /** The shared input can be added by us or by our peer, depending on who initiated the protocol. */ data class Shared(override val serialId: Long, override val outPoint: OutPoint, override val txOut: TxOut, override val sequence: UInt, val localAmount: MilliSatoshi, val remoteAmount: MilliSatoshi) : InteractiveTxInput(), Incoming, Outgoing } @@ -258,14 +273,23 @@ data class FundingContributions(val inputs: List, v } val sharedInput = sharedUtxo?.let { (i, balances) -> listOf(InteractiveTxInput.Shared(0, i.info.outPoint, i.info.txOut, 0xfffffffdU, balances.toLocal, balances.toRemote)) } ?: listOf() val localInputs = walletInputs.map { i -> - val version = if (Script.isPay2wsh(i.previousTx.txOut[i.outputIndex].publicKeyScript.toByteArray())) 1 else 2 - InteractiveTxInput.LocalSwapIn( - 0, - i.previousTx.stripInputWitnesses(), - i.outputIndex.toLong(), - 0xfffffffdU, - TxAddInputTlv.SwapInParams(swapInKeys.userPublicKey, swapInKeys.remoteServerPublicKey, swapInKeys.refundDelay, version) - ) + when { + Script.isPay2wsh(i.previousTx.txOut[i.outputIndex].publicKeyScript.toByteArray()) -> + InteractiveTxInput.LocalSwapIn( + 0, + i.previousTx.stripInputWitnesses(), + i.outputIndex.toLong(), + 0xfffffffdU, + TxAddInputTlv.SwapInParams(swapInKeys.userPublicKey, swapInKeys.remoteServerPublicKey, swapInKeys.refundDelay) + ) + else -> InteractiveTxInput.LocalMusig2SwapIn( + 0, + i.previousTx.stripInputWitnesses(), + i.outputIndex.toLong(), + 0xfffffffdU, + TxAddInputTlv.SwapInParamsMusig2(swapInKeys.userPublicKey, swapInKeys.remoteServerPublicKey, swapInKeys.userRefundPublicKey, swapInKeys.refundDelay) + ) + } } return if (params.isInitiator) { Either.Right(sortFundingContributions(params, sharedInput + localInputs, sharedOutput + nonChangeOutputs + changeOutput)) @@ -315,6 +339,7 @@ data class FundingContributions(val inputs: List, v when (input) { is InteractiveTxInput.LocalOnly -> input.copy(serialId = serialId) is InteractiveTxInput.LocalSwapIn -> input.copy(serialId = serialId) + is InteractiveTxInput.LocalMusig2SwapIn-> input.copy(serialId = serialId) is InteractiveTxInput.Shared -> input.copy(serialId = serialId) } } @@ -388,15 +413,13 @@ data class SharedTransaction( val swapUserSigs = unsignedTx.txIn.mapIndexed { i, txIn -> localInputs .filterIsInstance() - .filter { it.swapInParams.version == 1 } .find { txIn.outPoint == it.outPoint } ?.let { input -> keyManager.swapInOnChainWallet.signSwapInputUser(unsignedTx, i, input.previousTx.txOut) } }.filterNotNull() val swapUserPartialSigs = unsignedTx.txIn.mapIndexed { i, txIn -> localInputs - .filterIsInstance() - .filter { it.swapInParams.version == 2 } + .filterIsInstance() .find { txIn.outPoint == it.outPoint } ?.let { input -> val userNonce = session.secretNonces[input.serialId] @@ -413,7 +436,6 @@ data class SharedTransaction( val swapServerSigs = unsignedTx.txIn.mapIndexed { i, txIn -> remoteInputs .filterIsInstance() - .filter { it.swapInParams.version == 1 } .find { txIn.outPoint == it.outPoint } ?.let { input -> val serverKey = keyManager.swapInOnChainWallet.localServerPrivateKey(remoteNodeId) @@ -424,8 +446,7 @@ data class SharedTransaction( val swapServerPartialSigs = unsignedTx.txIn.mapIndexed { i, txIn -> remoteInputs - .filterIsInstance() - .filter { it.swapInParams.version == 2 } + .filterIsInstance() .find { txIn.outPoint == it.outPoint } ?.let { input -> val serverKey = keyManager.swapInOnChainWallet.localServerPrivateKey(remoteNodeId) @@ -435,7 +456,7 @@ data class SharedTransaction( val serverNonce = session.txCompleteReceived.publicNonces[input.serialId] require(serverNonce != null) val commonNonce = PublicNonce.aggregate(listOf(userNonce.publicNonce(), serverNonce)) - val swapInProtocol = SwapInProtocolMusig2(input.swapInParams.userKey, serverKey.publicKey(), input.swapInParams.refundDelay) + val swapInProtocol = SwapInProtocolMusig2(input.swapInParams.userKey, serverKey.publicKey(), input.swapInParams.userRefundKey, input.swapInParams.refundDelay) TxSignatures.Companion.PartialSignature(swapInProtocol.signSwapInputServer(unsignedTx, i, previousOutputs, serverNonce, serverKey, userNonce), commonNonce) } }.filterNotNull() @@ -457,12 +478,12 @@ data class PartiallySignedSharedTransaction(override val tx: SharedTransaction, override val signedTx = null fun addRemoteSigs(channelKeys: KeyManager.ChannelKeys, fundingParams: InteractiveTxParams, remoteSigs: TxSignatures): FullySignedSharedTransaction? { - if (localSigs.swapInUserSigs.size != tx.localInputs.filterIsInstance().filter { it.swapInParams.version == 1 }.size) return null - if (localSigs.swapInUserPartialSigs.size != tx.localInputs.filterIsInstance().filter { it.swapInParams.version == 2 }.size) return null - if (remoteSigs.swapInUserSigs.size != tx.remoteSwapInputs().filter { it.swapInParams.version ==1 }.size) return null - if (remoteSigs.swapInUserPartialSigs.size != tx.remoteSwapInputs().filter { it.swapInParams.version ==2 }.size) return null - if (remoteSigs.swapInServerSigs.size != tx.localInputs.filter { it is InteractiveTxInput.LocalSwapIn && it.swapInParams.version == 1}.size) return null - if (remoteSigs.swapInServerPartialSigs.size != tx.localInputs.filter { it is InteractiveTxInput.LocalSwapIn && it.swapInParams.version == 2}.size) return null + if (localSigs.swapInUserSigs.size != tx.localInputs.filterIsInstance().size) return null + if (localSigs.swapInUserPartialSigs.size != tx.localInputs.filterIsInstance().size) return null + if (remoteSigs.swapInUserSigs.size != tx.remoteInputs.filterIsInstance().size) return null + if (remoteSigs.swapInUserPartialSigs.size != tx.remoteInputs.filterIsInstance().size) return null + if (remoteSigs.swapInServerSigs.size != tx.localInputs.filterIsInstance().size) return null + if (remoteSigs.swapInServerPartialSigs.size != tx.localInputs.filterIsInstance().size) return null if (remoteSigs.witnesses.size != tx.remoteOnlyInputs().size) return null if (remoteSigs.txId != localSigs.txId) return null val sharedSigs = fundingParams.sharedInput?.let { @@ -487,13 +508,13 @@ data class FullySignedSharedTransaction(override val tx: SharedTransaction, over override val signedTx = run { val sharedTxIn = tx.sharedInput?.let { i -> listOf(Pair(i.serialId, TxIn(i.outPoint, ByteVector.empty, i.sequence.toLong(), sharedSigs ?: ScriptWitness.empty))) } ?: listOf() val localOnlyTxIn = tx.localOnlyInputs().sortedBy { i -> i.serialId }.zip(localSigs.witnesses).map { (i, w) -> Pair(i.serialId, TxIn(OutPoint(i.previousTx, i.previousTxOutput), ByteVector.empty, i.sequence.toLong(), w)) } - val localSwapTxIn = tx.localSwapInputs().filter { it.swapInParams.version == 1 }.sortedBy { i -> i.serialId }.zip(localSigs.swapInUserSigs.zip(remoteSigs.swapInServerSigs)).map { (i, sigs) -> + val localSwapTxIn = tx.localInputs.filterIsInstance().sortedBy { i -> i.serialId }.zip(localSigs.swapInUserSigs.zip(remoteSigs.swapInServerSigs)).map { (i, sigs) -> val (userSig, serverSig) = sigs val swapInProtocol = SwapInProtocol(i.swapInParams) val witness = swapInProtocol.witness(userSig, serverSig) Pair(i.serialId, TxIn(OutPoint(i.previousTx, i.previousTxOutput), ByteVector.empty, i.sequence.toLong(), witness)) } - val localSwapTxInMusig2 = tx.localSwapInputs().filter { it.swapInParams.version == 2 }.sortedBy { i -> i.serialId }.zip(localSigs.swapInUserPartialSigs.zip(remoteSigs.swapInServerPartialSigs)).map { (i, sigs) -> + val localSwapTxInMusig2 = tx.localInputs.filterIsInstance().sortedBy { i -> i.serialId }.zip(localSigs.swapInUserPartialSigs.zip(remoteSigs.swapInServerPartialSigs)).map { (i, sigs) -> val (userSig, serverSig) = sigs val swapInProtocol = SwapInProtocolMusig2(i.swapInParams) require(userSig.nonce == serverSig.nonce){ "user and server public nonces mismatch for local input ${i.serialId}"} @@ -506,13 +527,13 @@ data class FullySignedSharedTransaction(override val tx: SharedTransaction, over } val remoteOnlyTxIn = tx.remoteOnlyInputs().sortedBy { i -> i.serialId }.zip(remoteSigs.witnesses).map { (i, w) -> Pair(i.serialId, TxIn(i.outPoint, ByteVector.empty, i.sequence.toLong(), w)) } - val remoteSwapTxIn = tx.remoteSwapInputs().filter { it.swapInParams.version == 1 }.sortedBy { i -> i.serialId }.zip(remoteSigs.swapInUserSigs.zip(localSigs.swapInServerSigs)).map { (i, sigs) -> + val remoteSwapTxIn = tx.remoteInputs.filterIsInstance().sortedBy { i -> i.serialId }.zip(remoteSigs.swapInUserSigs.zip(localSigs.swapInServerSigs)).map { (i, sigs) -> val (userSig, serverSig) = sigs val swapInProtocol = SwapInProtocol(i.swapInParams.userKey, i.swapInParams.serverKey, i.swapInParams.refundDelay) val witness = swapInProtocol.witness(userSig, serverSig) Pair(i.serialId, TxIn(i.outPoint, ByteVector.empty, i.sequence.toLong(), witness)) } - val remoteSwapTxInMusig2 = tx.remoteSwapInputs().filter { it.swapInParams.version == 2 }.sortedBy { i -> i.serialId }.zip(remoteSigs.swapInUserPartialSigs.zip(localSigs.swapInServerPartialSigs)).map { (i, sigs) -> + val remoteSwapTxInMusig2 = tx.remoteInputs.filterIsInstance().sortedBy { i -> i.serialId }.zip(remoteSigs.swapInUserPartialSigs.zip(localSigs.swapInServerPartialSigs)).map { (i, sigs) -> val (userSig, serverSig) = sigs val swapInProtocol = SwapInProtocolMusig2(i.swapInParams) require(userSig.nonce == serverSig.nonce){ "user and server public nonces mismatch for remote input ${i.serialId}"} @@ -621,9 +642,9 @@ data class InteractiveTxSession( val currentNonces = secretNonces fun userNonce(serialId: Long) = currentNonces.getOrElse(serialId) { SecretNonce.generate(swapInKeys.userPrivateKey, swapInKeys.userPublicKey, null, null, null, randomBytes32()) } fun serverNonce(serialId: Long, serverKey: PublicKey) = currentNonces.getOrElse(serialId) { SecretNonce.generate(null, serverKey, null, null, null, randomBytes32()) } - val localMusig2SwapIns = localInputs.filterIsInstance().filter { swapInKeys.swapInProtocolMusig2.isMine(it.txOut) } + val localMusig2SwapIns = localInputs.filterIsInstance() val localNonces = localMusig2SwapIns.map { it.serialId to userNonce(it.serialId) }.toMap() - val remoteMusig2SwapIns = remoteInputs.filterIsInstance().filter { it.swapInParams.version == 2 } + val remoteMusig2SwapIns = remoteInputs.filterIsInstance() val remoteNonces = remoteMusig2SwapIns.map { it.serialId to serverNonce(it.serialId, it.swapInParams.serverKey) }.toMap() val txComplete = TxComplete(fundingParams.channelId, (localNonces + remoteNonces).mapValues { it.value.publicNonce() }) val next = copy(txCompleteSent = txComplete, secretNonces = localNonces + remoteNonces) @@ -639,11 +660,13 @@ data class InteractiveTxSession( val txAddInput = when (msg.value) { is InteractiveTxInput.LocalOnly -> TxAddInput(fundingParams.channelId, msg.value.serialId, msg.value.previousTx, msg.value.previousTxOutput, msg.value.sequence) is InteractiveTxInput.LocalSwapIn -> { - val version = if (swapInKeys.swapInProtocolMusig2.isMine(msg.value.txOut)) 2 else 1 - val swapInParams = TxAddInputTlv.SwapInParams(swapInKeys.userPublicKey, swapInKeys.remoteServerPublicKey, swapInKeys.refundDelay, version) + val swapInParams = TxAddInputTlv.SwapInParams(swapInKeys.userPublicKey, swapInKeys.remoteServerPublicKey, swapInKeys.refundDelay) + TxAddInput(fundingParams.channelId, msg.value.serialId, msg.value.previousTx, msg.value.previousTxOutput, msg.value.sequence, TlvStream(swapInParams)) + } + is InteractiveTxInput.LocalMusig2SwapIn -> { + val swapInParams = TxAddInputTlv.SwapInParamsMusig2(swapInKeys.userPublicKey, swapInKeys.remoteServerPublicKey, swapInKeys.userRefundPublicKey, swapInKeys.refundDelay) TxAddInput(fundingParams.channelId, msg.value.serialId, msg.value.previousTx, msg.value.previousTxOutput, msg.value.sequence, TlvStream(swapInParams)) } - is InteractiveTxInput.Shared -> TxAddInput(fundingParams.channelId, msg.value.serialId, msg.value.outPoint, msg.value.sequence) } Pair(next, InteractiveTxSessionAction.SendMessage(txAddInput)) @@ -690,9 +713,10 @@ data class InteractiveTxSession( } val outpoint = OutPoint(message.previousTx, message.previousTxOutput) val txOut = message.previousTx.txOut[message.previousTxOutput.toInt()] - when (message.swapInParams) { - null -> InteractiveTxInput.RemoteOnly(message.serialId, outpoint, txOut, message.sequence) - else -> InteractiveTxInput.RemoteSwapIn(message.serialId, outpoint, txOut, message.sequence, message.swapInParams) + when { + message.swapInParamsMusig2 != null -> InteractiveTxInput.RemoteSwapInMusig2(message.serialId, outpoint, txOut, message.sequence, message.swapInParamsMusig2) + message.swapInParams != null -> InteractiveTxInput.RemoteSwapIn(message.serialId, outpoint, txOut, message.sequence, message.swapInParams) + else -> InteractiveTxInput.RemoteOnly(message.serialId, outpoint, txOut, message.sequence) } } } @@ -826,7 +850,7 @@ data class InteractiveTxSession( } val remoteOnlyInputsWithNonces = remoteOnlyInputs.map { when { - it is InteractiveTxInput.RemoteSwapIn && it.swapInParams.version == 2 -> { + it is InteractiveTxInput.RemoteSwapInMusig2 -> { val userNonce = secretNonces[it.serialId] val serverNonce = txCompleteReceived.publicNonces[it.serialId] if (userNonce == null || serverNonce == null) return InteractiveTxSessionAction.MissingNonce(fundingParams.channelId, it.serialId) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt b/src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt index 7c598cb67..386d21000 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt @@ -121,17 +121,21 @@ interface KeyManager { val refundDelay: Int = DefaultSwapInParams.RefundDelay ) { private val userExtendedPrivateKey: DeterministicWallet.ExtendedPrivateKey = DeterministicWallet.derivePrivateKey(master, swapInUserKeyPath(chain)) + private val userRefundExtendedPrivateKey: DeterministicWallet.ExtendedPrivateKey = DeterministicWallet.derivePrivateKey(master, swapInUserRefundKeyPath(chain)) private val swapExtendedPublicKey = DeterministicWallet.publicKey(DeterministicWallet.derivePrivateKey(master, swapInLocalServerKeyPath(chain))) private val xpub = DeterministicWallet.encode(swapExtendedPublicKey, DeterministicWallet.tpub) val userPrivateKey: PrivateKey = userExtendedPrivateKey.privateKey val userPublicKey: PublicKey = userPrivateKey.publicKey() + val userRefundPrivateKey: PrivateKey = userRefundExtendedPrivateKey.privateKey + val userRefundPublicKey: PublicKey = userPrivateKey.publicKey() + private val localServerExtendedPrivateKey: DeterministicWallet.ExtendedPrivateKey = DeterministicWallet.derivePrivateKey(master, swapInLocalServerKeyPath(chain)) fun localServerPrivateKey(remoteNodeId: PublicKey): PrivateKey = DeterministicWallet.derivePrivateKey(localServerExtendedPrivateKey, perUserPath(remoteNodeId)).privateKey val swapInProtocol = SwapInProtocol(userPublicKey, remoteServerPublicKey, refundDelay) - val swapInProtocolMusig2 = SwapInProtocolMusig2(userPublicKey, remoteServerPublicKey, refundDelay) + val swapInProtocolMusig2 = SwapInProtocolMusig2(userPublicKey, remoteServerPublicKey, userRefundPublicKey, refundDelay) /** * The output script descriptor matching our swap-in addresses. @@ -211,6 +215,8 @@ interface KeyManager { fun swapInUserKeyPath(chain: NodeParams.Chain) = swapInKeyBasePath(chain) / hardened(0) + fun swapInUserRefundKeyPath(chain: NodeParams.Chain) = swapInKeyBasePath(chain) / hardened(0) / 0L + fun swapInLocalServerKeyPath(chain: NodeParams.Chain) = swapInKeyBasePath(chain) / hardened(1) fun encodedSwapInUserKeyPath(chain: NodeParams.Chain) = when (chain) { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt index 421199718..37b8a4c12 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Deserialization.kt @@ -234,6 +234,13 @@ object Deserialization { sequence = readNumber().toUInt(), swapInParams = TxAddInputTlv.SwapInParams.read(this), ) + 0x03 -> InteractiveTxInput.LocalMusig2SwapIn( + serialId = readNumber(), + previousTx = readTransaction(), + previousTxOutput = readNumber(), + sequence = readNumber().toUInt(), + swapInParams = TxAddInputTlv.SwapInParamsMusig2.read(this), + ) else -> error("unknown discriminator $discriminator for class ${InteractiveTxInput.Local::class}") } @@ -251,6 +258,13 @@ object Deserialization { sequence = readNumber().toUInt(), swapInParams = TxAddInputTlv.SwapInParams.read(this) ) + 0x03 -> InteractiveTxInput.RemoteSwapInMusig2( + serialId = readNumber(), + outPoint = readOutPoint(), + txOut = TxOut.read(readDelimitedByteArray()), + sequence = readNumber().toUInt(), + swapInParams = TxAddInputTlv.SwapInParamsMusig2.read(this) + ) else -> error("unknown discriminator $discriminator for class ${InteractiveTxInput.Remote::class}") } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt index 31ef72c6e..af1ae716b 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/serialization/v4/Serialization.kt @@ -277,6 +277,14 @@ object Serialization { writeNumber(sequence.toLong()) swapInParams.write(this@writeLocalInteractiveTxInput) } + is InteractiveTxInput.LocalMusig2SwapIn -> i.run { + write(0x03) + writeNumber(serialId) + writeBtcObject(previousTx) + writeNumber(previousTxOutput) + writeNumber(sequence.toLong()) + swapInParams.write(this@writeLocalInteractiveTxInput) + } } private fun Output.writeRemoteInteractiveTxInput(i: InteractiveTxInput.Remote) = when (i) { @@ -295,6 +303,14 @@ object Serialization { writeNumber(sequence.toLong()) swapInParams.write(this@writeRemoteInteractiveTxInput) } + is InteractiveTxInput.RemoteSwapInMusig2 -> i.run { + write(0x03) + writeNumber(serialId) + writeBtcObject(outPoint) + writeBtcObject(txOut) + writeNumber(sequence.toLong()) + swapInParams.write(this@writeRemoteInteractiveTxInput) + } } private fun Output.writeSharedInteractiveTxOutput(o: InteractiveTxOutput.Shared) = o.run { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/transactions/SwapInProtocol.kt b/src/commonMain/kotlin/fr/acinq/lightning/transactions/SwapInProtocol.kt index 11cd27772..9974255ab 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/transactions/SwapInProtocol.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/transactions/SwapInProtocol.kt @@ -5,14 +5,15 @@ 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.Lightning import fr.acinq.lightning.NodeParams import fr.acinq.lightning.wire.TxAddInputTlv -import org.kodein.log.newLogger +/** + * legacy swap-in protocol, that uses p2wsh and a single "user + server OR user + delay" script + */ class SwapInProtocol(val userPublicKey: PublicKey, val serverPublicKey: PublicKey, val refundDelay: Int) { - constructor(swapInParams: TxAddInputTlv.SwapInParams) : this(swapInParams.userKey, swapInParams.serverKey, swapInParams.refundDelay) + constructor(swapInParams: TxAddInputTlv.SwapInParams) : this(swapInParams.userKey, swapInParams.serverKey, swapInParams.refundDelay) // This script was generated with https://bitcoin.sipa.be/miniscript/ using the following miniscript policy: // and(pk(),or(99@pk(),older())) @@ -49,18 +50,31 @@ class SwapInProtocol(val userPublicKey: PublicKey, val serverPublicKey: PublicKe } } -class SwapInProtocolMusig2(val userPublicKey: PublicKey, val serverPublicKey: PublicKey, val refundDelay: Int) { - constructor(swapInParams: TxAddInputTlv.SwapInParams) : this(swapInParams.userKey, swapInParams.serverKey, swapInParams.refundDelay) +/** + * new swap-in protocol based on musig2 and taproot: (user key + server key) OR (user refund key + delay) + * for the common case, we use the musig2 aggregate of the user and server keys, spent through the key-spend path + * for the refund case, we use the refund script, spent through the script-spend path + * we use a different user key for the refund case: this allows us to generate generic descriptor for all swap-in addresses + * (see the descriptor() method below) + */ +class SwapInProtocolMusig2(val userPublicKey: PublicKey, val serverPublicKey: PublicKey, val userRefundKey: PublicKey, val refundDelay: Int) { + constructor(swapInParams: TxAddInputTlv.SwapInParamsMusig2) : this(swapInParams.userKey, swapInParams.serverKey, swapInParams.userRefundKey, swapInParams.refundDelay) // 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) + // it does not depend upon the user's or server's key, just the user's refund key and the refund delay + val redeemScript = listOf(OP_PUSHDATA(userRefundKey.xOnly()), OP_CHECKSIGVERIFY, OP_PUSHDATA(Script.encodeNumber(refundDelay)), OP_CHECKSEQUENCEVERIFY) private val scriptTree = ScriptTree.Leaf(ScriptLeaf(0, Script.write(redeemScript).byteVector(), Script.TAPROOT_LEAF_TAPSCRIPT)) private val merkleRoot = ScriptTree.hash(scriptTree) + + // 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 private val internalPubKey = Musig2.keyAgg(listOf(userPublicKey, serverPublicKey)).Q.xOnly() + + // it is tweaked with the script's merkle root to get the pubkey that will be exposed private val commonPubKeyAndParity = internalPubKey.outputKey(Crypto.TaprootTweak.ScriptTweak(merkleRoot)) val commonPubKey = commonPubKeyAndParity.first private val parity = commonPubKeyAndParity.second val pubkeyScript: List = Script.pay2tr(commonPubKey) + private val executionData = Script.ExecutionData(annex = null, tapleafHash = merkleRoot) private val controlBlock = byteArrayOf((Script.TAPROOT_LEAF_TAPSCRIPT + (if (parity) 1 else 0)).toByte()) + internalPubKey.value.toByteArray() @@ -111,4 +125,40 @@ class SwapInProtocolMusig2(val userPublicKey: PublicKey, val serverPublicKey: Pu txHash ) } + + /** + * + * @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 + } + val xpriv = DeterministicWallet.encode(masterRefundKey, prefix) + val path = masterRefundKey.path.toString().replace('\'', 'h').removePrefix("m") + val desc = "tr(${internalPubKey.value},and_v(v:pk($xpriv$path/*),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 { + val prefix = when (chain) { + NodeParams.Chain.Mainnet -> DeterministicWallet.xpub + else -> DeterministicWallet.tpub + } + val xpub = DeterministicWallet.encode(masterRefundKey, prefix) + val path = masterRefundKey.path.toString().replace('\'', 'h').removePrefix("m") + val desc = "tr(${internalPubKey.value},and_v(v:pk($xpub$path/*),older($refundDelay)))" + val checksum = Descriptor.checksum(desc) + return "$desc#$checksum" + } } \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/InteractiveTxTlv.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/InteractiveTxTlv.kt index 04a91586b..207d52f95 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/InteractiveTxTlv.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/InteractiveTxTlv.kt @@ -25,13 +25,12 @@ sealed class TxAddInputTlv : Tlv { } /** When adding a swap-in input to an interactive-tx, the user needs to provide the corresponding script parameters. */ - data class SwapInParams(val userKey: PublicKey, val serverKey: PublicKey, val refundDelay: Int, val version: Int) : TxAddInputTlv() { + data class SwapInParams(val userKey: PublicKey, val serverKey: PublicKey, val refundDelay: Int) : TxAddInputTlv() { override val tag: Long get() = SwapInParams.tag override fun write(out: Output) { LightningCodecs.writeBytes(userKey.value, out) LightningCodecs.writeBytes(serverKey.value, out) LightningCodecs.writeU32(refundDelay, out) - LightningCodecs.writeU32(version, out) } companion object : TlvValueReader { @@ -39,8 +38,28 @@ sealed class TxAddInputTlv : Tlv { override fun read(input: Input): SwapInParams = SwapInParams( PublicKey(LightningCodecs.bytes(input, 33)), PublicKey(LightningCodecs.bytes(input, 33)), - LightningCodecs.u32(input), - if (input.availableBytes >= 4) LightningCodecs.u32(input) else 1 + LightningCodecs.u32(input) + ) + } + } + + /** When adding a swap-in input to an interactive-tx, the user needs to provide the corresponding script parameters. */ + data class SwapInParamsMusig2(val userKey: PublicKey, val serverKey: PublicKey, val userRefundKey: PublicKey, val refundDelay: Int) : TxAddInputTlv() { + override val tag: Long get() = SwapInParamsMusig2.tag + override fun write(out: Output) { + LightningCodecs.writeBytes(userKey.value, out) + LightningCodecs.writeBytes(serverKey.value, out) + LightningCodecs.writeBytes(userRefundKey.value, out) + LightningCodecs.writeU32(refundDelay, out) + } + + companion object : TlvValueReader { + const val tag: Long = 1109 + override fun read(input: Input): SwapInParamsMusig2 = SwapInParamsMusig2( + PublicKey(LightningCodecs.bytes(input, 33)), + PublicKey(LightningCodecs.bytes(input, 33)), + PublicKey(LightningCodecs.bytes(input, 33)), + LightningCodecs.u32(input) ) } } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt index 4624a45ce..8140a94d6 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/LightningMessages.kt @@ -331,6 +331,7 @@ data class TxAddInput( override val type: Long get() = TxAddInput.type val sharedInput: OutPoint? = tlvs.get()?.let { OutPoint(it.txId.reversed(), previousTxOutput) } val swapInParams = tlvs.get() + val swapInParamsMusig2 = tlvs.get() override fun write(out: Output) { LightningCodecs.writeBytes(channelId.toByteArray(), out) @@ -355,6 +356,7 @@ data class TxAddInput( val readers = mapOf( TxAddInputTlv.SharedInputTxId.tag to TxAddInputTlv.SharedInputTxId.Companion as TlvValueReader, TxAddInputTlv.SwapInParams.tag to TxAddInputTlv.SwapInParams.Companion as TlvValueReader, + TxAddInputTlv.SwapInParamsMusig2.tag to TxAddInputTlv.SwapInParamsMusig2.Companion as TlvValueReader, ) override fun read(input: Input): TxAddInput = TxAddInput( diff --git a/src/commonTest/kotlin/fr/acinq/lightning/transactions/TransactionsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/transactions/TransactionsTestsCommon.kt index 498096f82..4a1e7b701 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/transactions/TransactionsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/transactions/TransactionsTestsCommon.kt @@ -14,6 +14,7 @@ import fr.acinq.lightning.CltvExpiry import fr.acinq.lightning.CltvExpiryDelta import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.Lightning.randomKey +import fr.acinq.lightning.NodeParams import fr.acinq.lightning.blockchain.fee.FeeratePerKw import fr.acinq.lightning.channel.Commitments import fr.acinq.lightning.channel.Helpers.Funding @@ -556,8 +557,14 @@ class TransactionsTestsCommon : LightningTestSuite() { fun `spend 2-of-2 swap-in taproot-musig2 version`() { val userPrivateKey = PrivateKey(ByteArray(32) { 1 }) val serverPrivateKey = PrivateKey(ByteArray(32) { 2 }) + val refundDelay = 25920 + + val mnemonics = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".split(" ") + val seed = MnemonicCode.toSeed(mnemonics, "") + val masterPrivateKey = DeterministicWallet.derivePrivateKey(DeterministicWallet.generate(seed), "/51'/0'/0'").copy(path = KeyPath.empty) + val userRefundPrivateKey = DeterministicWallet.derivePrivateKey(masterPrivateKey, "0").privateKey + val swapInProtocolMusig2 = SwapInProtocolMusig2(userPrivateKey.publicKey(), serverPrivateKey.publicKey(), userRefundPrivateKey.publicKey(), refundDelay) - val swapInProtocolMusig2 = SwapInProtocolMusig2(userPrivateKey.publicKey(), serverPrivateKey.publicKey(), 144) val swapInTx = Transaction( version = 2, txIn = listOf(), @@ -591,11 +598,11 @@ class TransactionsTestsCommon : LightningTestSuite() { run { val tx = Transaction( version = 2, - txIn = listOf(TxIn(OutPoint(swapInTx, 0), sequence = 144)), + txIn = listOf(TxIn(OutPoint(swapInTx, 0), sequence = refundDelay.toLong())), txOut = listOf(TxOut(Satoshi(10000), pay2wpkh(PrivateKey(randomBytes32()).publicKey()))), lockTime = 0 ) - val sig = swapInProtocolMusig2.signSwapInputRefund(tx, 0, swapInTx.txOut, userPrivateKey) + val sig = swapInProtocolMusig2.signSwapInputRefund(tx, 0, swapInTx.txOut, userRefundPrivateKey) val signedTx = tx.updateWitness(0, swapInProtocolMusig2.witnessRefund(sig)) Transaction.correctlySpends(signedTx, swapInTx, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) } @@ -621,7 +628,7 @@ class TransactionsTestsCommon : LightningTestSuite() { // DER-encoded ECDSA signatures usually take up to 72 bytes. val sig = ByteVector64.fromValidHex("90b658d172a51f1b3f1a2becd30942397f5df97da8cd2c026854607e955ad815ccfd87d366e348acc32aaf15ff45263aebbb7ecc913a0e5999133f447aee828c") val tx = Transaction(2, listOf(TxIn(OutPoint(ByteVector32.Zeroes, 2), 0)), listOf(TxOut(50_000.sat, pay2wpkh(pubkey))), 0) - val swapInProtocol = SwapInProtocolMusig2(pubkey, pubkey, 144) + val swapInProtocol = SwapInProtocolMusig2(pubkey, pubkey, pubkey, 144) val witness = swapInProtocol.witness(sig) val swapInput = TxIn(OutPoint(ByteVector32.Zeroes, 3), ByteVector.empty, 0, witness) val txWithAdditionalInput = tx.copy(txIn = tx.txIn + listOf(swapInput)) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt index ca15ad23a..2fe8e61b9 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/wire/LightningCodecsTestsCommon.kt @@ -4,7 +4,6 @@ import fr.acinq.bitcoin.* import fr.acinq.bitcoin.io.ByteArrayInput import fr.acinq.bitcoin.io.ByteArrayOutput import fr.acinq.bitcoin.musig2.PublicNonce -import fr.acinq.bitcoin.musig2.SecretNonce import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes import fr.acinq.lightning.Lightning.randomBytes32 @@ -398,7 +397,7 @@ class LightningCodecsTestsCommon : LightningTestSuite() { TxAddInput(channelId2, 0, tx2, 2, 0u) to ByteVector("0042 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 0000000000000000 0100 0200000000010142180a8812fc79a3da7fb2471eff3e22d7faee990604c2ba7f2fc8dfb15b550a0200000000feffffff030f241800000000001976a9146774040642a78ca3b8b395e70f8391b21ec026fc88ac4a155801000000001600148d2e0b57adcb8869e603fd35b5179caf053361253b1d010000000000160014e032f4f4b9f8611df0d30a20648c190c263bbc33024730440220506005aa347f5b698542cafcb4f1a10250aeb52a609d6fd67ef68f9c1a5d954302206b9bb844343f4012bccd9d08a0f5430afb9549555a3252e499be7df97aae477a012103976d6b3eea3de4b056cd88cdfd50a22daf121e0fb5c6e45ba0f40e1effbd275a00000000 00000002 00000000"), TxAddInput(channelId1, 561, tx1, 0, 0xfffffffdu) to ByteVector("0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 00f7 020000000001014ade359c5deb7c1cde2e94f401854658f97d7fa31c17ce9a831db253120a0a410100000017160014eb9a5bd79194a23d19d6ec473c768fb74f9ed32cffffffff021ca408000000000017a914946118f24bb7b37d5e9e39579e4a411e70f5b6a08763e703000000000017a9143638b2602d11f934c04abc6adb1494f69d1f14af8702473044022059ddd943b399211e4266a349f26b3289979e29f9b067792c6cfa8cc5ae25f44602204d627a5a5b603d0562e7969011fb3d64908af90a3ec7c876eaa9baf61e1958af012102f5188df1da92ed818581c29778047800ed6635788aa09d9469f7d17628f7323300000000 00000000 fffffffd"), TxAddInput(channelId1, 561, OutPoint(tx1, 1), 5u) to ByteVector("0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 0000 00000001 00000005 fd0451201f2ec025a33e39ef8e177afcdc1adc855bf128dc906182255aeb64efa825f106"), - TxAddInput(channelId1, 561, tx1, 1, 5u, TlvStream(TxAddInputTlv.SwapInParams(swapInUserKey, swapInServerKey, swapInRefundDelay, 1))) to ByteVector("0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 00f7 020000000001014ade359c5deb7c1cde2e94f401854658f97d7fa31c17ce9a831db253120a0a410100000017160014eb9a5bd79194a23d19d6ec473c768fb74f9ed32cffffffff021ca408000000000017a914946118f24bb7b37d5e9e39579e4a411e70f5b6a08763e703000000000017a9143638b2602d11f934c04abc6adb1494f69d1f14af8702473044022059ddd943b399211e4266a349f26b3289979e29f9b067792c6cfa8cc5ae25f44602204d627a5a5b603d0562e7969011fb3d64908af90a3ec7c876eaa9baf61e1958af012102f5188df1da92ed818581c29778047800ed6635788aa09d9469f7d17628f7323300000000 00000001 00000005 fd04534a03462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f0000009000000001"), + TxAddInput(channelId1, 561, tx1, 1, 5u, TlvStream(TxAddInputTlv.SwapInParams(swapInUserKey, swapInServerKey, swapInRefundDelay))) to ByteVector("0042 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000231 00f7 020000000001014ade359c5deb7c1cde2e94f401854658f97d7fa31c17ce9a831db253120a0a410100000017160014eb9a5bd79194a23d19d6ec473c768fb74f9ed32cffffffff021ca408000000000017a914946118f24bb7b37d5e9e39579e4a411e70f5b6a08763e703000000000017a9143638b2602d11f934c04abc6adb1494f69d1f14af8702473044022059ddd943b399211e4266a349f26b3289979e29f9b067792c6cfa8cc5ae25f44602204d627a5a5b603d0562e7969011fb3d64908af90a3ec7c876eaa9baf61e1958af012102f5188df1da92ed818581c29778047800ed6635788aa09d9469f7d17628f7323300000000 00000001 00000005 fd04534603462779ad4aad39514614751a71085f2f10e1c7a593e4e030efb5b8721ce55b0b03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f00000090"), TxAddOutput(channelId1, 1105, 2047.sat, ByteVector("00149357014afd0ccd265658c9ae81efa995e771f472")) to ByteVector("0043 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000451 00000000000007ff 0016 00149357014afd0ccd265658c9ae81efa995e771f472"), TxRemoveInput(channelId2, 561) to ByteVector("0044 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 0000000000000231"), TxRemoveOutput(channelId1, 1) to ByteVector("0045 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 0000000000000001"),