Skip to content

chantools scbforceclose: extract close tx from SCB and sign it #95

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

Merged
merged 6 commits into from
Mar 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions cmd/chantools/forceclose.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ import (
"github.com/spf13/cobra"
)

const forceCloseWarning = `
If you are certain that a node is offline for good (AFTER you've tried SCB!)
and a channel is still open, you can use this method to force-close your
latest state that you have in your channel.db.

**!!! WARNING !!! DANGER !!! WARNING !!!**

If you do this and the state that you publish is *not* the latest state, then
the remote node *could* punish you by taking the whole channel amount *if* they
come online before you can sweep the funds from the time locked (144 - 2000
blocks) transaction *or* they have a watch tower looking out for them.

**This should absolutely be the last resort and you have been warned!**`

type forceCloseCommand struct {
APIURL string
ChannelDB string
Expand All @@ -35,18 +49,7 @@ func newForceCloseCommand() *cobra.Command {
Use: "forceclose",
Short: "Force-close the last state that is in the channel.db " +
"provided",
Long: `If you are certain that a node is offline for good (AFTER
you've tried SCB!) and a channel is still open, you can use this method to
force-close your latest state that you have in your channel.db.

**!!! WARNING !!! DANGER !!! WARNING !!!**

If you do this and the state that you publish is *not* the latest state, then
the remote node *could* punish you by taking the whole channel amount *if* they
come online before you can sweep the funds from the time locked (144 - 2000
blocks) transaction *or* they have a watch tower looking out for them.

**This should absolutely be the last resort and you have been warned!**`,
Long: forceCloseWarning,
Example: `chantools forceclose \
--fromsummary results/summary-xxxx-yyyy.json
--channeldb ~/.lnd/data/graph/mainnet/channel.db \
Expand Down
1 change: 1 addition & 0 deletions cmd/chantools/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ func main() {
newFilterBackupCommand(),
newFixOldBackupCommand(),
newForceCloseCommand(),
newScbForceCloseCommand(),
newGenImportScriptCommand(),
newMigrateDBCommand(),
newPullAnchorCommand(),
Expand Down
226 changes: 226 additions & 0 deletions cmd/chantools/scbforceclose.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
package main

import (
"bytes"
"encoding/hex"
"errors"
"fmt"
"os"
"strings"

"github.com/lightninglabs/chantools/btc"
"github.com/lightninglabs/chantools/lnd"
"github.com/lightninglabs/chantools/scbforceclose"
"github.com/lightningnetwork/lnd/chanbackup"
"github.com/lightningnetwork/lnd/input"
"github.com/spf13/cobra"
)

type scbForceCloseCommand struct {
APIURL string
Publish bool

// channel.backup.
SingleBackup string
SingleFile string
MultiBackup string
MultiFile string

rootKey *rootKey
cmd *cobra.Command
}

func newScbForceCloseCommand() *cobra.Command {
cc := &scbForceCloseCommand{}
cc.cmd = &cobra.Command{
Use: "scbforceclose",
Short: "Force-close the last state that is in the SCB " +
"provided",
Long: forceCloseWarning,
Example: `chantools scbforceclose --multi_file channel.backup`,
RunE: cc.Execute,
}
cc.cmd.Flags().StringVar(
&cc.APIURL, "apiurl", defaultAPIURL, "API URL to use (must "+
"be esplora compatible)",
)

cc.cmd.Flags().StringVar(
&cc.SingleBackup, "single_backup", "", "a hex encoded single "+
"channel backup obtained from exportchanbackup for "+
"force-closing channels",
)
cc.cmd.Flags().StringVar(
&cc.MultiBackup, "multi_backup", "", "a hex encoded "+
"multi-channel backup obtained from exportchanbackup "+
"for force-closing channels",
)
cc.cmd.Flags().StringVar(
&cc.SingleFile, "single_file", "", "the path to a "+
"single-channel backup file",
)
cc.cmd.Flags().StringVar(
&cc.MultiFile, "multi_file", "", "the path to a "+
"single-channel backup file (channel.backup)",
)

cc.cmd.Flags().BoolVar(
&cc.Publish, "publish", false, "publish force-closing TX to "+
"the chain API instead of just printing the TX",
)

cc.rootKey = newRootKey(cc.cmd, "decrypting the backup and signing tx")

return cc.cmd
}

func (c *scbForceCloseCommand) Execute(_ *cobra.Command, _ []string) error {
extendedKey, err := c.rootKey.read()
if err != nil {
return fmt.Errorf("error reading root key: %w", err)
}

api := &btc.ExplorerAPI{BaseURL: c.APIURL}

keyRing := &lnd.HDKeyRing{
ExtendedKey: extendedKey,
ChainParams: chainParams,
}

signer := &lnd.Signer{
ExtendedKey: extendedKey,
ChainParams: chainParams,
}
signer.MusigSessionManager = input.NewMusigSessionManager(
signer.FetchPrivateKey,
)

var backups []chanbackup.Single
if c.SingleBackup != "" || c.SingleFile != "" {
if c.SingleBackup != "" && c.SingleFile != "" {
return errors.New("must not pass --single_backup and " +
"--single_file together")
}
var singleBackupBytes []byte
if c.SingleBackup != "" {
singleBackupBytes, err = hex.DecodeString(
c.SingleBackup,
)
} else if c.SingleFile != "" {
singleBackupBytes, err = os.ReadFile(c.SingleFile)
}
if err != nil {
return fmt.Errorf("failed to get single backup: %w",
err)
}
var s chanbackup.Single
r := bytes.NewReader(singleBackupBytes)
if err := s.UnpackFromReader(r, keyRing); err != nil {
return fmt.Errorf("failed to unpack single backup: %w",
err)
}
backups = append(backups, s)
}
if c.MultiBackup != "" || c.MultiFile != "" {
if len(backups) != 0 {
return errors.New("must not pass single and multi " +
"backups together")
}
if c.MultiBackup != "" && c.MultiFile != "" {
return errors.New("must not pass --multi_backup and " +
"--multi_file together")
}
var multiBackupBytes []byte
if c.MultiBackup != "" {
multiBackupBytes, err = hex.DecodeString(c.MultiBackup)
} else if c.MultiFile != "" {
multiBackupBytes, err = os.ReadFile(c.MultiFile)
}
if err != nil {
return fmt.Errorf("failed to get multi backup: %w", err)
}
var m chanbackup.Multi
r := bytes.NewReader(multiBackupBytes)
if err := m.UnpackFromReader(r, keyRing); err != nil {
return fmt.Errorf("failed to unpack multi backup: %w",
err)
}
backups = append(backups, m.StaticBackups...)
}

backupsWithInputs := make([]chanbackup.Single, 0, len(backups))
for _, s := range backups {
if s.CloseTxInputs.IsSome() {
backupsWithInputs = append(backupsWithInputs, s)
}
}

fmt.Println()
fmt.Printf("Found %d channel backups, %d of them have close tx.\n",
len(backups), len(backupsWithInputs))

if len(backupsWithInputs) == 0 {
fmt.Println("No channel backups that can be used for force " +
"close.")
return nil
}

fmt.Println()
fmt.Println("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
fmt.Println(strings.TrimSpace(forceCloseWarning))
fmt.Println("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
fmt.Println()

fmt.Printf("Type YES to proceed: ")
var userInput string
if _, err := fmt.Scan(&userInput); err != nil {
return errors.New("failed to read user input")
}
if strings.TrimSpace(userInput) != "YES" {
return errors.New("canceled by user, must type uppercase 'YES'")
}

if c.Publish {
fmt.Println("Signed transactions will be broadcasted " +
"automatically.")
fmt.Printf("Type YES again to proceed: ")
if _, err := fmt.Scan(&userInput); err != nil {
return errors.New("failed to read user input")
}
if strings.TrimSpace(userInput) != "YES" {
return errors.New("canceled by user, must type " +
"uppercase 'YES'")
}
}

for _, s := range backupsWithInputs {
signedTx, err := scbforceclose.SignCloseTx(
s, keyRing, signer, signer,
)
if err != nil {
return fmt.Errorf("signCloseTx failed for %s: %w",
s.FundingOutpoint, err)
}
var buf bytes.Buffer
if err := signedTx.Serialize(&buf); err != nil {
return fmt.Errorf("failed to serialize signed %s: %w",
s.FundingOutpoint, err)
}
txHex := hex.EncodeToString(buf.Bytes())
fmt.Println("Channel point:", s.FundingOutpoint)
fmt.Println("Raw transaction hex:", txHex)
fmt.Println()

// Publish TX.
if c.Publish {
response, err := api.PublishTx(txHex)
if err != nil {
return err
}
log.Infof("Published TX %s, response: %s",
signedTx.TxHash(), response)
}
}

return nil
}
43 changes: 43 additions & 0 deletions dump/dump.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ type BackupSingle struct {
LocalChanCfg ChannelConfig
RemoteChanCfg ChannelConfig
ShaChainRootDesc KeyDescriptor
CloseTxInputs *CloseTxInputs
}

// CloseTxInputs is a struct that contains data needed to produce a force close
// transaction from a channel backup as a last resort recovery method.
type CloseTxInputs struct {
CommitTx string
CommitSig string
CommitHeight uint64
TapscriptRoot string
}

// OpenChannel is the information we want to dump from an open channel in lnd's
Expand Down Expand Up @@ -406,7 +416,40 @@ func BackupDump(multi *chanbackup.Multi,
params, single.ShaChainRootDesc,
),
}

single.CloseTxInputs.WhenSome(
func(inputs chanbackup.CloseTxInputs) {
// Serialize unsigned transaction.
var buf bytes.Buffer
err := inputs.CommitTx.Serialize(&buf)
if err != nil {
buf.WriteString("error serializing " +
"commit tx: " + err.Error())
}
tx := buf.Bytes()

// Serialize TapscriptRoot if present.
var tapscriptRoot string
inputs.TapscriptRoot.WhenSome(
func(tr chainhash.Hash) {
tapscriptRoot = tr.String()
},
)

// Put all CloseTxInputs to dump in human
// readable form.
dumpSingles[idx].CloseTxInputs = &CloseTxInputs{
CommitTx: hex.EncodeToString(tx),
CommitSig: hex.EncodeToString(
inputs.CommitSig,
),
CommitHeight: inputs.CommitHeight,
TapscriptRoot: tapscriptRoot,
}
},
)
}

return dumpSingles
}

Expand Down
25 changes: 24 additions & 1 deletion lnd/signer.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import (
)

type Signer struct {
input.MockSigner
*input.MusigSessionManager

ExtendedKey *hdkeychain.ExtendedKey
ChainParams *chaincfg.Params
Expand Down Expand Up @@ -308,3 +308,26 @@ func ECDH(privKey *btcec.PrivateKey, pub *btcec.PublicKey) ([32]byte, error) {
sPubKey := btcec.NewPublicKey(&s.X, &s.Y)
return sha256.Sum256(sPubKey.SerializeCompressed()), nil
}

// ECDH performs a scalar multiplication (ECDH-like operation) between
// the target key descriptor and remote public key. The output
// returned will be the sha256 of the resulting shared point serialized
// in compressed format. If k is our private key, and P is the public
// key, we perform the following operation:
//
// sx := k*P
// s := sha256(sx.SerializeCompressed())
//
// NOTE: This is part of the keychain.ECDHRing interface.
func (s *Signer) ECDH(keyDesc keychain.KeyDescriptor, pubKey *btcec.PublicKey) (
[32]byte, error) {

// First, derive the private key.
privKey, err := s.FetchPrivateKey(&keyDesc)
if err != nil {
return [32]byte{}, fmt.Errorf("failed to derive the private "+
"key: %w", err)
}

return ECDH(privKey, pubKey)
}
Loading