Skip to content

Commit 099ca6c

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

File tree

3 files changed

+265
-1
lines changed

3 files changed

+265
-1
lines changed

cmd/chantools/scbforceclose.go

Lines changed: 166 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,154 @@ 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 int
276+
ToRemoteAmt int64
277+
ToRemotePkScript []byte
278+
AnchorIdxs []int
279+
OtherIdxs []int
280+
}
281+
282+
// classifyOutputs attempts to identify the to_remote output and classify the
283+
// other outputs into anchors and to_local/htlc.
284+
func classifyOutputs(s chanbackup.Single, tx *wire.MsgTx) (outputClassification,
285+
error) {
286+
287+
// Best-effort get the remote key used for to_remote.
288+
remoteDesc := s.RemoteChanCfg.PaymentBasePoint
289+
remoteKey := remoteDesc.PubKey
290+
291+
// Compute the expected to_remote pkScript.
292+
var toRemotePkScript []byte
293+
if remoteKey != nil {
294+
chanType, err := chanTypeFromBackupVersion(s.Version)
295+
if err != nil {
296+
return outputClassification{}, fmt.Errorf("failed to "+
297+
"get channel type: %w", err)
298+
}
299+
desc, _, err := lnwallet.CommitScriptToRemote(
300+
chanType, s.IsInitiator, remoteKey,
301+
s.LeaseExpiry,
302+
input.NoneTapLeaf(),
303+
)
304+
if err != nil {
305+
return outputClassification{}, fmt.Errorf("failed to "+
306+
"get commit script to remote: %w", err)
307+
}
308+
toRemotePkScript = desc.PkScript()
309+
}
310+
311+
// anchorSats is anchor output value in sats.
312+
const anchorSats = 330
313+
314+
result := outputClassification{
315+
ToRemoteIdx: -1,
316+
ToRemotePkScript: toRemotePkScript,
317+
}
318+
319+
// First pass: find to_remote by script match.
320+
for idx, out := range tx.TxOut {
321+
if len(toRemotePkScript) != 0 &&
322+
bytes.Equal(out.PkScript, toRemotePkScript) {
323+
324+
result.ToRemoteIdx = idx
325+
result.ToRemoteAmt = out.Value
326+
break
327+
}
328+
}
329+
330+
// Second pass: classify anchors and the rest.
331+
for idx, out := range tx.TxOut {
332+
if idx == result.ToRemoteIdx {
333+
continue
334+
}
335+
if out.Value == anchorSats {
336+
result.AnchorIdxs = append(result.AnchorIdxs, idx)
337+
} else {
338+
result.OtherIdxs = append(result.OtherIdxs, idx)
339+
}
340+
}
341+
342+
return result, nil
343+
}
344+
345+
// chanTypeFromBackupVersion maps a backup SingleBackupVersion to an approximate
346+
// channeldb.ChannelType sufficient for deriving to_remote scripts.
347+
func chanTypeFromBackupVersion(v chanbackup.SingleBackupVersion) (
348+
channeldb.ChannelType, error) {
349+
350+
var chanType channeldb.ChannelType
351+
switch v {
352+
case chanbackup.DefaultSingleVersion:
353+
chanType = channeldb.SingleFunderBit
354+
355+
case chanbackup.TweaklessCommitVersion:
356+
chanType = channeldb.SingleFunderTweaklessBit
357+
358+
case chanbackup.AnchorsCommitVersion:
359+
chanType = channeldb.AnchorOutputsBit
360+
chanType |= channeldb.SingleFunderTweaklessBit
361+
362+
case chanbackup.AnchorsZeroFeeHtlcTxCommitVersion:
363+
chanType = channeldb.ZeroHtlcTxFeeBit
364+
chanType |= channeldb.AnchorOutputsBit
365+
chanType |= channeldb.SingleFunderTweaklessBit
366+
367+
case chanbackup.ScriptEnforcedLeaseVersion:
368+
chanType = channeldb.LeaseExpirationBit
369+
chanType |= channeldb.ZeroHtlcTxFeeBit
370+
chanType |= channeldb.AnchorOutputsBit
371+
chanType |= channeldb.SingleFunderTweaklessBit
372+
373+
case chanbackup.SimpleTaprootVersion:
374+
chanType = channeldb.ZeroHtlcTxFeeBit
375+
chanType |= channeldb.AnchorOutputsBit
376+
chanType |= channeldb.SingleFunderTweaklessBit
377+
chanType |= channeldb.SimpleTaprootFeatureBit
378+
379+
case chanbackup.TapscriptRootVersion:
380+
chanType = channeldb.ZeroHtlcTxFeeBit
381+
chanType |= channeldb.AnchorOutputsBit
382+
chanType |= channeldb.SingleFunderTweaklessBit
383+
chanType |= channeldb.SimpleTaprootFeatureBit
384+
chanType |= channeldb.TapscriptRootBit
385+
386+
default:
387+
return 0, fmt.Errorf("unknown Single version: %v", v)
388+
}
389+
390+
return chanType, nil
391+
}
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)