Skip to content
Merged
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
12 changes: 12 additions & 0 deletions cmd/chantools/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,18 @@ func (h *harness) testdataFile(name string) string {
return fileCopy
}

func (h *harness) readTestdataFile(name string) []byte {
workingDir, err := os.Getwd()
require.NoError(h.t, err)

origFile := path.Join(workingDir, "testdata", name)

data, err := os.ReadFile(origFile)
require.NoError(h.t, err)

return data
}

func (h *harness) tempFile(name string) string {
return path.Join(h.tempDir, name)
}
Expand Down
199 changes: 197 additions & 2 deletions cmd/chantools/scbforceclose.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,29 @@ import (
"os"
"strings"

"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/chantools/btc"
"github.com/lightninglabs/chantools/lnd"
"github.com/lightninglabs/chantools/scbforceclose"
"github.com/lightningnetwork/lnd/chanbackup"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/spf13/cobra"
)

type scbForceCloseCommand struct {
APIURL string
Publish bool

// channel.backup.
// How the channel backup is provided.
SingleBackup string
SingleFile string
MultiBackup string
MultiFile string

ChannelPoint string

rootKey *rootKey
cmd *cobra.Command
}
Expand Down Expand Up @@ -64,6 +69,13 @@ func newScbForceCloseCommand() *cobra.Command {
"single-channel backup file (channel.backup)",
)

cc.cmd.Flags().StringVar(
&cc.ChannelPoint, "channel_point", "", "a single channel "+
"point of a channel to force close, in case a multi "+
"backup or multi file was provided but not all "+
"channels should be force-closed",
)

