-
Notifications
You must be signed in to change notification settings - Fork 41
Classify outputs and amounts for SCBForceCloseChannel #216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
| ) | ||
|
|
||
|
|
@@ -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 { | ||
|
|
@@ -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 { | ||
ziggie1984 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 { | ||
ziggie1984 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Idea, if you like it: Also SimpleTaprootVersion and SimpleTaprootVersion can be coupled. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| 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 | ||
| } | ||
| 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) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| { | ||
| "remote_pubkey": "029e5f4d86d9d6c845fbcf37b09ac7d59c25c19932ab34a2757e8ea88437a876c3", | ||
| "transaction_hex": "020000000001011f644a3f04139c2c3b1036f9deb924f7c8101e5825a2bf4a379579beea24bf320100000000b2448780044a010000000000002200202661eee6d24eaf71079b96f8df4dd88aa6280b61845dacdb10d8b0bcc51257af4a0100000000000022002074bcb8019840e0ac7abb16be6c8408fbbebd519cb86193965b33e8e69648865e971f0000000000002200209a1c8e727820d673859049f9305c02c39eb0a718f9219dc2e48a2621243d7dc8b8110c00000000002200205a596aa125a8a39e73f70dcf279cb06295eed49950c9e1f239b47ce41ab0e9320400483045022100ef18b0fe8d34f21ef13316d03cbb72445b61033489a8df81f163ebd60f430637022075a25aa0dc0a08e361540bd831430fc816b0a4ca9ca0169fb95de4a64c297cde01483045022100f8d7b5eee968157f0e06a65c389b6d1f5ca68a3189440b7638ab341c5ac77fdd022069db71847c48b1f762242b99b2fa254b1bce8f44a160293fe3b36ed2d2e32f650147522103ae9df242881bb10a2400e7812fc8cfe437f0f869538584d39d96f52cb2dbaf622103e71742ef40d136884a1f7368fb096cc5897fd697b41a3b481def37b60188c49152aebf573f20" | ||
| } | ||
|
|
Uh oh!
There was an error while loading. Please reload this page.