scbforceclose: classify the outputs and their amounts

This commit is contained in:
ziggie 2025-10-30 18:10:37 +01:00 committed by Oli
parent 8049650042
commit 7fa66ac0d5
No known key found for this signature in database
GPG key ID: 8E4256593F177720
4 changed files with 317 additions and 2 deletions

View file

@ -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)
}

View file

@ -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"
)
@ -20,12 +23,14 @@ 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
}
@ -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",
@ -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.")
@ -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 {
@ -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
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
}

View file

@ -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")
}

View file

@ -0,0 +1,5 @@
{
"remote_pubkey": "029e5f4d86d9d6c845fbcf37b09ac7d59c25c19932ab34a2757e8ea88437a876c3",
"transaction_hex": "020000000001011f644a3f04139c2c3b1036f9deb924f7c8101e5825a2bf4a379579beea24bf320100000000b2448780044a010000000000002200202661eee6d24eaf71079b96f8df4dd88aa6280b61845dacdb10d8b0bcc51257af4a0100000000000022002074bcb8019840e0ac7abb16be6c8408fbbebd519cb86193965b33e8e69648865e971f0000000000002200209a1c8e727820d673859049f9305c02c39eb0a718f9219dc2e48a2621243d7dc8b8110c00000000002200205a596aa125a8a39e73f70dcf279cb06295eed49950c9e1f239b47ce41ab0e9320400483045022100ef18b0fe8d34f21ef13316d03cbb72445b61033489a8df81f163ebd60f430637022075a25aa0dc0a08e361540bd831430fc816b0a4ca9ca0169fb95de4a64c297cde01483045022100f8d7b5eee968157f0e06a65c389b6d1f5ca68a3189440b7638ab341c5ac77fdd022069db71847c48b1f762242b99b2fa254b1bce8f44a160293fe3b36ed2d2e32f650147522103ae9df242881bb10a2400e7812fc8cfe437f0f869538584d39d96f52cb2dbaf622103e71742ef40d136884a1f7368fb096cc5897fd697b41a3b481def37b60188c49152aebf573f20"
}