diff --git a/cmd/chantools/root.go b/cmd/chantools/root.go index b32ba8f..8260993 100644 --- a/cmd/chantools/root.go +++ b/cmd/chantools/root.go @@ -154,6 +154,7 @@ func main() { newSummaryCommand(), newSweepTimeLockCommand(), newSweepTimeLockManualCommand(), + newSweepHtlcCommand(), newSweepRemoteClosedCommand(), newTriggerForceCloseCommand(), newVanityGenCommand(), diff --git a/cmd/chantools/sweephtlc.go b/cmd/chantools/sweephtlc.go new file mode 100644 index 0000000..3bdee8b --- /dev/null +++ b/cmd/chantools/sweephtlc.go @@ -0,0 +1,149 @@ +package main + +import ( + "errors" + "fmt" + "strings" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/chantools/lnd" + "github.com/spf13/cobra" +) + +// sweepHtlcCommand holds the CLI options and dependencies for sweephtlc. +type sweepHtlcCommand struct { + // ChannelDB is the lnd channel.db path used to recover HTLC metadata. + ChannelDB string + + // Outpoints is the comma separated list of exact HTLC outputs to sweep. + Outpoints string + + // CommitPoint is an optional commitment point override for DLP cases. + CommitPoint string + + // APIURL is the Esplora-compatible chain API endpoint. + APIURL string + + // Publish controls whether the signed sweep transaction is broadcast. + Publish bool + + // SweepAddr is the destination address for recovered funds. + SweepAddr string + + // FeeRate is the target sweep fee rate in sat/vByte. + FeeRate uint32 + + // rootKey loads the wallet root key used for HTLC signing. + rootKey *rootKey + + // cmd is the cobra command bound to this option set. + cmd *cobra.Command +} + +// newSweepHtlcCommand creates the sweephtlc cobra command. +func newSweepHtlcCommand() *cobra.Command { + cc := &sweepHtlcCommand{} + cc.cmd = &cobra.Command{ + Use: "sweephtlc", + Short: "Sweep channel HTLC outputs by matching outpoints against channel.db", + Long: `Sweep channel HTLC outputs by taking exact on-chain outpoints, +finding the corresponding channel in channel.db, reconstructing candidate HTLC +scripts and signing the matching spend. + +The first supported spend path is an outgoing HTLC on the remote party's +commitment transaction after CLTV timeout. This is the direct timeout spend used +when the remote party force-closes with an HTLC we offered. + +By default the command only prints the raw transaction. Use --publish to publish +through the configured Esplora-compatible API.`, + Example: `chantools sweephtlc \ + --channeldb ~/.lnd/data/graph/mainnet/channel.db \ + --outpoints aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:3,aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:4 \ + --commitpoint 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 \ + --sweepaddr bc1q..... \ + --feerate 1`, + RunE: cc.Execute, + } + cc.cmd.Flags().StringVar( + &cc.ChannelDB, "channeldb", "", "lnd channel.db file to read "+ + "channel state from", + ) + cc.cmd.Flags().StringVar( + &cc.Outpoints, "outpoints", "", "comma separated HTLC outpoints "+ + "to sweep, in txid:index format", + ) + cc.cmd.Flags().StringVar( + &cc.CommitPoint, "commitpoint", "", "optional commitment point "+ + "override to try when reconstructing HTLC scripts", + ) + cc.cmd.Flags().StringVar( + &cc.APIURL, "apiurl", defaultAPIURL, "API URL to use (must "+ + "be esplora compatible)", + ) + cc.cmd.Flags().BoolVar( + &cc.Publish, "publish", false, "publish sweep TX to the chain "+ + "API instead of just printing the TX", + ) + cc.cmd.Flags().StringVar( + &cc.SweepAddr, "sweepaddr", "", "address to recover the funds "+ + "to; specify '"+lnd.AddressDeriveFromWallet+"' to "+ + "derive a new address from the seed automatically", + ) + cc.cmd.Flags().Uint32Var( + &cc.FeeRate, "feerate", 1, "fee rate to "+ + "use for the sweep transaction in sat/vByte", + ) + + cc.rootKey = newRootKey(cc.cmd, "signing HTLC sweep transaction") + + return cc.cmd +} + +// Execute runs the sweephtlc command. +func (c *sweepHtlcCommand) Execute(_ *cobra.Command, _ []string) error { + if c.ChannelDB == "" { + return errors.New("channel DB is required") + } + if c.Outpoints == "" { + return errors.New("at least one outpoint is required") + } + if c.FeeRate == 0 { + c.FeeRate = 1 + } + + extendedKey, err := c.rootKey.read() + if err != nil { + return fmt.Errorf("error reading root key: %w", err) + } + + db, _, err := lnd.OpenDB(c.ChannelDB, true) + if err != nil { + return fmt.Errorf("error opening channel DB: %w", err) + } + defer func() { _ = db.Close() }() + + var commitPointOverride *btcec.PublicKey + if strings.TrimSpace(c.CommitPoint) != "" { + commitPointOverride, err = parsePubKey(c.CommitPoint) + if err != nil { + return fmt.Errorf("error parsing commit point: %w", err) + } + } + + api := newExplorerAPI(c.APIURL) + targets, err := fetchSweepHtlcTargets(api, c.Outpoints) + if err != nil { + return err + } + + matches, err := findSweepHtlcMatches( + db.ChannelStateDB(), targets, commitPointOverride, + ) + if err != nil { + return err + } + + return sweepMatchedHtlcs( + extendedKey, api, matches, c.SweepAddr, c.FeeRate, c.Publish, + ) +} diff --git a/cmd/chantools/sweephtlc_candidates.go b/cmd/chantools/sweephtlc_candidates.go new file mode 100644 index 0000000..04529fe --- /dev/null +++ b/cmd/chantools/sweephtlc_candidates.go @@ -0,0 +1,170 @@ +package main + +import ( + "encoding/hex" + "errors" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lntypes" +) + +// sweepHtlcCommitPoint pairs a commitment point with its source label. +type sweepHtlcCommitPoint struct { + // point is the commitment point used for key derivation. + point *btcec.PublicKey + + // source identifies where the commitment point was loaded from. + source string +} + +// sweepHtlcCommitCandidate describes one commitment state to test for matches. +type sweepHtlcCommitCandidate struct { + // name is a human readable label for the commitment candidate. + name string + + // side identifies whose commitment transaction this candidate represents. + side lntypes.ChannelParty + + // commitment contains the HTLC set to test against the target output. + commitment channeldb.ChannelCommitment + + // commitPoints are the commitment points to try for this candidate. + commitPoints []sweepHtlcCommitPoint +} + +// sweepHtlcCommitCandidates returns the local, remote, and pending remote +// commitment candidates that can be reconstructed from channel.db. +func sweepHtlcCommitCandidates(channel *channeldb.OpenChannel, + commitPointOverride *btcec.PublicKey) []sweepHtlcCommitCandidate { + + localPoints := make([]sweepHtlcCommitPoint, 0, 2) + remotePoints := make([]sweepHtlcCommitPoint, 0, 3) + + if commitPointOverride != nil { + localPoints = append(localPoints, sweepHtlcCommitPoint{ + point: commitPointOverride, + source: "override", + }) + remotePoints = append(remotePoints, sweepHtlcCommitPoint{ + point: commitPointOverride, + source: "override", + }) + } + + localCommitPoint, err := deriveLocalCommitPoint(channel) + if err == nil { + localPoints = append(localPoints, sweepHtlcCommitPoint{ + point: localCommitPoint, + source: "revocation_producer", + }) + } else { + log.Warnf("Unable to derive local commit point for %v: %v", + channel.FundingOutpoint, err) + } + + remotePoints = appendPubKeyCandidate( + remotePoints, channel.RemoteCurrentRevocation, + "remote_current_revocation", + ) + remotePoints = appendPubKeyCandidate( + remotePoints, channel.RemoteNextRevocation, "remote_next_revocation", + ) + + candidates := []sweepHtlcCommitCandidate{{ + name: "local_commitment", + side: lntypes.Local, + commitment: channel.LocalCommitment, + commitPoints: dedupeCommitPoints(localPoints), + }, { + name: "remote_commitment", + side: lntypes.Remote, + commitment: channel.RemoteCommitment, + commitPoints: dedupeCommitPoints(remotePoints), + }} + + if channel.Db == nil { + return candidates + } + + if tip, err := channel.RemoteCommitChainTip(); err == nil { + pendingRemotePoints := make([]sweepHtlcCommitPoint, 0, 2) + if commitPointOverride != nil { + pendingRemotePoints = append( + pendingRemotePoints, sweepHtlcCommitPoint{ + point: commitPointOverride, + source: "override", + }, + ) + } + pendingRemotePoints = appendPubKeyCandidate( + pendingRemotePoints, channel.RemoteNextRevocation, + "remote_next_revocation", + ) + + candidates = append(candidates, sweepHtlcCommitCandidate{ + name: "pending_remote_commitment", + side: lntypes.Remote, + commitment: tip.Commitment, + commitPoints: dedupeCommitPoints(pendingRemotePoints), + }) + } else if !errors.Is(err, channeldb.ErrNoPendingCommit) { + log.Warnf("Unable to fetch pending remote commitment for %v: %v", + channel.FundingOutpoint, err) + } + + return candidates +} + +// deriveLocalCommitPoint derives the local commitment point from the local +// revocation producer. +func deriveLocalCommitPoint(channel *channeldb.OpenChannel) (*btcec.PublicKey, error) { + if channel.RevocationProducer == nil { + return nil, errors.New("missing revocation producer") + } + + rev, err := channel.RevocationProducer.AtIndex( + channel.LocalCommitment.CommitHeight, + ) + if err != nil { + return nil, err + } + + return input.ComputeCommitmentPoint(rev[:]), nil +} + +// appendPubKeyCandidate adds a non-nil public key to the candidate list. +func appendPubKeyCandidate(candidates []sweepHtlcCommitPoint, + pubKey *btcec.PublicKey, source string) []sweepHtlcCommitPoint { + + if pubKey == nil { + return candidates + } + + return append(candidates, sweepHtlcCommitPoint{ + point: pubKey, + source: source, + }) +} + +// dedupeCommitPoints removes duplicate commitment points while preserving order. +func dedupeCommitPoints(points []sweepHtlcCommitPoint) []sweepHtlcCommitPoint { + seen := make(map[string]struct{}) + deduped := make([]sweepHtlcCommitPoint, 0, len(points)) + for _, point := range points { + if point.point == nil { + continue + } + + key := hex.EncodeToString(point.point.SerializeCompressed()) + if _, ok := seen[key]; ok { + continue + } + + seen[key] = struct{}{} + deduped = append(deduped, point) + } + + return deduped +} diff --git a/cmd/chantools/sweephtlc_channel.go b/cmd/chantools/sweephtlc_channel.go new file mode 100644 index 0000000..d3bd643 --- /dev/null +++ b/cmd/chantools/sweephtlc_channel.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/channeldb" +) + +// findSweepHtlcMatches loads the channels for the targets and matches each one. +func findSweepHtlcMatches(chanDB *channeldb.ChannelStateDB, + targets []*sweepHtlcTarget, commitPointOverride *btcec.PublicKey) ( + []*sweepHtlcMatch, error) { + + matches := make([]*sweepHtlcMatch, 0, len(targets)) + for _, target := range targets { + channel, source, err := fetchSweepHtlcChannel( + chanDB, target.fundingPoint, + ) + if err != nil { + return nil, err + } + + candidateMatches, err := matchTargetHtlc( + channel, source, target, commitPointOverride, + ) + if err != nil { + return nil, err + } + + switch len(candidateMatches) { + case 0: + return nil, fmt.Errorf("no HTLC in channel %v matched %v", + target.fundingPoint, target.outpoint) + + case 1: + matches = append(matches, candidateMatches[0]) + + default: + return nil, fmt.Errorf("multiple HTLC candidates matched %v; "+ + "refusing to guess", target.outpoint) + } + } + + return matches, nil +} + +// fetchSweepHtlcChannel finds a channel in either the open or historical bucket. +func fetchSweepHtlcChannel(chanDB *channeldb.ChannelStateDB, + chanPoint wire.OutPoint) (*channeldb.OpenChannel, string, error) { + + channel, err := chanDB.FetchChannel(chanPoint) + if err == nil { + return channel, "open", nil + } + + historical, histErr := chanDB.FetchHistoricalChannel(&chanPoint) + if histErr == nil { + return historical, "historical", nil + } + + return nil, "", fmt.Errorf("channel %v not found: open lookup: %v, "+ + "historical lookup: %v", chanPoint, err, histErr) +} diff --git a/cmd/chantools/sweephtlc_match.go b/cmd/chantools/sweephtlc_match.go new file mode 100644 index 0000000..041afcd --- /dev/null +++ b/cmd/chantools/sweephtlc_match.go @@ -0,0 +1,148 @@ +package main + +import ( + "bytes" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet" +) + +// sweepHtlcMatch contains the channel HTLC metadata matched to a target output. +type sweepHtlcMatch struct { + // target is the on-chain HTLC output being swept. + target *sweepHtlcTarget + + // channel is the channel.db record that owns the target output. + channel *channeldb.OpenChannel + + // channelSource identifies whether the channel was open or historical. + channelSource string + + // commitmentName identifies the commitment candidate that matched. + commitmentName string + + // commitmentSide identifies whose commitment transaction created the output. + commitmentSide lntypes.ChannelParty + + // commitPoint is the commitment point used to derive the HTLC script. + commitPoint *btcec.PublicKey + + // commitPointSrc identifies where commitPoint came from. + commitPointSrc string + + // htlc is the channel.db HTLC entry that matched the target output. + htlc channeldb.HTLC + + // keyRing is the commitment key ring for the matched commitment point. + keyRing *lnwallet.CommitmentKeyRing + + // witnessScript is the HTLC witness script that matched the output. + witnessScript []byte + + // pkScript is the witness script hash output script. + pkScript []byte + + // direction describes whether the HTLC was incoming or outgoing locally. + direction string + + // spendPath describes the HTLC script branch needed to spend the output. + spendPath string + + // supportedDirect marks whether sweephtlc can directly spend this match. + supportedDirect bool +} + +// matchTargetHtlc reconstructs candidate HTLC scripts and compares them to the +// target output. +func matchTargetHtlc(channel *channeldb.OpenChannel, channelSource string, + target *sweepHtlcTarget, commitPointOverride *btcec.PublicKey) ( + []*sweepHtlcMatch, error) { + + if channel.ChanType.IsTaproot() { + return nil, fmt.Errorf("channel %v is taproot; sweephtlc v1 only "+ + "supports segwit v0 HTLC outputs", channel.FundingOutpoint) + } + + candidates := sweepHtlcCommitCandidates(channel, commitPointOverride) + var matches []*sweepHtlcMatch + for _, candidate := range candidates { + for _, cp := range candidate.commitPoints { + if cp.point == nil { + continue + } + + keyRing := lnwallet.DeriveCommitmentKeys( + cp.point, candidate.side, channel.ChanType, + &channel.LocalChanCfg, &channel.RemoteChanCfg, + ) + + for _, htlc := range candidate.commitment.Htlcs { + match, matched, err := matchSingleHtlc( + channel, channelSource, target, candidate, cp, + keyRing, htlc, + ) + if err != nil { + return nil, err + } + if matched { + matches = append(matches, match) + } + } + } + } + + return matches, nil +} + +// matchSingleHtlc checks whether one channel HTLC matches one target output. +func matchSingleHtlc(channel *channeldb.OpenChannel, channelSource string, + target *sweepHtlcTarget, candidate sweepHtlcCommitCandidate, + cp sweepHtlcCommitPoint, keyRing *lnwallet.CommitmentKeyRing, + htlc channeldb.HTLC) (*sweepHtlcMatch, bool, error) { + + if htlc.OutputIndex < 0 { + return nil, false, nil + } + if uint32(htlc.OutputIndex) != target.outpoint.Index { + return nil, false, nil + } + if int64(htlc.Amt.ToSatoshis()) != target.value { + return nil, false, nil + } + + witnessScript, direction, spendPath, supportedDirect, err := htlcScript( + channel.ChanType, candidate.side, htlc, keyRing, + ) + if err != nil { + return nil, false, err + } + + pkScript, err := input.WitnessScriptHash(witnessScript) + if err != nil { + return nil, false, err + } + if !bytes.Equal(pkScript, target.pkScript) { + return nil, false, nil + } + + return &sweepHtlcMatch{ + target: target, + channel: channel, + channelSource: channelSource, + commitmentName: candidate.name, + commitmentSide: candidate.side, + commitPoint: cp.point, + commitPointSrc: cp.source, + htlc: htlc, + keyRing: keyRing, + witnessScript: witnessScript, + pkScript: pkScript, + direction: direction, + spendPath: spendPath, + supportedDirect: supportedDirect, + }, true, nil +} diff --git a/cmd/chantools/sweephtlc_scripts.go b/cmd/chantools/sweephtlc_scripts.go new file mode 100644 index 0000000..ce48f9e --- /dev/null +++ b/cmd/chantools/sweephtlc_scripts.go @@ -0,0 +1,52 @@ +package main + +import ( + "errors" + + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet" +) + +// htlcScript builds the segwit v0 HTLC script and describes the required spend +// path for a channel HTLC on a specific commitment side. +func htlcScript(chanType channeldb.ChannelType, whoseCommit lntypes.ChannelParty, + htlc channeldb.HTLC, keyRing *lnwallet.CommitmentKeyRing) ( + []byte, string, string, bool, error) { + + confirmedSpend := chanType.HasAnchors() + switch { + case htlc.Incoming && whoseCommit.IsLocal(): + script, err := input.ReceiverHTLCScript( + htlc.RefundTimeout, keyRing.RemoteHtlcKey, + keyRing.LocalHtlcKey, keyRing.RevocationKey, + htlc.RHash[:], confirmedSpend, + ) + return script, "incoming/accepted_by_us", "success", false, err + + case htlc.Incoming && whoseCommit.IsRemote(): + script, err := input.SenderHTLCScript( + keyRing.RemoteHtlcKey, keyRing.LocalHtlcKey, + keyRing.RevocationKey, htlc.RHash[:], confirmedSpend, + ) + return script, "incoming/accepted_by_us", "success", false, err + + case !htlc.Incoming && whoseCommit.IsLocal(): + script, err := input.SenderHTLCScript( + keyRing.LocalHtlcKey, keyRing.RemoteHtlcKey, + keyRing.RevocationKey, htlc.RHash[:], confirmedSpend, + ) + return script, "outgoing/offered_by_us", "timeout", false, err + + case !htlc.Incoming && whoseCommit.IsRemote(): + script, err := input.ReceiverHTLCScript( + htlc.RefundTimeout, keyRing.LocalHtlcKey, + keyRing.RemoteHtlcKey, keyRing.RevocationKey, + htlc.RHash[:], confirmedSpend, + ) + return script, "outgoing/offered_by_us", "timeout", true, err + } + + return nil, "", "", false, errors.New("unknown HTLC direction") +} diff --git a/cmd/chantools/sweephtlc_sign.go b/cmd/chantools/sweephtlc_sign.go new file mode 100644 index 0000000..5426214 --- /dev/null +++ b/cmd/chantools/sweephtlc_sign.go @@ -0,0 +1,148 @@ +package main + +import ( + "bytes" + "encoding/hex" + "errors" + "fmt" + + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "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/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" +) + +// sweepMatchedHtlcs signs and optionally publishes the sweep transaction for +// all matched direct-timeout HTLCs. +func sweepMatchedHtlcs(extendedKey *hdkeychain.ExtendedKey, + api *btc.ExplorerAPI, matches []*sweepHtlcMatch, sweepAddr string, + feeRate uint32, publish bool) error { + + if len(matches) == 0 { + return errors.New("no HTLC matches to sweep") + } + + for _, match := range matches { + log.Infof("Matched %v: channel=%v (%s), commitment=%s, "+ + "commit_point_source=%s, direction=%s, spend_path=%s, "+ + "amount=%d, expiry=%d, payment_hash=%x", + match.target.outpoint, match.channel.FundingOutpoint, + match.channelSource, match.commitmentName, + match.commitPointSrc, match.direction, match.spendPath, + match.target.value, match.htlc.RefundTimeout, + match.htlc.RHash) + + if !match.supportedDirect { + return fmt.Errorf("matched HTLC %v requires unsupported spend "+ + "path: commitment=%s direction=%s spend_path=%s", + match.target.outpoint, match.commitmentName, + match.direction, match.spendPath) + } + } + + var estimator input.TxWeightEstimator + sweepScript, err := lnd.PrepareWalletAddress( + sweepAddr, chainParams, &estimator, extendedKey, "sweep", + ) + if err != nil { + return err + } + + signer := &lnd.Signer{ + ExtendedKey: extendedKey, + ChainParams: chainParams, + } + sweepTx := wire.NewMsgTx(2) + prevOutFetcher := txscript.NewMultiPrevOutFetcher(nil) + signDescs := make([]*input.SignDescriptor, 0, len(matches)) + totalOutputValue := int64(0) + maxLockTime := uint32(0) + + for _, match := range matches { + prevOut := &wire.TxOut{ + PkScript: match.pkScript, + Value: match.target.value, + } + prevOutFetcher.AddPrevOut(match.target.outpoint, prevOut) + + sequence := lnwallet.HtlcSecondLevelInputSequence( + match.channel.ChanType, + ) + sweepTx.TxIn = append(sweepTx.TxIn, &wire.TxIn{ + PreviousOutPoint: match.target.outpoint, + Sequence: sequence, + }) + + signDescs = append(signDescs, &input.SignDescriptor{ + KeyDesc: match.channel.LocalChanCfg.HtlcBasePoint, + SingleTweak: match.keyRing.LocalHtlcKeyTweak, + WitnessScript: match.witnessScript, + Output: prevOut, + HashType: txscript.SigHashAll, + PrevOutputFetcher: prevOutFetcher, + }) + + if match.channel.ChanType.HasAnchors() { + estimator.AddWitnessInput( + input.AcceptedHtlcTimeoutWitnessSizeConfirmed, + ) + } else { + estimator.AddWitnessInput(input.AcceptedHtlcTimeoutWitnessSize) + } + + totalOutputValue += match.target.value + if match.htlc.RefundTimeout > maxLockTime { + maxLockTime = match.htlc.RefundTimeout + } + } + + sweepTx.LockTime = maxLockTime + feeRateKWeight := chainfee.SatPerKVByte(1000 * feeRate).FeePerKWeight() + totalFee := feeRateKWeight.FeeForWeight(estimator.Weight()) + if int64(totalFee) >= totalOutputValue { + return fmt.Errorf("fee %d exceeds total output value %d", + totalFee, totalOutputValue) + } + + log.Infof("Fee %d sats of %d total amount (estimated weight %d)", + totalFee, totalOutputValue, estimator.Weight()) + + sweepTx.TxOut = []*wire.TxOut{{ + Value: totalOutputValue - int64(totalFee), + PkScript: sweepScript, + }} + + sigHashes := txscript.NewTxSigHashes(sweepTx, prevOutFetcher) + for idx, desc := range signDescs { + desc.SigHashes = sigHashes + desc.InputIndex = idx + witness, err := input.ReceiverHtlcSpendTimeout( + signer, desc, sweepTx, -1, + ) + if err != nil { + return err + } + sweepTx.TxIn[idx].Witness = witness + } + + var buf bytes.Buffer + if err := sweepTx.Serialize(&buf); err != nil { + return err + } + + if publish { + response, err := api.PublishTx(hex.EncodeToString(buf.Bytes())) + if err != nil { + return err + } + log.Infof("Published TX %s, response: %s", + sweepTx.TxHash().String(), response) + } + + log.Infof("Transaction: %x", buf.Bytes()) + return nil +} diff --git a/cmd/chantools/sweephtlc_target.go b/cmd/chantools/sweephtlc_target.go new file mode 100644 index 0000000..6e7bc05 --- /dev/null +++ b/cmd/chantools/sweephtlc_target.go @@ -0,0 +1,124 @@ +package main + +import ( + "encoding/hex" + "errors" + "fmt" + "strings" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/chantools/btc" +) + +// sweepHtlcTarget describes one on-chain HTLC output requested by the user. +type sweepHtlcTarget struct { + // outpoint is the exact commitment transaction output to sweep. + outpoint wire.OutPoint + + // fundingPoint is the channel funding outpoint spent by the close tx. + fundingPoint wire.OutPoint + + // value is the HTLC output value in satoshis. + value int64 + + // pkScript is the HTLC output script pubkey. + pkScript []byte + + // closeTx is the force-close transaction that created the HTLC output. + closeTx *btc.TX +} + +// fetchSweepHtlcTargets parses and loads all requested HTLC targets. +func fetchSweepHtlcTargets(api *btc.ExplorerAPI, + outpoints string) ([]*sweepHtlcTarget, error) { + + parts := strings.Split(outpoints, ",") + targets := make([]*sweepHtlcTarget, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + op, err := wire.NewOutPointFromString(part) + if err != nil { + return nil, fmt.Errorf("invalid outpoint %q: %w", part, err) + } + + target, err := fetchSweepHtlcTarget(api, *op) + if err != nil { + return nil, err + } + targets = append(targets, target) + } + + if len(targets) == 0 { + return nil, errors.New("no valid outpoints specified") + } + + return targets, nil +} + +// fetchSweepHtlcTarget loads one target outpoint from the chain API. +func fetchSweepHtlcTarget(api *btc.ExplorerAPI, + outpoint wire.OutPoint) (*sweepHtlcTarget, error) { + + closeTx, err := api.Transaction(outpoint.Hash.String()) + if err != nil { + return nil, fmt.Errorf("error fetching close tx %v: %w", + outpoint.Hash, err) + } + if int(outpoint.Index) >= len(closeTx.Vout) { + return nil, fmt.Errorf("outpoint %v has invalid output index", + outpoint) + } + + vout := closeTx.Vout[outpoint.Index] + if vout.Outspend != nil && vout.Outspend.Spent { + return nil, fmt.Errorf("outpoint %v is already spent by %s:%d", + outpoint, vout.Outspend.Txid, vout.Outspend.Vin) + } + + pkScript, err := hex.DecodeString(vout.ScriptPubkey) + if err != nil { + return nil, fmt.Errorf("error decoding script for %v: %w", + outpoint, err) + } + + if len(closeTx.Vin) != 1 { + return nil, fmt.Errorf("close tx %v has %d inputs, expected 1", + outpoint.Hash, len(closeTx.Vin)) + } + + fundingHash, err := chainhash.NewHashFromStr(closeTx.Vin[0].Tixid) + if err != nil { + return nil, fmt.Errorf("error parsing funding txid: %w", err) + } + if closeTx.Vin[0].Vout < 0 { + return nil, fmt.Errorf("close tx %v has negative funding vout", + outpoint.Hash) + } + + return &sweepHtlcTarget{ + outpoint: outpoint, + fundingPoint: wire.OutPoint{ + Hash: *fundingHash, + Index: uint32(closeTx.Vin[0].Vout), + }, + value: int64(vout.Value), + pkScript: pkScript, + closeTx: closeTx, + }, nil +} + +// parsePubKey parses a compressed or uncompressed public key hex string. +func parsePubKey(pubKeyHex string) (*btcec.PublicKey, error) { + bytes, err := hex.DecodeString(strings.TrimSpace(pubKeyHex)) + if err != nil { + return nil, err + } + + return btcec.ParsePubKey(bytes) +} diff --git a/cmd/chantools/sweephtlc_test.go b/cmd/chantools/sweephtlc_test.go new file mode 100644 index 0000000..0d82e76 --- /dev/null +++ b/cmd/chantools/sweephtlc_test.go @@ -0,0 +1,256 @@ +package main + +import ( + "bytes" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/shachain" + "github.com/stretchr/testify/require" +) + +// testSweepHtlcPubKey returns a deterministic public key for tests. +func testSweepHtlcPubKey(seed byte) *btcec.PublicKey { + _, pubKey := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{seed}, 32)) + return pubKey +} + +// testSweepHtlcConfig returns a deterministic channel config for tests. +func testSweepHtlcConfig(seed byte) channeldb.ChannelConfig { + return channeldb.ChannelConfig{ + MultiSigKey: keychain.KeyDescriptor{ + PubKey: testSweepHtlcPubKey(seed), + }, + RevocationBasePoint: keychain.KeyDescriptor{ + PubKey: testSweepHtlcPubKey(seed + 1), + }, + PaymentBasePoint: keychain.KeyDescriptor{ + PubKey: testSweepHtlcPubKey(seed + 2), + }, + DelayBasePoint: keychain.KeyDescriptor{ + PubKey: testSweepHtlcPubKey(seed + 3), + }, + HtlcBasePoint: keychain.KeyDescriptor{ + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamilyHtlcBase, + Index: uint32(seed), + }, + PubKey: testSweepHtlcPubKey(seed + 4), + }, + } +} + +// testSweepHtlcChannel returns a minimal anchor channel for matching tests. +func testSweepHtlcChannel() *channeldb.OpenChannel { + var revRoot chainhash.Hash + copy(revRoot[:], bytes.Repeat([]byte{3}, 32)) + + return &channeldb.OpenChannel{ + ChanType: channeldb.SingleFunderTweaklessBit | + channeldb.AnchorOutputsBit | + channeldb.ZeroHtlcTxFeeBit, + FundingOutpoint: wire.OutPoint{Index: 1}, + LocalChanCfg: testSweepHtlcConfig(10), + RemoteChanCfg: testSweepHtlcConfig(50), + RevocationProducer: shachain.NewRevocationProducer(revRoot), + } +} + +// testSweepHtlc returns a deterministic outgoing HTLC for matching tests. +func testSweepHtlc() channeldb.HTLC { + var rHash [32]byte + copy(rHash[:], bytes.Repeat([]byte{9}, 32)) + + return channeldb.HTLC{ + RHash: rHash, + Amt: lnwire.NewMSatFromSatoshis(12345), + RefundTimeout: 900000, + OutputIndex: 3, + Incoming: false, + HtlcIndex: 11, + } +} + +// TestMatchSingleRemoteOfferedHtlc verifies the supported direct timeout match. +func TestMatchSingleRemoteOfferedHtlc(t *testing.T) { + channel := testSweepHtlcChannel() + htlc := testSweepHtlc() + _, commitPoint := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{1}, 32)) + + keyRing := lnwallet.DeriveCommitmentKeys( + commitPoint, lntypes.Remote, channel.ChanType, + &channel.LocalChanCfg, &channel.RemoteChanCfg, + ) + witnessScript, _, _, supported, err := htlcScript( + channel.ChanType, lntypes.Remote, htlc, keyRing, + ) + require.NoError(t, err) + require.True(t, supported) + + pkScript, err := input.WitnessScriptHash(witnessScript) + require.NoError(t, err) + + target := &sweepHtlcTarget{ + outpoint: wire.OutPoint{Index: 3}, + value: int64(htlc.Amt.ToSatoshis()), + pkScript: pkScript, + } + candidate := sweepHtlcCommitCandidate{ + name: "remote_commitment", + side: lntypes.Remote, + commitment: channeldb.ChannelCommitment{Htlcs: []channeldb.HTLC{htlc}}, + } + cp := sweepHtlcCommitPoint{point: commitPoint, source: "test"} + + match, matched, err := matchSingleHtlc( + channel, "test", target, candidate, cp, keyRing, htlc, + ) + require.NoError(t, err) + require.True(t, matched) + require.NotNil(t, match) + require.True(t, match.supportedDirect) + require.Equal(t, "remote_commitment", match.commitmentName) + require.Equal(t, "outgoing/offered_by_us", match.direction) + require.Equal(t, "timeout", match.spendPath) +} + +// TestMatchSingleHtlcWrongCommitPoint verifies that script mismatches are +// reported as no match. +func TestMatchSingleHtlcWrongCommitPoint(t *testing.T) { + channel := testSweepHtlcChannel() + htlc := testSweepHtlc() + _, correctCommitPoint := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{1}, 32)) + _, wrongCommitPoint := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{2}, 32)) + + correctKeyRing := lnwallet.DeriveCommitmentKeys( + correctCommitPoint, lntypes.Remote, channel.ChanType, + &channel.LocalChanCfg, &channel.RemoteChanCfg, + ) + witnessScript, direction, spendPath, supported, err := htlcScript( + channel.ChanType, lntypes.Remote, htlc, correctKeyRing, + ) + require.NoError(t, err) + require.Equal(t, "outgoing/offered_by_us", direction) + require.Equal(t, "timeout", spendPath) + require.True(t, supported) + pkScript, err := input.WitnessScriptHash(witnessScript) + require.NoError(t, err) + + wrongKeyRing := lnwallet.DeriveCommitmentKeys( + wrongCommitPoint, lntypes.Remote, channel.ChanType, + &channel.LocalChanCfg, &channel.RemoteChanCfg, + ) + target := &sweepHtlcTarget{ + outpoint: wire.OutPoint{Index: 3}, + value: int64(htlc.Amt.ToSatoshis()), + pkScript: pkScript, + } + candidate := sweepHtlcCommitCandidate{ + name: "remote_commitment", + side: lntypes.Remote, + commitment: channeldb.ChannelCommitment{Htlcs: []channeldb.HTLC{htlc}}, + } + cp := sweepHtlcCommitPoint{point: wrongCommitPoint, source: "test"} + + match, matched, err := matchSingleHtlc( + channel, "test", target, candidate, cp, wrongKeyRing, htlc, + ) + require.NoError(t, err) + require.False(t, matched) + require.Nil(t, match) +} + +// TestMatchTargetHtlcDlpCommitPointOverride verifies the DLP case where the +// HTLC metadata exists locally but the matching commitment point must be +// supplied by the peer's DLP message. +func TestMatchTargetHtlcDlpCommitPointOverride(t *testing.T) { + channel := testSweepHtlcChannel() + htlc := testSweepHtlc() + _, staleCommitPoint := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{1}, 32)) + _, dlpCommitPoint := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{2}, 32)) + + channel.RemoteCurrentRevocation = staleCommitPoint + channel.RemoteCommitment = channeldb.ChannelCommitment{ + Htlcs: []channeldb.HTLC{htlc}, + } + + dlpKeyRing := lnwallet.DeriveCommitmentKeys( + dlpCommitPoint, lntypes.Remote, channel.ChanType, + &channel.LocalChanCfg, &channel.RemoteChanCfg, + ) + witnessScript, direction, spendPath, supported, err := htlcScript( + channel.ChanType, lntypes.Remote, htlc, dlpKeyRing, + ) + require.NoError(t, err) + require.Equal(t, "outgoing/offered_by_us", direction) + require.Equal(t, "timeout", spendPath) + require.True(t, supported) + + pkScript, err := input.WitnessScriptHash(witnessScript) + require.NoError(t, err) + target := &sweepHtlcTarget{ + outpoint: wire.OutPoint{Index: 3}, + value: int64(htlc.Amt.ToSatoshis()), + pkScript: pkScript, + } + + matches, err := matchTargetHtlc(channel, "test", target, nil) + require.NoError(t, err) + require.Empty(t, matches) + + matches, err = matchTargetHtlc(channel, "test", target, dlpCommitPoint) + require.NoError(t, err) + require.Len(t, matches, 1) + require.Equal(t, "remote_commitment", matches[0].commitmentName) + require.Equal(t, "override", matches[0].commitPointSrc) + require.True(t, matches[0].supportedDirect) +} + +// TestMatchSingleLocalOfferedHtlcUnsupported verifies local commitment matches +// are detected but rejected by the signing path. +func TestMatchSingleLocalOfferedHtlcUnsupported(t *testing.T) { + channel := testSweepHtlcChannel() + htlc := testSweepHtlc() + _, commitPoint := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{1}, 32)) + + keyRing := lnwallet.DeriveCommitmentKeys( + commitPoint, lntypes.Local, channel.ChanType, + &channel.LocalChanCfg, &channel.RemoteChanCfg, + ) + witnessScript, _, _, supported, err := htlcScript( + channel.ChanType, lntypes.Local, htlc, keyRing, + ) + require.NoError(t, err) + require.False(t, supported) + + pkScript, err := input.WitnessScriptHash(witnessScript) + require.NoError(t, err) + target := &sweepHtlcTarget{ + outpoint: wire.OutPoint{Index: 3}, + value: int64(htlc.Amt.ToSatoshis()), + pkScript: pkScript, + } + candidate := sweepHtlcCommitCandidate{ + name: "local_commitment", + side: lntypes.Local, + commitment: channeldb.ChannelCommitment{Htlcs: []channeldb.HTLC{htlc}}, + } + cp := sweepHtlcCommitPoint{point: commitPoint, source: "test"} + + match, matched, err := matchSingleHtlc( + channel, "test", target, candidate, cp, keyRing, htlc, + ) + require.NoError(t, err) + require.True(t, matched) + require.NotNil(t, match) + require.False(t, match.supportedDirect) + require.Equal(t, "local_commitment", match.commitmentName) +} diff --git a/doc/chantools.md b/doc/chantools.md index 56db82e..0c31b7b 100644 --- a/doc/chantools.md +++ b/doc/chantools.md @@ -53,6 +53,7 @@ https://github.com/lightninglabs/chantools/. * [chantools signpsbt](chantools_signpsbt.md) - Sign a Partially Signed Bitcoin Transaction (PSBT) * [chantools signrescuefunding](chantools_signrescuefunding.md) - Rescue funds locked in a funding multisig output that never resulted in a proper channel; this is the command the remote node (the non-initiator) of the channel needs to run * [chantools summary](chantools_summary.md) - Compile a summary about the current state of channels +* [chantools sweephtlc](chantools_sweephtlc.md) - Sweep channel HTLC outputs by matching outpoints against channel.db * [chantools sweepremoteclosed](chantools_sweepremoteclosed.md) - Go through all the addresses that could have funds of channels that were force-closed by the remote party. A public block explorer is queried for each address and if any balance is found, all funds are swept to a given address * [chantools sweeptimelock](chantools_sweeptimelock.md) - Sweep the force-closed state after the time lock has expired * [chantools sweeptimelockmanual](chantools_sweeptimelockmanual.md) - Sweep the force-closed state of a single channel manually if only a channel backup file is available @@ -60,4 +61,3 @@ https://github.com/lightninglabs/chantools/. * [chantools vanitygen](chantools_vanitygen.md) - Generate a seed with a custom lnd node identity public key that starts with the given prefix * [chantools walletinfo](chantools_walletinfo.md) - Shows info about an lnd wallet.db file and optionally extracts the BIP32 HD root key * [chantools zombierecovery](chantools_zombierecovery.md) - Try rescuing funds stuck in channels with zombie nodes - diff --git a/doc/chantools_sweephtlc.md b/doc/chantools_sweephtlc.md new file mode 100644 index 0000000..3a8428c --- /dev/null +++ b/doc/chantools_sweephtlc.md @@ -0,0 +1,69 @@ +## chantools sweephtlc + +Sweep channel HTLC outputs by matching exact outpoints against `channel.db`. + +### Synopsis + +This command takes one or more on-chain HTLC output outpoints, loads the +corresponding channel from an lnd `channel.db`, reconstructs candidate HTLC +scripts, and signs the matching spend. + +The first supported spend path is an outgoing HTLC on the remote party's +commitment transaction after CLTV timeout. This is the direct timeout spend used +when the remote party force-closes with an HTLC we offered. + +By default the command only prints the raw transaction. Use `--publish` to +publish through the configured Esplora-compatible API. + +``` +chantools sweephtlc [flags] +``` + +### Examples + +``` +chantools sweephtlc \ + --channeldb ~/.lnd/data/graph/mainnet/channel.db \ + --outpoints aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:3,aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:4 \ + --commitpoint 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 \ + --sweepaddr bc1q..... \ + --feerate 1 +``` + +### Options + +``` + --apiurl string API URL to use (must be esplora compatible) (default "https://api.node-recovery.com") + --bip39 read a classic BIP39 seed and passphrase from the terminal instead of asking for lnd seed format or providing the --rootkey flag + --channeldb string lnd channel.db file to read channel state from + --commitpoint string optional commitment point override to try when reconstructing HTLC scripts + --feerate uint32 fee rate to use for the sweep transaction in sat/vByte (default 1) + -h, --help help for sweephtlc + --outpoints string comma separated HTLC outpoints to sweep, in txid:index format + --publish publish sweep TX to the chain API instead of just printing the TX + --rootkey string BIP32 HD root key of the wallet to use for signing HTLC sweep transaction; leave empty to prompt for lnd 24 word aezeed + --sweepaddr string address to recover the funds to; specify 'fromseed' to derive a new address from the seed automatically + --walletdb string read the seed/master root key to use for signing HTLC sweep transaction from an lnd wallet.db file instead of asking for a seed or providing the --rootkey flag +``` + +### Options inherited from parent commands + +``` + --nologfile If set, no log file will be created. This is useful for testing purposes where we don't want to create a log file. + -r, --regtest Indicates if regtest parameters should be used + --resultsdir string Directory where results should be stored (default "./results") + -s, --signet Indicates if the public signet parameters should be used + -t, --testnet Indicates if testnet parameters should be used + --testnet4 Indicates if testnet4 parameters should be used +``` + +### Notes + +- Supported in v1: segwit v0 outgoing HTLCs on a remote commitment after CLTV timeout. +- Unsupported in v1: incoming/preimage HTLCs, local commitment second-level HTLC flows, taproot HTLCs. +- `--commitpoint` is useful for data-loss-protection cases where `channel.db` has the HTLC metadata but the remote force-close commitment point must be supplied from the DLP message. +- `sweephtlc` cannot recover HTLC outputs whose payment hash, CLTV expiry, amount, and direction are absent from `channel.db`. + +### SEE ALSO + +* [chantools](chantools.md) - Chantools helps recover funds from lightning channels