From 517608ca3bf30fe91325866a2c3ad9cbd728160a Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 29 May 2024 19:57:39 +0200 Subject: [PATCH] lnwallet: add ability to add extra co-op close outputs --- lnwallet/channel.go | 153 ++++++++++++++++++- lnwallet/channel_test.go | 313 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 460 insertions(+), 6 deletions(-) diff --git a/lnwallet/channel.go b/lnwallet/channel.go index b509a4535..6f4140835 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -7691,10 +7691,21 @@ func NewLocalForceCloseSummary(chanState *channeldb.OpenChannel, }, nil } +// CloseOutput wraps a normal tx out with additional metadata that indicates if +// the output belongs to the initiator of the channel or not. +type CloseOutput struct { + wire.TxOut + + // IsLocal indicates if the output belong to the local party. + IsLocal bool +} + // chanCloseOpt is a functional option that can be used to modify the co-op // close process. type chanCloseOpt struct { musigSession *MusigSession + + extraCloseOutputs []CloseOutput } // ChanCloseOpt is a closure type that cen be used to modify the set of default @@ -7715,6 +7726,14 @@ func WithCoopCloseMusigSession(session *MusigSession) ChanCloseOpt { } } +// WithExtraCloseOutputs can be used to add extra outputs to the cooperative +// transaction. +func WithExtraCloseOutputs(extraOutputs []CloseOutput) ChanCloseOpt { + return func(opts *chanCloseOpt) { + opts.extraCloseOutputs = extraOutputs + } +} + // CreateCloseProposal is used by both parties in a cooperative channel close // workflow to generate proposed close transactions and signatures. This method // should only be executed once all pending HTLCs (if any) on the channel have @@ -7722,9 +7741,6 @@ func WithCoopCloseMusigSession(session *MusigSession) ChanCloseOpt { // the "closing" state, which indicates that all incoming/outgoing HTLC // requests should be rejected. A signature for the closing transaction is // returned. -// -// TODO(roasbeef): caller should initiate signal to reject all incoming HTLCs, -// settle any in flight. func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount, localDeliveryScript []byte, remoteDeliveryScript []byte, closeOpts ...ChanCloseOpt) (input.Signature, *chainhash.Hash, @@ -7735,7 +7751,6 @@ func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount, // If we're already closing the channel, then ignore this request. if lc.isClosed { - // TODO(roasbeef): check to ensure no pending payments return nil, nil, 0, ErrChanClosing } @@ -7762,6 +7777,14 @@ func (lc *LightningChannel) CreateCloseProposal(proposedFee btcutil.Amount, closeTxOpts = append(closeTxOpts, WithRBFCloseTx()) } + // If we have any extra outputs to pass along, then we'll map that to + // the co-op close option txn type. + if opts.extraCloseOutputs != nil { + closeTxOpts = append(closeTxOpts, WithExtraTxCloseOutputs( + opts.extraCloseOutputs, + )) + } + closeTx := CreateCooperativeCloseTx( fundingTxIn(lc.channelState), lc.channelState.LocalChanCfg.DustLimit, lc.channelState.RemoteChanCfg.DustLimit, ourBalance, theirBalance, @@ -7844,6 +7867,14 @@ func (lc *LightningChannel) CompleteCooperativeClose( closeTxOpts = append(closeTxOpts, WithRBFCloseTx()) } + // If we have any extra outputs to pass along, then we'll map that to + // the co-op close option txn type. + if opts.extraCloseOutputs != nil { + closeTxOpts = append(closeTxOpts, WithExtraTxCloseOutputs( + opts.extraCloseOutputs, + )) + } + // Create the transaction used to return the current settled balance // on this active channel back to both parties. In this current model, // the initiator pays full fees for the cooperative close transaction. @@ -8549,6 +8580,10 @@ type closeTxOpts struct { // enableRBF indicates whether the cooperative close tx should signal // RBF or not. enableRBF bool + + // extraCloseOutputs is a set of additional outputs that should be + // added the co-op close transaction. + extraCloseOutputs []CloseOutput } // defaultCloseTxOpts returns a closeTxOpts struct with default values. @@ -8569,6 +8604,14 @@ func WithRBFCloseTx() CloseTxOpt { } } +// WithExtraTxCloseOutputs can be used to add extra outputs to the cooperative +// transaction. +func WithExtraTxCloseOutputs(extraOutputs []CloseOutput) CloseTxOpt { + return func(o *closeTxOpts) { + o.extraCloseOutputs = extraOutputs + } +} + // CreateCooperativeCloseTx creates a transaction which if signed by both // parties, then broadcast cooperatively closes an active channel. The creation // of the closure transaction is modified by a boolean indicating if the party @@ -8600,17 +8643,115 @@ func CreateCooperativeCloseTx(fundingTxIn wire.TxIn, // Create both cooperative closure outputs, properly respecting the // dust limits of both parties. - if ourBalance >= localDust { + var localOutputIdx fn.Option[int] + haveLocalOutput := ourBalance >= localDust + if haveLocalOutput { closeTx.AddTxOut(&wire.TxOut{ PkScript: ourDeliveryScript, Value: int64(ourBalance), }) + + localOutputIdx = fn.Some(len(closeTx.TxOut) - 1) } - if theirBalance >= remoteDust { + + var remoteOutputIdx fn.Option[int] + haveRemoteOutput := theirBalance >= remoteDust + if haveRemoteOutput { closeTx.AddTxOut(&wire.TxOut{ PkScript: theirDeliveryScript, Value: int64(theirBalance), }) + + remoteOutputIdx = fn.Some(len(closeTx.TxOut) - 1) + } + + // If we have extra outputs to add to the co-op close transaction, then + // we'll examine them now. We'll deduct the output's value from the + // owning party. In the case that a party can't pay for the output, then + // their normal output will be omitted. + for _, extraTxOut := range opts.extraCloseOutputs { + switch { + // For additional local outputs, add the output, then deduct + // the balance from our local balance. + case extraTxOut.IsLocal: + // The extraCloseOutputs in the options just indicate if + // an extra output should be added in general. But we + // only add one if we actually _need_ one, based on the + // balance. If we don't have enough local balance to + // cover the extra output, then localOutputIdx is None. + localOutputIdx.WhenSome(func(idx int) { + // The output that currently represents the + // local balance, which means: + // txOut.Value == ourBalance. + txOut := closeTx.TxOut[idx] + + // The extra output (if one exists) is the more + // important one, as in custom channels it might + // carry some additional values. The normal + // output is just an address that sends the + // local balance back to our wallet. The extra + // one also goes to our wallet, but might also + // carry other values, so it has higher + // priority. Do we have enough balance to have + // both the extra output with the given value + // (which is subtracted from our balance) and + // still an above-dust normal output? If not, we + // skip the extra output and just overwrite the + // existing output script with the one from the + // extra output. + amtAfterOutput := btcutil.Amount( + txOut.Value - extraTxOut.Value, + ) + if amtAfterOutput <= localDust { + txOut.PkScript = extraTxOut.PkScript + + return + } + + txOut.Value -= extraTxOut.Value + closeTx.AddTxOut(&extraTxOut.TxOut) + }) + + // For extra remote outputs, we'll do the opposite. + case !extraTxOut.IsLocal: + // The extraCloseOutputs in the options just indicate if + // an extra output should be added in general. But we + // only add one if we actually _need_ one, based on the + // balance. If we don't have enough remote balance to + // cover the extra output, then remoteOutputIdx is None. + remoteOutputIdx.WhenSome(func(idx int) { + // The output that currently represents the + // remote balance, which means: + // txOut.Value == theirBalance. + txOut := closeTx.TxOut[idx] + + // The extra output (if one exists) is the more + // important one, as in custom channels it might + // carry some additional values. The normal + // output is just an address that sends the + // remote balance back to their wallet. The + // extra one also goes to their wallet, but + // might also carry other values, so it has + // higher priority. Do they have enough balance + // to have both the extra output with the given + // value (which is subtracted from their + // balance) and still an above-dust normal + // output? If not, we skip the extra output and + // just overwrite the existing output script + // with the one from the extra output. + amtAfterOutput := btcutil.Amount( + txOut.Value - extraTxOut.Value, + ) + if amtAfterOutput <= remoteDust { + txOut.PkScript = extraTxOut.PkScript + + return + } + + txOut.Value -= extraTxOut.Value + closeTx.AddTxOut(&extraTxOut.TxOut) + }) + } } txsort.InPlaceSort(closeTx) diff --git a/lnwallet/channel_test.go b/lnwallet/channel_test.go index c47f87710..0147c9426 100644 --- a/lnwallet/channel_test.go +++ b/lnwallet/channel_test.go @@ -15,6 +15,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/txsort" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" @@ -11476,3 +11477,315 @@ func TestBlindingPointPersistence(t *testing.T) { require.Equal(t, blinding, bobCommit.incomingHTLCs[0].BlindingPoint.UnwrapOrFailV(t)) } + +// TestCreateCooperativeCloseTx tests that the cooperative close transaction is +// properly created based on the standard and also optional parameters. +func TestCreateCooperativeCloseTx(t *testing.T) { + t.Parallel() + + fundingTxIn := &wire.TxIn{} + + localDust := btcutil.Amount(400) + remoteDust := btcutil.Amount(400) + + localScript := []byte{0} + localExtraScript := []byte{2} + + remoteScript := []byte{1} + remoteExtraScript := []byte{3} + + tests := []struct { + name string + + enableRBF bool + + localBalance btcutil.Amount + remoteBalance btcutil.Amount + + extraCloseOutputs []CloseOutput + + expectedTx *wire.MsgTx + }{ + { + name: "no dust, no extra outputs", + localBalance: 1_000, + remoteBalance: 1_000, + expectedTx: &wire.MsgTx{ + TxIn: []*wire.TxIn{ + fundingTxIn, + }, + Version: 2, + TxOut: []*wire.TxOut{ + { + Value: 1_000, + PkScript: localScript, + }, + { + Value: 1_000, + PkScript: remoteScript, + }, + }, + }, + }, + { + name: "local dust, no extra outputs", + localBalance: 100, + remoteBalance: 1_000, + expectedTx: &wire.MsgTx{ + TxIn: []*wire.TxIn{ + fundingTxIn, + }, + Version: 2, + TxOut: []*wire.TxOut{ + { + Value: 1_000, + PkScript: remoteScript, + }, + }, + }, + }, + { + name: "remote dust, no extra outputs", + localBalance: 1_000, + remoteBalance: 100, + expectedTx: &wire.MsgTx{ + TxIn: []*wire.TxIn{ + fundingTxIn, + }, + Version: 2, + TxOut: []*wire.TxOut{ + { + Value: 1_000, + PkScript: localScript, + }, + }, + }, + }, + { + name: "no dust, local extra output", + localBalance: 10_000, + remoteBalance: 10_000, + extraCloseOutputs: []CloseOutput{ + { + TxOut: wire.TxOut{ + Value: 1_000, + PkScript: localExtraScript, + }, + IsLocal: true, + }, + }, + expectedTx: &wire.MsgTx{ + TxIn: []*wire.TxIn{ + fundingTxIn, + }, + Version: 2, + TxOut: []*wire.TxOut{ + { + Value: 10_000, + PkScript: remoteScript, + }, + { + Value: 9_000, + PkScript: localScript, + }, + { + Value: 1_000, + PkScript: localExtraScript, + }, + }, + }, + }, + { + name: "no dust, remote extra output", + localBalance: 10_000, + remoteBalance: 10_000, + extraCloseOutputs: []CloseOutput{ + { + TxOut: wire.TxOut{ + Value: 1_000, + PkScript: remoteExtraScript, + }, + IsLocal: false, + }, + }, + expectedTx: &wire.MsgTx{ + TxIn: []*wire.TxIn{ + fundingTxIn, + }, + Version: 2, + TxOut: []*wire.TxOut{ + { + Value: 10_000, + PkScript: localScript, + }, + { + Value: 9_000, + PkScript: remoteScript, + }, + { + Value: 1_000, + PkScript: remoteExtraScript, + }, + }, + }, + }, + { + name: "no dust, local+remote extra output", + localBalance: 10_000, + remoteBalance: 10_000, + extraCloseOutputs: []CloseOutput{ + { + TxOut: wire.TxOut{ + Value: 1_000, + PkScript: remoteExtraScript, + }, + IsLocal: false, + }, + { + TxOut: wire.TxOut{ + Value: 1_000, + PkScript: localExtraScript, + }, + IsLocal: true, + }, + }, + expectedTx: &wire.MsgTx{ + TxIn: []*wire.TxIn{ + fundingTxIn, + }, + Version: 2, + TxOut: []*wire.TxOut{ + { + Value: 9_000, + PkScript: localScript, + }, + { + Value: 9_000, + PkScript: remoteScript, + }, + { + Value: 1_000, + PkScript: remoteExtraScript, + }, + { + Value: 1_000, + PkScript: localExtraScript, + }, + }, + }, + }, + { + name: "no dust, local+remote extra output, " + + "remote can't afford", + localBalance: 10_000, + remoteBalance: 1_000, + extraCloseOutputs: []CloseOutput{ + { + TxOut: wire.TxOut{ + Value: 1_000, + PkScript: remoteExtraScript, + }, + IsLocal: false, + }, + { + TxOut: wire.TxOut{ + Value: 1_000, + PkScript: localExtraScript, + }, + IsLocal: true, + }, + }, + expectedTx: &wire.MsgTx{ + TxIn: []*wire.TxIn{ + fundingTxIn, + }, + Version: 2, + TxOut: []*wire.TxOut{ + { + Value: 9_000, + PkScript: localScript, + }, + { + Value: 1_000, + PkScript: remoteExtraScript, + }, + { + Value: 1_000, + PkScript: localExtraScript, + }, + }, + }, + }, + { + name: "no dust, local+remote extra output, " + + "local can't afford", + localBalance: 1_000, + remoteBalance: 10_000, + extraCloseOutputs: []CloseOutput{ + { + TxOut: wire.TxOut{ + Value: 1_000, + PkScript: remoteExtraScript, + }, + IsLocal: false, + }, + { + TxOut: wire.TxOut{ + Value: 1_000, + PkScript: localExtraScript, + }, + IsLocal: true, + }, + }, + expectedTx: &wire.MsgTx{ + TxIn: []*wire.TxIn{ + fundingTxIn, + }, + Version: 2, + TxOut: []*wire.TxOut{ + { + Value: 9_000, + PkScript: remoteScript, + }, + { + Value: 1_000, + PkScript: remoteExtraScript, + }, + { + Value: 1_000, + PkScript: localExtraScript, + }, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var opts []CloseTxOpt + if test.extraCloseOutputs != nil { + opts = append( + opts, + WithExtraTxCloseOutputs( + test.extraCloseOutputs, + ), + ) + } + + closeTx := CreateCooperativeCloseTx( + *fundingTxIn, localDust, remoteDust, + test.localBalance, test.remoteBalance, + localScript, remoteScript, opts..., + ) + + txsort.InPlaceSort(test.expectedTx) + + require.Equal( + t, test.expectedTx, closeTx, + "expected %v, got %v", + spew.Sdump(test.expectedTx), + spew.Sdump(closeTx), + ) + }) + } +}