From 7227c7f1017395228d076c46a204c61d9e6d7da7 Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Thu, 14 Dec 2023 11:48:58 +0100 Subject: [PATCH] multi: add new pullanchor command --- README.md | 2 + cmd/chantools/pullanchor.go | 511 ++++++++++++++++++++++++++++++++++++ cmd/chantools/root.go | 3 +- doc/chantools.md | 1 + doc/chantools_pullanchor.md | 49 ++++ lnd/signer.go | 3 + 6 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 cmd/chantools/pullanchor.go create mode 100644 doc/chantools_pullanchor.md diff --git a/README.md b/README.md index 8b8379d..2fd3bed 100644 --- a/README.md +++ b/README.md @@ -423,6 +423,7 @@ Available Commands: forceclose Force-close the last state that is in the channel.db provided genimportscript Generate a script containing the on-chain keys of an lnd wallet that can be imported into other software like bitcoind migratedb Apply all recent lnd channel database migrations + pullanchor Attempt to CPFP an anchor output of a channel removechannel Remove a single channel from the given channel DB rescueclosed Try finding the private keys for funds that are in outputs of remotely force-closed channels rescuefunding Rescue funds locked in a funding multisig output that never resulted in a proper channel; this is the command the initiator of the channel needs to run @@ -481,6 +482,7 @@ Legend: | [forceclose](doc/chantools_forceclose.md) | :pencil: (:skull: :warning:) Publish an old channel state from a `channel.db` file | | [genimportscript](doc/chantools_genimportscript.md) | :pencil: Create a script/text file that can be used to import `lnd` keys into other software | | [migratedb](doc/chantools_migratedb.md) | Upgrade the `channel.db` file to the latest version | +| [pullanchor](doc/chantools_pullanchor.md) | :pencil: Attempt to CPFP an anchor output of a channel | | [recoverloopin](doc/chantools_recoverloopin.md) | :pencil: Recover funds from a failed Lightning Loop inbound swap | | [removechannel](doc/chantools_removechannel.md) | (:skull: :warning:) Remove a single channel from a `channel.db` file | | [rescueclosed](doc/chantools_rescueclosed.md) | :pencil: (:pushpin:) Rescue funds in a legacy (pre `STATIC_REMOTE_KEY`) channel output | diff --git a/cmd/chantools/pullanchor.go b/cmd/chantools/pullanchor.go new file mode 100644 index 0000000..8183717 --- /dev/null +++ b/cmd/chantools/pullanchor.go @@ -0,0 +1,511 @@ +package main + +import ( + "bytes" + "encoding/hex" + "fmt" + "math" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/mempool" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/chantools/btc" + "github.com/lightninglabs/chantools/lnd" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/spf13/cobra" +) + +type pullAnchorCommand struct { + APIURL string + SponsorInput string + AnchorAddrs []string + ChangeAddr string + FeeRate uint32 + + rootKey *rootKey + cmd *cobra.Command +} + +func newPullAnchorCommand() *cobra.Command { + cc := &pullAnchorCommand{} + cc.cmd = &cobra.Command{ + Use: "pullanchor", + Short: "Attempt to CPFP an anchor output of a channel", + Long: `Use this command to confirm a channel force close +transaction of an anchor output channel type. This will attempt to CPFP the +330 byte anchor output created for your node.`, + Example: `chantools pullanchor \ + --sponsorinput txid:vout \ + --anchoraddr bc1q..... \ + --changeaddr bc1q..... \ + --feerate 30`, + RunE: cc.Execute, + } + cc.cmd.Flags().StringVar( + &cc.APIURL, "apiurl", defaultAPIURL, "API URL to use (must "+ + "be esplora compatible)", + ) + cc.cmd.Flags().StringVar( + &cc.SponsorInput, "sponsorinput", "", "the input to use to "+ + "sponsor the CPFP transaction; must be owned by the "+ + "lnd node that owns the anchor output", + ) + cc.cmd.Flags().StringArrayVar( + &cc.AnchorAddrs, "anchoraddr", nil, "the address of the "+ + "anchor output (p2wsh or p2tr output with 330 "+ + "satoshis) that should be pulled; can be specified "+ + "multiple times per command to pull multiple anchors "+ + "with a single transaction", + ) + cc.cmd.Flags().StringVar( + &cc.ChangeAddr, "changeaddr", "", "the change address to "+ + "send the remaining funds to", + ) + cc.cmd.Flags().Uint32Var( + &cc.FeeRate, "feerate", defaultFeeSatPerVByte, "fee rate to "+ + "use for the sweep transaction in sat/vByte", + ) + + cc.rootKey = newRootKey(cc.cmd, "deriving keys") + + return cc.cmd +} + +func (c *pullAnchorCommand) Execute(_ *cobra.Command, _ []string) error { + extendedKey, err := c.rootKey.read() + if err != nil { + return fmt.Errorf("error reading root key: %w", err) + } + + // Make sure all input is provided. + if c.SponsorInput == "" { + return fmt.Errorf("sponsor input is required") + } + if len(c.AnchorAddrs) == 0 { + return fmt.Errorf("at least one anchor addr is required") + } + if c.ChangeAddr == "" { + return fmt.Errorf("change addr is required") + } + + outpoint, err := lnd.ParseOutpoint(c.SponsorInput) + if err != nil { + return fmt.Errorf("error parsing sponsor input outpoint: %w", + err) + } + + changeScript, err := lnd.GetP2WPKHScript(c.ChangeAddr, chainParams) + if err != nil { + return fmt.Errorf("error parsing change addr: %w", err) + } + + // Set default values. + if c.FeeRate == 0 { + c.FeeRate = defaultFeeSatPerVByte + } + return createPullTransactionTemplate( + extendedKey, c.APIURL, outpoint, c.AnchorAddrs, changeScript, + c.FeeRate, + ) +} + +type targetAnchor struct { + addr string + keyDesc *keychain.KeyDescriptor + outpoint wire.OutPoint + utxo *wire.TxOut + script []byte + scriptTree *input.AnchorScriptTree +} + +func createPullTransactionTemplate(rootKey *hdkeychain.ExtendedKey, + apiURL string, sponsorOutpoint *wire.OutPoint, anchorAddrs []string, + changeScript []byte, feeRate uint32) error { + + signer := &lnd.Signer{ + ExtendedKey: rootKey, + ChainParams: chainParams, + } + api := &btc.ExplorerAPI{BaseURL: apiURL} + estimator := input.TxWeightEstimator{} + + // Make sure the sponsor input is a P2WPKH or P2TR input and is known + // to the block explorer, so we can fetch the witness utxo. + sponsorTx, err := api.Transaction(sponsorOutpoint.Hash.String()) + if err != nil { + return fmt.Errorf("error fetching sponsor tx: %w", err) + } + sponsorTxOut := sponsorTx.Vout[sponsorOutpoint.Index] + sponsorPkScript, err := hex.DecodeString(sponsorTxOut.ScriptPubkey) + if err != nil { + return fmt.Errorf("error decoding sponsor pkscript: %w", err) + } + + sponsorType, err := txscript.ParsePkScript(sponsorPkScript) + if err != nil { + return fmt.Errorf("error parsing sponsor pkscript: %w", err) + } + var sponsorSigHashType txscript.SigHashType + switch sponsorType.Class() { + case txscript.WitnessV0PubKeyHashTy: + estimator.AddP2WKHInput() + sponsorSigHashType = txscript.SigHashAll + + case txscript.WitnessV1TaprootTy: + sponsorSigHashType = txscript.SigHashDefault + estimator.AddTaprootKeySpendInput(sponsorSigHashType) + + default: + return fmt.Errorf("unsupported sponsor input type: %v", + sponsorType.Class()) + } + + tx := wire.NewMsgTx(2) + packet, err := psbt.NewFromUnsignedTx(tx) + if err != nil { + return fmt.Errorf("error creating PSBT: %w", err) + } + + // Let's add the sponsor input to the PSBT. + sponsorUtxo := &wire.TxOut{ + Value: int64(sponsorTxOut.Value), + PkScript: sponsorPkScript, + } + packet.UnsignedTx.TxIn = append(packet.UnsignedTx.TxIn, &wire.TxIn{ + PreviousOutPoint: *sponsorOutpoint, + Sequence: mempool.MaxRBFSequence, + }) + packet.Inputs = append(packet.Inputs, psbt.PInput{ + WitnessUtxo: sponsorUtxo, + SighashType: sponsorSigHashType, + }) + + targets, err := addAnchorInputs( + anchorAddrs, packet, api, &estimator, rootKey, + ) + if err != nil { + return fmt.Errorf("error adding anchor inputs: %w", err) + } + + // Now we can calculate the fee and add the change output. + estimator.AddP2WKHOutput() + totalOutputValue := btcutil.Amount(sponsorTxOut.Value + 330) + feeRateKWeight := chainfee.SatPerKVByte(1000 * feeRate).FeePerKWeight() + totalFee := feeRateKWeight.FeeForWeight(int64(estimator.Weight())) + + log.Infof("Fee %d sats of %d total amount (estimated weight %d)", + totalFee, totalOutputValue, estimator.Weight()) + + packet.UnsignedTx.TxOut = append(packet.UnsignedTx.TxOut, &wire.TxOut{ + Value: int64(totalOutputValue - totalFee), + PkScript: changeScript, + }) + packet.Outputs = append(packet.Outputs, psbt.POutput{}) + + prevOutFetcher := txscript.NewMultiPrevOutFetcher( + map[wire.OutPoint]*wire.TxOut{ + *sponsorOutpoint: sponsorUtxo, + }, + ) + for idx := range targets { + prevOutFetcher.AddPrevOut( + targets[idx].outpoint, targets[idx].utxo, + ) + } + + // And now we sign the anchor inputs. + for idx := range targets { + target := targets[idx] + signDesc := &input.SignDescriptor{ + KeyDesc: *target.keyDesc, + WitnessScript: target.script, + Output: target.utxo, + PrevOutputFetcher: prevOutFetcher, + InputIndex: idx + 1, + } + + var anchorWitness wire.TxWitness + switch { + // Simple Taproot Channel: + case target.scriptTree != nil: + signDesc.SignMethod = input.TaprootKeySpendSignMethod + signDesc.HashType = txscript.SigHashDefault + signDesc.TapTweak = target.scriptTree.TapscriptRoot + + anchorSig, err := signer.SignOutputRaw( + packet.UnsignedTx, signDesc, + ) + if err != nil { + return fmt.Errorf("error signing anchor "+ + "input: %w", err) + } + + anchorWitness = wire.TxWitness{ + anchorSig.Serialize(), + } + + // Anchor Channel: + default: + signDesc.SignMethod = input.WitnessV0SignMethod + signDesc.HashType = txscript.SigHashAll + + anchorSig, err := signer.SignOutputRaw( + packet.UnsignedTx, signDesc, + ) + if err != nil { + return fmt.Errorf("error signing anchor "+ + "input: %w", err) + } + + anchorWitness = make(wire.TxWitness, 2) + anchorWitness[0] = append( + anchorSig.Serialize(), + byte(txscript.SigHashAll), + ) + anchorWitness[1] = target.script + } + + var witnessBuf bytes.Buffer + err = psbt.WriteTxWitness(&witnessBuf, anchorWitness) + if err != nil { + return fmt.Errorf("error serializing witness: %w", err) + } + + packet.Inputs[idx+1].FinalScriptWitness = witnessBuf.Bytes() + } + + packetBase64, err := packet.B64Encode() + if err != nil { + return fmt.Errorf("error encoding PSBT: %w", err) + } + + log.Infof("Prepared PSBT follows, please now call\n" + + "'lncli wallet psbt finalize ' to finalize the\n" + + "transaction, then publish it manually or by using\n" + + "'lncli wallet publishtx ':\n\n" + packetBase64 + + "\n") + + return nil +} + +func addAnchorInputs(anchorAddrs []string, packet *psbt.Packet, + api *btc.ExplorerAPI, estimator *input.TxWeightEstimator, + rootKey *hdkeychain.ExtendedKey) ([]targetAnchor, error) { + + // Fetch the additional info we need for the anchor output as well. + results := make([]targetAnchor, len(anchorAddrs)) + for idx, anchorAddr := range anchorAddrs { + anchorTx, anchorIndex, err := api.Outpoint(anchorAddr) + if err != nil { + return nil, fmt.Errorf("error fetching anchor "+ + "outpoint: %w", err) + } + anchorTxHash, err := chainhash.NewHashFromStr(anchorTx.TXID) + if err != nil { + return nil, fmt.Errorf("error decoding anchor txid: %w", + err) + } + + addr, err := btcutil.DecodeAddress(anchorAddr, chainParams) + if err != nil { + return nil, fmt.Errorf("error decoding address: %w", + err) + } + + anchorPkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + return nil, fmt.Errorf("error creating pk script: %w", + err) + } + + target := targetAnchor{ + addr: anchorAddr, + utxo: &wire.TxOut{ + Value: 330, + PkScript: anchorPkScript, + }, + outpoint: wire.OutPoint{ + Hash: *anchorTxHash, + Index: uint32(anchorIndex), + }, + } + switch addr.(type) { + case *btcutil.AddressWitnessScriptHash: + estimator.AddWitnessInput(input.AnchorWitnessSize) + + anchorKeyDesc, anchorWitnessScript, err := findAnchorKey( + rootKey, anchorPkScript, + ) + if err != nil { + return nil, fmt.Errorf("could not find "+ + "key for anchor address %v: %w", + anchorAddr, err) + } + + target.keyDesc = anchorKeyDesc + target.script = anchorWitnessScript + + case *btcutil.AddressTaproot: + estimator.AddTaprootKeySpendInput( + txscript.SigHashDefault, + ) + + anchorKeyDesc, scriptTree, err := findTaprootAnchorKey( + rootKey, anchorPkScript, + ) + if err != nil { + return nil, fmt.Errorf("could not find "+ + "key for anchor address %v: %w", + anchorAddr, err) + } + + target.keyDesc = anchorKeyDesc + target.scriptTree = scriptTree + + default: + return nil, fmt.Errorf("unsupported address type: %T", + addr) + } + + log.Infof("Found multisig key %x for anchor pk script %x", + target.keyDesc.PubKey.SerializeCompressed(), + anchorPkScript) + + packet.UnsignedTx.TxIn = append( + packet.UnsignedTx.TxIn, &wire.TxIn{ + PreviousOutPoint: target.outpoint, + Sequence: mempool.MaxRBFSequence, + }, + ) + packet.Inputs = append(packet.Inputs, psbt.PInput{ + WitnessUtxo: target.utxo, + WitnessScript: target.script, + }) + + results[idx] = target + } + + return results, nil +} + +func findAnchorKey(rootKey *hdkeychain.ExtendedKey, + targetScript []byte) (*keychain.KeyDescriptor, []byte, error) { + + family := keychain.KeyFamilyMultiSig + localMultisig, err := lnd.DeriveChildren(rootKey, []uint32{ + lnd.HardenedKeyStart + uint32(keychain.BIP0043Purpose), + lnd.HardenedKeyStart + chainParams.HDCoinType, + lnd.HardenedKeyStart + uint32(family), + 0, + }) + if err != nil { + return nil, nil, fmt.Errorf("could not derive local "+ + "multisig key: %w", err) + } + + // Loop through the local multisig keys to find the target anchor + // script. + for index := uint32(0); index < math.MaxInt16; index++ { + currentKey, err := localMultisig.DeriveNonStandard(index) + if err != nil { + return nil, nil, fmt.Errorf("error deriving child "+ + "key: %w", err) + } + + currentPubkey, err := currentKey.ECPubKey() + if err != nil { + return nil, nil, fmt.Errorf("error deriving public "+ + "key: %w", err) + } + + script, err := input.CommitScriptAnchor(currentPubkey) + if err != nil { + return nil, nil, fmt.Errorf("error deriving script: "+ + "%w", err) + } + + pkScript, err := input.WitnessScriptHash(script) + if err != nil { + return nil, nil, fmt.Errorf("error deriving script "+ + "hash: %w", err) + } + + if !bytes.Equal(pkScript, targetScript) { + continue + } + + return &keychain.KeyDescriptor{ + PubKey: currentPubkey, + KeyLocator: keychain.KeyLocator{ + Family: family, + Index: index, + }, + }, script, nil + } + + return nil, nil, fmt.Errorf("no matching pubkeys found") +} + +func findTaprootAnchorKey(rootKey *hdkeychain.ExtendedKey, + targetScript []byte) (*keychain.KeyDescriptor, *input.AnchorScriptTree, + error) { + + family := keychain.KeyFamilyPaymentBase + localPayment, err := lnd.DeriveChildren(rootKey, []uint32{ + lnd.HardenedKeyStart + uint32(keychain.BIP0043Purpose), + lnd.HardenedKeyStart + chainParams.HDCoinType, + lnd.HardenedKeyStart + uint32(family), + 0, + }) + if err != nil { + return nil, nil, fmt.Errorf("could not derive local "+ + "multisig key: %w", err) + } + + // Loop through the local multisig keys to find the target anchor + // script. + for index := uint32(0); index < math.MaxInt16; index++ { + currentKey, err := localPayment.DeriveNonStandard(index) + if err != nil { + return nil, nil, fmt.Errorf("error deriving child "+ + "key: %w", err) + } + + currentPubkey, err := currentKey.ECPubKey() + if err != nil { + return nil, nil, fmt.Errorf("error deriving public "+ + "key: %w", err) + } + + scriptTree, err := input.NewAnchorScriptTree(currentPubkey) + if err != nil { + return nil, nil, fmt.Errorf("error deriving taproot "+ + "key: %w", err) + } + + pkScript, err := input.PayToTaprootScript(scriptTree.TaprootKey) + if err != nil { + return nil, nil, fmt.Errorf("error deriving pk "+ + "script: %w", err) + } + if !bytes.Equal(pkScript, targetScript) { + continue + } + + return &keychain.KeyDescriptor{ + PubKey: currentPubkey, + KeyLocator: keychain.KeyLocator{ + Family: family, + Index: index, + }, + }, scriptTree, nil + } + + return nil, nil, fmt.Errorf("no matching pubkeys found") +} diff --git a/cmd/chantools/root.go b/cmd/chantools/root.go index 39da6aa..13a04f7 100644 --- a/cmd/chantools/root.go +++ b/cmd/chantools/root.go @@ -31,7 +31,7 @@ const ( // version is the current version of the tool. It is set during build. // NOTE: When changing this, please also update the version in the // download link shown in the README. - version = "0.12.0" + version = "0.12.1" na = "n/a" // lndVersion is the current version of lnd that we support. This is @@ -113,6 +113,7 @@ func main() { newForceCloseCommand(), newGenImportScriptCommand(), newMigrateDBCommand(), + newPullAnchorCommand(), newRecoverLoopInCommand(), newRemoveChannelCommand(), newRescueClosedCommand(), diff --git a/doc/chantools.md b/doc/chantools.md index 5c3fb3a..fb07e6d 100644 --- a/doc/chantools.md +++ b/doc/chantools.md @@ -35,6 +35,7 @@ Complete documentation is available at https://github.com/lightninglabs/chantool * [chantools forceclose](chantools_forceclose.md) - Force-close the last state that is in the channel.db provided * [chantools genimportscript](chantools_genimportscript.md) - Generate a script containing the on-chain keys of an lnd wallet that can be imported into other software like bitcoind * [chantools migratedb](chantools_migratedb.md) - Apply all recent lnd channel database migrations +* [chantools pullanchor](chantools_pullanchor.md) - Attempt to CPFP an anchor output of a channel * [chantools recoverloopin](chantools_recoverloopin.md) - Recover a loop in swap that the loop daemon is not able to sweep * [chantools removechannel](chantools_removechannel.md) - Remove a single channel from the given channel DB * [chantools rescueclosed](chantools_rescueclosed.md) - Try finding the private keys for funds that are in outputs of remotely force-closed channels diff --git a/doc/chantools_pullanchor.md b/doc/chantools_pullanchor.md new file mode 100644 index 0000000..b1155e2 --- /dev/null +++ b/doc/chantools_pullanchor.md @@ -0,0 +1,49 @@ +## chantools pullanchor + +Attempt to CPFP an anchor output of a channel + +### Synopsis + +Use this command to confirm a channel force close +transaction of an anchor output channel type. This will attempt to CPFP the +330 byte anchor output created for your node. + +``` +chantools pullanchor [flags] +``` + +### Examples + +``` +chantools pullanchor \ + --sponsorinput txid:vout \ + --anchoraddr bc1q..... \ + --changeaddr bc1q..... \ + --feerate 30 +``` + +### Options + +``` + --anchoraddr string the address of the anchor output (p2wsh output with 330 satoshis) + --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 + --changeaddr string the change address to send the remaining funds to + --feerate uint32 fee rate to use for the sweep transaction in sat/vByte (default 30) + -h, --help help for pullanchor + --rootkey string BIP32 HD root key of the wallet to use for deriving keys; leave empty to prompt for lnd 24 word aezeed + --sponsorinput string the input to use to sponsor the CPFP transaction; must be owned by the lnd node that owns the anchor output +``` + +### Options inherited from parent commands + +``` + -r, --regtest Indicates if regtest parameters should be used + -s, --signet Indicates if the public signet parameters should be used + -t, --testnet Indicates if testnet parameters should be used +``` + +### SEE ALSO + +* [chantools](chantools.md) - Chantools helps recover funds from lightning channels + diff --git a/lnd/signer.go b/lnd/signer.go index 19fcb68..599ee3c 100644 --- a/lnd/signer.go +++ b/lnd/signer.go @@ -42,6 +42,8 @@ func (s *Signer) SignOutputRawWithPrivkey(tx *wire.MsgTx, signDesc *input.SignDescriptor, privKey *secp256k1.PrivateKey) (input.Signature, error) { + fmt.Printf("Using private key %x (pubkey %x)\n", privKey.Serialize(), privKey.PubKey().SerializeCompressed()) + witnessScript := signDesc.WitnessScript privKey = maybeTweakPrivKey(signDesc, privKey) @@ -61,6 +63,7 @@ func (s *Signer) SignOutputRawWithPrivkey(tx *wire.MsgTx, // This function tweaks the private key using the tap // root key supplied as the tweak. + fmt.Printf("Using private key %x (pubkey %x)\n", privKey.Serialize(), privKey.PubKey().SerializeCompressed()) rawSig, err = txscript.RawTxInTaprootSignature( tx, sigHashes, signDesc.InputIndex, signDesc.Output.Value, signDesc.Output.PkScript,