Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/chantools/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ func main() {
newSummaryCommand(),
newSweepTimeLockCommand(),
newSweepTimeLockManualCommand(),
newSweepHtlcCommand(),
newSweepRemoteClosedCommand(),
newTriggerForceCloseCommand(),
newVanityGenCommand(),
Expand Down
149 changes: 149 additions & 0 deletions cmd/chantools/sweephtlc.go
Original file line number Diff line number Diff line change
@@ -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,
)
}
170 changes: 170 additions & 0 deletions cmd/chantools/sweephtlc_candidates.go
Original file line number Diff line number Diff line change
@@ -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
}
65 changes: 65 additions & 0 deletions cmd/chantools/sweephtlc_channel.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading