mirror of
https://github.com/guggero/chantools
synced 2024-11-07 03:20:43 +00:00
zombierecovery: add new commands for zombie channel recovery
This commit is contained in:
parent
7a3c9a3f0b
commit
af356685c1
11
README.md
11
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 <node-pubkey>@<updated-ip-address>:<port>` 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.
|
||||
<br/><br/>
|
||||
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
|
||||
|
@ -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))
|
||||
|
@ -102,7 +102,7 @@ func reportOutspend(api *ExplorerAPI,
|
||||
entry.ClosingTX.ToRemoteAddr = o.ScriptPubkeyAddr
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if couldBeOurs(entry, utxo) {
|
||||
summaryFile.ChannelsWithPotential++
|
||||
summaryFile.FundsForceClose += utxo[0].Value
|
||||
|
@ -67,6 +67,6 @@ func (c *dropChannelGraphCommand) Execute(_ *cobra.Command, _ []string) error {
|
||||
if err := rwTx.DeleteTopLevelBucket(graphMetaBucket); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
return rwTx.Commit()
|
||||
}
|
||||
|
@ -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: " +
|
||||
"<blockheight>x<transactionindex>x<outputindex>",
|
||||
)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -19,7 +19,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultFeeSatPerVByte = 2
|
||||
defaultFeeSatPerVByte = 30
|
||||
defaultCsvLimit = 2016
|
||||
)
|
||||
|
||||
|
200
cmd/chantools/zombierecovery_findmatches.go
Normal file
200
cmd/chantools/zombierecovery_findmatches.go
Normal file
@ -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
|
||||
}
|
505
cmd/chantools/zombierecovery_makeoffer.go
Normal file
505
cmd/chantools/zombierecovery_makeoffer.go
Normal file
@ -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-<pubkey1>.json \
|
||||
--node2_keys preparedkeys-xxxx-xx-xx-<pubkey2>.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
|
||||
}
|
138
cmd/chantools/zombierecovery_preparekeys.go
Normal file
138
cmd/chantools/zombierecovery_preparekeys.go
Normal file
@ -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-<pubkey1>-<pubkey2>.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)
|
||||
}
|
42
cmd/chantools/zombierecovery_root.go
Normal file
42
cmd/chantools/zombierecovery_root.go
Normal file
@ -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
|
||||
}
|
193
cmd/chantools/zombierecovery_signoffer.go
Normal file
193
cmd/chantools/zombierecovery_signoffer.go
Normal file
@ -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 <offered_psbt_base64>`,
|
||||
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 <enter> to continue and sign the transaction or " +
|
||||
"<ctrl+c> 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
|
||||
}
|
@ -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
|
||||
|
||||
|
@ -15,7 +15,7 @@ chantools derivekey [flags]
|
||||
|
||||
```
|
||||
chantools derivekey --path "m/1017'/0'/5'/0/0'" \
|
||||
--neuter
|
||||
--neuter
|
||||
|
||||
chantools derivekey --identity
|
||||
```
|
||||
|
@ -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 (<txid>:<txindex>) as it is recorded in the DB
|
||||
--confirmedchannelpoint string channel outpoint that got confirmed on chain (<txid>:<txindex>); 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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
37
doc/chantools_zombierecovery.md
Normal file
37
doc/chantools_zombierecovery.md
Normal file
@ -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
|
||||
|
45
doc/chantools_zombierecovery_findmatches.md
Normal file
45
doc/chantools_zombierecovery_findmatches.md
Normal file
@ -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
|
||||
|
49
doc/chantools_zombierecovery_makeoffer.md
Normal file
49
doc/chantools_zombierecovery_makeoffer.md
Normal file
@ -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-<pubkey1>.json \
|
||||
--node2_keys preparedkeys-xxxx-xx-xx-<pubkey2>.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
|
||||
|
45
doc/chantools_zombierecovery_preparekeys.md
Normal file
45
doc/chantools_zombierecovery_preparekeys.md
Normal file
@ -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-<pubkey1>-<pubkey2>.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
|
||||
|
35
doc/chantools_zombierecovery_signoffer.md
Normal file
35
doc/chantools_zombierecovery_signoffer.md
Normal file
@ -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 <offered_psbt_base64>
|
||||
```
|
||||
|
||||
### 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
|
||||
|
@ -24,6 +24,7 @@ if "Pending/Open\nchannels left?" then
|
||||
else
|
||||
-->[no] ===MANUAL===
|
||||
--> "<b>11:</b> Manual intervention necessary"
|
||||
--> "<b>12:</b> Use Zombie Channel Recovery Matcher"
|
||||
--> (*)
|
||||
endif
|
||||
else
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 74 KiB |
6
doc/zombierecovery.md
Normal file
6
doc/zombierecovery.md
Normal file
@ -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.
|
33
lnd/graph.go
33
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)
|
||||
}
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user