Skip to content

Commit e42b598

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

File tree

3 files changed

+280
-1
lines changed

3 files changed

+280
-1
lines changed

cmd/chantools/scbforceclose.go

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

0 commit comments

Comments
 (0)