diff --git a/README.md b/README.md index 4cf6845..47ae6f2 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,7 @@ Quick access: + [showrootkey](doc/chantools_showrootkey.md) + [signrescuefunding](doc/chantools_signrescuefunding.md) + [summary](doc/chantools_summary.md) ++ [sweepremoteclosed](doc/chantools_sweepremoteclosed.md) + [sweeptimelock](doc/chantools_sweeptimelock.md) + [sweeptimelockmanual](doc/chantools_sweeptimelockmanual.md) + [vanitygen](doc/chantools_vanitygen.md) diff --git a/btc/bitcoind.go b/btc/bitcoind.go index 787c456..c09f238 100644 --- a/btc/bitcoind.go +++ b/btc/bitcoind.go @@ -6,7 +6,6 @@ import ( "time" "github.com/btcsuite/btcd/chaincfg" - "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcutil" "github.com/btcsuite/btcutil/hdkeychain" @@ -193,24 +192,15 @@ func (i *ImportWallet) Format(hdKey *hdkeychain.ExtendedKey, if err != nil { return "", fmt.Errorf("could not encode WIF: %v", err) } - pubKey, err := hdKey.ECPubKey() - if err != nil { - return "", fmt.Errorf("could not derive private key: %v", err) - } - hash160 := btcutil.Hash160(pubKey.SerializeCompressed()) - addrP2PKH, err := btcutil.NewAddressPubKeyHash(hash160, params) + addrP2PKH, err := lnd.P2PKHAddr(privKey.PubKey(), params) if err != nil { return "", fmt.Errorf("could not create address: %v", err) } - addrP2WKH, err := btcutil.NewAddressWitnessPubKeyHash(hash160, params) + addrP2WKH, err := lnd.P2WKHAddr(privKey.PubKey(), params) if err != nil { return "", fmt.Errorf("could not create address: %v", err) } - script, err := txscript.PayToAddrScript(addrP2WKH) - if err != nil { - return "", fmt.Errorf("could not create script: %v", err) - } - addrNP2WKH, err := btcutil.NewAddressScriptHash(script, params) + addrNP2WKH, err := lnd.NP2WKHAddr(privKey.PubKey(), params) if err != nil { return "", fmt.Errorf("could not create address: %v", err) } diff --git a/btc/explorer_api.go b/btc/explorer_api.go index eeeb3c2..b0fdbc9 100644 --- a/btc/explorer_api.go +++ b/btc/explorer_api.go @@ -53,6 +53,20 @@ type Status struct { BlockHash string `json:"block_hash"` } +type Stats struct { + FundedTXOCount uint32 `json:"funded_txo_count"` + FundedTXOSum uint64 `json:"funded_txo_sum"` + SpentTXOCount uint32 `json:"spent_txo_count"` + SpentTXOSum uint64 `json:"spent_txo_sum"` + TXCount uint32 `json:"tx_count"` +} + +type AddressStats struct { + Address string `json:"address"` + ChainStats *Stats `json:"chain_stats"` + MempoolStats *Stats `json:"mempool_stats"` +} + func (a *ExplorerAPI) Transaction(txid string) (*TX, error) { tx := &TX{} err := fetchJSON(fmt.Sprintf("%s/tx/%s", a.BaseURL, txid), tx) @@ -90,6 +104,46 @@ func (a *ExplorerAPI) Outpoint(addr string) (*TX, int, error) { return nil, 0, fmt.Errorf("no tx found") } +func (a *ExplorerAPI) Unspent(addr string) ([]*Vout, error) { + var ( + stats = &AddressStats{} + outputs []*Vout + txs []*TX + err error + ) + err = fetchJSON(fmt.Sprintf("%s/address/%s", a.BaseURL, addr), &stats) + if err != nil { + return nil, err + } + + confirmedUnspent := stats.ChainStats.FundedTXOSum - + stats.ChainStats.SpentTXOSum + unconfirmedUnspent := stats.MempoolStats.FundedTXOSum - + stats.MempoolStats.SpentTXOSum + + if confirmedUnspent+unconfirmedUnspent == 0 { + return nil, nil + } + + err = fetchJSON(fmt.Sprintf("%s/address/%s/txs", a.BaseURL, addr), &txs) + if err != nil { + return nil, err + } + for _, tx := range txs { + for voutIdx, vout := range tx.Vout { + if vout.ScriptPubkeyAddr == addr { + vout.Outspend = &Outspend{ + Txid: tx.TXID, + Vin: voutIdx, + } + outputs = append(outputs, vout) + } + } + } + + return outputs, nil +} + func (a *ExplorerAPI) Address(outpoint string) (string, error) { parts := strings.Split(outpoint, ":") diff --git a/cmd/chantools/root.go b/cmd/chantools/root.go index a1b4d00..8592cfa 100644 --- a/cmd/chantools/root.go +++ b/cmd/chantools/root.go @@ -101,6 +101,7 @@ func main() { newSummaryCommand(), newSweepTimeLockCommand(), newSweepTimeLockManualCommand(), + newSweepRemoteClosedCommand(), newVanityGenCommand(), newWalletInfoCommand(), newZombieRecoveryCommand(), diff --git a/cmd/chantools/sweepremoteclosed.go b/cmd/chantools/sweepremoteclosed.go new file mode 100644 index 0000000..4eb5d7a --- /dev/null +++ b/cmd/chantools/sweepremoteclosed.go @@ -0,0 +1,358 @@ +package main + +import ( + "bytes" + "encoding/hex" + "fmt" + + "github.com/btcsuite/btcd/btcec" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcutil" + "github.com/btcsuite/btcutil/hdkeychain" + "github.com/guggero/chantools/btc" + "github.com/guggero/chantools/lnd" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/spf13/cobra" +) + +const ( + sweepRemoteClosedDefaultRecoveryWindow = 200 + sweepDustLimit = 600 +) + +type sweepRemoteClosedCommand struct { + RecoveryWindow uint32 + APIURL string + Publish bool + SweepAddr string + FeeRate uint16 + + rootKey *rootKey + cmd *cobra.Command +} + +func newSweepRemoteClosedCommand() *cobra.Command { + cc := &sweepRemoteClosedCommand{} + cc.cmd = &cobra.Command{ + Use: "sweepremoteclosed", + Short: "Go through all the addresses that could have funds of " + + "channels that were force-closed by the remote party. " + + "A public block explorer is queried for each address " + + "and if any balance is found, all funds are swept to " + + "a given address", + Long: `This command helps users sweep funds that are in +outputs of channels that were force-closed by the remote party. This command +only needs to be used if no channel.backup file is available. By manually +contacting the remote peers and asking them to force-close the channels, the +funds can be swept after the force-close transaction was confirmed. + +Supported remote force-closed channel types are: + - STATIC_REMOTE_KEY (a.k.a. tweakless channels) + - ANCHOR (a.k.a. anchor output channels) +`, + Example: `chantools sweepremoteclosed \ + --recoverywindow 300 \ + --feerate 20 \ + --sweepaddr bc1q..... \ + --publish`, + RunE: cc.Execute, + } + cc.cmd.Flags().Uint32Var( + &cc.RecoveryWindow, "recoverywindow", + sweepRemoteClosedDefaultRecoveryWindow, "number of keys to "+ + "scan per derivation path", + ) + cc.cmd.Flags().StringVar( + &cc.APIURL, "apiurl", defaultAPIURL, "API URL to use (must "+ + "be esplora compatible)", + ) + cc.cmd.Flags().BoolVar( + &cc.Publish, "publish", false, "publish sweep TX to the chain "+ + "API instead of just printing the TX", + ) + cc.cmd.Flags().StringVar( + &cc.SweepAddr, "sweepaddr", "", "address to sweep the funds to", + ) + cc.cmd.Flags().Uint16Var( + &cc.FeeRate, "feerate", defaultFeeSatPerVByte, "fee rate to "+ + "use for the sweep transaction in sat/vByte", + ) + + 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: %v", err) + } + + // Make sure sweep addr is set. + if c.SweepAddr == "" { + return fmt.Errorf("sweep addr is required") + } + + // Set default values. + if c.RecoveryWindow == 0 { + c.RecoveryWindow = sweepRemoteClosedDefaultRecoveryWindow + } + if c.FeeRate == 0 { + c.FeeRate = defaultFeeSatPerVByte + } + + return sweepRemoteClosed( + extendedKey, c.APIURL, c.SweepAddr, c.RecoveryWindow, c.FeeRate, + c.Publish, + ) +} + +type targetAddr struct { + addr btcutil.Address + pubKey *btcec.PublicKey + path string + keyDesc *keychain.KeyDescriptor + vouts []*btc.Vout + script []byte +} + +func sweepRemoteClosed(extendedKey *hdkeychain.ExtendedKey, apiURL, + sweepAddr string, recoveryWindow uint32, feeRate uint16, + publish bool) error { + + var ( + targets []*targetAddr + api = &btc.ExplorerAPI{BaseURL: apiURL} + ) + for index := uint32(0); index < recoveryWindow; index++ { + path := fmt.Sprintf("m/1017'/%d'/%d'/0/%d", + chainParams.HDCoinType, keychain.KeyFamilyPaymentBase, + index) + parsedPath, err := lnd.ParsePath(path) + if err != nil { + return fmt.Errorf("error parsing path: %v", err) + } + + hdKey, err := lnd.DeriveChildren( + extendedKey, parsedPath, + ) + if err != nil { + return fmt.Errorf("eror deriving children: %v", + err) + } + + privKey, err := hdKey.ECPrivKey() + if err != nil { + return fmt.Errorf("could not derive private "+ + "key: %v", err) + } + + foundTargets, err := queryAddressBalances( + privKey.PubKey(), path, &keychain.KeyDescriptor{ + PubKey: privKey.PubKey(), + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamilyPaymentBase, + Index: index, + }, + }, api, + ) + if err != nil { + return fmt.Errorf("could not query API for "+ + "addresses with funds: %v", err) + } + targets = append(targets, foundTargets...) + } + + // Create estimator and transaction template. + var ( + estimator input.TxWeightEstimator + signDescs []*input.SignDescriptor + sweepTx = wire.NewMsgTx(2) + totalOutputValue = uint64(0) + ) + + // Add all found target outputs. + for _, target := range targets { + for _, vout := range target.vouts { + totalOutputValue += vout.Value + + txHash, err := chainhash.NewHashFromStr( + vout.Outspend.Txid, + ) + if err != nil { + return fmt.Errorf("error parsing tx hash: %v", + err) + } + pkScript, err := lnd.GetWitnessAddrScript( + target.addr, chainParams, + ) + if err != nil { + return fmt.Errorf("error getting pk script: %v", + err) + } + + sequence := wire.MaxTxInSequenceNum + switch target.addr.(type) { + case *btcutil.AddressWitnessPubKeyHash: + estimator.AddP2WKHInput() + + case *btcutil.AddressWitnessScriptHash: + estimator.AddWitnessInput( + input.ToRemoteConfirmedWitnessSize, + ) + sequence = 1 + } + + sweepTx.TxIn = append(sweepTx.TxIn, &wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: *txHash, + Index: uint32(vout.Outspend.Vin), + }, + Sequence: sequence, + }) + + signDescs = append(signDescs, &input.SignDescriptor{ + KeyDesc: *target.keyDesc, + WitnessScript: target.script, + Output: &wire.TxOut{ + PkScript: pkScript, + Value: int64(vout.Value), + }, + HashType: txscript.SigHashAll, + }) + } + } + + if len(targets) == 0 || totalOutputValue < sweepDustLimit { + return fmt.Errorf("found %d sweep targets with total value "+ + "of %d satoshis which is below the dust limit of %d", + len(targets), totalOutputValue, sweepDustLimit) + } + + // Add our sweep destination output. + sweepScript, err := lnd.GetP2WPKHScript(sweepAddr, chainParams) + if err != nil { + return err + } + estimator.AddP2WKHOutput() + + // Calculate the fee based on the given fee rate and our weight + // estimation. + feeRateKWeight := chainfee.SatPerKVByte(1000 * feeRate).FeePerKWeight() + totalFee := feeRateKWeight.FeeForWeight(int64(estimator.Weight())) + + log.Infof("Fee %d sats of %d total amount (estimated weight %d)", + totalFee, totalOutputValue, estimator.Weight()) + + sweepTx.TxOut = []*wire.TxOut{{ + Value: int64(totalOutputValue) - int64(totalFee), + PkScript: sweepScript, + }} + + // Sign the transaction now. + var ( + signer = &lnd.Signer{ + ExtendedKey: extendedKey, + ChainParams: chainParams, + } + sigHashes = txscript.NewTxSigHashes(sweepTx) + ) + for idx, desc := range signDescs { + desc.SigHashes = sigHashes + desc.InputIndex = idx + + if len(desc.WitnessScript) > 0 { + witness, err := input.CommitSpendToRemoteConfirmed( + signer, desc, sweepTx, + ) + if err != nil { + return err + } + sweepTx.TxIn[idx].Witness = witness + } else { + // The txscript library expects the witness script of a + // P2WKH descriptor to be set to the pkScript of the + // output... + desc.WitnessScript = desc.Output.PkScript + witness, err := input.CommitSpendNoDelay( + signer, desc, sweepTx, true, + ) + if err != nil { + return err + } + sweepTx.TxIn[idx].Witness = witness + } + } + + var buf bytes.Buffer + err = sweepTx.Serialize(&buf) + if err != nil { + return err + } + + // Publish TX. + if publish { + response, err := api.PublishTx( + hex.EncodeToString(buf.Bytes()), + ) + if err != nil { + return err + } + log.Infof("Published TX %s, response: %s", + sweepTx.TxHash().String(), response) + } + + log.Infof("Transaction: %x", buf.Bytes()) + return nil +} + +func queryAddressBalances(pubKey *btcec.PublicKey, path string, + keyDesc *keychain.KeyDescriptor, api *btc.ExplorerAPI) ([]*targetAddr, + error) { + + var targets []*targetAddr + queryAddr := func(address btcutil.Address, script []byte) error { + unspent, err := api.Unspent(address.EncodeAddress()) + if err != nil { + return fmt.Errorf("could not query unspent: %v", err) + } + + if len(unspent) > 0 { + log.Infof("Found %d unspent outputs for address %v", + len(unspent), address.EncodeAddress()) + targets = append(targets, &targetAddr{ + addr: address, + pubKey: pubKey, + path: path, + keyDesc: keyDesc, + vouts: unspent, + script: script, + }) + } + + return nil + } + + p2wkh, err := lnd.P2WKHAddr(pubKey, chainParams) + if err != nil { + return nil, err + } + if err := queryAddr(p2wkh, nil); err != nil { + return nil, err + } + + p2anchor, script, err := lnd.P2AnchorStaticRemote(pubKey, chainParams) + if err != nil { + return nil, err + } + if err := queryAddr(p2anchor, script); err != nil { + return nil, err + } + + return targets, nil +} diff --git a/doc/chantools.md b/doc/chantools.md index 195c2df..66d7a32 100644 --- a/doc/chantools.md +++ b/doc/chantools.md @@ -37,6 +37,7 @@ Complete documentation is available at https://github.com/guggero/chantools/. * [chantools showrootkey](chantools_showrootkey.md) - Extract and show the BIP32 HD root key from the 24 word lnd aezeed * [chantools signrescuefunding](chantools_signrescuefunding.md) - Rescue funds locked in a funding multisig output that never resulted in a proper channel; this is the command the remote node (the non-initiator) of the channel needs to run * [chantools summary](chantools_summary.md) - Compile a summary about the current state of channels +* [chantools sweepremoteclosed](chantools_sweepremoteclosed.md) - Go through all the addresses that could have funds of channels that were force-closed by the remote party. A public block explorer is queried for each address and if any balance is found, all funds are swept to a given address * [chantools sweeptimelock](chantools_sweeptimelock.md) - Sweep the force-closed state after the time lock has expired * [chantools sweeptimelockmanual](chantools_sweeptimelockmanual.md) - Sweep the force-closed state of a single channel manually if only a channel backup file is available * [chantools vanitygen](chantools_vanitygen.md) - Generate a seed with a custom lnd node identity public key that starts with the given prefix diff --git a/doc/chantools_fakechanbackup.md b/doc/chantools_fakechanbackup.md index 2d2fc85..3f77ecd 100644 --- a/doc/chantools_fakechanbackup.md +++ b/doc/chantools_fakechanbackup.md @@ -61,7 +61,7 @@ chantools fakechanbackup --from_channel_graph lncli_describegraph.json \ --channelpoint string funding transaction outpoint of the channel to rescue (:) as it is displayed on 1ml.com --from_channel_graph string the full LN channel graph in the JSON format that the 'lncli describegraph' returns -h, --help help for fakechanbackup - --multi_file string the fake channel backup file to create (default "results/fake-2021-08-13-13-09-39.backup") + --multi_file string the fake channel backup file to create (default "results/fake-2021-08-29-18-21-11.backup") --remote_node_addr string the remote node connection information in the format pubkey@host:port --rootkey string BIP32 HD root key of the wallet to use for encrypting the backup; leave empty to prompt for lnd 24 word aezeed --short_channel_id string the short channel ID in the format xx diff --git a/doc/chantools_sweepremoteclosed.md b/doc/chantools_sweepremoteclosed.md new file mode 100644 index 0000000..ea457f1 --- /dev/null +++ b/doc/chantools_sweepremoteclosed.md @@ -0,0 +1,55 @@ +## chantools sweepremoteclosed + +Go through all the addresses that could have funds of channels that were force-closed by the remote party. A public block explorer is queried for each address and if any balance is found, all funds are swept to a given address + +### Synopsis + +This command helps users sweep funds that are in +outputs of channels that were force-closed by the remote party. This command +only needs to be used if no channel.backup file is available. By manually +contacting the remote peers and asking them to force-close the channels, the +funds can be swept after the force-close transaction was confirmed. + +Supported remote force-closed channel types are: + - STATIC_REMOTE_KEY (a.k.a. tweakless channels) + - ANCHOR (a.k.a. anchor output channels) + + +``` +chantools sweepremoteclosed [flags] +``` + +### Examples + +``` +chantools sweepremoteclosed \ + --recoverywindow 300 \ + --feerate 20 \ + --sweepaddr bc1q..... \ + --publish +``` + +### Options + +``` + --apiurl string API URL to use (must be esplora compatible) (default "https://blockstream.info/api") + --bip39 read a classic BIP39 seed and passphrase from the terminal instead of asking for lnd seed format or providing the --rootkey flag + --feerate uint16 fee rate to use for the sweep transaction in sat/vByte (default 30) + -h, --help help for sweepremoteclosed + --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 + --sweepaddr string address to sweep the funds to +``` + +### Options inherited from parent commands + +``` + -r, --regtest Indicates if regtest parameters should be used + -t, --testnet Indicates if testnet parameters should be used +``` + +### SEE ALSO + +* [chantools](chantools.md) - Chantools helps recover funds from lightning channels + diff --git a/lnd/hdkeychain.go b/lnd/hdkeychain.go index 82b61e6..fc344d7 100644 --- a/lnd/hdkeychain.go +++ b/lnd/hdkeychain.go @@ -1,6 +1,7 @@ package lnd import ( + "crypto/sha256" "fmt" "strconv" "strings" @@ -11,6 +12,7 @@ import ( "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcutil" "github.com/btcsuite/btcutil/hdkeychain" + "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/shachain" ) @@ -18,6 +20,7 @@ import ( const ( HardenedKeyStart = uint32(hdkeychain.HardenedKeyStart) WalletDefaultDerivationPath = "m/84'/0'/0'" + WalletBIP49DerivationPath = "m/49'/0'/0'" LndDerivationPath = "m/1017'/%d'/%d'" ) @@ -220,6 +223,21 @@ func DecodeAddressHash(addr string, chainParams *chaincfg.Params) ([]byte, bool, return targetHash, isScriptHash, nil } +func GetWitnessAddrScript(addr btcutil.Address, + chainParams *chaincfg.Params) ([]byte, error) { + + if !addr.IsForNet(chainParams) { + return nil, fmt.Errorf("address %v is not for net %v", addr, + chainParams.Name) + } + + builder := txscript.NewScriptBuilder() + builder.AddOp(txscript.OP_0) + builder.AddData(addr.ScriptAddress()) + + return builder.Script() +} + // GetP2WPKHScript creates a P2WKH output script from an address. If the address // is not a P2WKH address, an error is returned. func GetP2WPKHScript(addr string, chainParams *chaincfg.Params) ([]byte, @@ -268,6 +286,53 @@ func GetP2WSHScript(addr string, chainParams *chaincfg.Params) ([]byte, return builder.Script() } +func P2PKHAddr(pubKey *btcec.PublicKey, + params *chaincfg.Params) (*btcutil.AddressPubKeyHash, error) { + + hash160 := btcutil.Hash160(pubKey.SerializeCompressed()) + addrP2PKH, err := btcutil.NewAddressPubKeyHash(hash160, params) + if err != nil { + return nil, fmt.Errorf("could not create address: %v", err) + } + + return addrP2PKH, nil +} + +func P2WKHAddr(pubKey *btcec.PublicKey, + params *chaincfg.Params) (*btcutil.AddressWitnessPubKeyHash, error) { + + hash160 := btcutil.Hash160(pubKey.SerializeCompressed()) + return btcutil.NewAddressWitnessPubKeyHash(hash160, params) +} + +func NP2WKHAddr(pubKey *btcec.PublicKey, + params *chaincfg.Params) (*btcutil.AddressScriptHash, error) { + + hash160 := btcutil.Hash160(pubKey.SerializeCompressed()) + addrP2WKH, err := btcutil.NewAddressWitnessPubKeyHash(hash160, params) + if err != nil { + return nil, fmt.Errorf("could not create address: %v", err) + } + script, err := txscript.PayToAddrScript(addrP2WKH) + if err != nil { + return nil, fmt.Errorf("could not create script: %v", err) + } + return btcutil.NewAddressScriptHash(script, params) +} + +func P2AnchorStaticRemote(pubKey *btcec.PublicKey, + params *chaincfg.Params) (*btcutil.AddressWitnessScriptHash, []byte, + error) { + + commitScript, err := input.CommitScriptToRemoteConfirmed(pubKey) + if err != nil { + return nil, nil, fmt.Errorf("could not create script: %v", err) + } + scriptHash := sha256.Sum256(commitScript) + p2wsh, err := btcutil.NewAddressWitnessScriptHash(scriptHash[:], params) + return p2wsh, commitScript, err +} + type HDKeyRing struct { ExtendedKey *hdkeychain.ExtendedKey ChainParams *chaincfg.Params