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..1cb99be --- /dev/null +++ b/cmd/chantools/pullanchor.go @@ -0,0 +1,336 @@ +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 + AnchorAddr 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().StringVar( + &cc.AnchorAddr, "anchoraddr", "", "the address of the anchor "+ + "output (p2wsh output with 330 satoshis)", + ) + 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 c.AnchorAddr == "" { + return fmt.Errorf("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) + } + + // Make sure the anchor addr is a P2WSH address, so we can do accurate + // fee estimation. + anchorScript, err := lnd.GetP2WSHScript(c.AnchorAddr, chainParams) + if err != nil { + return fmt.Errorf("error parsing anchor addr: %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, anchorScript, c.AnchorAddr, + changeScript, c.FeeRate, + ) +} + +func createPullTransactionTemplate(rootKey *hdkeychain.ExtendedKey, + apiURL string, sponsorOutpoint *wire.OutPoint, anchorPkScript []byte, + anchorAddr 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()) + } + + // Fetch the additional info we need for the anchor output as well. + anchorTx, anchorIndex, err := api.Outpoint(anchorAddr) + if err != nil { + return fmt.Errorf("error fetching anchor outpoint: %w", err) + } + anchorTxHash, err := chainhash.NewHashFromStr(anchorTx.TXID) + if err != nil { + return fmt.Errorf("error decoding anchor txid: %w", err) + } + estimator.AddWitnessInput(input.AnchorWitnessSize) + + // 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: %w", + err) + } + + anchorKeyDesc, anchorWitnessScript, err := findAnchorKey( + localMultisig, anchorPkScript, + ) + if err != nil { + return fmt.Errorf("could not find anchor key: %w", err) + } + + log.Infof("Found multisig key %x for anchor pk script %x", + anchorKeyDesc.PubKey.SerializeCompressed(), anchorPkScript) + + tx := wire.NewMsgTx(2) + packet, err := psbt.NewFromUnsignedTx(tx) + if err != nil { + return fmt.Errorf("error creating PSBT: %w", err) + } + + // Let's add the inputs to the PSBT. + packet.UnsignedTx.TxIn = append(packet.UnsignedTx.TxIn, &wire.TxIn{ + PreviousOutPoint: *sponsorOutpoint, + Sequence: mempool.MaxRBFSequence, + }) + packet.Inputs = append(packet.Inputs, psbt.PInput{ + WitnessUtxo: &wire.TxOut{ + Value: int64(sponsorTxOut.Value), + PkScript: sponsorPkScript, + }, + SighashType: sponsorSigHashType, + }) + + anchorUtxo := &wire.TxOut{ + Value: 330, + PkScript: anchorPkScript, + } + packet.UnsignedTx.TxIn = append(packet.UnsignedTx.TxIn, &wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: *anchorTxHash, + Index: uint32(anchorIndex), + }, + Sequence: mempool.MaxRBFSequence, + }) + packet.Inputs = append(packet.Inputs, psbt.PInput{ + WitnessUtxo: anchorUtxo, + WitnessScript: anchorWitnessScript, + }) + + // 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{}) + + // And now we sign the anchor input. + anchorSig, err := signer.SignOutputRaw( + packet.UnsignedTx, &input.SignDescriptor{ + KeyDesc: *anchorKeyDesc, + WitnessScript: anchorWitnessScript, + SignMethod: input.WitnessV0SignMethod, + Output: anchorUtxo, + HashType: txscript.SigHashAll, + PrevOutputFetcher: txscript.NewCannedPrevOutputFetcher( + anchorUtxo.PkScript, anchorUtxo.Value, + ), + InputIndex: 1, + }, + ) + 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] = anchorWitnessScript + + var witnessBuf bytes.Buffer + if err = psbt.WriteTxWitness(&witnessBuf, anchorWitness); err != nil { + return fmt.Errorf("error serializing witness: %w", err) + } + + packet.Inputs[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 findAnchorKey(multisigBranch *hdkeychain.ExtendedKey, + targetScript []byte) (*keychain.KeyDescriptor, []byte, error) { + + // Loop through the local multisig keys to find the target anchor + // script. + for index := uint32(0); index < math.MaxInt16; index++ { + currentKey, err := multisigBranch.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: keychain.KeyFamilyMultiSig, + Index: index, + }, + }, script, 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 +