Skip to content

Commit 2db891c

Browse files
authored
Merge pull request #95 from starius/close-tx-in-static-backup
chantools scbforceclose: extract close tx from SCB and sign it
2 parents bd02855 + 63e1e5f commit 2db891c

File tree

8 files changed

+661
-13
lines changed

8 files changed

+661
-13
lines changed

cmd/chantools/forceclose.go

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@ import (
1919
"github.com/spf13/cobra"
2020
)
2121

22+
const forceCloseWarning = `
23+
If you are certain that a node is offline for good (AFTER you've tried SCB!)
24+
and a channel is still open, you can use this method to force-close your
25+
latest state that you have in your channel.db.
26+
27+
**!!! WARNING !!! DANGER !!! WARNING !!!**
28+
29+
If you do this and the state that you publish is *not* the latest state, then
30+
the remote node *could* punish you by taking the whole channel amount *if* they
31+
come online before you can sweep the funds from the time locked (144 - 2000
32+
blocks) transaction *or* they have a watch tower looking out for them.
33+
34+
**This should absolutely be the last resort and you have been warned!**`
35+
2236
type forceCloseCommand struct {
2337
APIURL string
2438
ChannelDB string
@@ -35,18 +49,7 @@ func newForceCloseCommand() *cobra.Command {
3549
Use: "forceclose",
3650
Short: "Force-close the last state that is in the channel.db " +
3751
"provided",
38-
Long: `If you are certain that a node is offline for good (AFTER
39-
you've tried SCB!) and a channel is still open, you can use this method to
40-
force-close your latest state that you have in your channel.db.
41-
42-
**!!! WARNING !!! DANGER !!! WARNING !!!**
43-
44-
If you do this and the state that you publish is *not* the latest state, then
45-
the remote node *could* punish you by taking the whole channel amount *if* they
46-
come online before you can sweep the funds from the time locked (144 - 2000
47-
blocks) transaction *or* they have a watch tower looking out for them.
48-
49-
**This should absolutely be the last resort and you have been warned!**`,
52+
Long: forceCloseWarning,
5053
Example: `chantools forceclose \
5154
--fromsummary results/summary-xxxx-yyyy.json
5255
--channeldb ~/.lnd/data/graph/mainnet/channel.db \

cmd/chantools/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ func main() {
121121
newFilterBackupCommand(),
122122
newFixOldBackupCommand(),
123123
newForceCloseCommand(),
124+
newScbForceCloseCommand(),
124125
newGenImportScriptCommand(),
125126
newMigrateDBCommand(),
126127
newPullAnchorCommand(),

cmd/chantools/scbforceclose.go

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/hex"
6+
"errors"
7+
"fmt"
8+
"os"
9+
"strings"
10+
11+
"github.com/lightninglabs/chantools/btc"
12+
"github.com/lightninglabs/chantools/lnd"
13+
"github.com/lightninglabs/chantools/scbforceclose"
14+
"github.com/lightningnetwork/lnd/chanbackup"
15+
"github.com/lightningnetwork/lnd/input"
16+
"github.com/spf13/cobra"
17+
)
18+
19+
type scbForceCloseCommand struct {
20+
APIURL string
21+
Publish bool
22+
23+
// channel.backup.
24+
SingleBackup string
25+
SingleFile string
26+
MultiBackup string
27+
MultiFile string
28+
29+
rootKey *rootKey
30+
cmd *cobra.Command
31+
}
32+
33+
func newScbForceCloseCommand() *cobra.Command {
34+
cc := &scbForceCloseCommand{}
35+
cc.cmd = &cobra.Command{
36+
Use: "scbforceclose",
37+
Short: "Force-close the last state that is in the SCB " +
38+
"provided",
39+
Long: forceCloseWarning,
40+
Example: `chantools scbforceclose --multi_file channel.backup`,
41+
RunE: cc.Execute,
42+
}
43+
cc.cmd.Flags().StringVar(
44+
&cc.APIURL, "apiurl", defaultAPIURL, "API URL to use (must "+
45+
"be esplora compatible)",
46+
)
47+
48+
cc.cmd.Flags().StringVar(
49+
&cc.SingleBackup, "single_backup", "", "a hex encoded single "+
50+
"channel backup obtained from exportchanbackup for "+
51+
"force-closing channels",
52+
)
53+
cc.cmd.Flags().StringVar(
54+
&cc.MultiBackup, "multi_backup", "", "a hex encoded "+
55+
"multi-channel backup obtained from exportchanbackup "+
56+
"for force-closing channels",
57+
)
58+
cc.cmd.Flags().StringVar(
59+
&cc.SingleFile, "single_file", "", "the path to a "+
60+
"single-channel backup file",
61+
)
62+
cc.cmd.Flags().StringVar(
63+
&cc.MultiFile, "multi_file", "", "the path to a "+
64+
"single-channel backup file (channel.backup)",
65+
)
66+
67+
cc.cmd.Flags().BoolVar(
68+
&cc.Publish, "publish", false, "publish force-closing TX to "+
69+
"the chain API instead of just printing the TX",
70+
)
71+
72+
cc.rootKey = newRootKey(cc.cmd, "decrypting the backup and signing tx")
73+
74+
return cc.cmd
75+
}
76+
77+
func (c *scbForceCloseCommand) Execute(_ *cobra.Command, _ []string) error {
78+
extendedKey, err := c.rootKey.read()
79+
if err != nil {
80+
return fmt.Errorf("error reading root key: %w", err)
81+
}
82+
83+
api := &btc.ExplorerAPI{BaseURL: c.APIURL}
84+
85+
keyRing := &lnd.HDKeyRing{
86+
ExtendedKey: extendedKey,
87+
ChainParams: chainParams,
88+
}
89+
90+
signer := &lnd.Signer{
91+
ExtendedKey: extendedKey,
92+
ChainParams: chainParams,
93+
}
94+
signer.MusigSessionManager = input.NewMusigSessionManager(
95+
signer.FetchPrivateKey,
96+
)
97+
98+
var backups []chanbackup.Single
99+
if c.SingleBackup != "" || c.SingleFile != "" {
100+
if c.SingleBackup != "" && c.SingleFile != "" {
101+
return errors.New("must not pass --single_backup and " +
102+
"--single_file together")
103+
}
104+
var singleBackupBytes []byte
105+
if c.SingleBackup != "" {
106+
singleBackupBytes, err = hex.DecodeString(
107+
c.SingleBackup,
108+
)
109+
} else if c.SingleFile != "" {
110+
singleBackupBytes, err = os.ReadFile(c.SingleFile)
111+
}
112+
if err != nil {
113+
return fmt.Errorf("failed to get single backup: %w",
114+
err)
115+
}
116+
var s chanbackup.Single
117+
r := bytes.NewReader(singleBackupBytes)
118+
if err := s.UnpackFromReader(r, keyRing); err != nil {
119+
return fmt.Errorf("failed to unpack single backup: %w",
120+
err)
121+
}
122+
backups = append(backups, s)
123+
}
124+
if c.MultiBackup != "" || c.MultiFile != "" {
125+
if len(backups) != 0 {
126+
return errors.New("must not pass single and multi " +
127+
"backups together")
128+
}
129+
if c.MultiBackup != "" && c.MultiFile != "" {
130+
return errors.New("must not pass --multi_backup and " +
131+
"--multi_file together")
132+
}
133+
var multiBackupBytes []byte
134+
if c.MultiBackup != "" {
135+
multiBackupBytes, err = hex.DecodeString(c.MultiBackup)
136+
} else if c.MultiFile != "" {
137+
multiBackupBytes, err = os.ReadFile(c.MultiFile)
138+
}
139+
if err != nil {
140+
return fmt.Errorf("failed to get multi backup: %w", err)
141+
}
142+
var m chanbackup.Multi
143+
r := bytes.NewReader(multiBackupBytes)
144+
if err := m.UnpackFromReader(r, keyRing); err != nil {
145+
return fmt.Errorf("failed to unpack multi backup: %w",
146+
err)
147+
}
148+
backups = append(backups, m.StaticBackups...)
149+
}
150+
151+
backupsWithInputs := make([]chanbackup.Single, 0, len(backups))
152+
for _, s := range backups {
153+
if s.CloseTxInputs.IsSome() {
154+
backupsWithInputs = append(backupsWithInputs, s)
155+
}
156+
}
157+
158+
fmt.Println()
159+
fmt.Printf("Found %d channel backups, %d of them have close tx.\n",
160+
len(backups), len(backupsWithInputs))
161+
162+
if len(backupsWithInputs) == 0 {
163+
fmt.Println("No channel backups that can be used for force " +
164+
"close.")
165+
return nil
166+
}
167+
168+
fmt.Println()
169+
fmt.Println("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
170+
fmt.Println(strings.TrimSpace(forceCloseWarning))
171+
fmt.Println("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
172+
fmt.Println()
173+
174+
fmt.Printf("Type YES to proceed: ")
175+
var userInput string
176+
if _, err := fmt.Scan(&userInput); err != nil {
177+
return errors.New("failed to read user input")
178+
}
179+
if strings.TrimSpace(userInput) != "YES" {
180+
return errors.New("canceled by user, must type uppercase 'YES'")
181+
}
182+
183+
if c.Publish {
184+
fmt.Println("Signed transactions will be broadcasted " +
185+
"automatically.")
186+
fmt.Printf("Type YES again to proceed: ")
187+
if _, err := fmt.Scan(&userInput); err != nil {
188+
return errors.New("failed to read user input")
189+
}
190+
if strings.TrimSpace(userInput) != "YES" {
191+
return errors.New("canceled by user, must type " +
192+
"uppercase 'YES'")
193+
}
194+
}
195+
196+
for _, s := range backupsWithInputs {
197+
signedTx, err := scbforceclose.SignCloseTx(
198+
s, keyRing, signer, signer,
199+
)
200+
if err != nil {
201+
return fmt.Errorf("signCloseTx failed for %s: %w",
202+
s.FundingOutpoint, err)
203+
}
204+
var buf bytes.Buffer
205+
if err := signedTx.Serialize(&buf); err != nil {
206+
return fmt.Errorf("failed to serialize signed %s: %w",
207+
s.FundingOutpoint, err)
208+
}
209+
txHex := hex.EncodeToString(buf.Bytes())
210+
fmt.Println("Channel point:", s.FundingOutpoint)
211+
fmt.Println("Raw transaction hex:", txHex)
212+
fmt.Println()
213+
214+
// Publish TX.
215+
if c.Publish {
216+
response, err := api.PublishTx(txHex)
217+
if err != nil {
218+
return err
219+
}
220+
log.Infof("Published TX %s, response: %s",
221+
signedTx.TxHash(), response)
222+
}
223+
}
224+
225+
return nil
226+
}

dump/dump.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ type BackupSingle struct {
4646
LocalChanCfg ChannelConfig
4747
RemoteChanCfg ChannelConfig
4848
ShaChainRootDesc KeyDescriptor
49+
CloseTxInputs *CloseTxInputs
50+
}
51+
52+
// CloseTxInputs is a struct that contains data needed to produce a force close
53+
// transaction from a channel backup as a last resort recovery method.
54+
type CloseTxInputs struct {
55+
CommitTx string
56+
CommitSig string
57+
CommitHeight uint64
58+
TapscriptRoot string
4959
}
5060

5161
// OpenChannel is the information we want to dump from an open channel in lnd's
@@ -406,7 +416,40 @@ func BackupDump(multi *chanbackup.Multi,
406416
params, single.ShaChainRootDesc,
407417
),
408418
}
419+
420+
single.CloseTxInputs.WhenSome(
421+
func(inputs chanbackup.CloseTxInputs) {
422+
// Serialize unsigned transaction.
423+
var buf bytes.Buffer
424+
err := inputs.CommitTx.Serialize(&buf)
425+
if err != nil {
426+
buf.WriteString("error serializing " +
427+
"commit tx: " + err.Error())
428+
}
429+
tx := buf.Bytes()
430+
431+
// Serialize TapscriptRoot if present.
432+
var tapscriptRoot string
433+
inputs.TapscriptRoot.WhenSome(
434+
func(tr chainhash.Hash) {
435+
tapscriptRoot = tr.String()
436+
},
437+
)
438+
439+
// Put all CloseTxInputs to dump in human
440+
// readable form.
441+
dumpSingles[idx].CloseTxInputs = &CloseTxInputs{
442+
CommitTx: hex.EncodeToString(tx),
443+
CommitSig: hex.EncodeToString(
444+
inputs.CommitSig,
445+
),
446+
CommitHeight: inputs.CommitHeight,
447+
TapscriptRoot: tapscriptRoot,
448+
}
449+
},
450+
)
409451
}
452+
410453
return dumpSingles
411454
}
412455

lnd/signer.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import (
2121
)
2222

2323
type Signer struct {
24-
input.MockSigner
24+
*input.MusigSessionManager
2525

2626
ExtendedKey *hdkeychain.ExtendedKey
2727
ChainParams *chaincfg.Params
@@ -308,3 +308,26 @@ func ECDH(privKey *btcec.PrivateKey, pub *btcec.PublicKey) ([32]byte, error) {
308308
sPubKey := btcec.NewPublicKey(&s.X, &s.Y)
309309
return sha256.Sum256(sPubKey.SerializeCompressed()), nil
310310
}
311+
312+
// ECDH performs a scalar multiplication (ECDH-like operation) between
313+
// the target key descriptor and remote public key. The output
314+
// returned will be the sha256 of the resulting shared point serialized
315+
// in compressed format. If k is our private key, and P is the public
316+
// key, we perform the following operation:
317+
//
318+
// sx := k*P
319+
// s := sha256(sx.SerializeCompressed())
320+
//
321+
// NOTE: This is part of the keychain.ECDHRing interface.
322+
func (s *Signer) ECDH(keyDesc keychain.KeyDescriptor, pubKey *btcec.PublicKey) (
323+
[32]byte, error) {
324+
325+
// First, derive the private key.
326+
privKey, err := s.FetchPrivateKey(&keyDesc)
327+
if err != nil {
328+
return [32]byte{}, fmt.Errorf("failed to derive the private "+
329+
"key: %w", err)
330+
}
331+
332+
return ECDH(privKey, pubKey)
333+
}

0 commit comments

Comments
 (0)