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
176 changes: 175 additions & 1 deletion cmd/chantools/scbforceclose.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ 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"
)

Expand Down Expand Up @@ -209,7 +212,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 +238,163 @@ 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("to_remote: idx=%d amount=%d sat", class.ToRemoteIdx,
class.ToRemoteAmt)

if len(class.ToRemotePkScript) != 0 {
log.Infof("to_remote PkScript (hex): %x",
class.ToRemotePkScript)
}
} else {
log.Infof("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 +367 to +374
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
}
100 changes: 100 additions & 0 deletions cmd/chantools/scbforceclose_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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"
)

//go:embed testdata/scbforceclose_testdata.json
var scbforcecloseTestData []byte

// TestClassifyOutputs_RealData verifies we can identify the to_remote output
// using lnwallet.CommitScriptToRemote with real world data provided.
func TestClassifyOutputs_RealData(t *testing.T) {
buf := &bytes.Buffer{}
logBackend := btclog.NewDefaultHandler(buf)
log = btclog.NewSLogger(logBackend.SubSystem("TEST"))
log.SetLevel(btclog.LevelTrace)

// Load test data from embedded file.
var testData struct {
RemotePubkey string `json:"remote_pubkey"`
TransactionHex string `json:"transaction_hex"`
}
err := json.Unmarshal(scbforcecloseTestData, &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)
}
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"
}