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" +} + diff --git a/itest/README.md b/itest/README.md index 31fbe4c..c6cbf3f 100644 --- a/itest/README.md +++ b/itest/README.md @@ -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`. @@ -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`. diff --git a/itest/cmd_scbforceclose_test.go b/itest/cmd_scbforceclose_test.go new file mode 100644 index 0000000..c75b229 --- /dev/null +++ b/itest/cmd_scbforceclose_test.go @@ -0,0 +1,43 @@ +package itest + +import ( + "fmt" + "testing" + + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/stretchr/testify/require" +) + +func runScbForceClose(t *testing.T) { + charlieChannels := readChannelsJSON(t, "charlie") + snykeIdentity := getNodeIdentityKeyCln(t, "snyke") + + var charlieSnykeChannel *lnrpc.Channel + for _, c := range charlieChannels { + if c.RemotePubkey == snykeIdentity { + charlieSnykeChannel = c + } + } + require.NotNil( + t, charlieSnykeChannel, "charlie-snyke channel not found", + ) + + scbFile := fmt.Sprintf(scbFilePattern, "charlie") + txHex, fullOutput := getScbForceClose( + t, "charlie", tempDir, scbFile, + charlieSnykeChannel.ChannelPoint, + ) + + // Outputs on a force-close transaction are always ordered by amount. + require.Contains( + t, fullOutput, "Possible anchor: idx=0 amount=330 sat", + ) + require.Contains( + t, fullOutput, "Possible anchor: idx=1 amount=330 sat", + ) + require.Contains(t, fullOutput, "Output to_remote: idx=2 amount=") + require.Contains(t, fullOutput, "Possible to_local/htlc: idx=3 amount=") + + backend := connectBitcoind(t) + publishTx(t, txHex, backend) +} diff --git a/itest/docker/setup-test-network.sh b/itest/docker/setup-test-network.sh index 7612617..f518a68 100755 --- a/itest/docker/setup-test-network.sh +++ b/itest/docker/setup-test-network.sh @@ -26,9 +26,7 @@ done # Spin up the network in detached mode. compose_up -# Set up the basic A ◄─► B ◄─► C ◄─► D network. -# └────►└► R ◄┘ -# └► N +# Set up the basic network. setup_bitcoin wait_for_nodes alice bob charlie dave rusty nifty snyke @@ -36,7 +34,7 @@ wait_for_nodes alice bob charlie dave rusty nifty snyke do_for fund_node alice bob charlie dave rusty nifty snyke # Alice, Bob and Charlie will open more than one channel each. -do_for fund_node alice alice bob charlie +do_for fund_node alice alice bob charlie charlie mine 6 @@ -51,6 +49,7 @@ connect_nodes bob rusty connect_nodes bob nifty connect_nodes charlie dave connect_nodes charlie rusty +connect_nodes charlie snyke connect_nodes rusty nifty open_channel alice bob @@ -59,17 +58,21 @@ open_channel bob charlie open_channel charlie dave open_channel bob rusty open_channel charlie rusty +open_channel charlie snyke open_channel rusty nifty open_channel alice snyke -echo "🔗 Set up network: Alice ◄─► Bob ◄─► Charlie ◄─► Dave network." -echo " └────────►└► Rusty ◄┘ " -echo " | └► Nifty" -echo " └► Snyke" +echo "🔗 Set up network:" +cat << EOF + Alice ◄──► Bob ◄──► Charlie ◄──► Dave + └───────►└──► Rusty ◄──┘ + | Nifty ◄──┘ | + └► Snyke ◄─────────────┘ +EOF mine 12 -num_channels=8 +num_channels=9 wait_graph_sync alice $num_channels wait_graph_sync bob $num_channels @@ -83,6 +86,7 @@ send_payment alice dave send_payment alice rusty send_payment dave rusty send_payment alice snyke +send_payment charlie snyke # Repeat the basic tests. send_payment bob dave @@ -91,6 +95,7 @@ send_payment alice dave send_payment alice rusty send_payment dave rusty send_payment alice snyke +send_payment charlie snyke # Store all the channel information in separate JSON files. alice listchannels > "$DIR/node-data/chantools/alice-channels.json" diff --git a/itest/helpers.go b/itest/helpers.go index f97123b..f5278c0 100644 --- a/itest/helpers.go +++ b/itest/helpers.go @@ -33,6 +33,8 @@ const ( channelsFilePattern = "docker/node-data/chantools/%s-channels.json" walletFilePattern = "docker/node-data/%s/data/chain/bitcoin/" + "regtest/wallet.db" + scbFilePattern = "docker/node-data/%s/data/chain/bitcoin/" + + "regtest/channel.backup" hsmSecretFilePattern = "docker/node-data/%s/regtest/hsm_secret" nodeIdentityFilePattern = "docker/node-data/chantools/identities.txt" nodeURIPattern = "%s@%s" @@ -48,9 +50,10 @@ const ( transactionHexIdent = "02000000" rowSign = "Press to continue and sign the " + "transaction or to abort:" - rowPublish = "Please publish this using any bitcoin node:" - rowTransaction = "Transaction:" - rowForceClose = "Found force close transaction" + rowPublish = "Please publish this using any bitcoin node:" + rowTransaction = "Transaction:" + rowForceClose = "Found force close transaction" + rowRawTransaction = "Raw transaction hex:" ) var ( @@ -321,6 +324,36 @@ func invokeCmdTriggerForceClose(t *testing.T, walletPassword *string, return output } +func invokeCmdScbForceClose(t *testing.T, walletPassword *string, + resultsDir string, args ...string) string { + + t.Helper() + + fullArgs := append([]string{ + "--regtest", "--resultsdir", resultsDir, "scbforceclose", + }, args...) + proc := StartChantools(t, fullArgs...) + defer proc.Wait(t) + + if walletPassword != nil { + pwPrompt := proc.ReadAvailableOutput(t, readTimeout) + + require.Contains(t, pwPrompt, "Input wallet password:") + proc.WriteInput(t, *walletPassword+"\n") + + proc.AssertNoStderr(t) + } + + warnPrompt := proc.ReadAvailableOutput(t, defaultTimeout) + require.Contains(t, warnPrompt, "Type YES to proceed: ") + proc.WriteInput(t, "YES\n") + + proc.AssertNoStderr(t) + + output := proc.ReadAvailableOutput(t, longTimeout) + return output +} + func getNodeIdentityKey(t *testing.T, node string) string { t.Helper() @@ -503,3 +536,20 @@ func getTriggerForceClose(t *testing.T, node, tempDir, apiURL, peerURI, return txid } + +func getScbForceClose(t *testing.T, node, tempDir, multiBackup, + channelPoint string) (string, string) { + + t.Helper() + + walletDbPath := fmt.Sprintf(walletFilePattern, node) + cmdOutput := invokeCmdScbForceClose( + t, &emptyPassword, tempDir, + "--channel_point", channelPoint, "--walletdb", walletDbPath, + "--multi_file", multiBackup, + ) + txHex := extractRowContent(cmdOutput, rowRawTransaction) + require.Contains(t, txHex, transactionHexIdent) + + return txHex, cmdOutput +} diff --git a/itest/integration_test.go b/itest/integration_test.go index a4940d2..aa24c4e 100644 --- a/itest/integration_test.go +++ b/itest/integration_test.go @@ -42,6 +42,10 @@ var testCases = []testCase{ name: "trigger force close cln", fn: runTriggerForceCloseCln, }, + { + name: "scb force close", + fn: runScbForceClose, + }, } // TestIntegration runs all integration test cases.