Skip to content

Commit b896dfb

Browse files
committed
scbforceclose: classify the outputs and their amounts
1 parent bc5a1d7 commit b896dfb

File tree

3 files changed

+276
-1
lines changed

3 files changed

+276
-1
lines changed

cmd/chantools/scbforceclose.go

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ import (
88
"os"
99
"strings"
1010

11+
"github.com/btcsuite/btcd/wire"
1112
"github.com/lightninglabs/chantools/btc"
1213
"github.com/lightninglabs/chantools/lnd"
1314
"github.com/lightninglabs/chantools/scbforceclose"
1415
"github.com/lightningnetwork/lnd/chanbackup"
16+
"github.com/lightningnetwork/lnd/channeldb"
1517
"github.com/lightningnetwork/lnd/input"
18+
"github.com/lightningnetwork/lnd/lnwallet"
1619
"github.com/spf13/cobra"
1720
)
1821

@@ -209,7 +212,18 @@ func (c *scbForceCloseCommand) Execute(_ *cobra.Command, _ []string) error {
209212
txHex := hex.EncodeToString(buf.Bytes())
210213
fmt.Println("Channel point:", s.FundingOutpoint)
211214
fmt.Println("Raw transaction hex:", txHex)
212-
fmt.Println()
215+
216+
// Classify outputs: identify to_remote using known templates,
217+
// anchors (330 sat), and log the rest as to_local/htlc without
218+
// deriving per-commitment. The remote key is not tweaked in
219+
// the backup (except for very old channels which we don't
220+
// support anyway).
221+
class, err := classifyOutputs(s, signedTx)
222+
if err == nil {
223+
printOutputClassification(class, signedTx)
224+
} else {
225+
fmt.Printf("failed to classify outputs: %v\n", err)
226+
}
213227

214228
// Publish TX.
215229
if c.Publish {
@@ -224,3 +238,165 @@ func (c *scbForceCloseCommand) Execute(_ *cobra.Command, _ []string) error {
224238

225239
return nil
226240
}
241+
242+
// classifyAndLogOutputs attempts to identify the to_remote output by comparing
243+
// against known script templates (p2wkh, delayed p2wsh, lease), marks 330-sat
244+
// anchors, and logs remaining outputs as to_local/htlc without deriving
245+
// per-commitment data.
246+
func printOutputClassification(class outputClassification, tx *wire.MsgTx) {
247+
248+
if class.ToRemoteIdx >= 0 {
249+
log.Infof("to_remote: idx=%d amount=%d sat", class.ToRemoteIdx,
250+
class.ToRemoteAmt)
251+
252+
if len(class.ToRemotePkScript) != 0 {
253+
log.Infof("to_remote PkScript (hex): %x",
254+
class.ToRemotePkScript)
255+
}
256+
} else {
257+
log.Infof("to_remote: not identified")
258+
}
259+
260+
for _, idx := range class.AnchorIdxs {
261+
log.Infof("possible anchor: idx=%d amount=%d sat", idx,
262+
tx.TxOut[idx].Value)
263+
}
264+
265+
for _, idx := range class.OtherIdxs {
266+
log.Infof("possible to_local/htlc: idx=%d amount=%d sat", idx,
267+
tx.TxOut[idx].Value)
268+
}
269+
270+
}
271+
272+
// outputClassification is the result of classifying the outputs of a channel
273+
// force close transaction.
274+
type outputClassification struct {
275+
// ToRemoteIdx is the index of the to_remote output.
276+
ToRemoteIdx int
277+
278+
// ToRemoteAmt is the amount of the to_remote output.
279+
ToRemoteAmt int64
280+
281+
// ToRemotePkScript is the PkScript of the to_remote output.
282+
ToRemotePkScript []byte
283+
284+
// AnchorIdxs is the indices of the anchor outputs on the commitment
285+
// transaction.
286+
AnchorIdxs []int
287+
288+
// OtherIdxs is the indices of the other outputs on the commitment
289+
// transaction.
290+
OtherIdxs []int
291+
}
292+
293+
// classifyOutputs attempts to identify the to_remote output and classify the
294+
// other outputs into anchors and to_local/htlc.
295+
func classifyOutputs(s chanbackup.Single, tx *wire.MsgTx) (outputClassification,
296+
error) {
297+
298+
// Best-effort get the remote key used for to_remote.
299+
remoteDesc := s.RemoteChanCfg.PaymentBasePoint
300+
remoteKey := remoteDesc.PubKey
301+
302+
// Compute the expected to_remote pkScript.
303+
var toRemotePkScript []byte
304+
if remoteKey != nil {
305+
chanType, err := chanTypeFromBackupVersion(s.Version)
306+
if err != nil {
307+
return outputClassification{}, fmt.Errorf("failed to "+
308+
"get channel type: %w", err)
309+
}
310+
desc, _, err := lnwallet.CommitScriptToRemote(
311+
chanType, s.IsInitiator, remoteKey,
312+
s.LeaseExpiry,
313+
input.NoneTapLeaf(),
314+
)
315+
if err != nil {
316+
return outputClassification{}, fmt.Errorf("failed to "+
317+
"get commit script to remote: %w", err)
318+
}
319+
toRemotePkScript = desc.PkScript()
320+
}
321+
322+
// anchorSats is anchor output value in sats.
323+
const anchorSats = 330
324+
325+
result := outputClassification{
326+
ToRemoteIdx: -1,
327+
ToRemotePkScript: toRemotePkScript,
328+
}
329+
330+
// First pass: find to_remote by script match.
331+
for idx, out := range tx.TxOut {
332+
if len(toRemotePkScript) != 0 &&
333+
bytes.Equal(out.PkScript, toRemotePkScript) {
334+
335+
result.ToRemoteIdx = idx
336+
result.ToRemoteAmt = out.Value
337+
break
338+
}
339+
}
340+
341+
// Second pass: classify anchors and the rest.
342+
for idx, out := range tx.TxOut {
343+
if idx == result.ToRemoteIdx {
344+
continue
345+
}
346+
if out.Value == anchorSats {
347+
result.AnchorIdxs = append(result.AnchorIdxs, idx)
348+
} else {
349+
result.OtherIdxs = append(result.OtherIdxs, idx)
350+
}
351+
}
352+
353+
return result, nil
354+
}
355+
356+
// chanTypeFromBackupVersion maps a backup SingleBackupVersion to an approximate
357+
// channeldb.ChannelType sufficient for deriving to_remote scripts.
358+
func chanTypeFromBackupVersion(v chanbackup.SingleBackupVersion) (
359+
channeldb.ChannelType, error) {
360+
361+
var chanType channeldb.ChannelType
362+
switch v {
363+
case chanbackup.DefaultSingleVersion:
364+
chanType = channeldb.SingleFunderBit
365+
366+
case chanbackup.TweaklessCommitVersion:
367+
chanType = channeldb.SingleFunderTweaklessBit
368+
369+
case chanbackup.AnchorsCommitVersion:
370+
chanType = channeldb.AnchorOutputsBit
371+
chanType |= channeldb.SingleFunderTweaklessBit
372+
373+
case chanbackup.AnchorsZeroFeeHtlcTxCommitVersion:
374+
chanType = channeldb.ZeroHtlcTxFeeBit
375+
chanType |= channeldb.AnchorOutputsBit
376+
chanType |= channeldb.SingleFunderTweaklessBit
377+
378+
case chanbackup.ScriptEnforcedLeaseVersion:
379+
chanType = channeldb.LeaseExpirationBit
380+
chanType |= channeldb.ZeroHtlcTxFeeBit
381+
chanType |= channeldb.AnchorOutputsBit
382+
chanType |= channeldb.SingleFunderTweaklessBit
383+
384+
case chanbackup.SimpleTaprootVersion:
385+
chanType = channeldb.ZeroHtlcTxFeeBit
386+
chanType |= channeldb.AnchorOutputsBit
387+
chanType |= channeldb.SingleFunderTweaklessBit
388+
chanType |= channeldb.SimpleTaprootFeatureBit
389+
390+
case chanbackup.TapscriptRootVersion:
391+
chanType = channeldb.ZeroHtlcTxFeeBit
392+
chanType |= channeldb.AnchorOutputsBit
393+
chanType |= channeldb.SingleFunderTweaklessBit
394+
chanType |= channeldb.SimpleTaprootFeatureBit
395+
chanType |= channeldb.TapscriptRootBit
396+
397+
default:
398+
return 0, fmt.Errorf("unknown Single version: %v", v)
399+
}
400+
401+
return chanType, nil
402+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
_ "embed"
6+
"encoding/hex"
7+
"encoding/json"
8+
"testing"
9+
10+
"github.com/btcsuite/btcd/btcec/v2"
11+
"github.com/btcsuite/btcd/wire"
12+
"github.com/lightningnetwork/lnd/chanbackup"
13+
"github.com/lightningnetwork/lnd/keychain"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
//go:embed testdata/scbforceclose_testdata.json
18+
var scbforcecloseTestData []byte
19+
20+
// TestClassifyOutputs_RealData verifies we can identify the to_remote output
21+
// using lnwallet.CommitScriptToRemote with real world data provided.
22+
func TestClassifyOutputs_RealData(t *testing.T) {
23+
// Load test data from embedded file.
24+
var testData struct {
25+
RemotePubkey string `json:"remotePubkey"`
26+
TransactionHex string `json:"transactionHex"`
27+
}
28+
err := json.Unmarshal(scbforcecloseTestData, &testData)
29+
require.NoError(t, err)
30+
31+
// Remote payment basepoint (compressed) provided by user.
32+
remoteBytes, err := hex.DecodeString(testData.RemotePubkey)
33+
require.NoError(t, err)
34+
remoteKey, err := btcec.ParsePubKey(remoteBytes)
35+
require.NoError(t, err)
36+
37+
// Example transaction hex from a real world channel.
38+
txBytes, err := hex.DecodeString(testData.TransactionHex)
39+
require.NoError(t, err)
40+
var tx wire.MsgTx
41+
require.NoError(t, tx.Deserialize(bytes.NewReader(txBytes)))
42+
43+
// Build a minimal Single with the remote payment basepoint.
44+
makeSingle := func(version chanbackup.SingleBackupVersion,
45+
initiator bool) chanbackup.Single {
46+
47+
s := chanbackup.Single{
48+
Version: version,
49+
IsInitiator: initiator,
50+
}
51+
s.RemoteChanCfg.PaymentBasePoint = keychain.KeyDescriptor{
52+
PubKey: remoteKey,
53+
}
54+
55+
return s
56+
}
57+
58+
// Try a set of plausible SCB versions and initiator roles to find
59+
// a match.
60+
versions := []chanbackup.SingleBackupVersion{
61+
chanbackup.AnchorsCommitVersion,
62+
chanbackup.AnchorsZeroFeeHtlcTxCommitVersion,
63+
chanbackup.ScriptEnforcedLeaseVersion,
64+
chanbackup.TweaklessCommitVersion,
65+
chanbackup.DefaultSingleVersion,
66+
}
67+
68+
found := false
69+
var lastClass outputClassification
70+
for _, v := range versions {
71+
for _, initiator := range []bool{true, false} {
72+
s := makeSingle(v, initiator)
73+
class, err := classifyOutputs(s, &tx)
74+
require.NoError(t, err)
75+
if class.ToRemoteIdx >= 0 {
76+
found = true
77+
lastClass = class
78+
t.Logf("Matched with version=%v initiator=%v",
79+
v, initiator)
80+
81+
break
82+
}
83+
}
84+
if found {
85+
break
86+
}
87+
}
88+
89+
require.True(t, found, "to_remote output not identified for "+
90+
"provided data")
91+
92+
// Log the results.
93+
printOutputClassification(lastClass, &tx)
94+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"remotePubkey": "029e5f4d86d9d6c845fbcf37b09ac7d59c25c19932ab34a2757e8ea88437a876c3",
3+
"transactionHex": "020000000001011f644a3f04139c2c3b1036f9deb924f7c8101e5825a2bf4a379579beea24bf320100000000b2448780044a010000000000002200202661eee6d24eaf71079b96f8df4dd88aa6280b61845dacdb10d8b0bcc51257af4a0100000000000022002074bcb8019840e0ac7abb16be6c8408fbbebd519cb86193965b33e8e69648865e971f0000000000002200209a1c8e727820d673859049f9305c02c39eb0a718f9219dc2e48a2621243d7dc8b8110c00000000002200205a596aa125a8a39e73f70dcf279cb06295eed49950c9e1f239b47ce41ab0e9320400483045022100ef18b0fe8d34f21ef13316d03cbb72445b61033489a8df81f163ebd60f430637022075a25aa0dc0a08e361540bd831430fc816b0a4ca9ca0169fb95de4a64c297cde01483045022100f8d7b5eee968157f0e06a65c389b6d1f5ca68a3189440b7638ab341c5ac77fdd022069db71847c48b1f762242b99b2fa254b1bce8f44a160293fe3b36ed2d2e32f650147522103ae9df242881bb10a2400e7812fc8cfe437f0f869538584d39d96f52cb2dbaf622103e71742ef40d136884a1f7368fb096cc5897fd697b41a3b481def37b60188c49152aebf573f20"
4+
}
5+

0 commit comments

Comments
 (0)