From a1aec882685475d9adbe1371250f65322943a484 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Wed, 15 Nov 2023 00:59:34 +0100 Subject: [PATCH 01/13] [lncli] exportchanbackup single channel in hex It used to be base64, which is not compatible with verifychanbackup, expecting hex. --- cmd/commands/commands.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/commands/commands.go b/cmd/commands/commands.go index 2c1191ab2b..38d480a3eb 100644 --- a/cmd/commands/commands.go +++ b/cmd/commands/commands.go @@ -2679,10 +2679,10 @@ func exportChanBackup(ctx *cli.Context) error { printJSON(struct { ChanPoint string `json:"chan_point"` - ChanBackup []byte `json:"chan_backup"` + ChanBackup string `json:"chan_backup"` }{ ChanPoint: chanPoint.String(), - ChanBackup: chanBackup.ChanBackup, + ChanBackup: hex.EncodeToString(chanBackup.ChanBackup), }) return nil } From e04aaa0de0561fc402c3e84cb077ec13dd3f7569 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sat, 30 Dec 2023 09:43:52 -0300 Subject: [PATCH 02/13] chanbackup: test encoding of taproot channel --- chanbackup/single_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/chanbackup/single_test.go b/chanbackup/single_test.go index c1e940740c..a2d44027b4 100644 --- a/chanbackup/single_test.go +++ b/chanbackup/single_test.go @@ -250,6 +250,13 @@ func TestSinglePackUnpack(t *testing.T) { valid: true, }, + // The new taproot channel lease version should + // pack/unpack with no problem. + { + version: SimpleTaprootVersion, + valid: true, + }, + // A non-default version, atm this should result in a failure. { version: 99, From e7776a4c1ed3b5c205133469de6fe7f0055af0ef Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sun, 15 Oct 2023 15:49:02 +0200 Subject: [PATCH 03/13] contractcourt: fix doc of commitSweepResolver It is used for sweeping time-locked outputs as well as non time-locked outputs. --- contractcourt/commit_sweep_resolver.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/contractcourt/commit_sweep_resolver.go b/contractcourt/commit_sweep_resolver.go index 8f11695801..4b47a34294 100644 --- a/contractcourt/commit_sweep_resolver.go +++ b/contractcourt/commit_sweep_resolver.go @@ -20,9 +20,15 @@ import ( ) // commitSweepResolver is a resolver that will attempt to sweep the commitment -// output paying to us, in the case that the remote party broadcasts their -// version of the commitment transaction. We can sweep this output immediately, -// as it doesn't have a time-lock delay. +// output paying to us (local channel balance). In the case that the local +// party (we) broadcasts their version of the commitment transaction, we have +// to wait before sweeping it, as it has a CSV delay. For anchor channel +// type, even if the remote party broadcasts the commitment transaction, +// we have to wait one block after commitment transaction is confirmed, +// because CSV 1 is put into the script of UTXO representing local balance. +// Additionally, if the channel is a channel lease, we have to wait for +// CLTV to expire. +// https://docs.lightning.engineering/lightning-network-tools/pool/overview type commitSweepResolver struct { // localChanCfg is used to provide the resolver with the keys required // to identify whether the commitment transaction was broadcast by the From 90c45ddd8ffd0580e8ded3f1d0c9bb8e52636dca Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sun, 31 Dec 2023 16:00:01 +0100 Subject: [PATCH 04/13] lnwallet: factor out func GetSignedCommitTx This pure function creates signed commit transaction, using various inputs passed as struct TaprootSignedCommitTxInputs and a signer. This is needed to be able to store the inputs without a signature in SCB and sign the transaction in chantools scbforceclose. See https://github.com/lightningnetwork/lnd/pull/8183/files#r1423959791 --- lnwallet/channel.go | 126 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 104 insertions(+), 22 deletions(-) diff --git a/lnwallet/channel.go b/lnwallet/channel.go index f3e0769506..aea4719b83 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -6433,29 +6433,84 @@ func (lc *LightningChannel) AbsoluteThawHeight() (uint32, error) { return lc.channelState.AbsoluteThawHeight() } -// getSignedCommitTx function take the latest commitment transaction and -// populate it with witness data. -func (lc *LightningChannel) getSignedCommitTx() (*wire.MsgTx, error) { - // Fetch the current commitment transaction, along with their signature - // for the transaction. - localCommit := lc.channelState.LocalCommitment - commitTx := localCommit.CommitTx.Copy() +// SignedCommitTxInputs contains data needed to create a signed commit +// transaction using a signer. See GetSignedCommitTx. +type SignedCommitTxInputs struct { + // CommitTx is the latest version of the commitment state, broadcast + // able by us. + CommitTx *wire.MsgTx + + // CommitSig is one half of the signature required to fully complete + // the script for the commitment transaction above. This is the + // signature signed by the remote party for our version of the + // commitment transactions. + CommitSig []byte + + // OurKey is our key to be used within the 2-of-2 output script + // for the owner of this channel. + OurKey keychain.KeyDescriptor + + // TheirKey is their key to be used within the 2-of-2 output script + // for the owner of this channel. + TheirKey keychain.KeyDescriptor + + // SignDesc is the primary sign descriptor that is capable of signing + // the commitment transaction that spends the multi-sig output. + SignDesc *input.SignDescriptor + + // Taproot holds fields needed in case of a taproot channel. + // Iff the channel is of taproot type, this field is filled. + Taproot fn.Option[TaprootSignedCommitTxInputs] +} + +// TaprootSignedCommitTxInputs contains additional data needed to create a +// signed commit transaction using a signer, used in case of a taproot channel. +// See GetSignedCommitTx. +type TaprootSignedCommitTxInputs struct { + // CommitHeight is the update number that this channel state represents. + // It is the total number of commitment updates up to this point. This + // can be viewed as sort of a "commitment height" as this number is + // monotonically increasing. This number is used to make a signature + // for a taproot channel, since it is used by shachain nonce producer + // (TaprootNonceProducer). + CommitHeight uint64 + + // TaprootNonceProducer is used to generate a shachain tree for the + // purpose of generating verification nonces for taproot channels. + TaprootNonceProducer shachain.Producer - ourKey := lc.channelState.LocalChanCfg.MultiSigKey - theirKey := lc.channelState.RemoteChanCfg.MultiSigKey + // TapscriptRoot is the root of the tapscript tree that will be used to + // create the funding output. This is an optional field that should + // only be set for taproot channels. + TapscriptRoot fn.Option[chainhash.Hash] +} + +// GetSignedCommitTx creates the witness stack of a channel commitment +// transaction. It can handle all commitment types (taproot, legacy). It is +// exported to give outside tooling the possibility to recreate the witness. +// A key use case is generating the witness data for a commitment transaction +// from a Static Channel Backup (SCB). +func GetSignedCommitTx(inputs SignedCommitTxInputs, + signer input.Signer) (*wire.MsgTx, error) { + + commitTx := inputs.CommitTx.Copy() var witness wire.TxWitness switch { // If this is a taproot channel, then we'll need to re-derive the nonce // we need to generate a new signature - case lc.channelState.ChanType.IsTaproot(): + case inputs.Taproot.IsSome(): + // Extract Taproot from fn.Option. It is safe to call + // UnsafeFromSome because we just checked that it is some. + taproot := inputs.Taproot.UnsafeFromSome() + // First, we'll need to re-derive the local nonce we sent to // the remote party to create this musig session. We pass in // the same height here as we're generating the nonce needed // for the _current_ state. localNonce, err := channeldb.NewMusigVerificationNonce( - ourKey.PubKey, lc.currentHeight, - lc.taprootNonceProducer, + inputs.OurKey.PubKey, taproot.CommitHeight, + taproot.TaprootNonceProducer, ) if err != nil { return nil, fmt.Errorf("unable to re-derive "+ @@ -6463,19 +6518,20 @@ func (lc *LightningChannel) getSignedCommitTx() (*wire.MsgTx, error) { } tapscriptTweak := fn.MapOption(TapscriptRootToTweak)( - lc.channelState.TapscriptRoot, + taproot.TapscriptRoot, ) // Now that we have the local nonce, we'll re-create the musig // session we had for this height. musigSession := NewPartialMusigSession( - *localNonce, ourKey, theirKey, lc.Signer, - &lc.fundingOutput, LocalMusigCommit, tapscriptTweak, + *localNonce, inputs.OurKey, inputs.TheirKey, signer, + inputs.SignDesc.Output, LocalMusigCommit, + tapscriptTweak, ) var remoteSig lnwire.PartialSigWithNonce err = remoteSig.Decode( - bytes.NewReader(localCommit.CommitSig), + bytes.NewReader(inputs.CommitSig), ) if err != nil { return nil, fmt.Errorf("unable to decode remote "+ @@ -6521,15 +6577,15 @@ func (lc *LightningChannel) getSignedCommitTx() (*wire.MsgTx, error) { // Otherwise, the final witness we generate will be a normal p2wsh // multi-sig spend. default: - theirSig, err := ecdsa.ParseDERSignature(localCommit.CommitSig) + theirSig, err := ecdsa.ParseDERSignature(inputs.CommitSig) if err != nil { return nil, err } // With this, we then generate the full witness so the caller // can broadcast a fully signed transaction. - lc.signDesc.SigHashes = input.NewTxSigHashesV0Only(commitTx) - ourSig, err := lc.Signer.SignOutputRaw(commitTx, lc.signDesc) + inputs.SignDesc.SigHashes = input.NewTxSigHashesV0Only(commitTx) + ourSig, err := signer.SignOutputRaw(commitTx, inputs.SignDesc) if err != nil { return nil, err } @@ -6537,9 +6593,9 @@ func (lc *LightningChannel) getSignedCommitTx() (*wire.MsgTx, error) { // With the final signature generated, create the witness stack // required to spend from the multi-sig output. witness = input.SpendMultiSig( - lc.signDesc.WitnessScript, - ourKey.PubKey.SerializeCompressed(), ourSig, - theirKey.PubKey.SerializeCompressed(), theirSig, + inputs.SignDesc.WitnessScript, + inputs.OurKey.PubKey.SerializeCompressed(), ourSig, + inputs.TheirKey.PubKey.SerializeCompressed(), theirSig, ) } @@ -6548,6 +6604,32 @@ func (lc *LightningChannel) getSignedCommitTx() (*wire.MsgTx, error) { return commitTx, nil } +// getSignedCommitTx method takes the latest commitment transaction and +// populates it with witness data. +func (lc *LightningChannel) getSignedCommitTx() (*wire.MsgTx, error) { + // Fetch the current commitment transaction, along with their signature + // for the transaction. + localCommit := lc.channelState.LocalCommitment + + inputs := SignedCommitTxInputs{ + CommitTx: localCommit.CommitTx, + CommitSig: localCommit.CommitSig, + OurKey: lc.channelState.LocalChanCfg.MultiSigKey, + TheirKey: lc.channelState.RemoteChanCfg.MultiSigKey, + SignDesc: lc.signDesc, + } + + if lc.channelState.ChanType.IsTaproot() { + inputs.Taproot = fn.Some(TaprootSignedCommitTxInputs{ + CommitHeight: lc.currentHeight, + TaprootNonceProducer: lc.taprootNonceProducer, + TapscriptRoot: lc.channelState.TapscriptRoot, + }) + } + + return GetSignedCommitTx(inputs, lc.Signer) +} + // CommitOutputResolution carries the necessary information required to allow // us to sweep our commitment output in the case that either party goes to // chain. From f485e079b71e96e69d62f06736c1b70afe9c98e9 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 16 Sep 2024 21:13:57 -0300 Subject: [PATCH 05/13] chanbackup: add backup version for TapscriptRoot Previous to this change taproot assets channels and simple taproot channels were considered the same in the context of chanbackup package, since they stored the same data. In the following commits we are adding the data needed to produce a signed commitment transaction from a SCB file and in order to do that we need to add more fields and a custom channel gets one additional field (TapscriptRoot) compared to a simple taproot channel. So now we have to distinguish these kinds of channels in chanbackup package. See PR https://github.com/lightningnetwork/lnd/pull/8183 for more details. --- chanbackup/single.go | 12 +++++++++++- chanbackup/single_test.go | 9 ++++++++- chanrestore.go | 7 +++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/chanbackup/single.go b/chanbackup/single.go index dd63bf1f65..47a4b50545 100644 --- a/chanbackup/single.go +++ b/chanbackup/single.go @@ -52,6 +52,10 @@ const ( // SimpleTaprootVersion is a version that denotes this channel is using // the musig2 based taproot commitment format. SimpleTaprootVersion = 5 + + // TapscriptRootVersion is a version that denotes this is a MuSig2 + // channel with a top level tapscript commitment. + TapscriptRootVersion = 6 ) // Single is a static description of an existing channel that can be used for @@ -218,7 +222,11 @@ func NewSingle(channel *channeldb.OpenChannel, switch { case channel.ChanType.IsTaproot(): - single.Version = SimpleTaprootVersion + if channel.ChanType.HasTapscriptRoot() { + single.Version = TapscriptRootVersion + } else { + single.Version = SimpleTaprootVersion + } case channel.ChanType.HasLeaseExpiration(): single.Version = ScriptEnforcedLeaseVersion @@ -252,6 +260,7 @@ func (s *Single) Serialize(w io.Writer) error { case AnchorsZeroFeeHtlcTxCommitVersion: case ScriptEnforcedLeaseVersion: case SimpleTaprootVersion: + case TapscriptRootVersion: default: return fmt.Errorf("unable to serialize w/ unknown "+ "version: %v", s.Version) @@ -429,6 +438,7 @@ func (s *Single) Deserialize(r io.Reader) error { case AnchorsZeroFeeHtlcTxCommitVersion: case ScriptEnforcedLeaseVersion: case SimpleTaprootVersion: + case TapscriptRootVersion: default: return fmt.Errorf("unable to de-serialize w/ unknown "+ "version: %v", s.Version) diff --git a/chanbackup/single_test.go b/chanbackup/single_test.go index a2d44027b4..68ce35c56c 100644 --- a/chanbackup/single_test.go +++ b/chanbackup/single_test.go @@ -250,13 +250,20 @@ func TestSinglePackUnpack(t *testing.T) { valid: true, }, - // The new taproot channel lease version should + // The new taproot channel version should // pack/unpack with no problem. { version: SimpleTaprootVersion, valid: true, }, + // The new tapscript root channel version should pack/unpack + // with no problem. + { + version: TapscriptRootVersion, + valid: true, + }, + // A non-default version, atm this should result in a failure. { version: 99, diff --git a/chanrestore.go b/chanrestore.go index 27f6d6d9e0..5b221c105a 100644 --- a/chanrestore.go +++ b/chanrestore.go @@ -162,6 +162,13 @@ func (c *chanDBRestorer) openChannelShell(backup chanbackup.Single) ( chanType |= channeldb.SingleFunderTweaklessBit chanType |= channeldb.SimpleTaprootFeatureBit + case chanbackup.TapscriptRootVersion: + chanType = channeldb.ZeroHtlcTxFeeBit + chanType |= channeldb.AnchorOutputsBit + chanType |= channeldb.SingleFunderTweaklessBit + chanType |= channeldb.SimpleTaprootFeatureBit + chanType |= channeldb.TapscriptRootBit + default: return nil, fmt.Errorf("unknown Single version: %w", err) } From df84148ed23ea103771c6265d8ba79d3a2762b5a Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sun, 5 Nov 2023 19:39:35 -0300 Subject: [PATCH 06/13] chanbackup: add Single.CloseTxInputs field The field is optional. It stores inputs needed to produce signed commit tx using chantools scbforceclose, which calls function GetSignedCommitTx. New backups have this field filled if commit tx is available (for all cases except when DLP is active). If a backup has this data, the field is filled from it, otherwise it is kept empty. Modified test function genRandomOpenChannelShell to cover new types of channels (simple taproot channel and custom channel) and to cover combinations of bits. Make sure that TapscriptRoot field is properly packed and unpacked. --- chanbackup/backup.go | 43 +++++++++ chanbackup/pubsub.go | 3 +- chanbackup/single.go | 196 +++++++++++++++++++++++++++++++++++++- chanbackup/single_test.go | 184 ++++++++++++++++++++++++++++++++++- 4 files changed, 421 insertions(+), 5 deletions(-) diff --git a/chanbackup/backup.go b/chanbackup/backup.go index f6768898a0..5d9d769e87 100644 --- a/chanbackup/backup.go +++ b/chanbackup/backup.go @@ -7,6 +7,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/wire" "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/fn" "github.com/lightningnetwork/lnd/kvdb" ) @@ -53,6 +54,48 @@ func assembleChanBackup(addrSource AddressSource, return &single, nil } +// buildCloseTxInputs generates inputs needed to force close a channel from +// an open channel. Anyone having these inputs and the signer, can sign the +// force closure transaction. Warning! If the channel state updates, an attempt +// to close the channel using this method with outdated CloseTxInputs can result +// in loss of funds! This may happen if an outdated channel backup is attempted +// to be used to force close the channel. +func buildCloseTxInputs( + targetChan *channeldb.OpenChannel) fn.Option[CloseTxInputs] { + + log.Debugf("Crafting CloseTxInputs for ChannelPoint(%v)", + targetChan.FundingOutpoint) + + localCommit := targetChan.LocalCommitment + + if localCommit.CommitTx == nil { + log.Infof("CommitTx is nil for ChannelPoint(%v), "+ + "skipping CloseTxInputs. This is possible when "+ + "DLP is active.", targetChan.FundingOutpoint) + + return fn.None[CloseTxInputs]() + } + + // We need unsigned force close tx and the counterparty's signature. + inputs := CloseTxInputs{ + CommitTx: localCommit.CommitTx, + CommitSig: localCommit.CommitSig, + } + + // In case of a taproot channel, commit height is needed as well to + // produce verification nonce for the taproot channel using shachain. + if targetChan.ChanType.IsTaproot() { + inputs.CommitHeight = localCommit.CommitHeight + } + + // In case of a custom taproot channel, TapscriptRoot is needed as well. + if targetChan.ChanType.HasTapscriptRoot() { + inputs.TapscriptRoot = targetChan.TapscriptRoot + } + + return fn.Some(inputs) +} + // FetchBackupForChan attempts to create a plaintext static channel backup for // the target channel identified by its channel point. If we're unable to find // the target channel, then an error will be returned. diff --git a/chanbackup/pubsub.go b/chanbackup/pubsub.go index 7652d3654c..3872319470 100644 --- a/chanbackup/pubsub.go +++ b/chanbackup/pubsub.go @@ -267,9 +267,10 @@ func (s *SubSwapper) backupUpdater() { log.Debugf("Adding channel %v to backup state", newChan.FundingOutpoint) - s.backupState[newChan.FundingOutpoint] = NewSingle( + single := NewSingle( newChan.OpenChannel, newChan.Addrs, ) + s.backupState[newChan.FundingOutpoint] = single } // For all closed channels, we'll remove the prior diff --git a/chanbackup/single.go b/chanbackup/single.go index 47a4b50545..b741320b07 100644 --- a/chanbackup/single.go +++ b/chanbackup/single.go @@ -2,6 +2,7 @@ package chanbackup import ( "bytes" + "errors" "fmt" "io" "net" @@ -11,6 +12,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/fn" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnencrypt" "github.com/lightningnetwork/lnd/lnwire" @@ -56,8 +58,51 @@ const ( // TapscriptRootVersion is a version that denotes this is a MuSig2 // channel with a top level tapscript commitment. TapscriptRootVersion = 6 + + // closeTxVersionMask is the byte mask used that is ORed to version byte + // on wire indicating that the backup has CloseTxInputs. + closeTxVersionMask = 1 << 7 ) +// Encode returns encoding of the version to put into channel backup. +// Argument "closeTx" specifies if the backup includes force close transaction. +func (v SingleBackupVersion) Encode(closeTx bool) byte { + encoded := byte(v) + + // If the backup includes closing transaction, set this bit in the + // encoded version. + if closeTx { + encoded |= closeTxVersionMask + } + + return encoded +} + +// DecodeVersion decodes the encoding of the version from a channel backup. +// It returns the version and if the backup includes the force close tx. +func DecodeVersion(encoded byte) (SingleBackupVersion, bool) { + // Find if it has a closing transaction by inspecting the bit. + closeTx := (encoded & closeTxVersionMask) != 0 + + // The version byte also encodes the closeTxVersion feature, so we + // extract it here and return it separately to the backup version. + version := SingleBackupVersion(encoded &^ closeTxVersionMask) + + return version, closeTx +} + +// IsTaproot returns if this is a backup of a taproot channel. This will also be +// true for simple taproot overlay channels when a version is added. +func (v SingleBackupVersion) IsTaproot() bool { + return v == SimpleTaprootVersion || v == TapscriptRootVersion +} + +// HasTapscriptRoot returns true if the channel is using a top level tapscript +// root commitment. +func (v SingleBackupVersion) HasTapscriptRoot() bool { + return v == TapscriptRootVersion +} + // Single is a static description of an existing channel that can be used for // the purposes of backing up. The fields in this struct allow a node to // recover the settled funds within a channel in the case of partial or @@ -142,11 +187,47 @@ type Single struct { // // - ScriptEnforcedLeaseVersion LeaseExpiry uint32 + + // CloseTxInputs contains data needed to produce a force close tx + // using for example the "chantools scbforceclose" command. + // + // The field is optional. + CloseTxInputs fn.Option[CloseTxInputs] +} + +// CloseTxInputs contains data needed to produce a force close transaction +// using for example the "chantools scbforceclose" command. +type CloseTxInputs struct { + // CommitTx is the latest version of the commitment state, broadcast + // able by us, but not signed. It can be signed by for example the + // "chantools scbforceclose" command. + CommitTx *wire.MsgTx + + // CommitSig is one half of the signature required to fully complete + // the script for the commitment transaction above. This is the + // signature signed by the remote party for our version of the + // commitment transactions. + CommitSig []byte + + // CommitHeight is the update number that this ChannelDelta represents + // the total number of commitment updates to this point. This can be + // viewed as sort of a "commitment height" as this number is + // monotonically increasing. + // + // This field is filled only for taproot channels. + CommitHeight uint64 + + // TapscriptRoot is the root of the tapscript tree that will be used to + // create the funding output. This is an optional field that should + // only be set for overlay taproot channels (HasTapscriptRoot). + TapscriptRoot fn.Option[chainhash.Hash] } // NewSingle creates a new static channel backup based on an existing open // channel. We also pass in the set of addresses that we used in the past to -// connect to the channel peer. +// connect to the channel peer. If possible, we include the data needed to +// produce a force close transaction from the most recent state using externally +// provided private key. func NewSingle(channel *channeldb.OpenChannel, nodeAddrs []net.Addr) Single { @@ -245,9 +326,18 @@ func NewSingle(channel *channeldb.OpenChannel, single.Version = DefaultSingleVersion } + // Include unsigned force-close transaction for the most recent channel + // state as well as the data needed to produce the signature, given the + // private key is provided separately. + single.CloseTxInputs = buildCloseTxInputs(channel) + return single } +// errEmptyTapscriptRoot is returned by Serialize if field TapscriptRoot is +// empty, when it should be filled according to the channel version. +var errEmptyTapscriptRoot = errors.New("field TapscriptRoot is not filled") + // Serialize attempts to write out the serialized version of the target // StaticChannelBackup into the passed io.Writer. func (s *Single) Serialize(w io.Writer) error { @@ -329,6 +419,60 @@ func (s *Single) Serialize(w io.Writer) error { } } + // Encode version enum and hasCloseTx flag to version byte. + version := s.Version.Encode(s.CloseTxInputs.IsSome()) + + // Serialize CloseTxInputs if it is provided. Fill err if it fails. + err := fn.MapOptionZ(s.CloseTxInputs, func(inputs CloseTxInputs) error { + err := inputs.CommitTx.Serialize(&singleBytes) + if err != nil { + return err + } + + err = lnwire.WriteElements( + &singleBytes, + uint16(len(inputs.CommitSig)), inputs.CommitSig, + ) + if err != nil { + return err + } + + if !s.Version.IsTaproot() { + return nil + } + + // Write fields needed for taproot channels. + err = lnwire.WriteElements( + &singleBytes, inputs.CommitHeight, + ) + if err != nil { + return err + } + + if s.Version.HasTapscriptRoot() { + opt := inputs.TapscriptRoot + var tapscriptRoot chainhash.Hash + tapscriptRoot, err = opt.UnwrapOrErr( + errEmptyTapscriptRoot, + ) + if err != nil { + return err + } + + err = lnwire.WriteElements( + &singleBytes, tapscriptRoot[:], + ) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + return fmt.Errorf("failed to encode CloseTxInputs: %w", err) + } + // TODO(yy): remove the type assertion when we finished refactoring db // into using write buffer. buf, ok := w.(*bytes.Buffer) @@ -338,7 +482,7 @@ func (s *Single) Serialize(w io.Writer) error { return lnwire.WriteElements( buf, - byte(s.Version), + version, uint16(len(singleBytes.Bytes())), singleBytes.Bytes(), ) @@ -429,7 +573,9 @@ func (s *Single) Deserialize(r io.Reader) error { return err } - s.Version = SingleBackupVersion(version) + // Decode version byte to version enum and hasCloseTx flag. + var hasCloseTx bool + s.Version, hasCloseTx = DecodeVersion(version) switch s.Version { case DefaultSingleVersion: @@ -543,6 +689,50 @@ func (s *Single) Deserialize(r io.Reader) error { } } + if !hasCloseTx { + return nil + } + + // Deserialize CloseTxInputs if it is present in serialized data. + commitTx := &wire.MsgTx{} + if err := commitTx.Deserialize(r); err != nil { + return err + } + + var commitSigLen uint16 + if err := lnwire.ReadElement(r, &commitSigLen); err != nil { + return err + } + commitSig := make([]byte, commitSigLen) + if err := lnwire.ReadElement(r, commitSig); err != nil { + return err + } + + var commitHeight uint64 + if s.Version.IsTaproot() { + err := lnwire.ReadElement(r, &commitHeight) + if err != nil { + return err + } + } + + tapscriptRootOpt := fn.None[chainhash.Hash]() + if s.Version.HasTapscriptRoot() { + var tapscriptRoot chainhash.Hash + err := lnwire.ReadElement(r, tapscriptRoot[:]) + if err != nil { + return err + } + tapscriptRootOpt = fn.Some(tapscriptRoot) + } + + s.CloseTxInputs = fn.Some(CloseTxInputs{ + CommitTx: commitTx, + CommitSig: commitSig, + CommitHeight: commitHeight, + TapscriptRoot: tapscriptRootOpt, + }) + return nil } diff --git a/chanbackup/single_test.go b/chanbackup/single_test.go index 68ce35c56c..d2212bd859 100644 --- a/chanbackup/single_test.go +++ b/chanbackup/single_test.go @@ -13,6 +13,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/davecgh/go-spew/spew" "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/fn" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnencrypt" "github.com/lightningnetwork/lnd/lnwire" @@ -95,6 +96,29 @@ func assertSingleEqual(t *testing.T, a, b Single) { a.Addresses[i], b.Addresses[i]) } } + + // Make sure that CloseTxInputs are present either in both backups, + // or in none of them. + require.Equal(t, a.CloseTxInputs.IsSome(), b.CloseTxInputs.IsSome()) + + if a.CloseTxInputs.IsSome() { + // Cache CloseTxInputs into short variables. + ai := a.CloseTxInputs.UnwrapOrFail(t) + bi := b.CloseTxInputs.UnwrapOrFail(t) + + // Compare serialized unsigned transactions. + var abuf, bbuf bytes.Buffer + require.NoError(t, ai.CommitTx.Serialize(&abuf)) + require.NoError(t, bi.CommitTx.Serialize(&bbuf)) + aBytes := abuf.Bytes() + bBytes := bbuf.Bytes() + require.Equal(t, aBytes, bBytes) + + // Compare counterparty's signature and commit height. + require.Equal(t, ai.CommitSig, bi.CommitSig) + require.Equal(t, ai.CommitHeight, bi.CommitHeight) + require.Equal(t, ai.TapscriptRoot, bi.TapscriptRoot) + } } func genRandomOpenChannelShell() (*channeldb.OpenChannel, error) { @@ -124,7 +148,7 @@ func genRandomOpenChannelShell() (*channeldb.OpenChannel, error) { isInitiator = true } - chanType := channeldb.ChannelType(rand.Intn(8)) + chanType := channeldb.ChannelType(rand.Intn(1 << 12)) localCfg := channeldb.ChannelConfig{ ChannelStateBounds: channeldb.ChannelStateBounds{}, @@ -184,6 +208,29 @@ func genRandomOpenChannelShell() (*channeldb.OpenChannel, error) { }, } + var localCommit channeldb.ChannelCommitment + if chanType.IsTaproot() { + var commitSig [64]byte + if _, err := rand.Read(commitSig[:]); err != nil { + return nil, err + } + + localCommit = channeldb.ChannelCommitment{ + CommitTx: sampleCommitTx, + CommitSig: commitSig[:], + CommitHeight: rand.Uint64(), + } + } + + var tapscriptRootOption fn.Option[chainhash.Hash] + if chanType.HasTapscriptRoot() { + var tapscriptRoot chainhash.Hash + if _, err := rand.Read(tapscriptRoot[:]); err != nil { + return nil, err + } + tapscriptRootOption = fn.Some(tapscriptRoot) + } + return &channeldb.OpenChannel{ ChainHash: chainHash, ChanType: chanType, @@ -196,10 +243,61 @@ func genRandomOpenChannelShell() (*channeldb.OpenChannel, error) { IdentityPub: pub, LocalChanCfg: localCfg, RemoteChanCfg: remoteCfg, + LocalCommitment: localCommit, RevocationProducer: shaChainProducer, + TapscriptRoot: tapscriptRootOption, }, nil } +// TestVersionEncoding tests encoding and decoding of version byte. +func TestVersionEncoding(t *testing.T) { + cases := []struct { + version SingleBackupVersion + hasCloseTx bool + versionByte byte + }{ + { + version: DefaultSingleVersion, + hasCloseTx: false, + versionByte: DefaultSingleVersion, + }, + { + version: DefaultSingleVersion, + hasCloseTx: true, + versionByte: DefaultSingleVersion | closeTxVersionMask, + }, + { + version: AnchorsCommitVersion, + hasCloseTx: false, + versionByte: AnchorsCommitVersion, + }, + { + version: AnchorsCommitVersion, + hasCloseTx: true, + versionByte: AnchorsCommitVersion | closeTxVersionMask, + }, + } + + for _, tc := range cases { + gotVersionByte := tc.version.Encode(tc.hasCloseTx) + require.Equal(t, tc.versionByte, gotVersionByte) + + gotVersion, gotHasCloseTx := DecodeVersion(tc.versionByte) + require.Equal(t, tc.version, gotVersion) + require.Equal(t, tc.hasCloseTx, gotHasCloseTx) + } +} + +var sampleCommitTx = &wire.MsgTx{ + TxIn: []*wire.TxIn{ + {PreviousOutPoint: wire.OutPoint{Hash: [32]byte{1}}}, + }, + TxOut: []*wire.TxOut{ + {Value: 1e8, PkScript: []byte("1")}, + {Value: 2e8, PkScript: []byte("2")}, + }, +} + // TestSinglePackUnpack tests that we're able to unpack a previously packed // channel backup. func TestSinglePackUnpack(t *testing.T) { @@ -220,6 +318,9 @@ func TestSinglePackUnpack(t *testing.T) { // decode/encode the final SCB. version SingleBackupVersion + // closeTxInputs is the data needed to produce a force close tx. + closeTxInputs fn.Option[CloseTxInputs] + // valid tests us if this test case should pass or not. valid bool }{ @@ -269,11 +370,92 @@ func TestSinglePackUnpack(t *testing.T) { version: 99, valid: false, }, + + // Versions with CloseTxInputs. + { + version: DefaultSingleVersion, + closeTxInputs: fn.Some(CloseTxInputs{ + CommitTx: sampleCommitTx, + CommitSig: []byte("signature"), + }), + valid: true, + }, + { + version: TweaklessCommitVersion, + closeTxInputs: fn.Some(CloseTxInputs{ + CommitTx: sampleCommitTx, + CommitSig: []byte("signature"), + }), + valid: true, + }, + { + version: AnchorsCommitVersion, + closeTxInputs: fn.Some(CloseTxInputs{ + CommitTx: sampleCommitTx, + CommitSig: []byte("signature"), + }), + valid: true, + }, + { + version: ScriptEnforcedLeaseVersion, + closeTxInputs: fn.Some(CloseTxInputs{ + CommitTx: sampleCommitTx, + CommitSig: []byte("signature"), + }), + valid: true, + }, + { + version: SimpleTaprootVersion, + closeTxInputs: fn.Some(CloseTxInputs{ + CommitTx: sampleCommitTx, + CommitSig: []byte("signature"), + CommitHeight: 42, + }), + valid: true, + }, + { + version: TapscriptRootVersion, + closeTxInputs: fn.Some(CloseTxInputs{ + CommitTx: sampleCommitTx, + CommitSig: []byte("signature"), + CommitHeight: 42, + TapscriptRoot: fn.Some(chainhash.Hash{1}), + }), + valid: true, + }, + { + version: 99, + closeTxInputs: fn.Some(CloseTxInputs{ + CommitTx: sampleCommitTx, + CommitSig: []byte("signature"), + }), + valid: false, + }, + { + version: 99, + closeTxInputs: fn.Some(CloseTxInputs{ + CommitTx: sampleCommitTx, + CommitSig: []byte("signature"), + CommitHeight: 42, + }), + valid: false, + }, + { + version: TapscriptRootVersion, + closeTxInputs: fn.Some(CloseTxInputs{ + CommitTx: sampleCommitTx, + CommitSig: []byte("signature"), + CommitHeight: 42, + // TapscriptRoot is not filled. + }), + valid: false, + }, } for i, versionCase := range versionTestCases { // First, we'll re-assign SCB version to what was indicated in // the test case. singleChanBackup.Version = versionCase.version + singleChanBackup.CloseTxInputs = versionCase.closeTxInputs var b bytes.Buffer From fb397c11f1ae533f4d11ece820346bb0b9d93609 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sat, 6 Jul 2024 13:07:48 -0300 Subject: [PATCH 07/13] chanbackup/pubsub: add method ManualUpdate This method inserts channel updates and waits for them to be processed. It will be used to update channel.backup upon LND shutdown. --- chanbackup/pubsub.go | 97 ++++++++++++++++++++++++++++++++++++--- chanbackup/pubsub_test.go | 14 ++++++ 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/chanbackup/pubsub.go b/chanbackup/pubsub.go index 3872319470..8fa1d5f348 100644 --- a/chanbackup/pubsub.go +++ b/chanbackup/pubsub.go @@ -48,6 +48,19 @@ type ChannelEvent struct { NewChans []ChannelWithAddrs } +// manualUpdate holds a group of channel state updates and an error channel +// to send back an error happened upon update processing or file updating. +type manualUpdate struct { + // singles hold channels backups. They can be either new or known + // channels in the Swapper. + singles []Single + + // errChan is the channel to send an error back. If the update handling + // and the subsequent file updating succeeds, nil is sent. + // The channel must have capacity of 1 to prevent Swapper blocking. + errChan chan error +} + // ChannelSubscription represents an intent to be notified of any updates to // the primary channel state. type ChannelSubscription struct { @@ -90,6 +103,8 @@ type SubSwapper struct { // over. chanEvents *ChannelSubscription + manualUpdates chan manualUpdate + // keyRing is the main key ring that will allow us to pack the new // multi backup. keyRing keychain.KeyRing @@ -126,11 +141,12 @@ func NewSubSwapper(startingChans []Single, chanNotifier ChannelNotifier, } return &SubSwapper{ - backupState: backupState, - chanEvents: chanEvents, - keyRing: keyRing, - Swapper: backupSwapper, - quit: make(chan struct{}), + backupState: backupState, + chanEvents: chanEvents, + keyRing: keyRing, + Swapper: backupSwapper, + quit: make(chan struct{}), + manualUpdates: make(chan manualUpdate), }, nil } @@ -168,6 +184,43 @@ func (s *SubSwapper) Stop() error { return nil } +// ManualUpdate inserts/updates channel states into the swapper. The updates +// are processed in another goroutine. The method waits for the updates to be +// fully processed and the file to be updated on-disk before returning. +func (s *SubSwapper) ManualUpdate(singles []Single) error { + // Create the channel to send an error back. If the update handling + // and the subsequent file updating succeeds, nil is sent. + // The channel must have capacity of 1 to prevent Swapper blocking. + errChan := make(chan error, 1) + + // Create the update object to insert into the processing loop. + update := manualUpdate{ + singles: singles, + errChan: errChan, + } + + select { + case s.manualUpdates <- update: + case <-s.quit: + return fmt.Errorf("swapper stopped when sending manual update") + } + + // Wait for processing, block on errChan. + select { + case err := <-errChan: + if err != nil { + return fmt.Errorf("processing of manual update "+ + "failed: %w", err) + } + + case <-s.quit: + return fmt.Errorf("swapper stopped when waiting for outcome") + } + + // Success. + return nil +} + // updateBackupFile updates the backup file in place given the current state of // the SubSwapper. We accept the set of channels that were closed between this // update and the last to make sure we leave them out of our backup set union. @@ -294,13 +347,45 @@ func (s *SubSwapper) backupUpdater() { "num_old_chans=%v, num_new_chans=%v", oldStateSize, newStateSize) - // With out new state constructed, we'll, atomically + // Without new state constructed, we'll, atomically // update the on-disk backup state. if err := s.updateBackupFile(closedChans...); err != nil { log.Errorf("unable to update backup file: %v", err) } + // We received a manual update. Handle it and update the file. + case manualUpdate := <-s.manualUpdates: + oldStateSize := len(s.backupState) + + // For all open channels, we'll create a new SCB given + // the required information. + for _, single := range manualUpdate.singles { + log.Debugf("Manual update of channel %v", + single.FundingOutpoint) + + s.backupState[single.FundingOutpoint] = single + } + + newStateSize := len(s.backupState) + + log.Infof("Updating on-disk multi SCB backup: "+ + "num_old_chans=%v, num_new_chans=%v", + oldStateSize, newStateSize) + + // Without new state constructed, we'll, atomically + // update the on-disk backup state. + err := s.updateBackupFile() + if err != nil { + log.Errorf("unable to update backup file: %v", + err) + } + + // Send the error (or nil) to the caller of + // ManualUpdate. The error channel must have capacity of + // 1 not to block here. + manualUpdate.errChan <- err + // TODO(roasbeef): refresh periodically on a time basis due to // possible addr changes from node diff --git a/chanbackup/pubsub_test.go b/chanbackup/pubsub_test.go index 6ab1cb1814..32694e5a75 100644 --- a/chanbackup/pubsub_test.go +++ b/chanbackup/pubsub_test.go @@ -277,4 +277,18 @@ func TestSubSwapperUpdater(t *testing.T) { // Verify that the new set of backups, now has one less after the // sub-swapper switches the new set with the old. assertExpectedBackupSwap(t, swapper, subSwapper, keyRing, backupSet) + + // Check ManualUpdate method. + channel, err := genRandomOpenChannelShell() + require.NoError(t, err) + single := NewSingle(channel, nil) + backupSet[channel.FundingOutpoint] = single + require.NoError(t, subSwapper.ManualUpdate([]Single{single})) + + // Verify that the state of the backup is as expected. + assertExpectedBackupSwap(t, swapper, subSwapper, keyRing, backupSet) + + // Check the case ManualUpdate returns an error. + swapper.fail = true + require.Error(t, subSwapper.ManualUpdate([]Single{single})) } From 29946df4e508c14abe99247fbe93472275373d13 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 25 Mar 2024 20:30:57 -0300 Subject: [PATCH 08/13] server: produces a channel backup upon shutdown This is needed to keep channel.backup up-to-date if the node is stopped. --- server.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/server.go b/server.go index 39600fb6f8..0fc95dc756 100644 --- a/server.go +++ b/server.go @@ -2504,6 +2504,23 @@ func (s *server) Stop() error { if err := s.htlcNotifier.Stop(); err != nil { srvrLog.Warnf("failed to stop htlcNotifier: %v", err) } + + // Update channel.backup file. Make sure to do it before + // stopping chanSubSwapper. + singles, err := chanbackup.FetchStaticChanBackups( + s.chanStateDB, s.addrSource, + ) + if err != nil { + srvrLog.Warnf("failed to fetch channel states: %v", + err) + } else { + err := s.chanSubSwapper.ManualUpdate(singles) + if err != nil { + srvrLog.Warnf("Manual update of channel "+ + "backup failed: %v", err) + } + } + if err := s.chanSubSwapper.Stop(); err != nil { srvrLog.Warnf("failed to stop chanSubSwapper: %v", err) } From ef8535356b9e61f735cef3b39c1759533e89eca0 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Wed, 27 Mar 2024 20:49:24 -0300 Subject: [PATCH 09/13] itest/lnd_channel_backup_test: fix typos --- itest/lnd_channel_backup_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/itest/lnd_channel_backup_test.go b/itest/lnd_channel_backup_test.go index 474cd663a0..37b7e9faf6 100644 --- a/itest/lnd_channel_backup_test.go +++ b/itest/lnd_channel_backup_test.go @@ -429,7 +429,7 @@ func testChannelBackupRestoreBasic(ht *lntest.HarnessTest) { func runChanRestoreScenarioBasic(ht *lntest.HarnessTest, restoreMethod restoreMethodType) { - // Create a new retore scenario. + // Create a new restore scenario. crs := newChanRestoreScenario( ht, lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, false, ) @@ -470,7 +470,7 @@ func testChannelBackupRestoreUnconfirmed(ht *lntest.HarnessTest) { // runChanRestoreScenarioUnConfirmed checks that Dave is able to restore for an // unconfirmed channel. func runChanRestoreScenarioUnConfirmed(ht *lntest.HarnessTest, useFile bool) { - // Create a new retore scenario. + // Create a new restore scenario. crs := newChanRestoreScenario( ht, lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, false, ) @@ -608,7 +608,7 @@ func testChannelBackupRestoreCommitTypes(ht *lntest.HarnessTest) { func runChanRestoreScenarioCommitTypes(ht *lntest.HarnessTest, ct lnrpc.CommitmentType, zeroConf bool) { - // Create a new retore scenario. + // Create a new restore scenario. crs := newChanRestoreScenario(ht, ct, zeroConf) carol, dave := crs.carol, crs.dave @@ -668,7 +668,7 @@ func runChanRestoreScenarioCommitTypes(ht *lntest.HarnessTest, // testChannelBackupRestoreLegacy checks a channel with the legacy revocation // producer format and makes sure old SCBs can still be recovered. func testChannelBackupRestoreLegacy(ht *lntest.HarnessTest) { - // Create a new retore scenario. + // Create a new restore scenario. crs := newChanRestoreScenario( ht, lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, false, ) From 3de94c11ae2a51e3eb6657b216036194bdcf0f71 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Thu, 25 Jul 2024 12:12:22 -0300 Subject: [PATCH 10/13] lntest: fix typo --- lntest/harness_miner.go | 2 +- lntest/miner/miner.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lntest/harness_miner.go b/lntest/harness_miner.go index bc9aef1805..cf07d45257 100644 --- a/lntest/harness_miner.go +++ b/lntest/harness_miner.go @@ -217,7 +217,7 @@ func (h *HarnessTest) AssertTxNotInMempool(txid chainhash.Hash) *wire.MsgTx { } // AssertNumTxsInMempool polls until finding the desired number of transactions -// in the provided miner's mempool. It will asserrt if this number is not met +// in the provided miner's mempool. It will assert if this number is not met // after the given timeout. func (h *HarnessTest) AssertNumTxsInMempool(n int) []*chainhash.Hash { return h.miner.AssertNumTxsInMempool(n) diff --git a/lntest/miner/miner.go b/lntest/miner/miner.go index 41147c76be..342793b902 100644 --- a/lntest/miner/miner.go +++ b/lntest/miner/miner.go @@ -196,7 +196,7 @@ func (h *HarnessMiner) MineBlocks(num uint32) []*wire.MsgBlock { } // AssertNumTxsInMempool polls until finding the desired number of transactions -// in the provided miner's mempool. It will asserrt if this number is not met +// in the provided miner's mempool. It will assert if this number is not met // after the given timeout. func (h *HarnessMiner) AssertNumTxsInMempool(n int) []*chainhash.Hash { var ( From 25eecd7a8732e377c226f2a3146979e0a230b9d0 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 9 Sep 2024 11:10:52 -0300 Subject: [PATCH 11/13] lnwallet: fix godoc of TapscriptTweak --- lnwallet/musig_session.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnwallet/musig_session.go b/lnwallet/musig_session.go index c3214d3f2f..822aa48a14 100644 --- a/lnwallet/musig_session.go +++ b/lnwallet/musig_session.go @@ -584,7 +584,7 @@ type MusigSessionCfg struct { // funding input. InputTxOut *wire.TxOut - // TapscriptRoot is an optional tweak that can be used to modify the + // TapscriptTweak is an optional tweak that can be used to modify the // MuSig2 public key used in the session. TapscriptTweak fn.Option[chainhash.Hash] } From eda06b19c69fb5f90703cdee80d843352f4c44eb Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Tue, 1 Oct 2024 23:17:33 -0300 Subject: [PATCH 12/13] itest: test channel.backup changes upon shutdown --- itest/lnd_channel_backup_test.go | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/itest/lnd_channel_backup_test.go b/itest/lnd_channel_backup_test.go index 37b7e9faf6..651b770969 100644 --- a/itest/lnd_channel_backup_test.go +++ b/itest/lnd_channel_backup_test.go @@ -786,8 +786,22 @@ func runChanRestoreScenarioForceClose(ht *lntest.HarnessTest, zeroConf bool) { ht.AssertNumTxsInMempool(1) // Now that we're able to make our restored now, we'll shutdown the old - // Dave node as we'll be storing it shortly below. - ht.Shutdown(dave) + // Dave node as we'll be storing it shortly below. Use SuspendNode, not + // Shutdown to keep its directory including channel.backup file. + ht.SuspendNode(dave) + + // Read Dave's channel.backup file again to make sure it was updated + // upon Dave's shutdown. In case LND state is lost and DLP protocol + // fails, the channel.backup file and the commit tx in it are the + // measure of last resort to recover funds from the channel. The file + // is updated upon LND server shutdown to update the commit tx just in + // case it is used this way. If an outdated commit tx is broadcasted, + // the funds may be lost in a justice transaction. The file is encrypted + // and we can't decrypt it here, so we just check that the content of + // the file has changed. + multi2, err := os.ReadFile(backupFilePath) + require.NoError(ht, err) + require.NotEqual(ht, multi, multi2) // Mine a block to confirm the closing tx from Dave. ht.MineBlocksAndAssertNumTxes(1, 1) From 65df996358a33244341d1c6ab933efabf495e3f1 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sat, 13 Jul 2024 13:21:46 -0300 Subject: [PATCH 13/13] docs/recovery: add Last resort manual force close Also updated release-notes. --- docs/recovery.md | 16 ++++++++++++++++ docs/release-notes/release-notes-0.19.0.md | 9 ++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/recovery.md b/docs/recovery.md index c99e0ddef0..89c2f069f1 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -258,6 +258,22 @@ An example of using file system level notification to [copy the backup to a distinct volume/partition/drive can be found here](https://gist.github.com/alexbosworth/2c5e185aedbdac45a03655b709e255a3). +##### Last resort manual force close + +Reserve this option as a last resort when the peer is offline and all other +avenues to retrieve funds from the channel have been exhausted. The primary +motivation for introducing this option is to provide a means of recovery, +albeit with some risk, rather than losing the funds indefinitely. This is a very +dangerous option, so it should only be used after consulting with a recovery +specialist or after opening an issue to make sure!!! + +Starting with release 0.19.0 LND includes unsigned force close transaction +for a channel into channel.backup file and RPCs returning channel backups. +To generate a force close transaction from the backup file, utilize the +`chantools scbforceclose` command. However, exercise caution as this action is +perilous. If the channel has been updated since the backup creation, another +node or a watchtower may issue a penalty transaction, seizing all funds! + #### Using the `ExportChanBackup` RPC Another way to obtain SCBS for all or a target channel is via the new diff --git a/docs/release-notes/release-notes-0.19.0.md b/docs/release-notes/release-notes-0.19.0.md index c7410fced0..204d25a54d 100644 --- a/docs/release-notes/release-notes-0.19.0.md +++ b/docs/release-notes/release-notes-0.19.0.md @@ -51,12 +51,18 @@ * [Allow](https://github.com/lightningnetwork/lnd/pull/9017) the compression of logs during rotation with ZSTD via the `logcompressor` startup argument. +* The SCB file now [contains more data][https://github.com/lightningnetwork/lnd/pull/8183] + that enable a last resort rescue for certain cases where the peer is no longer + around. + +* LND updates channel.backup file at shutdown time. + ## RPC Updates ## lncli Updates ## Code Health - + ## Breaking Changes ## Performance Improvements @@ -89,6 +95,7 @@ # Contributors (Alphabetical Order) +* Boris Nagaev * CharlieZKSmith * Elle Mouton * Pins