diff --git a/contractcourt/htlc_incoming_contest_resolver.go b/contractcourt/htlc_incoming_contest_resolver.go index 9ffa799437..9f39cf489d 100644 --- a/contractcourt/htlc_incoming_contest_resolver.go +++ b/contractcourt/htlc_incoming_contest_resolver.go @@ -103,7 +103,10 @@ func (h *htlcIncomingContestResolver) Resolve( // // TODO(roasbeef): Implement resolving HTLCs with custom records // (follow-up PR). - if len(h.htlc.CustomRecords) != 0 { + if len(h.htlc.CustomRecords) != 0 && h.isTapscriptRoot { + log.Warnf("Not resolving HTLC with: %v custom records", + len(h.htlc.CustomRecords)) + select { //nolint:gosimple case <-h.quit: return nil, errResolverShuttingDown diff --git a/contractcourt/htlc_outgoing_contest_resolver.go b/contractcourt/htlc_outgoing_contest_resolver.go index c75b898222..1ab9338033 100644 --- a/contractcourt/htlc_outgoing_contest_resolver.go +++ b/contractcourt/htlc_outgoing_contest_resolver.go @@ -62,7 +62,10 @@ func (h *htlcOutgoingContestResolver) Resolve( // // TODO(roasbeef): Implement resolving HTLCs with custom records // (follow-up PR). - if len(h.htlc.CustomRecords) != 0 { + if len(h.htlc.CustomRecords) != 0 && h.isTapscriptRoot { + log.Warnf("Not resolving HTLC with: %v custom records", + len(h.htlc.CustomRecords)) + select { //nolint:gosimple case <-h.quit: return nil, errResolverShuttingDown diff --git a/contractcourt/htlc_success_resolver.go b/contractcourt/htlc_success_resolver.go index 3b07828d48..434c1710cd 100644 --- a/contractcourt/htlc_success_resolver.go +++ b/contractcourt/htlc_success_resolver.go @@ -54,6 +54,12 @@ type htlcSuccessResolver struct { // htlc contains information on the htlc that we are resolving on-chain. htlc channeldb.HTLC + // isTapscriptRoot indicates whether the htlc is on a TapscriptRoot + // channel type. + // TODO: remove this when incoming HTLCs with custom records can be + // resolved for this channel type + isTapscriptRoot bool + // currentReport stores the current state of the resolver for reporting // over the rpc interface. This should only be reported in case we have // a non-nil SignDetails on the htlcResolution, otherwise the nursery @@ -127,7 +133,10 @@ func (h *htlcSuccessResolver) Resolve( // // TODO(roasbeef): Implement resolving HTLCs with custom records // (follow-up PR). - if len(h.htlc.CustomRecords) != 0 { + if len(h.htlc.CustomRecords) != 0 && h.isTapscriptRoot { + log.Warnf("Not resolving incoming htlc with %v custom records", + len(h.htlc.CustomRecords)) + select { //nolint:gosimple case <-h.quit: return nil, errResolverShuttingDown @@ -739,6 +748,17 @@ func (h *htlcSuccessResolver) HtlcPoint() wire.OutPoint { func (h *htlcSuccessResolver) SupplementDeadline(_ fn.Option[int32]) { } +// SupplementState allows the user of a ContractResolver to supplement it with +// state required for the proper resolution of a contract. +// +// NOTE: Part of the ContractResolver interface. +func (h *htlcIncomingContestResolver) SupplementState( + state *channeldb.OpenChannel) { + + h.isTapscriptRoot = state.ChanType.HasTapscriptRoot() + h.htlcLeaseResolver.SupplementState(state) +} + // A compile time assertion to ensure htlcSuccessResolver meets the // ContractResolver interface. var _ htlcContractResolver = (*htlcSuccessResolver)(nil) diff --git a/contractcourt/htlc_timeout_resolver.go b/contractcourt/htlc_timeout_resolver.go index 670da607d4..9271de52fe 100644 --- a/contractcourt/htlc_timeout_resolver.go +++ b/contractcourt/htlc_timeout_resolver.go @@ -50,6 +50,12 @@ type htlcTimeoutResolver struct { // htlc contains information on the htlc that we are resolving on-chain. htlc channeldb.HTLC + // isTapscriptRoot indicates whether the htlc is on a TapscriptRoot + // channel type. + // TODO: remove this when incoming HTLCs with custom records can be + // resolved for this channel type + isTapscriptRoot bool + // currentReport stores the current state of the resolver for reporting // over the rpc interface. This should only be reported in case we have // a non-nil SignDetails on the htlcResolution, otherwise the nursery @@ -430,7 +436,10 @@ func (h *htlcTimeoutResolver) Resolve( // // TODO(roasbeef): Implement resolving HTLCs with custom records // (follow-up PR). - if len(h.htlc.CustomRecords) != 0 { + if len(h.htlc.CustomRecords) != 0 && h.isTapscriptRoot { + log.Warnf("Not resolving incoming htlc with %v custom records", + len(h.htlc.CustomRecords)) + select { //nolint:gosimple case <-h.quit: return nil, errResolverShuttingDown @@ -1095,6 +1104,15 @@ func (h *htlcTimeoutResolver) SupplementDeadline(d fn.Option[int32]) { h.incomingHTLCExpiryHeight = d } +// SupplementState allows the user of a ContractResolver to supplement it with +// state required for the proper resolution of a contract. +// +// NOTE: Part of the ContractResolver interface. +func (h *htlcTimeoutResolver) SupplementState(state *channeldb.OpenChannel) { + h.isTapscriptRoot = state.ChanType.HasTapscriptRoot() + h.htlcLeaseResolver.SupplementState(state) +} + // A compile time assertion to ensure htlcTimeoutResolver meets the // ContractResolver interface. var _ htlcContractResolver = (*htlcTimeoutResolver)(nil) diff --git a/docs/release-notes/release-notes-0.19.0.md b/docs/release-notes/release-notes-0.19.0.md index 8e83deeb8d..5233eda1c2 100644 --- a/docs/release-notes/release-notes-0.19.0.md +++ b/docs/release-notes/release-notes-0.19.0.md @@ -41,6 +41,12 @@ cases. # New Features + +* [Support](https://github.com/lightningnetwork/lnd/pull/8390) for + [experimental endorsement](https://github.com/lightning/blips/pull/27) + signal relay was added. This signal has *no impact* on routing, and + is deployed experimentally to assist ongoing channel jamming research. + ## Functional Enhancements ## RPC Additions @@ -173,6 +179,7 @@ * Animesh Bilthare * Boris Nagaev +* Carla Kirk-Cohen * CharlieZKSmith * Elle Mouton * George Tsagkarelis diff --git a/feature/default_sets.go b/feature/default_sets.go index 4a9b2bf64d..616abc8ba3 100644 --- a/feature/default_sets.go +++ b/feature/default_sets.go @@ -96,4 +96,7 @@ var defaultSetDesc = setDesc{ SetInit: {}, // I SetNodeAnn: {}, // N }, + lnwire.ExperimentalEndorsementOptional: { + SetNodeAnn: {}, // N + }, } diff --git a/feature/manager.go b/feature/manager.go index 89f1d4b6bc..e0bcfc96bb 100644 --- a/feature/manager.go +++ b/feature/manager.go @@ -66,6 +66,10 @@ type Config struct { // NoTaprootOverlay unsets the taproot overlay channel feature bits. NoTaprootOverlay bool + // NoExperimentalEndorsement unsets any bits that signal support for + // forwarding experimental endorsement. + NoExperimentalEndorsement bool + // CustomFeatures is a set of custom features to advertise in each // set. CustomFeatures map[Set][]lnwire.FeatureBit @@ -199,6 +203,12 @@ func newManager(cfg Config, desc setDesc) (*Manager, error) { raw.Unset(lnwire.SimpleTaprootOverlayChansOptional) raw.Unset(lnwire.SimpleTaprootOverlayChansRequired) } + + if cfg.NoExperimentalEndorsement { + raw.Unset(lnwire.ExperimentalEndorsementOptional) + raw.Unset(lnwire.ExperimentalEndorsementRequired) + } + for _, custom := range cfg.CustomFeatures[set] { if custom > set.Maximum() { return nil, fmt.Errorf("feature bit: %v "+ diff --git a/htlcswitch/interceptable_switch.go b/htlcswitch/interceptable_switch.go index 71302bf075..0d1fec9aed 100644 --- a/htlcswitch/interceptable_switch.go +++ b/htlcswitch/interceptable_switch.go @@ -715,9 +715,12 @@ func (f *interceptedForward) ResumeModified( htlc.Amount = amount }) - if len(validatedRecords) > 0 { - htlc.CustomRecords = validatedRecords - } + // Merge custom records with any validated records that were + // added in the modify request, overwriting any existing values + // with those supplied in the modifier API. + htlc.CustomRecords = htlc.CustomRecords.MergedCopy( + validatedRecords, + ) case *lnwire.UpdateFulfillHTLC: if len(validatedRecords) > 0 { diff --git a/htlcswitch/link.go b/htlcswitch/link.go index 383a28546f..f4d3b52ce1 100644 --- a/htlcswitch/link.go +++ b/htlcswitch/link.go @@ -30,6 +30,7 @@ import ( "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/queue" + "github.com/lightningnetwork/lnd/record" "github.com/lightningnetwork/lnd/ticker" "github.com/lightningnetwork/lnd/tlv" ) @@ -285,6 +286,10 @@ type ChannelLinkConfig struct { // MaxFeeExposure is the threshold in milli-satoshis after which we'll // restrict the flow of HTLCs and fee updates. MaxFeeExposure lnwire.MilliSatoshi + + // ShouldFwdExpEndorsement is a closure that indicates whether the link + // should forward experimental endorsement signals. + ShouldFwdExpEndorsement func() bool } // channelLink is the service which drives a channel's commitment update @@ -3624,6 +3629,13 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) { continue } + endorseValue := l.experimentalEndorsement( + record.CustomSet(add.CustomRecords), + ) + endorseType := uint64( + lnwire.ExperimentalEndorsementType, + ) + switch fwdPkg.State { case channeldb.FwdStateProcessed: // This add was not forwarded on the previous @@ -3645,6 +3657,14 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) { BlindingPoint: fwdInfo.NextBlinding, } + endorseValue.WhenSome(func(e byte) { + custRecords := map[uint64][]byte{ + endorseType: {e}, + } + + outgoingAdd.CustomRecords = custRecords + }) + // Finally, we'll encode the onion packet for // the _next_ hop using the hop iterator // decoded for the current hop. @@ -3695,6 +3715,12 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) { BlindingPoint: fwdInfo.NextBlinding, } + endorseValue.WhenSome(func(e byte) { + addMsg.CustomRecords = map[uint64][]byte{ + endorseType: {e}, + } + }) + // Finally, we'll encode the onion packet for the // _next_ hop using the hop iterator decoded for the // current hop. @@ -3782,6 +3808,46 @@ func (l *channelLink) processRemoteAdds(fwdPkg *channeldb.FwdPkg) { l.forwardBatch(replay, switchPackets...) } +// experimentalEndorsement returns the value to set for our outgoing +// experimental endorsement field, and a boolean indicating whether it should +// be populated on the outgoing htlc. +func (l *channelLink) experimentalEndorsement( + customUpdateAdd record.CustomSet) fn.Option[byte] { + + // Only relay experimental signal if we are within the experiment + // period. + if !l.cfg.ShouldFwdExpEndorsement() { + return fn.None[byte]() + } + + // If we don't have any custom records or the experimental field is + // not set, just forward a zero value. + if len(customUpdateAdd) == 0 { + return fn.Some[byte](lnwire.ExperimentalUnendorsed) + } + + t := uint64(lnwire.ExperimentalEndorsementType) + value, set := customUpdateAdd[t] + if !set { + return fn.Some[byte](lnwire.ExperimentalUnendorsed) + } + + // We expect at least one byte for this field, consider it invalid if + // it has no data and just forward a zero value. + if len(value) == 0 { + return fn.Some[byte](lnwire.ExperimentalUnendorsed) + } + + // Only forward endorsed if the incoming link is endorsed. + if value[0] == lnwire.ExperimentalEndorsed { + return fn.Some[byte](lnwire.ExperimentalEndorsed) + } + + // Forward as unendorsed otherwise, including cases where we've + // received an invalid value that uses more than 3 bits of information. + return fn.Some[byte](lnwire.ExperimentalUnendorsed) +} + // processExitHop handles an htlc for which this link is the exit hop. It // returns a boolean indicating whether the commitment tx needs an update. func (l *channelLink) processExitHop(add lnwire.UpdateAddHTLC, diff --git a/htlcswitch/link_test.go b/htlcswitch/link_test.go index 78bb99d04e..c72a255384 100644 --- a/htlcswitch/link_test.go +++ b/htlcswitch/link_test.go @@ -2245,6 +2245,7 @@ func newSingleLinkTestHarness(t *testing.T, chanAmt, NotifyInactiveLinkEvent: func(wire.OutPoint) {}, HtlcNotifier: aliceSwitch.cfg.HtlcNotifier, GetAliases: getAliases, + ShouldFwdExpEndorsement: func() bool { return true }, } aliceLink := NewChannelLink(aliceCfg, aliceLc.channel) @@ -4888,6 +4889,8 @@ func (h *persistentLinkHarness) restartLink( // Instantiate with a long interval, so that we can precisely control // the firing via force feeding. bticker := ticker.NewForce(time.Hour) + + //nolint:lll aliceCfg := ChannelLinkConfig{ FwrdingPolicy: globalPolicy, Peer: alicePeer, @@ -4932,6 +4935,7 @@ func (h *persistentLinkHarness) restartLink( HtlcNotifier: h.hSwitch.cfg.HtlcNotifier, SyncStates: syncStates, GetAliases: getAliases, + ShouldFwdExpEndorsement: func() bool { return true }, } aliceLink := NewChannelLink(aliceCfg, aliceChannel) diff --git a/htlcswitch/test_utils.go b/htlcswitch/test_utils.go index 3719d7ae4c..cdb4f1f4ea 100644 --- a/htlcswitch/test_utils.go +++ b/htlcswitch/test_utils.go @@ -1154,6 +1154,7 @@ func (h *hopNetwork) createChannelLink(server, peer *mockServer, return server.htlcSwitch.ForwardPackets(linkQuit, packets...) } + //nolint:lll link := NewChannelLink( ChannelLinkConfig{ BestHeight: server.htlcSwitch.BestHeight, @@ -1193,6 +1194,7 @@ func (h *hopNetwork) createChannelLink(server, peer *mockServer, NotifyInactiveLinkEvent: func(wire.OutPoint) {}, HtlcNotifier: server.htlcSwitch.cfg.HtlcNotifier, GetAliases: getAliases, + ShouldFwdExpEndorsement: func() bool { return true }, }, channel, ) diff --git a/itest/list_on_test.go b/itest/list_on_test.go index c5fa7cebf3..d48ad78b37 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -702,4 +702,8 @@ var allTestCases = []*lntest.TestCase{ Name: "send to route failed htlc timeout", TestFunc: testSendToRouteFailHTLCTimeout, }, + { + Name: "experimental endorsement", + TestFunc: testExperimentalEndorsement, + }, } diff --git a/itest/lnd_experimental_endorsement.go b/itest/lnd_experimental_endorsement.go new file mode 100644 index 0000000000..de2bee275e --- /dev/null +++ b/itest/lnd_experimental_endorsement.go @@ -0,0 +1,123 @@ +package itest + +import ( + "math" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" + "github.com/lightningnetwork/lnd/lntest" + "github.com/lightningnetwork/lnd/lntest/rpc" + "github.com/lightningnetwork/lnd/lntest/wait" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" +) + +// testExperimentalEndorsement tests setting of positive and negative +// experimental endorsement signals. +func testExperimentalEndorsement(ht *lntest.HarnessTest) { + testEndorsement(ht, true) + testEndorsement(ht, false) +} + +// testEndorsement sets up a 5 hop network and tests propagation of +// experimental endorsement signals. +func testEndorsement(ht *lntest.HarnessTest, aliceEndorse bool) { + alice, bob := ht.Alice, ht.Bob + carol := ht.NewNode( + "carol", []string{"--protocol.no-experimental-endorsement"}, + ) + dave := ht.NewNode("dave", nil) + eve := ht.NewNode("eve", nil) + + ht.EnsureConnected(alice, bob) + ht.EnsureConnected(bob, carol) + ht.EnsureConnected(carol, dave) + ht.EnsureConnected(dave, eve) + + ht.FundCoins(btcutil.SatoshiPerBitcoin, carol) + ht.FundCoins(btcutil.SatoshiPerBitcoin, dave) + // Open and wait for channels. + const chanAmt = btcutil.Amount(300000) + p := lntest.OpenChannelParams{Amt: chanAmt} + reqs := []*lntest.OpenChannelRequest{ + {Local: alice, Remote: bob, Param: p}, + {Local: bob, Remote: carol, Param: p}, + {Local: carol, Remote: dave, Param: p}, + {Local: dave, Remote: eve, Param: p}, + } + resp := ht.OpenMultiChannelsAsync(reqs) + cpAB, cpBC, cpCD, cpDE := resp[0], resp[1], resp[2], resp[3] + + // Make sure Alice is aware of Bob=>Carol=>Dave=>Eve channels. + ht.AssertTopologyChannelOpen(alice, cpBC) + ht.AssertTopologyChannelOpen(alice, cpCD) + ht.AssertTopologyChannelOpen(alice, cpDE) + + bobIntercept, cancelBob := bob.RPC.HtlcInterceptor() + defer cancelBob() + + carolIntercept, cancelCarol := carol.RPC.HtlcInterceptor() + defer cancelCarol() + + daveIntercept, cancelDave := dave.RPC.HtlcInterceptor() + defer cancelDave() + + req := &lnrpc.Invoice{ValueMsat: 1000} + addResponse := eve.RPC.AddInvoice(req) + invoice := eve.RPC.LookupInvoice(addResponse.RHash) + + sendReq := &routerrpc.SendPaymentRequest{ + PaymentRequest: invoice.PaymentRequest, + TimeoutSeconds: int32(wait.PaymentTimeout.Seconds()), + FeeLimitMsat: math.MaxInt64, + } + + expectedValue := []byte{lnwire.ExperimentalUnendorsed} + if aliceEndorse { + expectedValue = []byte{lnwire.ExperimentalEndorsed} + t := uint64(lnwire.ExperimentalEndorsementType) + sendReq.FirstHopCustomRecords = map[uint64][]byte{ + t: expectedValue, + } + } + + _ = alice.RPC.SendPayment(sendReq) + + // Validate that our signal (positive or zero) propagates until carol + // and then is dropped because she has disabled the feature. + validateEndorsedAndResume(ht, bobIntercept, true, expectedValue) + validateEndorsedAndResume(ht, carolIntercept, true, expectedValue) + validateEndorsedAndResume(ht, daveIntercept, false, nil) + + var preimage lntypes.Preimage + copy(preimage[:], invoice.RPreimage) + ht.AssertPaymentStatus(alice, preimage, lnrpc.Payment_SUCCEEDED) + + ht.CloseChannel(alice, cpAB) + ht.CloseChannel(bob, cpBC) + ht.CloseChannel(carol, cpCD) +} + +func validateEndorsedAndResume(ht *lntest.HarnessTest, + interceptor rpc.InterceptorClient, hasEndorsement bool, + expectedValue []byte) { + + packet := ht.ReceiveHtlcInterceptor(interceptor) + + var expectedRecords map[uint64][]byte + if hasEndorsement { + u64Type := uint64(lnwire.ExperimentalEndorsementType) + expectedRecords = map[uint64][]byte{ + u64Type: expectedValue, + } + } + require.Equal(ht, expectedRecords, packet.InWireCustomRecords) + + err := interceptor.Send(&routerrpc.ForwardHtlcInterceptResponse{ + IncomingCircuitKey: packet.IncomingCircuitKey, + Action: routerrpc.ResolveHoldForwardAction_RESUME, + }) + require.NoError(ht, err) +} diff --git a/itest/lnd_forward_interceptor_test.go b/itest/lnd_forward_interceptor_test.go index 6148ba7f1a..441c2035fd 100644 --- a/itest/lnd_forward_interceptor_test.go +++ b/itest/lnd_forward_interceptor_test.go @@ -515,12 +515,9 @@ func testForwardInterceptorWireRecords(ht *lntest.HarnessTest) { // all intercepted packets. These packets are held to simulate a // pending payment. packet := ht.ReceiveHtlcInterceptor(bobInterceptor) - - require.Len(ht, packet.InWireCustomRecords, 1) - - val, ok := packet.InWireCustomRecords[65537] - require.True(ht, ok, "expected custom record") - require.Equal(ht, []byte("test"), val) + require.Equal(ht, lntest.CustomRecordsWithUnendorsed( + customRecords, + ), packet.InWireCustomRecords) // Just resume the payment on Bob. err := bobInterceptor.Send(&routerrpc.ForwardHtlcInterceptResponse{ @@ -530,9 +527,10 @@ func testForwardInterceptorWireRecords(ht *lntest.HarnessTest) { require.NoError(ht, err, "failed to send request") // Assert that the Alice -> Bob custom records in update_add_htlc are - // not propagated on the Bob -> Carol link. + // not propagated on the Bob -> Carol link, just an endorsement signal. packet = ht.ReceiveHtlcInterceptor(carolInterceptor) - require.Len(ht, packet.InWireCustomRecords, 0) + require.Equal(ht, lntest.CustomRecordsWithUnendorsed(nil), + packet.InWireCustomRecords) // We're going to tell Carol to forward 5k sats less to Dave. We need to // set custom records on the HTLC as well, to make sure the HTLC isn't @@ -568,7 +566,9 @@ func testForwardInterceptorWireRecords(ht *lntest.HarnessTest) { func(p *lnrpc.Payment) error { recordsEqual := reflect.DeepEqual( p.FirstHopCustomRecords, - sendReq.FirstHopCustomRecords, + lntest.CustomRecordsWithUnendorsed( + customRecords, + ), ) if !recordsEqual { return fmt.Errorf("expected custom records to "+ @@ -642,9 +642,9 @@ func testForwardInterceptorRestart(ht *lntest.HarnessTest) { // all intercepted packets. These packets are held to simulate a // pending payment. packet := ht.ReceiveHtlcInterceptor(bobInterceptor) - - require.Len(ht, packet.InWireCustomRecords, 1) - require.Equal(ht, customRecords, packet.InWireCustomRecords) + require.Equal(ht, lntest.CustomRecordsWithUnendorsed( + customRecords, + ), packet.InWireCustomRecords) // We accept the payment at Bob and resume it, so it gets to Carol. // This means the HTLC should now be fully locked in on Alice's side and @@ -680,8 +680,9 @@ func testForwardInterceptorRestart(ht *lntest.HarnessTest) { // We should get another notification about the held HTLC. packet = ht.ReceiveHtlcInterceptor(bobInterceptor) - require.Len(ht, packet.InWireCustomRecords, 1) - require.Equal(ht, customRecords, packet.InWireCustomRecords) + require.Len(ht, packet.InWireCustomRecords, 2) + require.Equal(ht, lntest.CustomRecordsWithUnendorsed(customRecords), + packet.InWireCustomRecords) err = carolInterceptor.Send(&routerrpc.ForwardHtlcInterceptResponse{ IncomingCircuitKey: packet.IncomingCircuitKey, @@ -689,9 +690,10 @@ func testForwardInterceptorRestart(ht *lntest.HarnessTest) { }) require.NoError(ht, err, "failed to send request") - // And now we forward the payment at Carol. + // And now we forward the payment at Carol, expecting only an + // endorsement signal in our incoming custom records. packet = ht.ReceiveHtlcInterceptor(carolInterceptor) - require.Len(ht, packet.InWireCustomRecords, 0) + require.Len(ht, packet.InWireCustomRecords, 1) err = carolInterceptor.Send(&routerrpc.ForwardHtlcInterceptResponse{ IncomingCircuitKey: packet.IncomingCircuitKey, Action: actionResume, @@ -703,8 +705,9 @@ func testForwardInterceptorRestart(ht *lntest.HarnessTest) { alice, preimage, lnrpc.Payment_SUCCEEDED, func(p *lnrpc.Payment) error { recordsEqual := reflect.DeepEqual( - p.FirstHopCustomRecords, - sendReq.FirstHopCustomRecords, + lntest.CustomRecordsWithUnendorsed( + sendReq.FirstHopCustomRecords, + ), p.FirstHopCustomRecords, ) if !recordsEqual { return fmt.Errorf("expected custom records to "+ diff --git a/itest/lnd_invoice_acceptor_test.go b/itest/lnd_invoice_acceptor_test.go index 5a4a35ba05..4462343377 100644 --- a/itest/lnd_invoice_acceptor_test.go +++ b/itest/lnd_invoice_acceptor_test.go @@ -102,9 +102,12 @@ func testInvoiceHtlcModifierBasic(ht *lntest.HarnessTest) { require.EqualValues( ht, tc.sendAmountMsat, modifierRequest.ExitHtlcAmt, ) + + // Expect custom records plus endorsement signal. require.Equal( - ht, tc.lastHopCustomRecords, - modifierRequest.ExitHtlcWireCustomRecords, + ht, lntest.CustomRecordsWithUnendorsed( + tc.lastHopCustomRecords, + ), modifierRequest.ExitHtlcWireCustomRecords, ) // For all other packets we resolve according to the test case. @@ -140,8 +143,9 @@ func testInvoiceHtlcModifierBasic(ht *lntest.HarnessTest) { require.Len(ht, updatedInvoice.Htlcs, 1) require.Equal( - ht, tc.lastHopCustomRecords, - updatedInvoice.Htlcs[0].CustomRecords, + ht, lntest.CustomRecordsWithUnendorsed( + tc.lastHopCustomRecords, + ), updatedInvoice.Htlcs[0].CustomRecords, ) // Make sure the custom channel data contains the encoded diff --git a/lncfg/protocol.go b/lncfg/protocol.go index c670b18947..80809f49d6 100644 --- a/lncfg/protocol.go +++ b/lncfg/protocol.go @@ -67,6 +67,9 @@ type ProtocolOptions struct { // NoRouteBlindingOption disables forwarding of payments in blinded routes. NoRouteBlindingOption bool `long:"no-route-blinding" description:"do not forward payments that are a part of a blinded route"` + // NoExperimentalEndorsementOption disables experimental endorsement. + NoExperimentalEndorsementOption bool `long:"no-experimental-endorsement" description:"do not forward experimental endorsement signals"` + // CustomMessage allows the custom message APIs to handle messages with // the provided protocol numbers, which fall outside the custom message // number range. @@ -132,6 +135,12 @@ func (l *ProtocolOptions) NoRouteBlinding() bool { return l.NoRouteBlindingOption } +// NoExperimentalEndorsement returns true if experimental endorsement should +// be disabled. +func (l *ProtocolOptions) NoExperimentalEndorsement() bool { + return l.NoExperimentalEndorsementOption +} + // CustomMessageOverrides returns the set of protocol messages that we override // to allow custom handling. func (p ProtocolOptions) CustomMessageOverrides() []uint16 { diff --git a/lncfg/protocol_integration.go b/lncfg/protocol_integration.go index 5c5150a0ef..52cc658c3b 100644 --- a/lncfg/protocol_integration.go +++ b/lncfg/protocol_integration.go @@ -70,6 +70,9 @@ type ProtocolOptions struct { // NoRouteBlindingOption disables forwarding of payments in blinded routes. NoRouteBlindingOption bool `long:"no-route-blinding" description:"do not forward payments that are a part of a blinded route"` + // NoExperimentalEndorsementOption disables experimental endorsement. + NoExperimentalEndorsementOption bool `long:"no-experimental-endorsement" description:"do not forward experimental endorsement signals"` + // CustomMessage allows the custom message APIs to handle messages with // the provided protocol numbers, which fall outside the custom message // number range. @@ -127,6 +130,12 @@ func (l *ProtocolOptions) NoRouteBlinding() bool { return l.NoRouteBlindingOption } +// NoExperimentalEndorsement returns true if experimental endorsement should +// be disabled. +func (l *ProtocolOptions) NoExperimentalEndorsement() bool { + return l.NoExperimentalEndorsementOption +} + // CustomMessageOverrides returns the set of protocol messages that we override // to allow custom handling. func (l ProtocolOptions) CustomMessageOverrides() []uint16 { diff --git a/lnrpc/routerrpc/router.pb.go b/lnrpc/routerrpc/router.pb.go index a8fa5b0d40..5c6d798bd3 100644 --- a/lnrpc/routerrpc/router.pb.go +++ b/lnrpc/routerrpc/router.pb.go @@ -3254,13 +3254,9 @@ type ForwardHtlcInterceptResponse struct { // the resumed HTLC. This field is ignored if the action is not // RESUME_MODIFIED. // - // If this map is populated, it will overwrite any of the wire custom - // records set by LND. It is the caller's responsibility to copy any desired - // records across. If the map is empty, no action will be taken and existing - // custom records will be propagated. - // - // The API does not currently support deleting custom records, unless they - // are overwritten by a new set. + // This map will merge with the existing set of custom records (if any), + // replacing any conflicting types. Note that there currently is no support + // deleting existing custom records (they can only be replaced). OutWireCustomRecords map[uint64][]byte `protobuf:"bytes,8,rep,name=out_wire_custom_records,json=outWireCustomRecords,proto3" json:"out_wire_custom_records,omitempty" protobuf_key:"varint,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` } diff --git a/lnrpc/routerrpc/router.proto b/lnrpc/routerrpc/router.proto index 6eb928e31e..ab3c4ce4a7 100644 --- a/lnrpc/routerrpc/router.proto +++ b/lnrpc/routerrpc/router.proto @@ -1064,13 +1064,9 @@ message ForwardHtlcInterceptResponse { // the resumed HTLC. This field is ignored if the action is not // RESUME_MODIFIED. // - // If this map is populated, it will overwrite any of the wire custom - // records set by LND. It is the caller's responsibility to copy any desired - // records across. If the map is empty, no action will be taken and existing - // custom records will be propagated. - // - // The API does not currently support deleting custom records, unless they - // are overwritten by a new set. + // This map will merge with the existing set of custom records (if any), + // replacing any conflicting types. Note that there currently is no support + // deleting existing custom records (they can only be replaced). map out_wire_custom_records = 8; } @@ -1119,4 +1115,4 @@ message DeleteAliasesRequest { message DeleteAliasesResponse { repeated lnrpc.AliasMap alias_maps = 1; -} \ No newline at end of file +} diff --git a/lnrpc/routerrpc/router.swagger.json b/lnrpc/routerrpc/router.swagger.json index 157e2e7bb3..158bbcfc9e 100644 --- a/lnrpc/routerrpc/router.swagger.json +++ b/lnrpc/routerrpc/router.swagger.json @@ -1525,7 +1525,7 @@ "type": "string", "format": "byte" }, - "description": "Any custom records that should be set on the p2p wire message message of\nthe resumed HTLC. This field is ignored if the action is not\nRESUME_MODIFIED.\n\nIf this map is populated, it will overwrite any of the wire custom\nrecords set by LND. It is the caller's responsibility to copy any desired\nrecords across. If the map is empty, no action will be taken and existing\ncustom records will be propagated.\n\nThe API does not currently support deleting custom records, unless they\nare overwritten by a new set." + "description": "Any custom records that should be set on the p2p wire message message of\nthe resumed HTLC. This field is ignored if the action is not\nRESUME_MODIFIED.\n\nThis map will merge with the existing set of custom records (if any),\nreplacing any conflicting types. Note that there currently is no support\ndeleting existing custom records (they can only be replaced)." } }, "description": "*\nForwardHtlcInterceptResponse enables the caller to resolve a previously hold\nforward. The caller can choose either to:\n- `Resume`: Execute the default behavior (usually forward).\n- `ResumeModified`: Execute the default behavior (usually forward) with HTLC\nfield modifications.\n- `Reject`: Fail the htlc backwards.\n- `Settle`: Settle this htlc with a given preimage." diff --git a/lnrpc/routerrpc/router_backend.go b/lnrpc/routerrpc/router_backend.go index f2d21750ae..9421e991b6 100644 --- a/lnrpc/routerrpc/router_backend.go +++ b/lnrpc/routerrpc/router_backend.go @@ -110,6 +110,10 @@ type RouterBackend struct { // ParseCustomChannelData is a function that can be used to parse custom // channel data from the first hop of a route. ParseCustomChannelData func(message proto.Message) error + + // ShouldSetExpEndorsement returns a boolean indicating whether the + // experimental endorsement bit should be set. + ShouldSetExpEndorsement func() bool } // MissionControl defines the mission control dependencies of routerrpc. @@ -891,6 +895,23 @@ func (r *RouterBackend) extractIntentFromSendRequest( } payIntent.FirstHopCustomRecords = firstHopRecords + // If the experimental endorsement signal is not already set, propagate + // a zero value field if configured to set this signal. + if r.ShouldSetExpEndorsement() { + if payIntent.FirstHopCustomRecords == nil { + payIntent.FirstHopCustomRecords = make( + map[uint64][]byte, + ) + } + + t := uint64(lnwire.ExperimentalEndorsementType) + if _, set := payIntent.FirstHopCustomRecords[t]; !set { + payIntent.FirstHopCustomRecords[t] = []byte{ + lnwire.ExperimentalUnendorsed, + } + } + } + payIntent.PayAttemptTimeout = time.Second * time.Duration(rpcPayReq.TimeoutSeconds) diff --git a/lntest/utils.go b/lntest/utils.go index d4ca705c31..feaae57e7b 100644 --- a/lntest/utils.go +++ b/lntest/utils.go @@ -282,3 +282,15 @@ func CalcStaticFeeBuffer(c lnrpc.CommitmentType, numHTLCs int) btcutil.Amount { return feeBuffer.ToSatoshis() } + +// CustomRecordsWithUnendorsed copies the map of custom records and adds an +// endorsed signal (replacing in the case of conflict) for assertion in tests. +func CustomRecordsWithUnendorsed( + originalRecords lnwire.CustomRecords) map[uint64][]byte { + + return originalRecords.MergedCopy(map[uint64][]byte{ + uint64(lnwire.ExperimentalEndorsementType): { + lnwire.ExperimentalUnendorsed, + }}, + ) +} diff --git a/lnwire/features.go b/lnwire/features.go index c597b03988..bc6204f424 100644 --- a/lnwire/features.go +++ b/lnwire/features.go @@ -263,6 +263,14 @@ const ( // being finalized. SimpleTaprootChannelsOptionalStaging = 181 + // ExperimentalEndorsementRequired is a required feature bit that + // indicates that the node will relay experimental endorsement signals. + ExperimentalEndorsementRequired FeatureBit = 260 + + // ExperimentalEndorsementOptional is an optional feature bit that + // indicates that the node will relay experimental endorsement signals. + ExperimentalEndorsementOptional FeatureBit = 261 + // Bolt11BlindedPathsRequired is a required feature bit that indicates // that the node is able to understand the blinded path tagged field in // a BOLT 11 invoice. @@ -349,6 +357,8 @@ var Features = map[FeatureBit]string{ SimpleTaprootChannelsOptionalStaging: "simple-taproot-chans-x", SimpleTaprootOverlayChansOptional: "taproot-overlay-chans", SimpleTaprootOverlayChansRequired: "taproot-overlay-chans", + ExperimentalEndorsementRequired: "endorsement-x", + ExperimentalEndorsementOptional: "endorsement-x", Bolt11BlindedPathsOptional: "bolt-11-blinded-paths", Bolt11BlindedPathsRequired: "bolt-11-blinded-paths", } diff --git a/lnwire/update_add_htlc.go b/lnwire/update_add_htlc.go index 0a377e710f..5251748f0b 100644 --- a/lnwire/update_add_htlc.go +++ b/lnwire/update_add_htlc.go @@ -8,11 +8,29 @@ import ( "github.com/lightningnetwork/lnd/tlv" ) -// OnionPacketSize is the size of the serialized Sphinx onion packet included -// in each UpdateAddHTLC message. The breakdown of the onion packet is as -// follows: 1-byte version, 33-byte ephemeral public key (for ECDH), 1300-bytes -// of per-hop data, and a 32-byte HMAC over the entire packet. -const OnionPacketSize = 1366 +const ( + // OnionPacketSize is the size of the serialized Sphinx onion packet + // included in each UpdateAddHTLC message. The breakdown of the onion + // packet is as follows: 1-byte version, 33-byte ephemeral public key + // (for ECDH), 1300-bytes of per-hop data, and a 32-byte HMAC over the + // entire packet. + OnionPacketSize = 1366 + + // ExperimentalEndorsementType is the TLV type used for a custom + // record that sets an experimental endorsement value. + ExperimentalEndorsementType tlv.Type = 106823 + + // ExperimentalUnendorsed is the value that the experimental endorsement + // field contains when a htlc is not endorsed. + ExperimentalUnendorsed = 0 + + // ExperimentalEndorsed is the value that the experimental endorsement + // field contains when a htlc is endorsed. We're using a single byte + // to represent our endorsement value, but limit the value to using + // the first three bits (max value = 00000111). Interpreted as a uint8 + // (an alias for byte in go), we can just define this constant as 7. + ExperimentalEndorsed = 7 +) type ( // BlindingPointTlvType is the type for ephemeral pubkeys used in diff --git a/peer/brontide.go b/peer/brontide.go index ae4d373cac..14722fbd38 100644 --- a/peer/brontide.go +++ b/peer/brontide.go @@ -424,6 +424,10 @@ type Config struct { // used to modify the way the co-op close transaction is constructed. AuxChanCloser fn.Option[chancloser.AuxChanCloser] + // ShouldFwdExpEndorsement is a closure that indicates whether + // experimental endorsement signals should be set. + ShouldFwdExpEndorsement func() bool + // Quit is the server's quit channel. If this is closed, we halt operation. Quit chan struct{} } @@ -1319,6 +1323,7 @@ func (p *Brontide) addLink(chanPoint *wire.OutPoint, PreviouslySentShutdown: shutdownMsg, DisallowRouteBlinding: p.cfg.DisallowRouteBlinding, MaxFeeExposure: p.cfg.MaxFeeExposure, + ShouldFwdExpEndorsement: p.cfg.ShouldFwdExpEndorsement, } // Before adding our new link, purge the switch of any pending or live diff --git a/rpcserver.go b/rpcserver.go index 43d922c383..124df10329 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -44,6 +44,7 @@ import ( "github.com/lightningnetwork/lnd/channeldb/graphsession" "github.com/lightningnetwork/lnd/channeldb/models" "github.com/lightningnetwork/lnd/channelnotifier" + "github.com/lightningnetwork/lnd/clock" "github.com/lightningnetwork/lnd/contractcourt" "github.com/lightningnetwork/lnd/discovery" "github.com/lightningnetwork/lnd/feature" @@ -758,6 +759,15 @@ func (r *rpcServer) addDeps(s *server, macService *macaroons.Service, return nil }, + ShouldSetExpEndorsement: func() bool { + if s.cfg.ProtocolOptions.NoExperimentalEndorsement() { + return false + } + + return clock.NewDefaultClock().Now().Before( + EndorsementExperimentEnd, + ) + }, } genInvoiceFeatures := func() *lnwire.FeatureVector { diff --git a/sample-lnd.conf b/sample-lnd.conf index 6d64603e8f..0ef2d56f9f 100644 --- a/sample-lnd.conf +++ b/sample-lnd.conf @@ -1377,6 +1377,9 @@ ; Set to disable blinded route forwarding. ; protocol.no-route-blinding=false +; Set to disable experimental endorsement signaling. +; protocol.no-experimental-endorsement=false + ; Set to handle messages of a particular type that falls outside of the ; custom message number range (i.e. 513 is onion messages). Note that you can ; set this option as many times as you want to support more than one custom diff --git a/server.go b/server.go index 73456735a8..b9ec25898c 100644 --- a/server.go +++ b/server.go @@ -133,6 +133,12 @@ var ( // // TODO(roasbeef): add command line param to modify. MaxFundingAmount = funding.MaxBtcFundingAmount + + // EndorsementExperimentEnd is the time after which nodes should stop + // propagating experimental endorsement signals. + // + // Per blip04: January 1, 2026 12:00:00 AM UTC in unix seconds. + EndorsementExperimentEnd = time.Unix(1767225600, 0) ) // errPeerAlreadyConnected is an error returned by the server when we're @@ -559,19 +565,20 @@ func newServer(cfg *Config, listenAddrs []net.Addr, //nolint:lll featureMgr, err := feature.NewManager(feature.Config{ - NoTLVOnion: cfg.ProtocolOptions.LegacyOnion(), - NoStaticRemoteKey: cfg.ProtocolOptions.NoStaticRemoteKey(), - NoAnchors: cfg.ProtocolOptions.NoAnchorCommitments(), - NoWumbo: !cfg.ProtocolOptions.Wumbo(), - NoScriptEnforcementLease: cfg.ProtocolOptions.NoScriptEnforcementLease(), - NoKeysend: !cfg.AcceptKeySend, - NoOptionScidAlias: !cfg.ProtocolOptions.ScidAlias(), - NoZeroConf: !cfg.ProtocolOptions.ZeroConf(), - NoAnySegwit: cfg.ProtocolOptions.NoAnySegwit(), - CustomFeatures: cfg.ProtocolOptions.CustomFeatures(), - NoTaprootChans: !cfg.ProtocolOptions.TaprootChans, - NoTaprootOverlay: !cfg.ProtocolOptions.TaprootOverlayChans, - NoRouteBlinding: cfg.ProtocolOptions.NoRouteBlinding(), + NoTLVOnion: cfg.ProtocolOptions.LegacyOnion(), + NoStaticRemoteKey: cfg.ProtocolOptions.NoStaticRemoteKey(), + NoAnchors: cfg.ProtocolOptions.NoAnchorCommitments(), + NoWumbo: !cfg.ProtocolOptions.Wumbo(), + NoScriptEnforcementLease: cfg.ProtocolOptions.NoScriptEnforcementLease(), + NoKeysend: !cfg.AcceptKeySend, + NoOptionScidAlias: !cfg.ProtocolOptions.ScidAlias(), + NoZeroConf: !cfg.ProtocolOptions.ZeroConf(), + NoAnySegwit: cfg.ProtocolOptions.NoAnySegwit(), + CustomFeatures: cfg.ProtocolOptions.CustomFeatures(), + NoTaprootChans: !cfg.ProtocolOptions.TaprootChans, + NoTaprootOverlay: !cfg.ProtocolOptions.TaprootOverlayChans, + NoRouteBlinding: cfg.ProtocolOptions.NoRouteBlinding(), + NoExperimentalEndorsement: cfg.ProtocolOptions.NoExperimentalEndorsement(), }) if err != nil { return nil, err @@ -4184,6 +4191,15 @@ func (s *server) peerConnected(conn net.Conn, connReq *connmgr.ConnReq, MsgRouter: s.implCfg.MsgRouter, AuxChanCloser: s.implCfg.AuxChanCloser, AuxResolver: s.implCfg.AuxContractResolver, + ShouldFwdExpEndorsement: func() bool { + if s.cfg.ProtocolOptions.NoExperimentalEndorsement() { + return false + } + + return clock.NewDefaultClock().Now().Before( + EndorsementExperimentEnd, + ) + }, } copy(pCfg.PubKeyBytes[:], peerAddr.IdentityKey.SerializeCompressed())