diff --git a/cmd/chantools/root_test.go b/cmd/chantools/root_test.go index a2a3c9e..a4931f3 100644 --- a/cmd/chantools/root_test.go +++ b/cmd/chantools/root_test.go @@ -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) } diff --git a/cmd/chantools/scbforceclose.go b/cmd/chantools/scbforceclose.go index de5d41b..0b4d2ae 100644 --- a/cmd/chantools/scbforceclose.go +++ b/cmd/chantools/scbforceclose.go @@ -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 +} diff --git a/cmd/chantools/scbforceclose_test.go b/cmd/chantools/scbforceclose_test.go new file mode 100644 index 0000000..8c2644a --- /dev/null +++ b/cmd/chantools/scbforceclose_test.go @@ -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") +} diff --git a/cmd/chantools/testdata/scbforceclose_testdata.json b/cmd/chantools/testdata/scbforceclose_testdata.json new file mode 100644 index 0000000..59b5343 --- /dev/null +++ b/cmd/chantools/testdata/scbforceclose_testdata.json @@ -0,0 +1,5 @@ +{ + "remote_pubkey": "029e5f4d86d9d6c845fbcf37b09ac7d59c25c19932ab34a2757e8ea88437a876c3", + "transaction_hex": "020000000001011f644a3f04139c2c3b1036f9deb924f7c8101e5825a2bf4a379579beea24bf320100000000b2448780044a010000000000002200202661eee6d24eaf71079b96f8df4dd88aa6280b61845dacdb10d8b0bcc51257af4a0100000000000022002074bcb8019840e0ac7abb16be6c8408fbbebd519cb86193965b33e8e69648865e971f0000000000002200209a1c8e727820d673859049f9305c02c39eb0a718f9219dc2e48a2621243d7dc8b8110c00000000002200205a596aa125a8a39e73f70dcf279cb06295eed49950c9e1f239b47ce41ab0e9320400483045022100ef18b0fe8d34f21ef13316d03cbb72445b61033489a8df81f163ebd60f430637022075a25aa0dc0a08e361540bd831430fc816b0a4ca9ca0169fb95de4a64c297cde01483045022100f8d7b5eee968157f0e06a65c389b6d1f5ca68a3189440b7638ab341c5ac77fdd022069db71847c48b1f762242b99b2fa254b1bce8f44a160293fe3b36ed2d2e32f650147522103ae9df242881bb10a2400e7812fc8cfe437f0f869538584d39d96f52cb2dbaf622103e71742ef40d136884a1f7368fb096cc5897fd697b41a3b481def37b60188c49152aebf573f20" +} +