cc.cmd.Flags().BoolVar(
&cc.Publish, "publish", false, "publish force-closing TX to "+
"the chain API instead of just printing the TX",
Expand Down Expand Up @@ -159,6 +171,19 @@ func (c *scbForceCloseCommand) Execute(_ *cobra.Command, _ []string) error {
fmt.Printf("Found %d channel backups, %d of them have close tx.\n",
len(backups), len(backupsWithInputs))

if c.ChannelPoint != "" && len(backupsWithInputs) > 1 {
for _, s := range backupsWithInputs {
if s.FundingOutpoint.String() == c.ChannelPoint {
backupsWithInputs = []chanbackup.Single{s}

fmt.Printf("Only force-closing channel %s as "+
"requested.\n", c.ChannelPoint)

break
}
}
}

if len(backupsWithInputs) == 0 {
fmt.Println("No channel backups that can be used for force " +
"close.")
Expand Down Expand Up @@ -209,7 +234,18 @@ func (c *scbForceCloseCommand) Execute(_ *cobra.Command, _ []string) error {
txHex := hex.EncodeToString(buf.Bytes())
fmt.Println("Channel point:", s.FundingOutpoint)
fmt.Println("Raw transaction hex:", txHex)
fmt.Println()

// Classify outputs: identify to_remote using known templates,
// anchors (330 sat), and log the rest as to_local/htlc without
// deriving per-commitment. The remote key is not tweaked in
// the backup (except for very old channels which we don't
// support anyway).
class, err := classifyOutputs(s, signedTx)
if err == nil {
printOutputClassification(class, signedTx)
} else {
fmt.Printf("Failed to classify outputs: %v\n", err)
}

// Publish TX.
if c.Publish {
Expand All @@ -224,3 +260,162 @@ func (c *scbForceCloseCommand) Execute(_ *cobra.Command, _ []string) error {

return nil
}

// classifyAndLogOutputs attempts to identify the to_remote output by comparing
// against known script templates (p2wkh, delayed p2wsh, lease), marks 330-sat
// anchors, and logs remaining outputs as to_local/htlc without deriving
// per-commitment data.
func printOutputClassification(class outputClassification, tx *wire.MsgTx) {
if class.toRemoteIdx >= 0 {
log.Infof("Output to_remote: idx=%d amount=%d sat",
class.toRemoteIdx, class.toRemoteAmt)

if len(class.toRemotePkScript) != 0 {
log.Infof("Output to_remote PkScript (hex): %x",
class.toRemotePkScript)
}
} else {
log.Infof("Output to_remote: not identified")
}

for _, idx := range class.anchorIdxs {
log.Infof("Possible anchor: idx=%d amount=%d sat", idx,
tx.TxOut[idx].Value)
}

for _, idx := range class.otherIdxs {
log.Infof("Possible to_local/htlc: idx=%d amount=%d sat", idx,
tx.TxOut[idx].Value)
}
}

// outputClassification is the result of classifying the outputs of a channel
// force close transaction.
type outputClassification struct {
// toRemoteIdx is the index of the to_remote output.
toRemoteIdx int

// toRemoteAmt is the amount of the to_remote output.
toRemoteAmt int64

// toRemotePkScript is the PkScript of the to_remote output.
toRemotePkScript []byte

// anchorIdxs is the indices of the anchor outputs on the commitment
// transaction.
anchorIdxs []int

// otherIdxs is the indices of the other outputs on the commitment
// transaction.
otherIdxs []int
}

// classifyOutputs attempts to identify the to_remote output and classify the
// other outputs into anchors and to_local/htlc.
func classifyOutputs(s chanbackup.Single, tx *wire.MsgTx) (outputClassification,
error) {

// Best-effort get the remote key used for to_remote.
remoteDesc := s.RemoteChanCfg.PaymentBasePoint
remoteKey := remoteDesc.PubKey

// Compute the expected to_remote pkScript.
var toRemotePkScript []byte
if remoteKey != nil {
chanType, err := chanTypeFromBackupVersion(s.Version)
if err != nil {
return outputClassification{}, fmt.Errorf("failed to "+
"get channel type: %w", err)
}
desc, _, err := lnwallet.CommitScriptToRemote(
chanType, s.IsInitiator, remoteKey, s.LeaseExpiry,
input.NoneTapLeaf(),
)
if err != nil {
return outputClassification{}, fmt.Errorf("failed to "+
"get commit script to remote: %w", err)
}
toRemotePkScript = desc.PkScript()
}

// anchorSats is anchor output value in sats.
const anchorSats = 330

result := outputClassification{
toRemoteIdx: -1,
toRemotePkScript: toRemotePkScript,
}

// First pass: find to_remote by script match.
for idx, out := range tx.TxOut {
if len(toRemotePkScript) != 0 &&
bytes.Equal(out.PkScript, toRemotePkScript) {

result.toRemoteIdx = idx
result.toRemoteAmt = out.Value
break
}
}

// Second pass: classify anchors and the rest.
for idx, out := range tx.TxOut {
if idx == result.toRemoteIdx {
continue
}
if out.Value == anchorSats {
result.anchorIdxs = append(result.anchorIdxs, idx)
} else {
result.otherIdxs = append(result.otherIdxs, idx)
}
}

return result, nil
}

// chanTypeFromBackupVersion maps a backup SingleBackupVersion to an approximate
// channeldb.ChannelType sufficient for deriving to_remote scripts.
func chanTypeFromBackupVersion(v chanbackup.SingleBackupVersion) (
channeldb.ChannelType, error) {

var chanType channeldb.ChannelType
switch v {
case chanbackup.DefaultSingleVersion:
chanType = channeldb.SingleFunderBit

case chanbackup.TweaklessCommitVersion:
chanType = channeldb.SingleFunderTweaklessBit

case chanbackup.AnchorsCommitVersion:
chanType = channeldb.AnchorOutputsBit
chanType |= channeldb.SingleFunderTweaklessBit

case chanbackup.AnchorsZeroFeeHtlcTxCommitVersion:
chanType = channeldb.ZeroHtlcTxFeeBit
chanType |= channeldb.AnchorOutputsBit
chanType |= channeldb.SingleFunderTweaklessBit
Comment on lines +388 to +395
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idea, if you like it:

case chanbackup.AnchorsCommitVersion:
		chanType = channeldb.AnchorOutputsBit
		chanType |= channeldb.SingleFunderTweaklessBit
		fallthrough

case chanbackup.AnchorsZeroFeeHtlcTxCommitVersion:
		chanType |= channeldb.ZeroHtlcTxFeeBit

Also SimpleTaprootVersion and SimpleTaprootVersion can be coupled.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are you sure they can be couple because the version check for every backup version is different ?

For AnchorsZeroFeeHtlcTxCommitVersion we won't enter the AnchorsCommitVersion case or don't I understand your proposal here ?


case chanbackup.ScriptEnforcedLeaseVersion:
chanType = channeldb.LeaseExpirationBit
chanType |= channeldb.ZeroHtlcTxFeeBit
chanType |= channeldb.AnchorOutputsBit
chanType |= channeldb.SingleFunderTweaklessBit

case chanbackup.SimpleTaprootVersion:
chanType = channeldb.ZeroHtlcTxFeeBit
chanType |= channeldb.AnchorOutputsBit
chanType |= channeldb.SingleFunderTweaklessBit
chanType |= channeldb.SimpleTaprootFeatureBit

case chanbackup.TapscriptRootVersion:
chanType = channeldb.ZeroHtlcTxFeeBit
chanType |= channeldb.AnchorOutputsBit
chanType |= channeldb.SingleFunderTweaklessBit
chanType |= channeldb.SimpleTaprootFeatureBit
chanType |= channeldb.TapscriptRootBit

default:
return 0, fmt.Errorf("unknown Single version: %v", v)
}

return chanType, nil
}
103 changes: 103 additions & 0 deletions cmd/chantools/scbforceclose_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package main

import (
"bytes"
_ "embed"
"encoding/hex"
"encoding/json"
"testing"

"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btclog/v2"
"github.com/lightningnetwork/lnd/chanbackup"
"github.com/lightningnetwork/lnd/keychain"
"github.com/stretchr/testify/require"
)

// TestClassifyOutputs_RealData verifies we can identify the to_remote output
// using lnwallet.CommitScriptToRemote with real world data provided.
func TestClassifyOutputs_RealData(t *testing.T) {
h := newHarness(t)
h.logger.SetLevel(btclog.LevelTrace)

// Load test data from embedded file.
testDataBytes := h.readTestdataFile("scbforceclose_testdata.json")

var testData struct {
RemotePubkey string `json:"remote_pubkey"`
TransactionHex string `json:"transaction_hex"`
}
err := json.Unmarshal(testDataBytes, &testData)
require.NoError(t, err)

// Remote payment basepoint (compressed) provided by user.
remoteBytes, err := hex.DecodeString(testData.RemotePubkey)
require.NoError(t, err)
remoteKey, err := btcec.ParsePubKey(remoteBytes)
require.NoError(t, err)

// Example transaction hex from a real world channel.
txBytes, err := hex.DecodeString(testData.TransactionHex)
require.NoError(t, err)
var tx wire.MsgTx
require.NoError(t, tx.Deserialize(bytes.NewReader(txBytes)))

// Build a minimal Single with the remote payment basepoint.
makeSingle := func(version chanbackup.SingleBackupVersion,
initiator bool) chanbackup.Single {

s := chanbackup.Single{
Version: version,
IsInitiator: initiator,
}
s.RemoteChanCfg.PaymentBasePoint = keychain.KeyDescriptor{
PubKey: remoteKey,
}

return s
}

// Try a set of plausible SCB versions and initiator roles to find
// a match.
versions := []chanbackup.SingleBackupVersion{
chanbackup.AnchorsCommitVersion,
chanbackup.AnchorsZeroFeeHtlcTxCommitVersion,
chanbackup.ScriptEnforcedLeaseVersion,
chanbackup.TweaklessCommitVersion,
chanbackup.DefaultSingleVersion,
}

found := false
var lastClass outputClassification
for _, v := range versions {
for _, initiator := range []bool{true, false} {
s := makeSingle(v, initiator)
class, err := classifyOutputs(s, &tx)
require.NoError(t, err)
if class.toRemoteIdx >= 0 {
found = true
lastClass = class
t.Logf("Matched with version=%v initiator=%v",
v, initiator)

break
}
}
if found {
break
}
}

require.True(t, found, "to_remote output not identified for "+
"provided data")

// Log the results.
printOutputClassification(lastClass, &tx)

// Verify the logged classification.
h.assertLogContains("Output to_remote: idx=3 amount=790968 sat")
h.assertLogContains("Possible anchor: idx=0 amount=330 sat")
h.assertLogContains("Possible anchor: idx=1 amount=330 sat")
h.assertLogContains("Possible to_local/htlc: idx=2 amount=8087 sat")
}
5 changes: 5 additions & 0 deletions cmd/chantools/testdata/scbforceclose_testdata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"remote_pubkey": "029e5f4d86d9d6c845fbcf37b09ac7d59c25c19932ab34a2757e8ea88437a876c3",
"transaction_hex": "020000000001011f644a3f04139c2c3b1036f9deb924f7c8101e5825a2bf4a379579beea24bf320100000000b2448780044a010000000000002200202661eee6d24eaf71079b96f8df4dd88aa6280b61845dacdb10d8b0bcc51257af4a0100000000000022002074bcb8019840e0ac7abb16be6c8408fbbebd519cb86193965b33e8e69648865e971f0000000000002200209a1c8e727820d673859049f9305c02c39eb0a718f9219dc2e48a2621243d7dc8b8110c00000000002200205a596aa125a8a39e73f70dcf279cb06295eed49950c9e1f239b47ce41ab0e9320400483045022100ef18b0fe8d34f21ef13316d03cbb72445b61033489a8df81f163ebd60f430637022075a25aa0dc0a08e361540bd831430fc816b0a4ca9ca0169fb95de4a64c297cde01483045022100f8d7b5eee968157f0e06a65c389b6d1f5ca68a3189440b7638ab341c5ac77fdd022069db71847c48b1f762242b99b2fa254b1bce8f44a160293fe3b36ed2d2e32f650147522103ae9df242881bb10a2400e7812fc8cfe437f0f869538584d39d96f52cb2dbaf622103e71742ef40d136884a1f7368fb096cc5897fd697b41a3b481def37b60188c49152aebf573f20"
}

9 changes: 5 additions & 4 deletions itest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ around the network).
The network is set up as follows:

```
Alice ◄──► Bob ◄──► Charlie ◄──► Dave
└───────►└──► Rusty ◄──┘
| └► Nifty
└► Snyke
Alice ◄──► Bob ◄──► Charlie ◄──► Dave
└───────►└──► Rusty ◄──┘
| Nifty ◄──┘ |
└► Snyke ◄─────────────┘
```

- Channel **Alice** - **Bob**: Remains open, used by `runZombieRecoveryLndLnd`.
Expand All @@ -26,6 +26,7 @@ Alice ◄──► Bob ◄──► Charlie ◄──► Dave
`runSweepRemoteClosedLnd`.
- Channel **Charlie** - **Dave**: Remains open, used by
`runTriggerForceCloseLnd`.
- Channel **Charlie** - **Snyke**: Remains open, used by `runSCBForceClose`.
- Channel **Bob** - **Rusty**: Remains open, used by `runZombieRecoveryLndCln`.
- Channel **Rusty** - **Charlie**: Remains open, used by
`runZombieRecoveryClnLnd`.
Expand Down
Loading