diff --git a/README.md b/README.md index fdc447f..7a95797 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ compacting the DB). don't have a `channel.db` file or because `chantools` couldn't rescue all your node's channels. There are a few things you can try manually that have some chance of working: - - Make sure you can connect to all nodes when restoring from SCB: It happens + - Make sure you can connect to all nodes when restoring from SCB: It happens all the time that nodes change their IP addresses. When restoring from a static channel backup, your node tries to connect to the node using the IP address encoded in the backup file. If the address changed, the SCB restore @@ -194,13 +194,20 @@ compacting the DB). `lncli connect @:` in the recovered `lnd` node from step 3 and wait a few hours to see if the channel is now being force closed by the remote node. - - Find out who the node belongs to: Maybe you opened the channel with someone + - Find out who the node belongs to: Maybe you opened the channel with someone you know. Or maybe their node alias contains some information about who the node belongs to. If you can find out who operates the remote node, you can ask them to force-close the channel from their end. If the channel was opened with the `option_static_remote_key`, (`lnd v0.8.0` and later), the funds can be swept by your node. +12. **Use Zombie Channel Recovery Matcher**: As a final, last resort, you can + go to [node-recovery.com](https://www.node-recovery.com/) and register your + node's ID for being matched up against other nodes with the same problem. +

+ Once you were contacted with a match, follow the instructions on the + [Zombie Channel Recovery Guide](doc/zombierecovery.md) page. + ## Seed and passphrase input All commands that require the seed (and, if set, the seed's passphrase) offer diff --git a/btc/explorer_api.go b/btc/explorer_api.go index f00ed6d..eeeb3c2 100644 --- a/btc/explorer_api.go +++ b/btc/explorer_api.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "strings" ) @@ -89,6 +90,30 @@ func (a *ExplorerAPI) Outpoint(addr string) (*TX, int, error) { return nil, 0, fmt.Errorf("no tx found") } +func (a *ExplorerAPI) Address(outpoint string) (string, error) { + parts := strings.Split(outpoint, ":") + + if len(parts) != 2 { + return "", fmt.Errorf("invalid outpoint: %v", outpoint) + } + + tx, err := a.Transaction(parts[0]) + if err != nil { + return "", err + } + + vout, err := strconv.Atoi(parts[1]) + if err != nil { + return "", err + } + + if len(tx.Vout) <= vout { + return "", fmt.Errorf("invalid output index: %d", vout) + } + + return tx.Vout[vout].ScriptPubkeyAddr, nil +} + func (a *ExplorerAPI) PublishTx(rawTxHex string) (string, error) { url := fmt.Sprintf("%s/tx", a.BaseURL) resp, err := http.Post(url, "text/plain", strings.NewReader(rawTxHex)) diff --git a/btc/summary.go b/btc/summary.go index 20ecb33..6649dbf 100644 --- a/btc/summary.go +++ b/btc/summary.go @@ -102,7 +102,7 @@ func reportOutspend(api *ExplorerAPI, entry.ClosingTX.ToRemoteAddr = o.ScriptPubkeyAddr } } - + if couldBeOurs(entry, utxo) { summaryFile.ChannelsWithPotential++ summaryFile.FundsForceClose += utxo[0].Value diff --git a/cmd/chantools/dropchannelgraph.go b/cmd/chantools/dropchannelgraph.go index 298b97c..5105c7a 100644 --- a/cmd/chantools/dropchannelgraph.go +++ b/cmd/chantools/dropchannelgraph.go @@ -67,6 +67,6 @@ func (c *dropChannelGraphCommand) Execute(_ *cobra.Command, _ []string) error { if err := rwTx.DeleteTopLevelBucket(graphMetaBucket); err != nil { return err } - + return rwTx.Commit() } diff --git a/cmd/chantools/fakechanbackup.go b/cmd/chantools/fakechanbackup.go index 232c549..949240e 100644 --- a/cmd/chantools/fakechanbackup.go +++ b/cmd/chantools/fakechanbackup.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/hex" "fmt" - "github.com/lightningnetwork/lnd/tor" "io/ioutil" "net" "strconv" @@ -21,6 +20,7 @@ import ( "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/tor" "github.com/spf13/cobra" ) @@ -166,21 +166,21 @@ func (c *fakeChanBackupCommand) Execute(_ *cobra.Command, _ []string) error { } // Parse the short channel ID. - splitChanId := strings.Split(c.ShortChanID, "x") - if len(splitChanId) != 3 { + splitChanID := strings.Split(c.ShortChanID, "x") + if len(splitChanID) != 3 { return fmt.Errorf("--short_channel_id expected in format: " + "xx", ) } - blockHeight, err := strconv.ParseInt(splitChanId[0], 10, 32) + blockHeight, err := strconv.ParseInt(splitChanID[0], 10, 32) if err != nil { return fmt.Errorf("could not parse block height: %s", err) } - txIndex, err := strconv.ParseInt(splitChanId[1], 10, 32) + txIndex, err := strconv.ParseInt(splitChanID[1], 10, 32) if err != nil { return fmt.Errorf("could not parse transaction index: %s", err) } - chanOutputIdx, err := strconv.ParseInt(splitChanId[2], 10, 32) + chanOutputIdx, err := strconv.ParseInt(splitChanID[2], 10, 32) if err != nil { return fmt.Errorf("could not parse output index: %s", err) } diff --git a/cmd/chantools/rescueclosed.go b/cmd/chantools/rescueclosed.go index 92261ac..1a1766f 100644 --- a/cmd/chantools/rescueclosed.go +++ b/cmd/chantools/rescueclosed.go @@ -225,7 +225,7 @@ func commitPointsFromLogFile(lndLog string) ([]*btcec.PublicKey, error) { dedupMap[groups[1]] = commitPoint } - var result []*btcec.PublicKey + result := make([]*btcec.PublicKey, 0, len(dedupMap)) for _, commitPoint := range dedupMap { result = append(result, commitPoint) } diff --git a/cmd/chantools/root.go b/cmd/chantools/root.go index 7853d7a..55950ef 100644 --- a/cmd/chantools/root.go +++ b/cmd/chantools/root.go @@ -26,7 +26,7 @@ import ( const ( defaultAPIURL = "https://blockstream.info/api" - version = "0.8.3" + version = "0.8.4" na = "n/a" Commit = "" @@ -102,6 +102,7 @@ func main() { newSweepTimeLockManualCommand(), newVanityGenCommand(), newWalletInfoCommand(), + newZombieRecoveryCommand(), ) if err := rootCmd.Execute(); err != nil { diff --git a/cmd/chantools/sweeptimelock.go b/cmd/chantools/sweeptimelock.go index bfbbc0d..0aeeb80 100644 --- a/cmd/chantools/sweeptimelock.go +++ b/cmd/chantools/sweeptimelock.go @@ -19,7 +19,7 @@ import ( ) const ( - defaultFeeSatPerVByte = 2 + defaultFeeSatPerVByte = 30 defaultCsvLimit = 2016 ) diff --git a/cmd/chantools/zombierecovery_findmatches.go b/cmd/chantools/zombierecovery_findmatches.go new file mode 100644 index 0000000..4bf930d --- /dev/null +++ b/cmd/chantools/zombierecovery_findmatches.go @@ -0,0 +1,200 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "regexp" + "time" + + "github.com/btcsuite/btcd/btcec" + "github.com/gogo/protobuf/jsonpb" + "github.com/guggero/chantools/btc" + "github.com/guggero/chantools/lnd" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/spf13/cobra" +) + +var ( + patternRegistration = regexp.MustCompile( + "(?m)(?s)ID: ([0-9a-f]{66})\nContact: (.*?)\n" + + "Time: ") +) + +type nodeInfo struct { + PubKey string `json:"identity_pubkey"` + Contact string `json:"contact"` + PayoutAddr string `json:"payout_addr,omitempty"` + MultisigKeys []string `json:"multisig_keys,omitempty"` +} + +type channel struct { + ChannelID string `json:"short_channel_id"` + ChanPoint string `json:"chan_point"` + Address string `json:"address"` + Capacity int64 `json:"capacity"` + txid string + vout uint32 + ourKeyIndex uint32 + ourKey *btcec.PublicKey + theirKey *btcec.PublicKey + witnessScript []byte +} + +type match struct { + Node1 *nodeInfo `json:"node1"` + Node2 *nodeInfo `json:"node2"` + Channels []*channel `json:"channels"` +} + +type zombieRecoveryFindMatchesCommand struct { + APIURL string + Registrations string + ChannelGraph string + + cmd *cobra.Command +} + +func newZombieRecoveryFindMatchesCommand() *cobra.Command { + cc := &zombieRecoveryFindMatchesCommand{} + cc.cmd = &cobra.Command{ + Use: "findmatches", + Short: "[0/3] Match maker only: Find matches between " + + "registered nodes", + Long: `Match maker only: Runs through all the nodes that have +registered their ID on https://www.node-recovery.com and checks whether there +are any matches of channels between them by looking at the whole channel graph. + +This command will be run by guggero and the result will be sent to the +registered nodes.`, + Example: `chantools zombierecovery findmatches \ + --registrations data.txt \ + --channel_graph lncli_describegraph.json`, + RunE: cc.Execute, + } + + cc.cmd.Flags().StringVar( + &cc.APIURL, "apiurl", defaultAPIURL, "API URL to use (must "+ + "be esplora compatible)", + ) + cc.cmd.Flags().StringVar( + &cc.Registrations, "registrations", "", "the raw data.txt "+ + "where the registrations are stored in", + ) + cc.cmd.Flags().StringVar( + &cc.ChannelGraph, "channel_graph", "", "the full LN channel "+ + "graph in the JSON format that the "+ + "'lncli describegraph' returns", + ) + + return cc.cmd +} + +func (c *zombieRecoveryFindMatchesCommand) Execute(_ *cobra.Command, + _ []string) error { + + api := &btc.ExplorerAPI{BaseURL: c.APIURL} + + logFileBytes, err := ioutil.ReadFile(c.Registrations) + if err != nil { + return fmt.Errorf("error reading registrations file %s: %v", + c.Registrations, err) + } + + allMatches := patternRegistration.FindAllStringSubmatch( + string(logFileBytes), -1, + ) + registrations := make(map[string]string, len(allMatches)) + for _, groups := range allMatches { + if _, err := pubKeyFromHex(groups[1]); err != nil { + return fmt.Errorf("error parsing node ID: %v", err) + } + + registrations[groups[1]] = groups[2] + + log.Infof("%s: %s", groups[1], groups[2]) + } + + graphBytes, err := ioutil.ReadFile(c.ChannelGraph) + if err != nil { + return fmt.Errorf("error reading graph JSON file %s: "+ + "%v", c.ChannelGraph, err) + } + graph := &lnrpc.ChannelGraph{} + err = jsonpb.UnmarshalString(string(graphBytes), graph) + if err != nil { + return fmt.Errorf("error parsing graph JSON: %v", err) + } + + // Loop through all nodes now. + matches := make(map[string]map[string]*match) + for node1, contact1 := range registrations { + matches[node1] = make(map[string]*match) + for node2, contact2 := range registrations { + if node1 == node2 { + continue + } + + // We've already looked at this pair. + if matches[node2][node1] != nil { + continue + } + + edges := lnd.FindCommonEdges(graph, node1, node2) + if len(edges) > 0 { + matches[node1][node2] = &match{ + Node1: &nodeInfo{ + PubKey: node1, + Contact: contact1, + }, + Node2: &nodeInfo{ + PubKey: node2, + Contact: contact2, + }, + Channels: make([]*channel, len(edges)), + } + + for idx, edge := range edges { + cid := fmt.Sprintf("%d", edge.ChannelId) + c := &channel{ + ChannelID: cid, + ChanPoint: edge.ChanPoint, + Capacity: edge.Capacity, + } + + addr, err := api.Address(c.ChanPoint) + if err == nil { + c.Address = addr + } + + matches[node1][node2].Channels[idx] = c + } + } + } + } + + // Write the matches to files. + for node1, node1map := range matches { + for node2, match := range node1map { + if match == nil { + continue + } + + matchBytes, err := json.MarshalIndent(match, "", " ") + if err != nil { + return err + } + + fileName := fmt.Sprintf("results/match-%s-%s-%s.json", + time.Now().Format("2006-01-02"), + node1, node2) + log.Infof("Writing result to %s", fileName) + err = ioutil.WriteFile(fileName, matchBytes, 0644) + if err != nil { + return err + } + } + } + + return nil +} diff --git a/cmd/chantools/zombierecovery_makeoffer.go b/cmd/chantools/zombierecovery_makeoffer.go new file mode 100644 index 0000000..9afce76 --- /dev/null +++ b/cmd/chantools/zombierecovery_makeoffer.go @@ -0,0 +1,505 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "io/ioutil" + "os" + "strconv" + "strings" + + "github.com/btcsuite/btcd/btcec" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcutil/psbt" + "github.com/guggero/chantools/lnd" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/spf13/cobra" +) + +type zombieRecoveryMakeOfferCommand struct { + Node1 string + Node2 string + FeeRate uint16 + + rootKey *rootKey + cmd *cobra.Command +} + +func newZombieRecoveryMakeOfferCommand() *cobra.Command { + cc := &zombieRecoveryMakeOfferCommand{} + cc.cmd = &cobra.Command{ + Use: "makeoffer", + Short: "[2/3] Make an offer on how to split the funds to " + + "recover", + Long: `After both parties have prepared their keys with the +'preparekeys' command and have exchanged the files generated from that step, +one party has to create an offer on how to split the funds that are in the +channels to be rescued. +If the other party agrees with the offer, they can sign and publish the offer +with the 'signoffer' command. If the other party does not agree, they can create +a counter offer.`, + Example: `chantools zombierecovery makeoffer \ + --node1_keys preparedkeys-xxxx-xx-xx-.json \ + --node2_keys preparedkeys-xxxx-xx-xx-.json \ + --feerate 15`, + RunE: cc.Execute, + } + + cc.cmd.Flags().StringVar( + &cc.Node1, "node1_keys", "", "the JSON file generated in the"+ + "previous step ('preparekeys') command of node 1", + ) + cc.cmd.Flags().StringVar( + &cc.Node2, "node2_keys", "", "the JSON file generated in the"+ + "previous step ('preparekeys') command of node 2", + ) + cc.cmd.Flags().Uint16Var( + &cc.FeeRate, "feerate", defaultFeeSatPerVByte, "fee rate to "+ + "use for the sweep transaction in sat/vByte", + ) + + cc.rootKey = newRootKey(cc.cmd, "signing the offer") + + return cc.cmd +} + +func (c *zombieRecoveryMakeOfferCommand) Execute(_ *cobra.Command, // nolint:gocyclo + _ []string) error { + + extendedKey, err := c.rootKey.read() + if err != nil { + return fmt.Errorf("error reading root key: %v", err) + } + + if c.FeeRate == 0 { + c.FeeRate = defaultFeeSatPerVByte + } + + node1Bytes, err := ioutil.ReadFile(c.Node1) + if err != nil { + return fmt.Errorf("error reading node1 key file %s: %v", + c.Node1, err) + } + node2Bytes, err := ioutil.ReadFile(c.Node2) + if err != nil { + return fmt.Errorf("error reading node2 key file %s: %v", + c.Node2, err) + } + keys1, keys2 := &match{}, &match{} + decoder := json.NewDecoder(bytes.NewReader(node1Bytes)) + if err := decoder.Decode(&keys1); err != nil { + return fmt.Errorf("error decoding node1 key file %s: %v", + c.Node1, err) + } + decoder = json.NewDecoder(bytes.NewReader(node2Bytes)) + if err := decoder.Decode(&keys2); err != nil { + return fmt.Errorf("error decoding node2 key file %s: %v", + c.Node2, err) + } + + // Make sure the key files were filled correctly. + if keys1.Node1 == nil || keys1.Node2 == nil { + return fmt.Errorf("invalid node1 file, node info missing") + } + if keys2.Node1 == nil || keys2.Node2 == nil { + return fmt.Errorf("invalid node2 file, node info missing") + } + if keys1.Node1.PubKey != keys2.Node1.PubKey { + return fmt.Errorf("invalid files, node 1 pubkey doesn't match") + } + if keys1.Node2.PubKey != keys2.Node2.PubKey { + return fmt.Errorf("invalid files, node 2 pubkey doesn't match") + } + if len(keys1.Node1.MultisigKeys) == 0 && + len(keys1.Node2.MultisigKeys) == 0 { + + return fmt.Errorf("invalid node1 file, missing multisig keys") + } + if len(keys2.Node1.MultisigKeys) == 0 && + len(keys2.Node2.MultisigKeys) == 0 { + + return fmt.Errorf("invalid node2 file, missing multisig keys") + } + if len(keys1.Node1.MultisigKeys) == len(keys2.Node1.MultisigKeys) { + return fmt.Errorf("invalid files, channel info incorrect") + } + if len(keys1.Node2.MultisigKeys) == len(keys2.Node2.MultisigKeys) { + return fmt.Errorf("invalid files, channel info incorrect") + } + if len(keys1.Channels) != len(keys2.Channels) { + return fmt.Errorf("invalid files, channels don't match") + } + for idx, node1Channel := range keys1.Channels { + if keys2.Channels[idx].ChanPoint != node1Channel.ChanPoint { + return fmt.Errorf("invalid files, channels don't match") + } + + if keys2.Channels[idx].Address != node1Channel.Address { + return fmt.Errorf("invalid files, channels don't match") + } + + if keys2.Channels[idx].Address == "" || + node1Channel.Address == "" { + + return fmt.Errorf("invalid files, channel address " + + "missing") + } + } + + // Make sure one of the nodes is ours. + _, pubKey, _, err := lnd.DeriveKey( + extendedKey, lnd.IdentityPath(chainParams), chainParams, + ) + if err != nil { + return fmt.Errorf("error deriving identity pubkey: %v", err) + } + + pubKeyStr := hex.EncodeToString(pubKey.SerializeCompressed()) + if keys1.Node1.PubKey != pubKeyStr && keys1.Node2.PubKey != pubKeyStr { + return fmt.Errorf("derived pubkey %s from seed but that key "+ + "was not found in the match files", pubKeyStr) + } + + // Pick the correct list of keys. There are 4 possibilities, given 2 + // files with 2 node slots each. + var ( + ourKeys []string + ourPayoutAddr string + theirKeys []string + theirPayoutAddr string + ) + if keys1.Node1.PubKey == pubKeyStr && len(keys1.Node1.MultisigKeys) > 0 { + ourKeys = keys1.Node1.MultisigKeys + ourPayoutAddr = keys1.Node1.PayoutAddr + theirKeys = keys2.Node2.MultisigKeys + theirPayoutAddr = keys2.Node2.PayoutAddr + } + if keys1.Node2.PubKey == pubKeyStr && len(keys1.Node2.MultisigKeys) > 0 { + ourKeys = keys1.Node2.MultisigKeys + ourPayoutAddr = keys1.Node2.PayoutAddr + theirKeys = keys2.Node1.MultisigKeys + theirPayoutAddr = keys2.Node1.PayoutAddr + } + if keys2.Node1.PubKey == pubKeyStr && len(keys2.Node1.MultisigKeys) > 0 { + ourKeys = keys2.Node1.MultisigKeys + ourPayoutAddr = keys2.Node1.PayoutAddr + theirKeys = keys1.Node2.MultisigKeys + theirPayoutAddr = keys1.Node2.PayoutAddr + } + if keys2.Node2.PubKey == pubKeyStr && len(keys2.Node2.MultisigKeys) > 0 { + ourKeys = keys2.Node2.MultisigKeys + ourPayoutAddr = keys2.Node2.PayoutAddr + theirKeys = keys1.Node1.MultisigKeys + theirPayoutAddr = keys1.Node1.PayoutAddr + } + if len(ourKeys) == 0 || len(theirKeys) == 0 { + return fmt.Errorf("couldn't find necessary keys") + } + if ourPayoutAddr == "" || theirPayoutAddr == "" { + return fmt.Errorf("payout address missing") + } + + ourPubKeys := make([]*btcec.PublicKey, len(ourKeys)) + theirPubKeys := make([]*btcec.PublicKey, len(theirKeys)) + for idx, pubKeyHex := range ourKeys { + ourPubKeys[idx], err = pubKeyFromHex(pubKeyHex) + if err != nil { + return fmt.Errorf("error parsing our pubKey: %v", err) + } + } + for idx, pubKeyHex := range theirKeys { + theirPubKeys[idx], err = pubKeyFromHex(pubKeyHex) + if err != nil { + return fmt.Errorf("error parsing their pubKey: %v", err) + } + } + + // Loop through all channels and all keys now, this will definitely take + // a while. +channelLoop: + for _, channel := range keys1.Channels { + for ourKeyIndex, ourKey := range ourPubKeys { + for _, theirKey := range theirPubKeys { + match, witnessScript, err := matchScript( + channel.Address, ourKey, theirKey, + chainParams, + ) + if err != nil { + return fmt.Errorf("error matching "+ + "keys to script: %v", err) + } + + if match { + channel.ourKeyIndex = uint32(ourKeyIndex) + channel.ourKey = ourKey + channel.theirKey = theirKey + channel.witnessScript = witnessScript + + log.Infof("Found keys for channel %s", + channel.ChanPoint) + + continue channelLoop + } + } + } + + return fmt.Errorf("didn't find matching multisig keys for "+ + "channel %s", channel.ChanPoint) + } + + // Let's now sum up the tally of how much of the rescued funds should + // go to which party. + var ( + inputs = make([]*wire.TxIn, 0, len(keys1.Channels)) + ourSum int64 + theirSum int64 + ) + for idx, channel := range keys1.Channels { + op, err := lnd.ParseOutpoint(channel.ChanPoint) + if err != nil { + return fmt.Errorf("error parsing channel out point: %v", + err) + } + channel.txid = op.Hash.String() + channel.vout = op.Index + + ourPart, theirPart, err := askAboutChannel( + channel, idx+1, len(keys1.Channels), ourPayoutAddr, + theirPayoutAddr, + ) + if err != nil { + return err + } + + ourSum += ourPart + theirSum += theirPart + inputs = append(inputs, &wire.TxIn{ + PreviousOutPoint: *op, + // It's not actually an old sig script but a witness + // script but we'll move that to the correct place once + // we create the PSBT. + SignatureScript: channel.witnessScript, + }) + } + + // Let's create a fee estimator now to give an overview over the + // deducted fees. + estimator := input.TxWeightEstimator{} + + // Only add output for us if we should receive something. + if ourSum > 0 { + estimator.AddP2WKHOutput() + } + if theirSum > 0 { + estimator.AddP2WKHOutput() + } + for range inputs { + estimator.AddWitnessInput(input.MultiSigWitnessSize) + } + feeRateKWeight := chainfee.SatPerKVByte(1000 * c.FeeRate).FeePerKWeight() + totalFee := int64(feeRateKWeight.FeeForWeight(int64(estimator.Weight()))) + + fmt.Printf("Current tally (before fees):\n\t"+ + "To our address (%s): %d sats\n\t"+ + "To their address (%s): %d sats\n\t"+ + "Estimated fees (at rate %d sat/vByte): %d sats\n", + ourPayoutAddr, ourSum, theirPayoutAddr, theirSum, c.FeeRate, + totalFee) + + // Distribute the fees. + halfFee := totalFee / 2 + switch { + case ourSum-halfFee > 0 && theirSum-halfFee > 0: + ourSum -= halfFee + theirSum -= halfFee + + case ourSum-totalFee > 0: + ourSum -= totalFee + + case theirSum-totalFee > 0: + theirSum -= totalFee + + default: + return fmt.Errorf("error distributing fees, unhandled case") + } + + // Don't create dust. + if ourSum <= int64(lnwallet.DefaultDustLimit()) { + ourSum = 0 + } + if theirSum <= int64(lnwallet.DefaultDustLimit()) { + theirSum = 0 + } + + fmt.Printf("Current tally (after fees):\n\t"+ + "To our address (%s): %d sats\n\t"+ + "To their address (%s): %d sats\n", + ourPayoutAddr, ourSum, theirPayoutAddr, theirSum) + + // And now create the PSBT. + tx := wire.NewMsgTx(2) + if ourSum > 0 { + pkScript, err := lnd.GetP2WPKHScript(ourPayoutAddr, chainParams) + if err != nil { + return fmt.Errorf("error parsing our payout address: "+ + "%v", err) + } + tx.TxOut = append(tx.TxOut, &wire.TxOut{ + PkScript: pkScript, + Value: ourSum, + }) + } + if theirSum > 0 { + pkScript, err := lnd.GetP2WPKHScript( + theirPayoutAddr, chainParams, + ) + if err != nil { + return fmt.Errorf("error parsing their payout "+ + "address: %v", err) + } + tx.TxOut = append(tx.TxOut, &wire.TxOut{ + PkScript: pkScript, + Value: theirSum, + }) + } + for _, txIn := range inputs { + tx.TxIn = append(tx.TxIn, &wire.TxIn{ + PreviousOutPoint: txIn.PreviousOutPoint, + }) + } + packet, err := psbt.NewFromUnsignedTx(tx) + if err != nil { + return fmt.Errorf("error creating PSBT from TX: %v", err) + } + + signer := &lnd.Signer{ + ExtendedKey: extendedKey, + ChainParams: chainParams, + } + for idx, txIn := range inputs { + channel := keys1.Channels[idx] + + // We've mis-used this field to transport the witness script, + // let's now copy it to the correct place. + packet.Inputs[idx].WitnessScript = txIn.SignatureScript + + // Let's prepare the witness UTXO. + pkScript, err := input.WitnessScriptHash(channel.witnessScript) + if err != nil { + return err + } + packet.Inputs[idx].WitnessUtxo = &wire.TxOut{ + PkScript: pkScript, + Value: channel.Capacity, + } + + // We'll be signing with our key so we can just add the other + // party's pubkey as additional info so it's easy for them to + // sign as well. + packet.Inputs[idx].Unknowns = append( + packet.Inputs[idx].Unknowns, &psbt.Unknown{ + Key: PsbtKeyTypeOutputMissingSigPubkey, + Value: channel.theirKey.SerializeCompressed(), + }, + ) + + keyDesc := keychain.KeyDescriptor{ + PubKey: channel.ourKey, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamilyMultiSig, + Index: channel.ourKeyIndex, + }, + } + utxo := &wire.TxOut{ + Value: channel.Capacity, + } + err = signer.AddPartialSignature( + packet, keyDesc, utxo, txIn.SignatureScript, idx, + ) + if err != nil { + return fmt.Errorf("error signing input %d: %v", idx, + err) + } + } + + // Looks like we're done! + base64, err := packet.B64Encode() + if err != nil { + return fmt.Errorf("error encoding PSBT: %v", err) + } + + fmt.Printf("Done creating offer, please send this PSBT string to \n"+ + "the other party to review and sign (if they accept): \n%s\n", + base64) + + return nil +} + +func matchScript(address string, key1, key2 *btcec.PublicKey, + params *chaincfg.Params) (bool, []byte, error) { + + channelScript, err := lnd.GetP2WSHScript(address, params) + if err != nil { + return false, nil, err + } + + witnessScript, err := input.GenMultiSigScript( + key1.SerializeCompressed(), key2.SerializeCompressed(), + ) + if err != nil { + return false, nil, err + } + pkScript, err := input.WitnessScriptHash(witnessScript) + if err != nil { + return false, nil, err + } + + return bytes.Equal(channelScript, pkScript), witnessScript, nil +} + +func askAboutChannel(channel *channel, current, total int, ourAddr, + theirAddr string) (int64, int64, error) { + + fundingTxid := strings.Split(channel.ChanPoint, ":")[0] + + fmt.Printf("Channel %s (%d of %d): \n\tCapacity: %d sat\n\t"+ + "Funding TXID: https://blockstream.info/tx/%v\n\t"+ + "Channel info: https://1ml.com/channel/%s\n\t"+ + "Channel funding address: %s\n\n"+ + "How many sats should go to you (%s) before fees?: ", + channel.ChanPoint, current, total, channel.Capacity, + fundingTxid, channel.ChannelID, channel.Address, ourAddr) + reader := bufio.NewReader(os.Stdin) + ourPartStr, err := reader.ReadString('\n') + if err != nil { + return 0, 0, err + } + + ourPart, err := strconv.ParseUint(strings.TrimSpace(ourPartStr), 10, 64) + if err != nil { + return 0, 0, err + } + + // Let the user try again if they entered something incorrect. + if int64(ourPart) > channel.Capacity { + fmt.Printf("Cannot send more than %d sats to ourself!\n", + channel.Capacity) + return askAboutChannel( + channel, current, total, ourAddr, theirAddr, + ) + } + + theirPart := channel.Capacity - int64(ourPart) + fmt.Printf("\nWill send: \n\t%d sats to our address (%s) and \n\t"+ + "%d sats to the other peer's address (%s).\n\n", ourPart, + ourAddr, theirPart, theirAddr) + + return int64(ourPart), theirPart, nil +} diff --git a/cmd/chantools/zombierecovery_preparekeys.go b/cmd/chantools/zombierecovery_preparekeys.go new file mode 100644 index 0000000..2549844 --- /dev/null +++ b/cmd/chantools/zombierecovery_preparekeys.go @@ -0,0 +1,138 @@ +package main + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "io/ioutil" + "time" + + "github.com/guggero/chantools/lnd" + "github.com/spf13/cobra" +) + +const ( + numMultisigKeys = 2500 +) + +type zombieRecoveryPrepareKeysCommand struct { + MatchFile string + PayoutAddr string + + rootKey *rootKey + cmd *cobra.Command +} + +func newZombieRecoveryPrepareKeysCommand() *cobra.Command { + cc := &zombieRecoveryPrepareKeysCommand{} + cc.cmd = &cobra.Command{ + Use: "preparekeys", + Short: "[1/3] Prepare all public keys for a recovery attempt", + Long: `Takes a match file, validates it against the seed and +then adds the first 2500 multisig pubkeys to it. +This must be run by both parties of a channel for a successful recovery. The +next step (makeoffer) takes two such key enriched files and tries to find the +correct ones for the matched channels.`, + Example: `chantools zombierecovery preparekeys \ + --match_file match-xxxx-xx-xx--.json \ + --payout_addr bc1q...`, + RunE: cc.Execute, + } + + cc.cmd.Flags().StringVar( + &cc.MatchFile, "match_file", "", "the match JSON file that "+ + "was sent to both nodes by the match maker", + ) + cc.cmd.Flags().StringVar( + &cc.PayoutAddr, "payout_addr", "", "the address where this "+ + "node's rescued funds should be sent to, must be a "+ + "P2WPKH (native SegWit) address") + + cc.rootKey = newRootKey(cc.cmd, "deriving the multisig keys") + + return cc.cmd +} + +func (c *zombieRecoveryPrepareKeysCommand) Execute(_ *cobra.Command, + _ []string) error { + + extendedKey, err := c.rootKey.read() + if err != nil { + return fmt.Errorf("error reading root key: %v", err) + } + + _, err = lnd.GetP2WPKHScript(c.PayoutAddr, chainParams) + if err != nil { + return fmt.Errorf("invalid payout address, must be P2WPKH") + } + + matchFileBytes, err := ioutil.ReadFile(c.MatchFile) + if err != nil { + return fmt.Errorf("error reading match file %s: %v", + c.MatchFile, err) + } + + decoder := json.NewDecoder(bytes.NewReader(matchFileBytes)) + match := &match{} + if err := decoder.Decode(&match); err != nil { + return fmt.Errorf("error decoding match file %s: %v", + c.MatchFile, err) + } + + // Make sure the match file was filled correctly. + if match.Node1 == nil || match.Node2 == nil { + return fmt.Errorf("invalid match file, node info missing") + } + + _, pubKey, _, err := lnd.DeriveKey( + extendedKey, lnd.IdentityPath(chainParams), chainParams, + ) + if err != nil { + return fmt.Errorf("error deriving identity pubkey: %v", err) + } + + pubKeyStr := hex.EncodeToString(pubKey.SerializeCompressed()) + var nodeInfo *nodeInfo + switch { + case match.Node1.PubKey != pubKeyStr && match.Node2.PubKey != pubKeyStr: + return fmt.Errorf("derived pubkey %s from seed but that key "+ + "was not found in the match file %s", pubKeyStr, + c.MatchFile) + + case match.Node1.PubKey == pubKeyStr: + nodeInfo = match.Node1 + + default: + nodeInfo = match.Node2 + } + + // Derive all 2500 keys now, this might take a while. + for index := 0; index < numMultisigKeys; index++ { + _, pubKey, _, err := lnd.DeriveKey( + extendedKey, lnd.MultisigPath(chainParams, index), + chainParams, + ) + if err != nil { + return fmt.Errorf("error deriving multisig pubkey: %v", + err) + } + + nodeInfo.MultisigKeys = append( + nodeInfo.MultisigKeys, + hex.EncodeToString(pubKey.SerializeCompressed()), + ) + } + nodeInfo.PayoutAddr = c.PayoutAddr + + // Write the result back into a new file. + matchBytes, err := json.MarshalIndent(match, "", " ") + if err != nil { + return err + } + + fileName := fmt.Sprintf("results/preparedkeys-%s-%s.json", + time.Now().Format("2006-01-02"), pubKeyStr) + log.Infof("Writing result to %s", fileName) + return ioutil.WriteFile(fileName, matchBytes, 0644) +} diff --git a/cmd/chantools/zombierecovery_root.go b/cmd/chantools/zombierecovery_root.go new file mode 100644 index 0000000..d081b61 --- /dev/null +++ b/cmd/chantools/zombierecovery_root.go @@ -0,0 +1,42 @@ +package main + +import ( + "os" + + "github.com/spf13/cobra" +) + +type zombieRecoveryCommand struct { + cmd *cobra.Command +} + +func newZombieRecoveryCommand() *cobra.Command { + cc := &zombieRecoveryCommand{} + cc.cmd = &cobra.Command{ + Use: "zombierecovery", + Short: "Try rescuing funds stuck in channels with zombie nodes", + Long: `A sub command that hosts a set of further sub commands +to help with recovering funds tuck in zombie channels. + +Please visit https://github.com/guggero/chantools/blob/master/doc/zombierecovery.md +for more information on how to use these commands.`, + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + _ = cmd.Help() + os.Exit(0) + } + }, + } + + cobra.EnableCommandSorting = false + cc.cmd.AddCommand( + // Here the order matters, we don't want them to be + // alphabetically sorted but by step number. + newZombieRecoveryFindMatchesCommand(), + newZombieRecoveryPrepareKeysCommand(), + newZombieRecoveryMakeOfferCommand(), + newZombieRecoverySignOfferCommand(), + ) + + return cc.cmd +} diff --git a/cmd/chantools/zombierecovery_signoffer.go b/cmd/chantools/zombierecovery_signoffer.go new file mode 100644 index 0000000..83ce010 --- /dev/null +++ b/cmd/chantools/zombierecovery_signoffer.go @@ -0,0 +1,193 @@ +package main + +import ( + "bufio" + "bytes" + "fmt" + "github.com/btcsuite/btcd/txscript" + "os" + + "github.com/btcsuite/btcd/btcec" + "github.com/btcsuite/btcutil/hdkeychain" + "github.com/btcsuite/btcutil/psbt" + "github.com/guggero/chantools/lnd" + "github.com/lightningnetwork/lnd/keychain" + "github.com/spf13/cobra" +) + +type zombieRecoverySignOfferCommand struct { + Psbt string + + rootKey *rootKey + cmd *cobra.Command +} + +func newZombieRecoverySignOfferCommand() *cobra.Command { + cc := &zombieRecoverySignOfferCommand{} + cc.cmd = &cobra.Command{ + Use: "signoffer", + Short: "[3/3] Sign an offer sent by the remote peer to " + + "recover funds", + Long: `Inspect and sign an offer that was sent by the remote +peer to recover funds from one or more channels.`, + Example: `chantools zombierecovery signoffer \ + --psbt `, + RunE: cc.Execute, + } + + cc.cmd.Flags().StringVar( + &cc.Psbt, "psbt", "", "the base64 encoded PSBT that the other "+ + "party sent as an offer to rescue funds", + ) + + cc.rootKey = newRootKey(cc.cmd, "signing the offer") + + return cc.cmd +} + +func (c *zombieRecoverySignOfferCommand) Execute(_ *cobra.Command, + _ []string) error { + + extendedKey, err := c.rootKey.read() + if err != nil { + return fmt.Errorf("error reading root key: %v", err) + } + + signer := &lnd.Signer{ + ExtendedKey: extendedKey, + ChainParams: chainParams, + } + + // Decode the PSBT. + packet, err := psbt.NewFromRawBytes( + bytes.NewReader([]byte(c.Psbt)), true, + ) + if err != nil { + return fmt.Errorf("error decoding PSBT: %v", err) + } + + return signOffer(extendedKey, packet, signer) +} + +func signOffer(rootKey *hdkeychain.ExtendedKey, + packet *psbt.Packet, signer *lnd.Signer) error { + + // First, we need to derive the correct branch from the local root key. + localMultisig, err := lnd.DeriveChildren(rootKey, []uint32{ + lnd.HardenedKeyStart + uint32(keychain.BIP0043Purpose), + lnd.HardenedKeyStart + chainParams.HDCoinType, + lnd.HardenedKeyStart + uint32(keychain.KeyFamilyMultiSig), + 0, + }) + if err != nil { + return fmt.Errorf("could not derive local multisig key: %v", + err) + } + + // Now let's check that the packet has the expected proprietary key with + // our pubkey that we need to sign with. + if len(packet.Inputs) == 0 { + return fmt.Errorf("invalid PSBT, expected at least 1 input, "+ + "got %d", len(packet.Inputs)) + } + for idx := range packet.Inputs { + if len(packet.Inputs[idx].Unknowns) != 1 { + return fmt.Errorf("invalid PSBT, expected 1 unknown "+ + "in input %d, got %d", idx, + len(packet.Inputs[idx].Unknowns)) + } + } + + fmt.Printf("The PSBT contains the following proposal:\n\n\t"+ + "Close %d channels: \n", len(packet.Inputs)) + var totalInput int64 + for idx, txIn := range packet.UnsignedTx.TxIn { + value := packet.Inputs[idx].WitnessUtxo.Value + totalInput += value + fmt.Printf("\tChannel %d (%s:%d), capacity %d sats\n", + idx, txIn.PreviousOutPoint.Hash.String(), + txIn.PreviousOutPoint.Index, value) + } + fmt.Println() + var totalOutput int64 + for _, txOut := range packet.UnsignedTx.TxOut { + totalOutput += txOut.Value + pkScript, err := txscript.ParsePkScript(txOut.PkScript) + if err != nil { + return fmt.Errorf("error parsing pk script: %v", err) + } + addr, err := pkScript.Address(chainParams) + if err != nil { + return fmt.Errorf("error parsing address: %v", err) + } + fmt.Printf("\tSend %d sats to address %s\n", txOut.Value, addr) + } + fmt.Printf("\n\tTotal fees: %d sats\n\nDo you want to continue?\n", + totalInput-totalOutput) + fmt.Printf("Press to continue and sign the transaction or " + + " to abort: ") + _, _ = bufio.NewReader(os.Stdin).ReadString('\n') + + for idx := range packet.Inputs { + unknown := packet.Inputs[idx].Unknowns[0] + if !bytes.Equal(unknown.Key, PsbtKeyTypeOutputMissingSigPubkey) { + return fmt.Errorf("invalid PSBT, unknown has invalid "+ + "key %x, expected %x", unknown.Key, + PsbtKeyTypeOutputMissingSigPubkey) + } + targetKey, err := btcec.ParsePubKey(unknown.Value, btcec.S256()) + if err != nil { + return fmt.Errorf("invalid PSBT, proprietary key has "+ + "invalid pubkey: %v", err) + } + + // Now we can look up the local key and check the PSBT further, + // then add our signature. + localKeyDesc, err := findLocalMultisigKey( + localMultisig, targetKey, + ) + if err != nil { + return fmt.Errorf("could not find local multisig key: "+ + "%v", err) + } + if len(packet.Inputs[idx].WitnessScript) == 0 { + return fmt.Errorf("invalid PSBT, missing witness " + + "script") + } + witnessScript := packet.Inputs[idx].WitnessScript + if packet.Inputs[idx].WitnessUtxo == nil { + return fmt.Errorf("invalid PSBT, witness UTXO missing") + } + utxo := packet.Inputs[idx].WitnessUtxo + + err = signer.AddPartialSignature( + packet, *localKeyDesc, utxo, witnessScript, idx, + ) + if err != nil { + return fmt.Errorf("error adding partial signature: %v", + err) + } + } + + // We're almost done. Now we just need to make sure we can finalize and + // extract the final TX. + err = psbt.MaybeFinalizeAll(packet) + if err != nil { + return fmt.Errorf("error finalizing PSBT: %v", err) + } + finalTx, err := psbt.Extract(packet) + if err != nil { + return fmt.Errorf("unable to extract final TX: %v", err) + } + var buf bytes.Buffer + err = finalTx.Serialize(&buf) + if err != nil { + return fmt.Errorf("unable to serialize final TX: %v", err) + } + + fmt.Printf("Success, we counter signed the PSBT and extracted the "+ + "final\ntransaction. Please publish this using any bitcoin "+ + "node:\n\n%x\n\n", buf.Bytes()) + + return nil +} diff --git a/doc/chantools.md b/doc/chantools.md index bbd34b1..fcd69c4 100644 --- a/doc/chantools.md +++ b/doc/chantools.md @@ -40,4 +40,5 @@ Complete documentation is available at https://github.com/guggero/chantools/. * [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 * [chantools walletinfo](chantools_walletinfo.md) - Shows info about an lnd wallet.db file and optionally extracts the BIP32 HD root key +* [chantools zombierecovery](chantools_zombierecovery.md) - Try rescuing funds stuck in channels with zombie nodes diff --git a/doc/chantools_derivekey.md b/doc/chantools_derivekey.md index a75ea92..d14279f 100644 --- a/doc/chantools_derivekey.md +++ b/doc/chantools_derivekey.md @@ -15,7 +15,7 @@ chantools derivekey [flags] ``` chantools derivekey --path "m/1017'/0'/5'/0/0'" \ - --neuter + --neuter chantools derivekey --identity ``` diff --git a/doc/chantools_rescuefunding.md b/doc/chantools_rescuefunding.md index b6bd9f2..bdc54dd 100644 --- a/doc/chantools_rescuefunding.md +++ b/doc/chantools_rescuefunding.md @@ -36,7 +36,7 @@ chantools rescuefunding \ --channeldb string lnd channel.db file to rescue a channel from; must contain the pending channel specified with --channelpoint --channelpoint string funding transaction outpoint of the channel to rescue (:) as it is recorded in the DB --confirmedchannelpoint string channel outpoint that got confirmed on chain (:); normally this is the same as the --channelpoint so it will be set to that value ifthis is left empty - --feerate uint16 fee rate to use for the sweep transaction in sat/vByte (default 2) + --feerate uint16 fee rate to use for the sweep transaction in sat/vByte (default 30) -h, --help help for rescuefunding --rootkey string BIP32 HD root key of the wallet to use for deriving keys; leave empty to prompt for lnd 24 word aezeed --sweepaddr string address to sweep the funds to diff --git a/doc/chantools_sweeptimelock.md b/doc/chantools_sweeptimelock.md index 9e2e8fc..d12317c 100644 --- a/doc/chantools_sweeptimelock.md +++ b/doc/chantools_sweeptimelock.md @@ -30,7 +30,7 @@ chantools sweeptimelock \ ``` --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 2) + --feerate uint16 fee rate to use for the sweep transaction in sat/vByte (default 30) --fromchanneldb string channel input is in the format of an lnd channel.db file --fromsummary string channel input is in the format of chantool's channel summary; specify '-' to read from stdin -h, --help help for sweeptimelock diff --git a/doc/chantools_sweeptimelockmanual.md b/doc/chantools_sweeptimelockmanual.md index 36c81ec..ab97b67 100644 --- a/doc/chantools_sweeptimelockmanual.md +++ b/doc/chantools_sweeptimelockmanual.md @@ -36,7 +36,7 @@ chantools sweeptimelockmanual \ ``` --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 2) + --feerate uint16 fee rate to use for the sweep transaction in sat/vByte (default 30) --fromchanneldb string channel input is in the format of an lnd channel.db file --fromsummary string channel input is in the format of chantool's channel summary; specify '-' to read from stdin -h, --help help for sweeptimelockmanual diff --git a/doc/chantools_zombierecovery.md b/doc/chantools_zombierecovery.md new file mode 100644 index 0000000..322db7a --- /dev/null +++ b/doc/chantools_zombierecovery.md @@ -0,0 +1,37 @@ +## chantools zombierecovery + +Try rescuing funds stuck in channels with zombie nodes + +### Synopsis + +A sub command that hosts a set of further sub commands +to help with recovering funds tuck in zombie channels. + +Please visit https://github.com/guggero/chantools/blob/master/doc/zombierecovery.md +for more information on how to use these commands. + +``` +chantools zombierecovery [flags] +``` + +### Options + +``` + -h, --help help for zombierecovery +``` + +### 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 +* [chantools zombierecovery findmatches](chantools_zombierecovery_findmatches.md) - [0/3] Match maker only: Find matches between registered nodes +* [chantools zombierecovery makeoffer](chantools_zombierecovery_makeoffer.md) - [2/3] Make an offer on how to split the funds to recover +* [chantools zombierecovery preparekeys](chantools_zombierecovery_preparekeys.md) - [1/3] Prepare all public keys for a recovery attempt +* [chantools zombierecovery signoffer](chantools_zombierecovery_signoffer.md) - [3/3] Sign an offer sent by the remote peer to recover funds + diff --git a/doc/chantools_zombierecovery_findmatches.md b/doc/chantools_zombierecovery_findmatches.md new file mode 100644 index 0000000..b0d0321 --- /dev/null +++ b/doc/chantools_zombierecovery_findmatches.md @@ -0,0 +1,45 @@ +## chantools zombierecovery findmatches + +[0/3] Match maker only: Find matches between registered nodes + +### Synopsis + +Match maker only: Runs through all the nodes that have +registered their ID on https://www.node-recovery.com and checks whether there +are any matches of channels between them by looking at the whole channel graph. + +This command will be run by guggero and the result will be sent to the +registered nodes. + +``` +chantools zombierecovery findmatches [flags] +``` + +### Examples + +``` +chantools zombierecovery findmatches \ + --registrations data.txt \ + --channel_graph lncli_describegraph.json +``` + +### Options + +``` + --apiurl string API URL to use (must be esplora compatible) (default "https://blockstream.info/api") + --channel_graph string the full LN channel graph in the JSON format that the 'lncli describegraph' returns + -h, --help help for findmatches + --registrations string the raw data.txt where the registrations are stored in +``` + +### 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 zombierecovery](chantools_zombierecovery.md) - Try rescuing funds stuck in channels with zombie nodes + diff --git a/doc/chantools_zombierecovery_makeoffer.md b/doc/chantools_zombierecovery_makeoffer.md new file mode 100644 index 0000000..a5367a2 --- /dev/null +++ b/doc/chantools_zombierecovery_makeoffer.md @@ -0,0 +1,49 @@ +## chantools zombierecovery makeoffer + +[2/3] Make an offer on how to split the funds to recover + +### Synopsis + +After both parties have prepared their keys with the +'preparekeys' command and have exchanged the files generated from that step, +one party has to create an offer on how to split the funds that are in the +channels to be rescued. +If the other party agrees with the offer, they can sign and publish the offer +with the 'signoffer' command. If the other party does not agree, they can create +a counter offer. + +``` +chantools zombierecovery makeoffer [flags] +``` + +### Examples + +``` +chantools zombierecovery makeoffer \ + --node1_keys preparedkeys-xxxx-xx-xx-.json \ + --node2_keys preparedkeys-xxxx-xx-xx-.json \ + --feerate 15 +``` + +### Options + +``` + --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 makeoffer + --node1_keys string the JSON file generated in theprevious step ('preparekeys') command of node 1 + --node2_keys string the JSON file generated in theprevious step ('preparekeys') command of node 2 + --rootkey string BIP32 HD root key of the wallet to use for signing the offer; leave empty to prompt for lnd 24 word aezeed +``` + +### 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 zombierecovery](chantools_zombierecovery.md) - Try rescuing funds stuck in channels with zombie nodes + diff --git a/doc/chantools_zombierecovery_preparekeys.md b/doc/chantools_zombierecovery_preparekeys.md new file mode 100644 index 0000000..608f110 --- /dev/null +++ b/doc/chantools_zombierecovery_preparekeys.md @@ -0,0 +1,45 @@ +## chantools zombierecovery preparekeys + +[1/3] Prepare all public keys for a recovery attempt + +### Synopsis + +Takes a match file, validates it against the seed and +then adds the first 2500 multisig pubkeys to it. +This must be run by both parties of a channel for a successful recovery. The +next step (makeoffer) takes two such key enriched files and tries to find the +correct ones for the matched channels. + +``` +chantools zombierecovery preparekeys [flags] +``` + +### Examples + +``` +chantools zombierecovery preparekeys \ + --match_file match-xxxx-xx-xx--.json \ + --payout_addr bc1q... +``` + +### Options + +``` + --bip39 read a classic BIP39 seed and passphrase from the terminal instead of asking for lnd seed format or providing the --rootkey flag + -h, --help help for preparekeys + --match_file string the match JSON file that was sent to both nodes by the match maker + --payout_addr string the address where this node's rescued funds should be sent to, must be a P2WPKH (native SegWit) address + --rootkey string BIP32 HD root key of the wallet to use for deriving the multisig keys; leave empty to prompt for lnd 24 word aezeed +``` + +### 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 zombierecovery](chantools_zombierecovery.md) - Try rescuing funds stuck in channels with zombie nodes + diff --git a/doc/chantools_zombierecovery_signoffer.md b/doc/chantools_zombierecovery_signoffer.md new file mode 100644 index 0000000..e082bdf --- /dev/null +++ b/doc/chantools_zombierecovery_signoffer.md @@ -0,0 +1,35 @@ +## chantools zombierecovery signoffer + +[3/3] Sign an offer sent by the remote peer to recover funds + +``` +chantools zombierecovery signoffer [flags] +``` + +### Examples + +``` +chantools zombierecovery signoffer \ + --psbt +``` + +### Options + +``` + --bip39 read a classic BIP39 seed and passphrase from the terminal instead of asking for lnd seed format or providing the --rootkey flag + -h, --help help for signoffer + --psbt string the base64 encoded PSBT that the other party sent as an offer to rescue funds + --rootkey string BIP32 HD root key of the wallet to use for signing the offer; leave empty to prompt for lnd 24 word aezeed +``` + +### 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 zombierecovery](chantools_zombierecovery.md) - Try rescuing funds stuck in channels with zombie nodes + diff --git a/doc/rescue-flow.plantuml b/doc/rescue-flow.plantuml index 5b27c08..5d47a43 100644 --- a/doc/rescue-flow.plantuml +++ b/doc/rescue-flow.plantuml @@ -24,6 +24,7 @@ if "Pending/Open\nchannels left?" then else -->[no] ===MANUAL=== --> "11: Manual intervention necessary" + --> "12: Use Zombie Channel Recovery Matcher" --> (*) endif else diff --git a/doc/rescue-flow.png b/doc/rescue-flow.png index 7a66e0c..ab8eec1 100644 Binary files a/doc/rescue-flow.png and b/doc/rescue-flow.png differ diff --git a/doc/zombierecovery.md b/doc/zombierecovery.md new file mode 100644 index 0000000..64a9e9e --- /dev/null +++ b/doc/zombierecovery.md @@ -0,0 +1,6 @@ +# Zombie Channel Recovery + +TODO, more detailed documentation is being worked on. + +Please look at the help output of `chantools zombierecovery --help` or the +generated [documentation for it](chantools_zombierecovery.md) for now. diff --git a/lnd/graph.go b/lnd/graph.go index c369527..d6ba993 100644 --- a/lnd/graph.go +++ b/lnd/graph.go @@ -8,27 +8,46 @@ import ( func AllNodeChannels(graph *lnrpc.ChannelGraph, nodePubKey string) []*lnrpc.ChannelEdge { - - var result []*lnrpc.ChannelEdge + + var result []*lnrpc.ChannelEdge // nolint:prealloc for _, edge := range graph.Edges { if edge.Node1Pub != nodePubKey && edge.Node2Pub != nodePubKey { continue } - + + result = append(result, edge) + } + + return result +} + +func FindCommonEdges(graph *lnrpc.ChannelGraph, node1, + node2 string) []*lnrpc.ChannelEdge { + + var result []*lnrpc.ChannelEdge // nolint:prealloc + for _, edge := range graph.Edges { + if edge.Node1Pub != node1 && edge.Node2Pub != node1 { + continue + } + + if edge.Node1Pub != node2 && edge.Node2Pub != node2 { + continue + } + result = append(result, edge) } - + return result } func FindNode(graph *lnrpc.ChannelGraph, nodePubKey string) (*lnrpc.LightningNode, error) { - + for _, node := range graph.Nodes { if node.PubKey == nodePubKey { return node, nil } } - + return nil, fmt.Errorf("node %s not found in graph", nodePubKey) -} \ No newline at end of file +} diff --git a/lnd/hdkeychain.go b/lnd/hdkeychain.go index 09f5b8e..b3e9867 100644 --- a/lnd/hdkeychain.go +++ b/lnd/hdkeychain.go @@ -140,6 +140,13 @@ func IdentityPath(params *chaincfg.Params) string { ) } +func MultisigPath(params *chaincfg.Params, index int) string { + return fmt.Sprintf( + LndDerivationPath+"/0/%d", params.HDCoinType, + keychain.KeyFamilyMultiSig, index, + ) +} + func AllDerivationPaths(params *chaincfg.Params) ([]string, [][]uint32, error) { mkPath := func(f keychain.KeyFamily) string { return fmt.Sprintf( diff --git a/lnd/signer.go b/lnd/signer.go index 81af520..0e60dc9 100644 --- a/lnd/signer.go +++ b/lnd/signer.go @@ -90,7 +90,7 @@ func (s *Signer) AddPartialSignature(packet *psbt.Packet, return fmt.Errorf("error creating PSBT updater: %v", err) } status, err := updater.Sign( - 0, ourSig, keyDesc.PubKey.SerializeCompressed(), nil, + inputIndex, ourSig, keyDesc.PubKey.SerializeCompressed(), nil, witnessScript, ) if err != nil {