diff --git a/docs/release-notes/release-notes-0.21.0.md b/docs/release-notes/release-notes-0.21.0.md index 754fedc20a4..b61d0931d42 100644 --- a/docs/release-notes/release-notes-0.21.0.md +++ b/docs/release-notes/release-notes-0.21.0.md @@ -389,6 +389,9 @@ ## Code Health +* [Extends sat/kw support to rbf cooperative channel closes](https://github.com/lightningnetwork/lnd/pull/10425), ensuring consistent fee handling across the +closing flow and adding `fee_per_kw` to the RPC message `PendingUpdate`. + * [Update taproot detection](https://github.com/lightningnetwork/lnd/pull/10683) to accommodate buried activation (and modified RPC `getdeploymentinfo` response) beginning in Bitcoin Core v32. diff --git a/itest/lnd_coop_close_rbf_test.go b/itest/lnd_coop_close_rbf_test.go index f1acf0288f1..8ea0cb5a9f3 100644 --- a/itest/lnd_coop_close_rbf_test.go +++ b/itest/lnd_coop_close_rbf_test.go @@ -33,7 +33,7 @@ func runRbfCoopCloseTest(st *lntest.HarnessTest, alicePendingUpdate := aliceCloseUpdate.GetClosePending() require.NotNil(st, aliceCloseUpdate) require.Equal( - st, int64(aliceFeeRate), alicePendingUpdate.FeePerVbyte, + st, int64(aliceFeeRate), int64(alicePendingUpdate.FeePerKw/250), ) require.True(st, alicePendingUpdate.LocalCloseTx) @@ -48,7 +48,9 @@ func runRbfCoopCloseTest(st *lntest.HarnessTest, // Confirm that this new update was at 10 sat/vb. bobPendingUpdate := bobCloseUpdate.GetClosePending() require.NotNil(st, bobCloseUpdate) - require.Equal(st, bobPendingUpdate.FeePerVbyte, int64(bobFeeRate)) + require.Equal( + st, int64(bobPendingUpdate.FeePerKw/250), int64(bobFeeRate), + ) require.True(st, bobPendingUpdate.LocalCloseTx) var err error @@ -69,11 +71,11 @@ func runRbfCoopCloseTest(st *lntest.HarnessTest, // calculation for taproot. require.InDelta( st, int64(bobFeeRate), - alicePendingUpdate.FeePerVbyte, 1, + int64(alicePendingUpdate.FeePerKw/250), 1, ) } else { require.Equal( - st, alicePendingUpdate.FeePerVbyte, + st, int64(alicePendingUpdate.FeePerKw/250), int64(bobFeeRate), ) } @@ -92,7 +94,7 @@ func runRbfCoopCloseTest(st *lntest.HarnessTest, alicePendingUpdate = aliceCloseUpdate.GetClosePending() require.NotNil(st, aliceCloseUpdate) require.Equal( - st, alicePendingUpdate.FeePerVbyte, + st, int64(alicePendingUpdate.FeePerKw/250), int64(aliceRejectedFeeRate), ) require.True(st, alicePendingUpdate.LocalCloseTx) @@ -126,7 +128,7 @@ func runRbfCoopCloseTest(st *lntest.HarnessTest, alicePendingUpdate = aliceCloseUpdate.GetClosePending() require.NotNil(st, aliceCloseUpdate) require.Equal( - st, alicePendingUpdate.FeePerVbyte, int64(aliceFeeRate), + st, int64(alicePendingUpdate.FeePerKw/250), int64(aliceFeeRate), ) require.True(st, alicePendingUpdate.LocalCloseTx) diff --git a/lnrpc/lightning.pb.go b/lnrpc/lightning.pb.go index 59d01914159..616244e7c07 100644 --- a/lnrpc/lightning.pb.go +++ b/lnrpc/lightning.pb.go @@ -7350,11 +7350,13 @@ func (*CloseStatusUpdate_ChanClose) isCloseStatusUpdate_Update() {} func (*CloseStatusUpdate_CloseInstant) isCloseStatusUpdate_Update() {} type PendingUpdate struct { - state protoimpl.MessageState `protogen:"open.v1"` - Txid []byte `protobuf:"bytes,1,opt,name=txid,proto3" json:"txid,omitempty"` - OutputIndex uint32 `protobuf:"varint,2,opt,name=output_index,json=outputIndex,proto3" json:"output_index,omitempty"` - FeePerVbyte int64 `protobuf:"varint,3,opt,name=fee_per_vbyte,json=feePerVbyte,proto3" json:"fee_per_vbyte,omitempty"` - LocalCloseTx bool `protobuf:"varint,4,opt,name=local_close_tx,json=localCloseTx,proto3" json:"local_close_tx,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Txid []byte `protobuf:"bytes,1,opt,name=txid,proto3" json:"txid,omitempty"` + OutputIndex uint32 `protobuf:"varint,2,opt,name=output_index,json=outputIndex,proto3" json:"output_index,omitempty"` + // The update carries the fee rate in sat/vbyte and sat/kw + FeePerVbyte int64 `protobuf:"varint,3,opt,name=fee_per_vbyte,json=feePerVbyte,proto3" json:"fee_per_vbyte,omitempty"` + FeePerKw uint64 `protobuf:"varint,5,opt,name=fee_per_kw,json=feePerKw,proto3" json:"fee_per_kw,omitempty"` + LocalCloseTx bool `protobuf:"varint,4,opt,name=local_close_tx,json=localCloseTx,proto3" json:"local_close_tx,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -7410,6 +7412,13 @@ func (x *PendingUpdate) GetFeePerVbyte() int64 { return 0 } +func (x *PendingUpdate) GetFeePerKw() uint64 { + if x != nil { + return x.FeePerKw + } + return 0 +} + func (x *PendingUpdate) GetLocalCloseTx() bool { if x != nil { return x.LocalCloseTx @@ -19353,11 +19362,13 @@ const file_lightning_proto_rawDesc = "" + "\n" + "chan_close\x18\x03 \x01(\v2\x19.lnrpc.ChannelCloseUpdateH\x00R\tchanClose\x12;\n" + "\rclose_instant\x18\x04 \x01(\v2\x14.lnrpc.InstantUpdateH\x00R\fcloseInstantB\b\n" + - "\x06update\"\x90\x01\n" + + "\x06update\"\xae\x01\n" + "\rPendingUpdate\x12\x12\n" + "\x04txid\x18\x01 \x01(\fR\x04txid\x12!\n" + "\foutput_index\x18\x02 \x01(\rR\voutputIndex\x12\"\n" + - "\rfee_per_vbyte\x18\x03 \x01(\x03R\vfeePerVbyte\x12$\n" + + "\rfee_per_vbyte\x18\x03 \x01(\x03R\vfeePerVbyte\x12\x1c\n" + + "\n" + + "fee_per_kw\x18\x05 \x01(\x04R\bfeePerKw\x12$\n" + "\x0elocal_close_tx\x18\x04 \x01(\bR\flocalCloseTx\";\n" + "\rInstantUpdate\x12*\n" + "\x11num_pending_htlcs\x18\x01 \x01(\x05R\x0fnumPendingHtlcs\"y\n" + diff --git a/lnrpc/lightning.proto b/lnrpc/lightning.proto index b60f45e0950..e7d80bd3cef 100644 --- a/lnrpc/lightning.proto +++ b/lnrpc/lightning.proto @@ -2286,7 +2286,9 @@ message CloseStatusUpdate { message PendingUpdate { bytes txid = 1; uint32 output_index = 2; + // The update carries the fee rate in sat/vbyte and sat/kw int64 fee_per_vbyte = 3; + uint64 fee_per_kw = 5; bool local_close_tx = 4; } diff --git a/lnrpc/lightning.swagger.json b/lnrpc/lightning.swagger.json index 0818132ede3..ad40ecfab93 100644 --- a/lnrpc/lightning.swagger.json +++ b/lnrpc/lightning.swagger.json @@ -7207,7 +7207,12 @@ }, "fee_per_vbyte": { "type": "string", - "format": "int64" + "format": "int64", + "title": "The update carries the fee rate in sat/vbyte and sat/kw" + }, + "fee_per_kw": { + "type": "string", + "format": "uint64" }, "local_close_tx": { "type": "boolean" diff --git a/lntest/harness.go b/lntest/harness.go index 08c62a984b1..2da2cdf129a 100644 --- a/lntest/harness.go +++ b/lntest/harness.go @@ -1348,9 +1348,14 @@ func (h *HarnessTest) CloseChannelAssertPending(hn *node.HarnessNode, continue } - notifyRate := pendingClose.ClosePending.FeePerVbyte + // Close pending notification in FeePerKw overwrites + // FeePerVbyte. + notifyRate := uint64(pendingClose.ClosePending.FeePerVbyte) + if pendingClose.ClosePending.FeePerKw != 0 { + notifyRate = pendingClose.ClosePending.FeePerKw / 250 + } if closeOpts.localTxOnly && - notifyRate != int64(closeReq.SatPerVbyte) { + notifyRate != closeReq.SatPerVbyte { continue } diff --git a/lnwallet/chancloser/rbf_coop_states.go b/lnwallet/chancloser/rbf_coop_states.go index 61f7718b5a6..7009b8b4b84 100644 --- a/lnwallet/chancloser/rbf_coop_states.go +++ b/lnwallet/chancloser/rbf_coop_states.go @@ -105,7 +105,7 @@ type SendShutdown struct { // IdealFeeRate is the ideal fee rate we'd like to use for the closing // attempt. - IdealFeeRate chainfee.SatPerVByte + IdealFeeRate chainfee.SatPerKWeight // CloseeNonce is the nonce we'll send in the shutdown message. The // remote party will use this when they create their closing transaction @@ -197,7 +197,7 @@ func (c *ChannelFlushed) protocolSealed() {} // - toState: LocalOfferSent type SendOfferEvent struct { // TargetFeeRate is the fee rate we'll use for the closing transaction. - TargetFeeRate chainfee.SatPerVByte + TargetFeeRate chainfee.SatPerKWeight } // protocolSealed indicates that this struct is a ProtocolEvent instance. @@ -319,7 +319,7 @@ type Environment struct { // DefaultFeeRate is the fee we'll use for the closing transaction if // the user didn't specify an ideal fee rate. This may happen if the // remote party is the one that initiates the co-op close. - DefaultFeeRate chainfee.SatPerVByte + DefaultFeeRate chainfee.SatPerKWeight // ThawHeight is the height at which the channel will be thawed. If // this is None, then co-op close can occur at any moment. @@ -484,7 +484,7 @@ type ShutdownPending struct { // IdealFeeRate is the ideal fee rate we'd like to use for the closing // attempt. - IdealFeeRate fn.Option[chainfee.SatPerVByte] + IdealFeeRate fn.Option[chainfee.SatPerKWeight] // EarlyRemoteOffer is the offer we received from the remote party // before we received their shutdown message. We'll stash it to process @@ -533,7 +533,7 @@ type ChannelFlushing struct { // IdealFeeRate is the ideal fee rate we'd like to use for the closing // transaction. Once the channel has been flushed, we'll use this as // our target fee rate. - IdealFeeRate fn.Option[chainfee.SatPerVByte] + IdealFeeRate fn.Option[chainfee.SatPerKWeight] // NonceState tracks the nonces exchanged during shutdown for taproot // channels. @@ -788,7 +788,7 @@ type LocalOfferSent struct { ProposedFee btcutil.Amount // ProposedFeeRate is the fee rate we proposed to the remote party. - ProposedFeeRate chainfee.SatPerVByte + ProposedFeeRate chainfee.SatPerKWeight // LocalSig is the signature we sent to the remote party. LocalSig lnwire.Sig @@ -843,7 +843,7 @@ type ClosePending struct { *CloseChannelTerms // FeeRate is the fee rate of the closing transaction. - FeeRate chainfee.SatPerVByte + FeeRate chainfee.SatPerKWeight // Party indicates which party is at this state. This is used to // implement the state transition properly, based on ShouldRouteTo. diff --git a/lnwallet/chancloser/rbf_coop_test.go b/lnwallet/chancloser/rbf_coop_test.go index e8bbcc3f40c..7d0f984b387 100644 --- a/lnwallet/chancloser/rbf_coop_test.go +++ b/lnwallet/chancloser/rbf_coop_test.go @@ -988,7 +988,7 @@ func newRbfCloserTestHarness(t *testing.T, ChanPoint: chanPoint, ChanID: chanID, Scid: scid, - DefaultFeeRate: defaultFeeRate.FeePerVByte(), + DefaultFeeRate: defaultFeeRate, ThawHeight: cfg.thawHeight, RemoteUpfrontShutdown: cfg.remoteUpfrontAddr, LocalUpfrontShutdown: cfg.localUpfrontAddr, @@ -1069,7 +1069,7 @@ func testInitiatorShutdownRecvOkNonTap(t *testing.T, ctx context.Context, t.Run("non_taproot", func(t *testing.T) { firstState := *startingState firstState.IdealFeeRate = fn.Some( - chainfee.FeePerKwFloor.FeePerVByte(), + chainfee.FeePerKwFloor, ) firstState.ShutdownScripts = ShutdownScripts{ LocalDeliveryScript: localAddr, @@ -1128,7 +1128,7 @@ func testInitiatorShutdownRecvOkTaproot(t *testing.T, ctx context.Context, t.Run("taproot", func(t *testing.T) { firstState := *startingState firstState.IdealFeeRate = fn.Some( - chainfee.FeePerKwFloor.FeePerVByte(), + chainfee.FeePerKwFloor, ) firstState.ShutdownScripts = ShutdownScripts{ LocalDeliveryScript: localAddr, @@ -1377,7 +1377,7 @@ func TestRbfChannelActiveTransitions(t *testing.T) { localAddr := lnwire.DeliveryAddress(bytes.Repeat([]byte{0x01}, 20)) remoteAddr := lnwire.DeliveryAddress(bytes.Repeat([]byte{0x02}, 20)) - feeRate := chainfee.SatPerVByte(1000) + feeRate := chainfee.SatPerKWeight(250_000) // Test that if a spend event is received, the FSM transitions to the // CloseFin terminal state. @@ -1580,7 +1580,7 @@ func TestRbfShutdownPendingTransitions(t *testing.T) { t.Run("initiator_shutdown_recv_taproot_no_nonce_fail", func(t *testing.T) { //nolint:ll firstState := *startingState firstState.IdealFeeRate = fn.Some( - chainfee.FeePerKwFloor.FeePerVByte(), + chainfee.FeePerKwFloor, ) firstState.ShutdownScripts = ShutdownScripts{ LocalDeliveryScript: localAddr, @@ -1632,7 +1632,7 @@ func TestRbfShutdownPendingTransitions(t *testing.T) { t.Run("responder_complete", func(t *testing.T) { firstState := *startingState firstState.IdealFeeRate = fn.Some( - chainfee.FeePerKwFloor.FeePerVByte(), + chainfee.FeePerKwFloor, ) firstState.ShutdownScripts = ShutdownScripts{ LocalDeliveryScript: localAddr, @@ -1663,7 +1663,7 @@ func TestRbfShutdownPendingTransitions(t *testing.T) { t.Run("early_remote_offer_shutdown_complete", func(t *testing.T) { firstState := *startingState firstState.IdealFeeRate = fn.Some( - chainfee.FeePerKwFloor.FeePerVByte(), + chainfee.FeePerKwFloor, ) firstState.ShutdownScripts = ShutdownScripts{ LocalDeliveryScript: localAddr, @@ -1710,7 +1710,7 @@ func TestRbfShutdownPendingTransitions(t *testing.T) { t.Run("early_remote_offer_shutdown_received", func(t *testing.T) { firstState := *startingState firstState.IdealFeeRate = fn.Some( - chainfee.FeePerKwFloor.FeePerVByte(), + chainfee.FeePerKwFloor, ) firstState.ShutdownScripts = ShutdownScripts{ LocalDeliveryScript: localAddr, @@ -1970,7 +1970,7 @@ func testSendOfferRbfIterationLoopNonTap(t *testing.T, noDustExpect, false, ) - rbfFeeBump := chainfee.FeePerKwFloor.FeePerVByte() * 10 + rbfFeeBump := chainfee.FeePerKwFloor * 10 localOffer := &SendOfferEvent{ TargetFeeRate: rbfFeeBump, } @@ -2033,7 +2033,7 @@ func testSendOfferRbfIterationLoopTaproot(t *testing.T, lnwire.Musig2Nonce{7, 8, 9}, ) - rbfFeeBump := chainfee.FeePerKwFloor.FeePerVByte() * 10 + rbfFeeBump := chainfee.FeePerKwFloor * 10 localOffer := &SendOfferEvent{ TargetFeeRate: rbfFeeBump, } @@ -2331,7 +2331,7 @@ func TestRbfCloseClosingNegotiationLocal(t *testing.T) { } sendOfferEvent := &SendOfferEvent{ - TargetFeeRate: chainfee.FeePerKwFloor.FeePerVByte(), + TargetFeeRate: chainfee.FeePerKwFloor, } balanceAfterClose := localBalance.ToSatoshis() - absoluteFee @@ -2519,7 +2519,7 @@ func TestRbfCloseClosingNegotiationLocal(t *testing.T) { // the amount we have in the channel. closeHarness.expectFeeEstimate(btcutil.SatoshiPerBitcoin, 1) - rbfFeeBump := chainfee.FeePerKwFloor.FeePerVByte() + rbfFeeBump := chainfee.FeePerKwFloor localOffer := &SendOfferEvent{ TargetFeeRate: rbfFeeBump, } @@ -2960,7 +2960,7 @@ func TestRbfCloseErr(t *testing.T) { }) defer closeHarness.stopAndAssert() - rbfFeeBump := chainfee.FeePerKwFloor.FeePerVByte() + rbfFeeBump := chainfee.FeePerKwFloor localOffer := &SendOfferEvent{ TargetFeeRate: rbfFeeBump, } @@ -3293,7 +3293,7 @@ func TestLocalOfferSentUsesStoredSig(t *testing.T) { localOfferSent := &LocalOfferSent{ CloseChannelTerms: closeTerms, ProposedFee: btcutil.Amount(1000), - ProposedFeeRate: chainfee.FeePerKwFloor.FeePerVByte(), + ProposedFeeRate: chainfee.FeePerKwFloor, LocalSig: localSchnorrSig, LocalMusigSig: fn.Some(lnwallet.MusigPartialSig{}), } diff --git a/lnwallet/chancloser/rbf_coop_transitions.go b/lnwallet/chancloser/rbf_coop_transitions.go index eb8b1d67968..a5c93a9e5e6 100644 --- a/lnwallet/chancloser/rbf_coop_transitions.go +++ b/lnwallet/chancloser/rbf_coop_transitions.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" + "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/btcutil" @@ -607,7 +608,7 @@ func (c *ChannelFlushing) ProcessEvent(event ProtocolEvent, env *Environment, localTxOut, remoteTxOut := closeTerms.DeriveCloseTxOuts() absoluteFee := env.FeeEstimator.EstimateFee( env.ChanType, localTxOut, remoteTxOut, - idealFeeRate.FeePerKWeight(), + idealFeeRate, ) chancloserLog.Infof("ChannelPoint(%v): using ideal_fee=%v, "+ @@ -1119,7 +1120,7 @@ func (l *LocalCloseStart) ProcessEvent(event ProtocolEvent, env *Environment, localTxOut, remoteTxOut := l.DeriveCloseTxOuts() absoluteFee := env.FeeEstimator.EstimateFee( env.ChanType, localTxOut, remoteTxOut, - msg.TargetFeeRate.FeePerKWeight(), + msg.TargetFeeRate, ) // If we can't actually pay for fees here, then we'll just do a @@ -2115,11 +2116,16 @@ func (l *RemoteCloseStart) ProcessEvent(event ProtocolEvent, env *Environment, // We'll also compute the final fee rate that the remote party // paid based off the absolute fee and the size of the closing // transaction. - vSize := mempool.GetTxVirtualSize(btcutil.NewTx(closeTx)) - feeRate := chainfee.SatPerVByte( - int64(msg.SigMsg.FeeSatoshis) / vSize, + weight := blockchain.GetTransactionWeight( + btcutil.NewTx(closeTx), ) + // Convert absolute fee to a fee rate in sat/kw, rounding up. + fee := int64(msg.SigMsg.FeeSatoshis) + rate := ((fee * 1000) + weight - 1) / weight + + feeRate := chainfee.SatPerKWeight(rate) + // Now that we've extracted the signature, we'll transition to // the next state where we'll sign+broadcast the sig. return &CloseStateTransition{ diff --git a/peer/brontide.go b/peer/brontide.go index 2ec32b09dca..67122beaa1f 100644 --- a/peer/brontide.go +++ b/peer/brontide.go @@ -157,10 +157,10 @@ type PendingUpdate struct { // transaction. OutputIndex uint32 - // FeePerVByte is an optional field, that is set only when the new RBF + // FeePerKw is an optional field, that is set only when the new RBF // coop close flow is used. This indicates the new closing fee rate on // the closing transaction. - FeePerVbyte fn.Option[chainfee.SatPerVByte] + FeePerKw fn.Option[chainfee.SatPerKWeight] // IsLocalCloseTx is an optional field that indicates if this update is // sent for our local close txn, or the close txn of the remote party. @@ -3728,7 +3728,7 @@ func (p *Brontide) observeRbfCloseUpdates(chanCloser *chancloser.RbfChanCloser, var ( lastTxids lntypes.Dual[chainhash.Hash] - lastFeeRates lntypes.Dual[chainfee.SatPerVByte] + lastFeeRates lntypes.Dual[chainfee.SatPerKWeight] ) maybeNotifyTxBroadcast := func(state chancloser.AsymmetricPeerState, @@ -3787,8 +3787,8 @@ func (p *Brontide) observeRbfCloseUpdates(chanCloser *chancloser.RbfChanCloser, if closeReq != nil && closingTxid != lastTxid { select { case closeReq.Updates <- &PendingUpdate{ - Txid: closingTxid[:], - FeePerVbyte: fn.Some(closePending.FeeRate), + Txid: closingTxid[:], + FeePerKw: fn.Some(closePending.FeeRate), IsLocalCloseTx: fn.Some( party == lntypes.Local, ), @@ -4056,7 +4056,7 @@ func (p *Brontide) initRbfChanCloser( ChanID: chanID, Scid: scid, ChanType: channel.ChanType(), - DefaultFeeRate: defaultFeePerKw.FeePerVByte(), + DefaultFeeRate: defaultFeePerKw, ThawHeight: fn.Some(thawHeight), RemoteUpfrontShutdown: ChooseAddr( channel.RemoteUpfrontShutdownScript(), @@ -4325,7 +4325,7 @@ func (p *Brontide) startRbfChanCloser(shutdown shutdownInit, } ctx, _ := p.cg.Create(context.Background()) - feeRate := defaultFeePerKw.FeePerVByte() + feeRate := defaultFeePerKw // Depending on the state of the state machine, we'll either // kick things off by sending shutdown, or attempt to send a new diff --git a/peer/musig_nonce_order_test.go b/peer/musig_nonce_order_test.go index 13918649c68..be877324c62 100644 --- a/peer/musig_nonce_order_test.go +++ b/peer/musig_nonce_order_test.go @@ -127,7 +127,7 @@ func TestRemoteCloseStartTaprootIntegration(t *testing.T) { aliceChan.ChannelPoint(), ), ChanType: chanType, - DefaultFeeRate: chainfee.SatPerVByte(10), + DefaultFeeRate: chainfee.SatPerKWeight(10 * 250), FeeEstimator: feeEstimator, ChanObserver: chanObserver, CloseSigner: closeSigner, diff --git a/rpcserver.go b/rpcserver.go index e62de95fb1d..cf88ddadd68 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -3207,8 +3207,11 @@ func createRPCCloseUpdate( u.IsLocalCloseTx.WhenSome(func(isLocal bool) { upd.LocalCloseTx = isLocal }) - u.FeePerVbyte.WhenSome(func(feeRate chainfee.SatPerVByte) { - upd.FeePerVbyte = int64(feeRate) + u.FeePerKw.WhenSome(func(feeRate chainfee.SatPerKWeight) { + upd.FeePerKw = uint64(feeRate) + + // Convert to vbyte fee for the RPC response. + upd.FeePerVbyte = int64(feeRate.FeePerVByte()) }) return &lnrpc.CloseStatusUpdate{