Merge pull request #193 from lightninglabs/sweepremoteclosed-cln

CLN: Make sweepremoteclosed and triggerforceclose CLN compatible
This commit is contained in:
Oliver Gugger 2025-06-09 21:22:38 +02:00 committed by GitHub
commit 27138373ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 512 additions and 107 deletions

View file

@ -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

View file

@ -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) {

View file

@ -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
}

View file

@ -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)
}

View file

@ -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"`
}

View file

@ -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

View file

@ -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 (<txid>:<txindex>)
-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 (<pubkey>@<host>[:<port>])
--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)

View file

@ -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)

View file

@ -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) {