mirror of
https://github.com/guggero/chantools.git
synced 2026-08-13 12:33:34 +02:00
scbforceclose: classify the outputs and their amounts
This commit is contained in:
parent
8049650042
commit
7fa66ac0d5
4 changed files with 317 additions and 2 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
103
cmd/chantools/scbforceclose_test.go
Normal file
103
cmd/chantools/scbforceclose_test.go
Normal 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")
|
||||
}
|
||||
5
cmd/chantools/testdata/scbforceclose_testdata.json
vendored
Normal file
5
cmd/chantools/testdata/scbforceclose_testdata.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"remote_pubkey": "029e5f4d86d9d6c845fbcf37b09ac7d59c25c19932ab34a2757e8ea88437a876c3",
|
||||
"transaction_hex": "020000000001011f644a3f04139c2c3b1036f9deb924f7c8101e5825a2bf4a379579beea24bf320100000000b2448780044a010000000000002200202661eee6d24eaf71079b96f8df4dd88aa6280b61845dacdb10d8b0bcc51257af4a0100000000000022002074bcb8019840e0ac7abb16be6c8408fbbebd519cb86193965b33e8e69648865e971f0000000000002200209a1c8e727820d673859049f9305c02c39eb0a718f9219dc2e48a2621243d7dc8b8110c00000000002200205a596aa125a8a39e73f70dcf279cb06295eed49950c9e1f239b47ce41ab0e9320400483045022100ef18b0fe8d34f21ef13316d03cbb72445b61033489a8df81f163ebd60f430637022075a25aa0dc0a08e361540bd831430fc816b0a4ca9ca0169fb95de4a64c297cde01483045022100f8d7b5eee968157f0e06a65c389b6d1f5ca68a3189440b7638ab341c5ac77fdd022069db71847c48b1f762242b99b2fa254b1bce8f44a160293fe3b36ed2d2e32f650147522103ae9df242881bb10a2400e7812fc8cfe437f0f869538584d39d96f52cb2dbaf622103e71742ef40d136884a1f7368fb096cc5897fd697b41a3b481def37b60188c49152aebf573f20"
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue