diff --git a/README.md b/README.md index eb227c7..4a32699 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ * [Commands](#commands) This tool provides helper functions that can be used to rescue funds locked in -`lnd` channels in case `lnd` itself cannot run properly anymore. +`lnd` channels in case `lnd` itself cannot run properly anymore (some commands +also support Core Lightning (CLN), check [command overview](#commands) below +for a list of compatible commands). **WARNING**: This tool was specifically built for a certain rescue operation and might not be well-suited for your use case. Or not all edge cases for your needs @@ -30,7 +32,7 @@ Example (make sure you always use the latest version!): ```shell $ cd /tmp -$ wget -O chantools.tar.gz https://github.com/lightninglabs/chantools/releases/download/v0.13.4/chantools-linux-amd64-v0.13.4.tar.gz +$ wget -O chantools.tar.gz https://github.com/lightninglabs/chantools/releases/download/v0.13.7/chantools-linux-amd64-v0.13.7.tar.gz $ tar -zxvf chantools.tar.gz $ sudo mv chantools-*/chantools /usr/local/bin/ ``` @@ -472,44 +474,46 @@ Legend: - :skull: Danger of loss of funds, only use when instructed to. - :pushpin: Command was created for a very specific version or use case and most likely does not apply to 99.9% of users +- **CLN**: Command is compatible with Core Lightning (CLN), use `--hsm_secret` + flag instead of root key or wallet. -| Command | Use when | -|-------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------| -| [chanbackup](doc/chantools_chanbackup.md) | :pencil: Extract a `channel.backup` file from a `channel.db` file | -| [closepoolaccount](doc/chantools_closepoolaccount.md) | :pencil: Manually close an expired Lightning Pool account | -| [compactdb](doc/chantools_compactdb.md) | Run database compaction manually to reclaim space | -| [createwallet](doc/chantools_createwallet.md) | :pencil: Create a new lnd compatible wallet.db file from an existing seed or by generating a new one | -| [deletepayments](doc/chantools_deletepayments.md) | Remove ALL payments from a `channel.db` file to reduce size | -| [derivekey](doc/chantools_derivekey.md) | :pencil: Derive a single private/public key from `lnd`'s seed, use to test seed | -| [doublespendinputs](doc/chantools_doublespendinputs.md) | :pencil: Tries to double spend the given inputs by deriving the private for the address and sweeping the funds to the given address | -| [dropchannelgraph](doc/chantools_dropchannelgraph.md) | (:warning:) Completely drop the channel graph from a `channel.db` to force re-sync | -| [dropgraphzombies](doc/chantools_dropgraphzombies.md) | Drop all zombie channels from a `channel.db` to force a graph re-sync | -| [dumpbackup](doc/chantools_dumpbackup.md) | :pencil: Show the content of a `channel.backup` file as text | -| [dumpchannels](doc/chantools_dumpchannels.md) | Show the content of a `channel.db` file as text | -| [fakechanbackup](doc/chantools_fakechanbackup.md) | :pencil: Create a fake `channel.backup` file from public information | -| [filterbackup](doc/chantools_filterbackup.md) | :pencil: Remove a channel from a `channel.backup` file | -| [fixoldbackup](doc/chantools_fixoldbackup.md) | :pencil: (:pushpin:) Fixes an issue with old `channel.backup` files | -| [forceclose](doc/chantools_forceclose.md) | :pencil: (:skull: :warning:) Publish an old channel state from a `channel.db` file | -| [genimportscript](doc/chantools_genimportscript.md) | :pencil: Create a script/text file that can be used to import `lnd` keys into other software | -| [migratedb](doc/chantools_migratedb.md) | Upgrade the `channel.db` file to the latest version | -| [pullanchor](doc/chantools_pullanchor.md) | :pencil: Attempt to CPFP an anchor output of a channel | -| [recoverloopin](doc/chantools_recoverloopin.md) | :pencil: Recover funds from a failed Lightning Loop inbound swap | -| [removechannel](doc/chantools_removechannel.md) | (:skull: :warning:) Remove a single channel from a `channel.db` file | -| [rescueclosed](doc/chantools_rescueclosed.md) | :pencil: (:pushpin:) Rescue funds in a legacy (pre `STATIC_REMOTE_KEY`) channel output | -| [rescuefunding](doc/chantools_rescuefunding.md) | :pencil: (:pushpin:) Rescue funds from a funding transaction. Deprecated, use [zombierecovery](doc/chantools_zombierecovery.md) instead | -| [scbforceclose](doc/chantools_scbforceclose.md) | :pencil: :warning: :skull: Force close a channel using the latest state from a channel backup. EXTREMELY DANGEROUS, read help text! | -| [showrootkey](doc/chantools_showrootkey.md) | :pencil: Display the master root key (`xprv`) from your seed (DO NOT SHARE WITH ANYONE) | -| [signmessage](doc/chantools_signmessage.md) | :pencil: Sign a message with the nodes identity pubkey. | -| [signpsbt](doc/chantools_signpsbt.md) | :pencil: Sign a Partially Signed Bitcoin Transaction (PSBT) | -| [signrescuefunding](doc/chantools_signrescuefunding.md) | :pencil: (:pushpin:) Sign to funds from a funding transaction. Deprecated, use [zombierecovery](doc/chantools_zombierecovery.md) instead | -| [summary](doc/chantools_summary.md) | Create a summary of channel funds from a `channel.db` file | -| [sweepremoteclosed](doc/chantools_sweepremoteclosed.md) | :pencil: Find channel funds from remotely force closed channels and sweep them | -| [sweeptimelock](doc/chantools_sweeptimelock.md) | :pencil: Sweep funds in locally force closed channels once time lock has expired (requires `channel.db`) | -| [sweeptimelockmanual](doc/chantools_sweeptimelockmanual.md) | :pencil: Manually sweep funds in a locally force closed channel where no `channel.db` file is available | -| [triggerforceclose](doc/chantools_triggerforceclose.md) | :pencil: (:pushpin:) Request a peer to force close a channel | -| [vanitygen](doc/chantools_vanitygen.md) | Generate an `lnd` seed for a node public key that starts with a certain sequence of hex digits | -| [walletinfo](doc/chantools_walletinfo.md) | Show information from a `wallet.db` file, requires access to the wallet password | -| [zombierecovery](doc/chantools_zombierecovery.md) | :pencil: Cooperatively rescue funds from channels where normal recovery is not possible (see [full guide here][zombie-recovery]) | +| Command | Use when | +|-------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------| +| [chanbackup](doc/chantools_chanbackup.md) | :pencil: Extract a `channel.backup` file from a `channel.db` file | +| [closepoolaccount](doc/chantools_closepoolaccount.md) | :pencil: Manually close an expired Lightning Pool account | +| [compactdb](doc/chantools_compactdb.md) | Run database compaction manually to reclaim space | +| [createwallet](doc/chantools_createwallet.md) | :pencil: Create a new lnd compatible wallet.db file from an existing seed or by generating a new one | +| [deletepayments](doc/chantools_deletepayments.md) | Remove ALL payments from a `channel.db` file to reduce size | +| [derivekey](doc/chantools_derivekey.md) | :pencil: Derive a single private/public key from `lnd`'s seed, use to test seed | +| [doublespendinputs](doc/chantools_doublespendinputs.md) | :pencil: Tries to double spend the given inputs by deriving the private for the address and sweeping the funds to the given address | +| [dropchannelgraph](doc/chantools_dropchannelgraph.md) | ( :warning: ) Completely drop the channel graph from a `channel.db` to force re-sync (not recommended while channels are open!) | +| [dropgraphzombies](doc/chantools_dropgraphzombies.md) | Drop all zombie channels from a `channel.db` to force a graph re-sync | +| [dumpbackup](doc/chantools_dumpbackup.md) | :pencil: Show the content of a `channel.backup` file as text | +| [dumpchannels](doc/chantools_dumpchannels.md) | Show the content of a `channel.db` file as text | +| [fakechanbackup](doc/chantools_fakechanbackup.md) | :pencil: Create a fake `channel.backup` file from public information | +| [filterbackup](doc/chantools_filterbackup.md) | :pencil: Remove a channel from a `channel.backup` file | +| [fixoldbackup](doc/chantools_fixoldbackup.md) | :pencil: ( :pushpin: ) Fixes an issue with old `channel.backup` files | +| [forceclose](doc/chantools_forceclose.md) | :pencil: ( :skull: :warning: ) Publish an old channel state from a `channel.db` file | +| [genimportscript](doc/chantools_genimportscript.md) | :pencil: Create a script/text file that can be used to import `lnd` keys into other software | +| [migratedb](doc/chantools_migratedb.md) | Upgrade the `channel.db` file to the latest version | +| [pullanchor](doc/chantools_pullanchor.md) | :pencil: Attempt to CPFP an anchor output of a channel | +| [recoverloopin](doc/chantools_recoverloopin.md) | :pencil: Recover funds from a failed Lightning Loop inbound swap | +| [removechannel](doc/chantools_removechannel.md) | (:skull: :warning:) Remove a single channel from a `channel.db` file | +| [rescueclosed](doc/chantools_rescueclosed.md) | :pencil: ( :pushpin: ) Rescue funds in a legacy (pre `STATIC_REMOTE_KEY`) channel output | +| [rescuefunding](doc/chantools_rescuefunding.md) | :pencil: ( :pushpin: ) Rescue funds from a funding transaction. Deprecated, use [zombierecovery](doc/chantools_zombierecovery.md) instead | +| [scbforceclose](doc/chantools_scbforceclose.md) | :pencil: :warning: :skull: Force close a channel using the latest state from a channel backup. EXTREMELY DANGEROUS, read help text! | +| [showrootkey](doc/chantools_showrootkey.md) | :pencil: Display the master root key (`xprv`) from your seed (DO NOT SHARE WITH ANYONE) | +| [signmessage](doc/chantools_signmessage.md) | :pencil: Sign a message with the nodes identity pubkey. | +| [signpsbt](doc/chantools_signpsbt.md) | :pencil: Sign a Partially Signed Bitcoin Transaction (PSBT) | +| [signrescuefunding](doc/chantools_signrescuefunding.md) | :pencil: ( :pushpin: ) Sign to funds from a funding transaction. Deprecated, use [zombierecovery](doc/chantools_zombierecovery.md) instead | +| [summary](doc/chantools_summary.md) | Create a summary of channel funds from a `channel.db` file | +| [sweepremoteclosed](doc/chantools_sweepremoteclosed.md) | :pencil: (**CLN**) Find channel funds from remotely force closed channels and sweep them | +| [sweeptimelock](doc/chantools_sweeptimelock.md) | :pencil: Sweep funds in locally force closed channels once time lock has expired (requires `channel.db`) | +| [sweeptimelockmanual](doc/chantools_sweeptimelockmanual.md) | :pencil: Manually sweep funds in a locally force closed channel where no `channel.db` file is available | +| [triggerforceclose](doc/chantools_triggerforceclose.md) | :pencil: (**CLN** :pushpin: ) Request a peer to force close a channel | +| [vanitygen](doc/chantools_vanitygen.md) | Generate an `lnd` seed for a node public key that starts with a certain sequence of hex digits | +| [walletinfo](doc/chantools_walletinfo.md) | Show information from a `wallet.db` file, requires access to the wallet password | +| [zombierecovery](doc/chantools_zombierecovery.md) | :pencil: (**CLN**) Cooperatively rescue funds from channels where normal recovery is not possible (see [full guide here][zombie-recovery]) | [safety]: https://github.com/lightningnetwork/lnd/blob/master/docs/safety.md diff --git a/cln/signer.go b/cln/signer.go index e09b31e..5685cde 100644 --- a/cln/signer.go +++ b/cln/signer.go @@ -15,7 +15,17 @@ import ( ) type Signer struct { + *input.MusigSessionManager + HsmSecret [32]byte + + // SwapDescKeyAfterDerive is a boolean that indicates that after + // deriving the private key from the key descriptor (which interprets + // the public key as the peer's public key), we should swap the public + // key in the key descriptor to the actual derived public key. This is + // required for P2WKH signatures that need to have the public key in the + // witness stack. + SwapDescKeyAfterDerive bool } func (s *Signer) SignOutputRaw(tx *wire.MsgTx, @@ -28,9 +38,22 @@ func (s *Signer) SignOutputRaw(tx *wire.MsgTx, return nil, err } + if s.SwapDescKeyAfterDerive { + // If we need to swap the public key in the descriptor, we do so + // now. This is required for P2WKH signatures that need to have + // the public key in the witness stack. + signDesc.KeyDesc.PubKey = privKey.PubKey() + } + return lnd.SignOutputRawWithPrivateKey(tx, signDesc, privKey) } +func (s *Signer) ComputeInputScript(_ *wire.MsgTx, _ *input.SignDescriptor) ( + *input.Script, error) { + + return nil, errors.New("unimplemented") +} + func (s *Signer) FetchPrivateKey( descriptor *keychain.KeyDescriptor) (*btcec.PrivateKey, error) { diff --git a/cmd/chantools/sweepremoteclosed.go b/cmd/chantools/sweepremoteclosed.go index 0fbca5e..598bbfd 100644 --- a/cmd/chantools/sweepremoteclosed.go +++ b/cmd/chantools/sweepremoteclosed.go @@ -7,6 +7,10 @@ import ( "encoding/json" "errors" "fmt" + "os" + "regexp" + "slices" + "strings" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" @@ -16,9 +20,13 @@ import ( "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/chantools/btc" + "github.com/lightninglabs/chantools/cln" "github.com/lightninglabs/chantools/lnd" + "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lncfg" + "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/spf13/cobra" ) @@ -38,6 +46,10 @@ type sweepRemoteClosedCommand struct { SweepAddr string FeeRate uint32 + HsmSecret string + PeerPubKeys string + KnownOutputs string + rootKey *rootKey cmd *cobra.Command } @@ -92,19 +104,36 @@ Supported remote force-closed channel types are: "use for the sweep transaction in sat/vByte", ) + cc.cmd.Flags().StringVar( + &cc.HsmSecret, "hsm_secret", "", "the hex encoded HSM secret "+ + "to use for deriving the multisig keys for a CLN "+ + "node; obtain by running 'xxd -p -c32 "+ + "~/.lightning/bitcoin/hsm_secret'", + ) + cc.cmd.Flags().StringVar( + &cc.PeerPubKeys, "peers", "", "comma separated list of "+ + "hex encoded public keys of the remote peers "+ + "to recover funds from, only required when using "+ + "--hsm_secret to derive the keys; can also be a file "+ + "name to a file that contains the public keys, one "+ + "per line", + ) + cc.cmd.Flags().StringVar( + &cc.KnownOutputs, "known_outputs", "", "a comma separated "+ + "list of known output addresses to use for matching "+ + "against, instead of querying the API; can also be "+ + "a file name to a file that contains the known "+ + "outputs, one per line", + ) + cc.rootKey = newRootKey(cc.cmd, "sweeping the wallet") return cc.cmd } func (c *sweepRemoteClosedCommand) Execute(_ *cobra.Command, _ []string) error { - extendedKey, err := c.rootKey.read() - if err != nil { - return fmt.Errorf("error reading root key: %w", err) - } - // Make sure sweep addr is set. - err = lnd.CheckAddress( + err := lnd.CheckAddress( c.SweepAddr, chainParams, true, "sweep", lnd.AddrTypeP2WKH, lnd.AddrTypeP2TR, ) @@ -120,9 +149,123 @@ func (c *sweepRemoteClosedCommand) Execute(_ *cobra.Command, _ []string) error { c.FeeRate = defaultFeeSatPerVByte } + var ( + signer lnd.ChannelSigner + estimator input.TxWeightEstimator + knownOutputs []string + sweepScript []byte + targets []*targetAddr + ) + + if c.KnownOutputs != "" { + knownOutputs, err = listOrFile(c.KnownOutputs) + if err != nil { + return fmt.Errorf("error reading known outputs: %w", + err) + } + + for _, output := range knownOutputs { + _, err = lnd.ParseAddress(output, chainParams) + if err != nil { + return fmt.Errorf("error parsing known output "+ + "address %s: %w", output, err) + } + } + + log.Infof("Using %d known outputs for matching.", + len(knownOutputs)) + } + + switch { + case c.HsmSecret != "": + secretBytes, err := hex.DecodeString(c.HsmSecret) + if err != nil { + return fmt.Errorf("error decoding HSM secret: %w", err) + } + + var hsmSecret [32]byte + copy(hsmSecret[:], secretBytes) + + if c.PeerPubKeys == "" { + return errors.New("invalid peer public keys, must be " + + "a comma separated list of hex encoded " + + "public keys or a file name") + } + + var pubKeys []*btcec.PublicKey + hexPubKeys, err := listOrFile(c.PeerPubKeys) + if err != nil { + return fmt.Errorf("error reading peer public keys: %w", + err) + } + for _, pubKeyHex := range hexPubKeys { + pkHex, err := hex.DecodeString(pubKeyHex) + if err != nil { + return fmt.Errorf("error decoding peer "+ + "public key hex %s: %w", pubKeyHex, err) + } + + pk, err := btcec.ParsePubKey(pkHex) + if err != nil { + return fmt.Errorf("error parsing peer public "+ + "key hex %s: %w", pubKeyHex, err) + } + + pubKeys = append(pubKeys, pk) + } + + log.Infof("Using %d peer public keys for recovery.", + len(pubKeys)) + + signer = &cln.Signer{ + HsmSecret: hsmSecret, + } + + targets, err = findTargetsCln( + hsmSecret, pubKeys, c.APIURL, c.RecoveryWindow, + knownOutputs, + ) + if err != nil { + return fmt.Errorf("error finding targets: %w", err) + } + + sweepScript, err = lnd.CheckAndEstimateAddress( + c.SweepAddr, chainParams, &estimator, "sweep", + ) + if err != nil { + return err + } + + default: + extendedKey, err := c.rootKey.read() + if err != nil { + return fmt.Errorf("error reading root key: %w", err) + } + + signer = &lnd.Signer{ + ExtendedKey: extendedKey, + ChainParams: chainParams, + } + + targets, err = findTargetsLnd( + extendedKey, c.APIURL, c.RecoveryWindow, knownOutputs, + ) + if err != nil { + return fmt.Errorf("error finding targets: %w", err) + } + + sweepScript, err = lnd.PrepareWalletAddress( + c.SweepAddr, chainParams, &estimator, extendedKey, + "sweep", + ) + if err != nil { + return err + } + } + return sweepRemoteClosed( - extendedKey, c.APIURL, c.SweepAddr, c.RecoveryWindow, c.FeeRate, - c.Publish, + signer, &estimator, sweepScript, targets, + newExplorerAPI(c.APIURL), c.FeeRate, c.Publish, ) } @@ -135,17 +278,8 @@ type targetAddr struct { scriptTree *input.CommitScriptTree } -func sweepRemoteClosed(extendedKey *hdkeychain.ExtendedKey, apiURL, - sweepAddr string, recoveryWindow uint32, feeRate uint32, - publish bool) error { - - var estimator input.TxWeightEstimator - sweepScript, err := lnd.PrepareWalletAddress( - sweepAddr, chainParams, &estimator, extendedKey, "sweep", - ) - if err != nil { - return err - } +func findTargetsLnd(extendedKey *hdkeychain.ExtendedKey, apiURL string, + recoveryWindow uint32, knownOutputs []string) ([]*targetAddr, error) { var ( targets []*targetAddr @@ -157,17 +291,18 @@ func sweepRemoteClosed(extendedKey *hdkeychain.ExtendedKey, apiURL, index) parsedPath, err := lnd.ParsePath(path) if err != nil { - return fmt.Errorf("error parsing path: %w", err) + return nil, fmt.Errorf("error parsing path: %w", err) } hdKey, err := lnd.DeriveChildren(extendedKey, parsedPath) if err != nil { - return fmt.Errorf("eror deriving children: %w", err) + return nil, fmt.Errorf("eror deriving children: %w", + err) } privKey, err := hdKey.ECPrivKey() if err != nil { - return fmt.Errorf("could not derive private "+ + return nil, fmt.Errorf("could not derive private "+ "key: %w", err) } @@ -178,10 +313,10 @@ func sweepRemoteClosed(extendedKey *hdkeychain.ExtendedKey, apiURL, Family: keychain.KeyFamilyPaymentBase, Index: index, }, - }, api, + }, api, knownOutputs, ) if err != nil { - return fmt.Errorf("could not query API for "+ + return nil, fmt.Errorf("could not query API for "+ "addresses with funds: %w", err) } targets = append(targets, foundTargets...) @@ -193,14 +328,61 @@ func sweepRemoteClosed(extendedKey *hdkeychain.ExtendedKey, apiURL, api, recoveryWindow, extendedKey, ) if err != nil && !errors.Is(err, errAddrNotFound) { - return fmt.Errorf("could not check ancient channel points: %w", - err) + return nil, fmt.Errorf("could not check ancient channel "+ + "points: %w", err) } if len(ancientChannelTargets) > 0 { targets = append(targets, ancientChannelTargets...) } + return targets, nil +} + +func findTargetsCln(hsmSecret [32]byte, pubKeys []*btcec.PublicKey, + apiURL string, recoveryWindow uint32, + knownOutputs []string) ([]*targetAddr, error) { + + var ( + targets []*targetAddr + api = newExplorerAPI(apiURL) + ) + for _, pubKey := range pubKeys { + for index := range recoveryWindow { + desc := &keychain.KeyDescriptor{ + PubKey: pubKey, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamilyPaymentBase, + Index: index, + }, + } + _, privKey, err := cln.DeriveKeyPair(hsmSecret, desc) + if err != nil { + return nil, fmt.Errorf("could not derive "+ + "private key: %w", err) + } + + foundTargets, err := queryAddressBalances( + privKey.PubKey(), desc, api, knownOutputs, + ) + if err != nil { + return nil, fmt.Errorf("could not query API "+ + "for addresses with funds: %w", err) + } + targets = append(targets, foundTargets...) + } + } + + log.Infof("Found %d addresses with funds to sweep.", len(targets)) + + return targets, nil +} + +func sweepRemoteClosed(signer lnd.ChannelSigner, + estimator *input.TxWeightEstimator, sweepScript []byte, + targets []*targetAddr, api *btc.ExplorerAPI, feeRate uint32, + publish bool) error { + // Create estimator and transaction template. var ( signDescs []*input.SignDescriptor @@ -332,13 +514,7 @@ func sweepRemoteClosed(extendedKey *hdkeychain.ExtendedKey, apiURL, }} // Sign the transaction now. - var ( - signer = &lnd.Signer{ - ExtendedKey: extendedKey, - ChainParams: chainParams, - } - sigHashes = txscript.NewTxSigHashes(sweepTx, prevOutFetcher) - ) + var sigHashes = txscript.NewTxSigHashes(sweepTx, prevOutFetcher) for idx, desc := range signDescs { desc.SigHashes = sigHashes desc.InputIndex = idx @@ -370,6 +546,13 @@ func sweepRemoteClosed(extendedKey *hdkeychain.ExtendedKey, apiURL, // P2WKH descriptor to be set to the pkScript of the // output... desc.WitnessScript = desc.Output.PkScript + + // For CLN we need to activate a flag to make sure we + // put the correct public key on the witness stack. + if clnSigner, ok := signer.(*cln.Signer); ok { + clnSigner.SwapDescKeyAfterDerive = true + } + witness, err := input.CommitSpendNoDelay( signer, desc, sweepTx, len(desc.SingleTweak) == 0, @@ -382,7 +565,7 @@ func sweepRemoteClosed(extendedKey *hdkeychain.ExtendedKey, apiURL, } var buf bytes.Buffer - err = sweepTx.Serialize(&buf) + err := sweepTx.Serialize(&buf) if err != nil { return err } @@ -404,13 +587,19 @@ func sweepRemoteClosed(extendedKey *hdkeychain.ExtendedKey, apiURL, } func queryAddressBalances(pubKey *btcec.PublicKey, - keyDesc *keychain.KeyDescriptor, api *btc.ExplorerAPI) ([]*targetAddr, - error) { + keyDesc *keychain.KeyDescriptor, api *btc.ExplorerAPI, + knownOutputs []string) ([]*targetAddr, error) { var targets []*targetAddr queryAddr := func(address btcutil.Address, script []byte, scriptTree *input.CommitScriptTree) error { + if len(knownOutputs) > 0 { + if !slices.Contains(knownOutputs, address.String()) { + return nil + } + } + unspent, err := api.Unspent(address.EncodeAddress()) if err != nil { return fmt.Errorf("could not query unspent: %w", err) @@ -580,3 +769,21 @@ func checkAncientChannelPoints(api *btc.ExplorerAPI, numKeys uint32, return targets, nil } + +func listOrFile(listOrPath string) ([]string, error) { + if lnrpc.FileExists(lncfg.CleanAndExpandPath(listOrPath)) { + contents, err := os.ReadFile(listOrPath) + if err != nil { + return nil, fmt.Errorf("error reading file %s: %w", + listOrPath, err) + } + + re := regexp.MustCompile(`[,\s]+`) + parts := re.Split(string(contents), -1) + return fn.Filter(parts, func(s string) bool { + return len(strings.TrimSpace(s)) > 0 + }), nil + } + + return strings.Split(listOrPath, ","), nil +} diff --git a/cmd/chantools/triggerforceclose.go b/cmd/chantools/triggerforceclose.go index 9b0f34b..ce2cfa7 100644 --- a/cmd/chantools/triggerforceclose.go +++ b/cmd/chantools/triggerforceclose.go @@ -1,7 +1,10 @@ package main import ( + "encoding/hex" + "errors" "fmt" + "os" "strconv" "strings" "time" @@ -10,8 +13,12 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/connmgr" "github.com/btcsuite/btcd/wire" + "github.com/hasura/go-graphql-client" + "github.com/lightninglabs/chantools/btc" + "github.com/lightninglabs/chantools/cln" "github.com/lightninglabs/chantools/lnd" "github.com/lightningnetwork/lnd/brontide" + "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lncfg" "github.com/lightningnetwork/lnd/lnwire" @@ -30,10 +37,13 @@ type triggerForceCloseCommand struct { Peer string ChannelPoint string - APIURL string + APIURL string + AllPublicChannels bool TorProxy string + HsmSecret string + rootKey *rootKey cmd *cobra.Command } @@ -67,67 +77,197 @@ does not properly respond to a Data Loss Protection re-establish message).'`, &cc.APIURL, "apiurl", defaultAPIURL, "API URL to use (must "+ "be esplora compatible)", ) + cc.cmd.Flags().BoolVar( + &cc.AllPublicChannels, "all_public_channels", false, + "query all public channels from the Amboss API and attempt "+ + "to trigger a force close for each of them", + ) cc.cmd.Flags().StringVar( &cc.TorProxy, "torproxy", "", "SOCKS5 proxy to use for Tor "+ "connections (to .onion addresses)", ) + cc.cmd.Flags().StringVar( + &cc.HsmSecret, "hsm_secret", "", "the hex encoded HSM secret "+ + "to use for deriving the node key for a CLN "+ + "node; obtain by running 'xxd -p -c32 "+ + "~/.lightning/bitcoin/hsm_secret'", + ) cc.rootKey = newRootKey(cc.cmd, "deriving the identity key") return cc.cmd } func (c *triggerForceCloseCommand) Execute(_ *cobra.Command, _ []string) error { - extendedKey, err := c.rootKey.read() - if err != nil { - return fmt.Errorf("error reading root key: %w", err) + var identityPriv *btcec.PrivateKey + switch { + case c.HsmSecret != "": + secretBytes, err := hex.DecodeString(c.HsmSecret) + if err != nil { + return fmt.Errorf("error decoding HSM secret: %w", err) + } + + var hsmSecret [32]byte + copy(hsmSecret[:], secretBytes) + + _, identityPriv, err = cln.NodeKey(hsmSecret) + if err != nil { + return fmt.Errorf("error deriving identity key: %w", + err) + } + + default: + extendedKey, err := c.rootKey.read() + if err != nil { + return fmt.Errorf("error reading root key: %w", err) + } + + identityPath := lnd.IdentityPath(chainParams) + child, _, _, err := lnd.DeriveKey( + extendedKey, identityPath, chainParams, + ) + if err != nil { + return fmt.Errorf("could not derive identity key: %w", + err) + } + identityPriv, err = child.ECPrivKey() + if err != nil { + return fmt.Errorf("could not get identity private "+ + "key: %w", err) + } } - identityPath := lnd.IdentityPath(chainParams) - child, pubKey, _, err := lnd.DeriveKey( - extendedKey, identityPath, chainParams, - ) - if err != nil { - return fmt.Errorf("could not derive identity key: %w", err) + api := newExplorerAPI(c.APIURL) + switch { + case c.ChannelPoint != "" && c.Peer != "": + _, err := closeChannel( + identityPriv, api, c.ChannelPoint, c.Peer, c.TorProxy, + ) + return err + + case c.AllPublicChannels: + client := graphql.NewClient( + "https://api.amboss.space/graphql", nil, + ) + ourNodeKey := hex.EncodeToString( + identityPriv.PubKey().SerializeCompressed(), + ) + + log.Infof("Fetching public channels for node %s", ourNodeKey) + channels, err := fetchChannels(client, ourNodeKey) + if err != nil { + return fmt.Errorf("error fetching channels: %w", err) + } + + channels = fn.Filter(channels, func(c *gqChannel) bool { + return c.ClosureInfo.ClosedHeight == 0 + }) + + log.Infof("Found %d public open channels, attempting to force "+ + "close each of them", len(channels)) + + var ( + pubKeys []string + outputs []string + ) + for _, openChan := range channels { + addr := pickAddr(openChan.Node2Info.Node.Addresses) + peerAddr := fmt.Sprintf("%s@%s", openChan.Node2, addr) + log.Infof("Attempting to force close channel %s with "+ + "peer %s", openChan.ChanPoint, peerAddr) + + outputAddrs, err := closeChannel( + identityPriv, api, openChan.ChanPoint, + peerAddr, c.TorProxy, + ) + if err != nil { + log.Errorf("Error closing channel %s, "+ + "skipping: %v", openChan.ChanPoint, err) + continue + } + + pubKeys = append(pubKeys, openChan.Node2) + outputs = append(outputs, outputAddrs...) + } + + peersBytes := []byte(strings.Join(pubKeys, "\n")) + outputsBytes := []byte(strings.Join(outputs, "\n")) + + fileName := fmt.Sprintf("results/forceclose-peers-%s.txt", + time.Now().Format("2006-01-02")) + log.Infof("Writing peers to %s", fileName) + err = os.WriteFile(fileName, peersBytes, 0644) + if err != nil { + return fmt.Errorf("error writing peers to file: %w", + err) + } + + fileName = fmt.Sprintf("results/forceclose-addresses-%s.txt", + time.Now().Format("2006-01-02")) + log.Infof("Writing addresses to %s", fileName) + return os.WriteFile(fileName, outputsBytes, 0644) + + default: + return errors.New("either --channel_point and --peer or " + + "--all_public_channels must be specified") } - identityPriv, err := child.ECPrivKey() - if err != nil { - return fmt.Errorf("could not get identity private key: %w", err) +} + +func pickAddr(addrs []*gqAddress) string { + // If there's only one address, we'll just return that one. + if len(addrs) == 1 { + return addrs[0].Address } + + // We'll pick the first address that is not a Tor address. + for _, addr := range addrs { + if !strings.HasSuffix(addr.Address, ".onion") { + return addr.Address + } + } + + // If all addresses are Tor addresses, we'll just return the first one. + if len(addrs) > 0 { + return addrs[0].Address + } + + return "" +} + +func closeChannel(identityPriv *btcec.PrivateKey, api *btc.ExplorerAPI, + channelPoint, peer, torProxy string) ([]string, error) { + identityECDH := &keychain.PrivKeyECDH{ PrivKey: identityPriv, } - outPoint, err := parseOutPoint(c.ChannelPoint) + outPoint, err := parseOutPoint(channelPoint) if err != nil { - return fmt.Errorf("error parsing channel point: %w", err) + return nil, fmt.Errorf("error parsing channel point: %w", err) } - err = requestForceClose( - c.Peer, c.TorProxy, pubKey, *outPoint, identityECDH, - ) + err = requestForceClose(peer, torProxy, *outPoint, identityECDH) if err != nil { - return fmt.Errorf("error requesting force close: %w", err) + return nil, fmt.Errorf("error requesting force close: %w", err) } log.Infof("Message sent, waiting for force close transaction to " + "appear in mempool") - api := newExplorerAPI(c.APIURL) - channelAddress, err := api.Address(c.ChannelPoint) + channelAddress, err := api.Address(channelPoint) if err != nil { - return fmt.Errorf("error getting channel address: %w", err) + return nil, fmt.Errorf("error getting channel address: %w", err) } spends, err := api.Spends(channelAddress) if err != nil { - return fmt.Errorf("error getting spends: %w", err) + return nil, fmt.Errorf("error getting spends: %w", err) } for len(spends) == 0 { log.Infof("No spends found yet, waiting 5 seconds...") time.Sleep(5 * time.Second) spends, err = api.Spends(channelAddress) if err != nil { - return fmt.Errorf("error getting spends: %w", err) + return nil, fmt.Errorf("error getting spends: %w", err) } } @@ -135,7 +275,11 @@ func (c *triggerForceCloseCommand) Execute(_ *cobra.Command, _ []string) error { log.Infof("You can now use the sweepremoteclosed command to sweep " + "the funds from the channel") - return nil + outputAddrs := fn.Map(spends[0].Vout, func(v *btc.Vout) string { + return v.ScriptPubkeyAddr + }) + + return outputAddrs, nil } func noiseDial(idKey keychain.SingleKeyECDH, lnAddr *lnwire.NetAddress, @@ -144,8 +288,7 @@ func noiseDial(idKey keychain.SingleKeyECDH, lnAddr *lnwire.NetAddress, return brontide.Dial(idKey, lnAddr, timeout, netCfg.Dial) } -func connectPeer(peerHost, torProxy string, peerPubKey *btcec.PublicKey, - identity keychain.SingleKeyECDH, +func connectPeer(peerHost, torProxy string, identity keychain.SingleKeyECDH, dialTimeout time.Duration) (*peer.Brontide, error) { var dialNet tor.Net = &tor.ClearNet{} @@ -166,6 +309,8 @@ func connectPeer(peerHost, torProxy string, peerPubKey *btcec.PublicKey, return nil, fmt.Errorf("error parsing peer address: %w", err) } + peerPubKey := peerAddr.IdentityKey + log.Debugf("Attempting to dial resolved peer address %v", peerAddr.String()) conn, err := noiseDial(identity, peerAddr, dialNet, dialTimeout) @@ -198,12 +343,10 @@ func connectPeer(peerHost, torProxy string, peerPubKey *btcec.PublicKey, return p, nil } -func requestForceClose(peerHost, torProxy string, peerPubKey *btcec.PublicKey, - channelPoint wire.OutPoint, identity keychain.SingleKeyECDH) error { +func requestForceClose(peerHost, torProxy string, channelPoint wire.OutPoint, + identity keychain.SingleKeyECDH) error { - p, err := connectPeer( - peerHost, torProxy, peerPubKey, identity, dialTimeout, - ) + p, err := connectPeer(peerHost, torProxy, identity, dialTimeout) if err != nil { return fmt.Errorf("error connecting to peer: %w", err) } diff --git a/cmd/chantools/zombierecovery_findmatches.go b/cmd/chantools/zombierecovery_findmatches.go index 1e04eaa..0256873 100644 --- a/cmd/chantools/zombierecovery_findmatches.go +++ b/cmd/chantools/zombierecovery_findmatches.go @@ -62,6 +62,10 @@ P.S.: If you don't want to be notified about future matches, please let me know. ` ) +type gqAddress struct { + Address string `graphql:"addr"` +} + type gqChannel struct { ChanPoint string `graphql:"chan_point"` Capacity string `graphql:"capacity"` @@ -70,6 +74,11 @@ type gqChannel struct { } `graphql:"closure_info"` Node1 string `graphql:"node1_pub"` Node2 string `graphql:"node2_pub"` + Node2Info struct { + Node struct { + Addresses []*gqAddress `graphql:"addresses"` + } `graphql:"node"` + } `graphql:"node2_info"` ChannelID string `graphql:"long_channel_id"` } diff --git a/doc/chantools_sweepremoteclosed.md b/doc/chantools_sweepremoteclosed.md index b509198..3d26980 100644 --- a/doc/chantools_sweepremoteclosed.md +++ b/doc/chantools_sweepremoteclosed.md @@ -37,6 +37,8 @@ chantools sweepremoteclosed \ --bip39 read a classic BIP39 seed and passphrase from the terminal instead of asking for lnd seed format or providing the --rootkey flag --feerate uint32 fee rate to use for the sweep transaction in sat/vByte (default 30) -h, --help help for sweepremoteclosed + --hsm_secret string the hex encoded HSM secret to use for deriving the multisig keys for a CLN node; obtain by running 'xxd -p -c32 ~/.lightning/bitcoin/hsm_secret' + --peers string comma separated list of hex encoded public keys of the remote peers to recover funds from, only required when using --hsm_secret to derive the keys --publish publish sweep TX to the chain API instead of just printing the TX --recoverywindow uint32 number of keys to scan per derivation path (default 200) --rootkey string BIP32 HD root key of the wallet to use for sweeping the wallet; leave empty to prompt for lnd 24 word aezeed diff --git a/doc/chantools_triggerforceclose.md b/doc/chantools_triggerforceclose.md index 8436cb4..0bb75c5 100644 --- a/doc/chantools_triggerforceclose.md +++ b/doc/chantools_triggerforceclose.md @@ -28,6 +28,7 @@ chantools triggerforceclose \ --bip39 read a classic BIP39 seed and passphrase from the terminal instead of asking for lnd seed format or providing the --rootkey flag --channel_point string funding transaction outpoint of the channel to trigger the force close of (:) -h, --help help for triggerforceclose + --hsm_secret string the hex encoded HSM secret to use for deriving the node key for a CLN node; obtain by running 'xxd -p -c32 ~/.lightning/bitcoin/hsm_secret' --peer string remote peer address (@[:]) --rootkey string BIP32 HD root key of the wallet to use for deriving the identity key; leave empty to prompt for lnd 24 word aezeed --torproxy string SOCKS5 proxy to use for Tor connections (to .onion addresses) diff --git a/lnd/hdkeychain.go b/lnd/hdkeychain.go index a3752aa..fe80fe4 100644 --- a/lnd/hdkeychain.go +++ b/lnd/hdkeychain.go @@ -477,9 +477,19 @@ func PrepareWalletAddress(addr string, chainParams *chaincfg.Params, return nil, err } + if estimator != nil { + estimator.AddP2WKHOutput() + } + return txscript.PayToAddrScript(p2wkhAddr) } + return CheckAndEstimateAddress(addr, chainParams, estimator, hint) +} + +func CheckAndEstimateAddress(addr string, chainParams *chaincfg.Params, + estimator *input.TxWeightEstimator, hint string) ([]byte, error) { + parsedAddr, err := ParseAddress(addr, chainParams) if err != nil { return nil, fmt.Errorf("%s address is invalid: %w", hint, err) diff --git a/lnd/signer.go b/lnd/signer.go index 0139383..3d142ed 100644 --- a/lnd/signer.go +++ b/lnd/signer.go @@ -21,9 +21,14 @@ import ( ) type ChannelSigner interface { + input.MuSig2Signer + SignOutputRaw(tx *wire.MsgTx, signDesc *input.SignDescriptor) (input.Signature, error) + ComputeInputScript(tx *wire.MsgTx, + desc *input.SignDescriptor) (*input.Script, error) + FetchPrivateKey(descriptor *keychain.KeyDescriptor) ( *btcec.PrivateKey, error) @@ -133,6 +138,7 @@ func SignOutputRawWithPrivateKey(tx *wire.MsgTx, // Chop off the sighash flag at the end of the signature. return ecdsa.ParseDERSignature(sig[:len(sig)-1]) } + func (s *Signer) ComputeInputScript(_ *wire.MsgTx, _ *input.SignDescriptor) ( *input.Script, error) {