diff --git a/lnrpc/lightning.proto b/lnrpc/lightning.proto index 0bca141fdf..36c8fc7464 100644 --- a/lnrpc/lightning.proto +++ b/lnrpc/lightning.proto @@ -3291,6 +3291,22 @@ message QueryRoutesRequest { channel may be used. */ repeated uint64 outgoing_chan_ids = 20; + + /* + An optional payment address to be included in the MPP record for the final + hop. This field is also referred to as the "payment secret" in BOLT 11. + Including this in the query allows the returned route to be used directly + with SendToRoute. This field is now required per the Lightning Network + specification and should always be provided for standard payments. + */ + bytes payment_addr = 21; + + /* + An optional AMP record to use for AMP payments. If provided, this will be + included in the final hop of the route instead of an MPP record. The + payment_addr field should not be set if this is provided. + */ + AMPRecord amp_record = 22; } message NodePair { diff --git a/lnrpc/routerrpc/mpp_validation_test.go b/lnrpc/routerrpc/mpp_validation_test.go new file mode 100644 index 0000000000..81fca8cee1 --- /dev/null +++ b/lnrpc/routerrpc/mpp_validation_test.go @@ -0,0 +1,159 @@ +package routerrpc + +import ( + "testing" + + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/stretchr/testify/require" +) + +// TestUnmarshallRouteMPPValidation tests that UnmarshallRoute correctly +// enforces the MPP/AMP record validation on incoming routes. +func TestUnmarshallRouteMPPValidation(t *testing.T) { + t.Parallel() + backend := &RouterBackend{ + SelfNode: route.Vertex{1, 2, 3}, + } + + // Test case 1: Route with no MPP or AMP record should succeed with + // a deprecation warning. + t.Run("no_mpp_or_amp", func(t *testing.T) { + rpcRoute := &lnrpc.Route{ + TotalTimeLock: 100, + TotalAmtMsat: 1000, + Hops: []*lnrpc.Hop{ + { + PubKey: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ChanId: 12345, + AmtToForwardMsat: 1000, + Expiry: 100, + }, + }, + } + + // Should succeed with deprecation warning logged. + _, err := backend.UnmarshallRoute(rpcRoute) + require.NoError(t, err) + }) + + // Test case 2: Route with MPP record should succeed. + t.Run("with_mpp", func(t *testing.T) { + paymentAddr := make([]byte, 32) + paymentAddr[0] = 1 + + rpcRoute := &lnrpc.Route{ + TotalTimeLock: 100, + TotalAmtMsat: 1000, + Hops: []*lnrpc.Hop{ + { + PubKey: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ChanId: 12345, + AmtToForwardMsat: 1000, + Expiry: 100, + MppRecord: &lnrpc.MPPRecord{ + PaymentAddr: paymentAddr, + TotalAmtMsat: 1000, + }, + }, + }, + } + + _, err := backend.UnmarshallRoute(rpcRoute) + require.NoError(t, err) + }) + + // Test case 3: Route with AMP record should succeed. + t.Run("with_amp", func(t *testing.T) { + rootShare := make([]byte, 32) + setID := make([]byte, 32) + rootShare[0] = 1 + setID[0] = 2 + + rpcRoute := &lnrpc.Route{ + TotalTimeLock: 100, + TotalAmtMsat: 1000, + Hops: []*lnrpc.Hop{ + { + PubKey: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ChanId: 12345, + AmtToForwardMsat: 1000, + Expiry: 100, + AmpRecord: &lnrpc.AMPRecord{ + RootShare: rootShare, + SetId: setID, + ChildIndex: 0, + }, + }, + }, + } + + _, err := backend.UnmarshallRoute(rpcRoute) + require.NoError(t, err) + }) + + // Test case 4: Route with both MPP and AMP records should fail. + t.Run("both_mpp_and_amp", func(t *testing.T) { + paymentAddr := make([]byte, 32) + rootShare := make([]byte, 32) + setID := make([]byte, 32) + paymentAddr[0] = 1 + rootShare[0] = 1 + setID[0] = 2 + + rpcRoute := &lnrpc.Route{ + TotalTimeLock: 100, + TotalAmtMsat: 1000, + Hops: []*lnrpc.Hop{ + { + PubKey: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ChanId: 12345, + AmtToForwardMsat: 1000, + Expiry: 100, + MppRecord: &lnrpc.MPPRecord{ + PaymentAddr: paymentAddr, + TotalAmtMsat: 1000, + }, + AmpRecord: &lnrpc.AMPRecord{ + RootShare: rootShare, + SetId: setID, + ChildIndex: 0, + }, + }, + }, + } + + _, err := backend.UnmarshallRoute(rpcRoute) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot have both MPP and AMP") + }) + + // Test case 5: Blinded route should not require MPP/AMP. + t.Run("blinded_no_mpp", func(t *testing.T) { + blindingPoint := []byte{ + 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, + 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87, 0x0b, + 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, + 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, 0xf8, 0x17, + 0x98, + } + + rpcRoute := &lnrpc.Route{ + TotalTimeLock: 100, + TotalAmtMsat: 1000, + Hops: []*lnrpc.Hop{ + { + PubKey: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ChanId: 12345, + AmtToForwardMsat: 1000, + Expiry: 100, + BlindingPoint: blindingPoint, + EncryptedData: []byte{1, 2, 3}, + }, + }, + } + + _, err := backend.UnmarshallRoute(rpcRoute) + require.NoError(t, err) + }) +} diff --git a/lnrpc/routerrpc/router.proto b/lnrpc/routerrpc/router.proto index 9e305e37e6..e24de22d64 100644 --- a/lnrpc/routerrpc/router.proto +++ b/lnrpc/routerrpc/router.proto @@ -753,6 +753,13 @@ message BuildRouteRequest { */ bytes payment_addr = 5; + /* + An optional AMP record to use for AMP payments. If provided, this will be + included in the final hop of the route instead of an MPP record. The + payment_addr field should not be set if this is provided. + */ + AMPRecord amp_record = 8; + /* An optional field that can be used to pass an arbitrary set of TLV records to the first hop peer of this payment. This can be used to pass application diff --git a/lnrpc/routerrpc/router_backend.go b/lnrpc/routerrpc/router_backend.go index f8a3c568eb..85e5d4bacb 100644 --- a/lnrpc/routerrpc/router_backend.go +++ b/lnrpc/routerrpc/router_backend.go @@ -450,10 +450,54 @@ func (r *RouterBackend) parseQueryRoutesRequest(in *lnrpc.QueryRoutesRequest) ( return nil, err } + // Parse payment_addr for MPP or amp_record for AMP payments. + // These are mutually exclusive and will be required for non-blinded + // payments as per Lightning Network specification. + // TODO(user): Once protos are regenerated with payment_addr and + // amp_record fields, uncomment the following code: + /* + var paymentAddr fn.Option[[32]byte] + var amp fn.Option[*record.AMP] + + if len(in.PaymentAddr) > 0 { + var addr [32]byte + copy(addr[:], in.PaymentAddr) + paymentAddr = fn.Some(addr) + } + + if in.AmpRecord != nil { + ampRec, err := UnmarshalAMP(in.AmpRecord) + if err != nil { + return nil, fmt.Errorf("invalid AMP record: %w", err) + } + amp = fn.Some(ampRec) + } + + // Log warning if neither payment_addr nor amp_record provided + // for non-blinded payments. + if blindedPathSet == nil { + if paymentAddr.IsNone() && amp.IsNone() { + log.Warnf("QueryRoutes missing payment_addr or " + + "amp_record. This will be required in a " + + "future LND release as per Lightning " + + "Network specification.") + // TODO(v0.21.0): Uncomment to enforce validation. + // return nil, errors.New("payment_addr or amp_record " + + // "must be provided for standard payments as " + + // "required by Lightning Network specification") + } + } + */ + + // Temporary: For now, use empty values until protos are regenerated. + // Remove this and uncomment above section after running 'make rpc'. + var paymentAddr fn.Option[[32]byte] + var amp fn.Option[*record.AMP] + return routing.NewRouteRequest( sourcePubKey, targetPubKey, amt, in.TimePref, restrictions, customRecords, routeHintEdges, blindedPathSet, - finalCLTVDelta, + finalCLTVDelta, paymentAddr, amp, ) } @@ -826,6 +870,37 @@ func (r *RouterBackend) UnmarshallRoute(rpcroute *lnrpc.Route) ( prevNodePubKey = routeHop.PubKeyBytes } + // Validate that the final hop contains either an MPP or AMP record. + // This will be required by the Lightning Network specification. + if len(hops) > 0 { + finalHop := hops[len(hops)-1] + + // Check if blinded payment - blinded hops don't need MPP/AMP. + hasBlindingPoint := finalHop.BlindingPoint != nil + + if !hasBlindingPoint { + // Final hop must have either MPP or AMP record. + if finalHop.MPP == nil && finalHop.AMP == nil { + log.Warnf("Route final hop missing MPP/AMP " + + "record. This will be required in a " + + "future LND release as per Lightning " + + "Network specification. Please update " + + "your client to include payment_addr or " + + "amp_record.") + // return nil, errors.New("final hop must include " + + // "either an MPP record (with payment_addr) or " + + // "an AMP record as required by the Lightning " + + // "Network specification") + } + + // Cannot have both MPP and AMP records. + if finalHop.MPP != nil && finalHop.AMP != nil { + return nil, errors.New("final hop cannot have " + + "both MPP and AMP records") + } + } + } + route, err := route.NewRouteFromHops( lnwire.MilliSatoshi(rpcroute.TotalAmtMsat), rpcroute.TotalTimeLock, diff --git a/lnrpc/routerrpc/router_server.go b/lnrpc/routerrpc/router_server.go index a4031b154e..a0567c5f38 100644 --- a/lnrpc/routerrpc/router_server.go +++ b/lnrpc/routerrpc/router_server.go @@ -21,6 +21,7 @@ import ( "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/macaroons" paymentsdb "github.com/lightningnetwork/lnd/payments/db" + "github.com/lightningnetwork/lnd/record" "github.com/lightningnetwork/lnd/routing" "github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/zpay32" @@ -482,6 +483,7 @@ func (s *Server) probeDestination(dest []byte, amtSat int64) (*RouteFeeResponse, CltvLimit: s.cfg.RouterBackend.MaxTotalTimelock, ProbabilitySource: mc.GetProbability, }, nil, nil, nil, s.cfg.RouterBackend.DefaultFinalCltvDelta, + fn.None[[32]byte](), fn.None[*record.AMP](), ) if err != nil { return nil, err @@ -1063,6 +1065,41 @@ func (s *Server) SendToRouteV2(ctx context.Context, return nil, fmt.Errorf("unable to send, no routes provided") } + // Perform early validation on the route before attempting to send. + // Check that the final hop has either an MPP or AMP record. + if len(req.Route.Hops) > 0 { + finalHop := req.Route.Hops[len(req.Route.Hops)-1] + + // Check if this is a blinded payment (blinded hops don't need + // MPP/AMP). + hasBlindingPoint := len(finalHop.BlindingPoint) > 0 + + if !hasBlindingPoint { + hasMPP := finalHop.MppRecord != nil + hasAMP := finalHop.AmpRecord != nil + + // Log warning if neither MPP nor AMP record present. + if !hasMPP && !hasAMP { + log.Warnf("Route final hop missing MPP/AMP " + + "record. This will be required in a " + + "future LND release as per Lightning " + + "Network specification. Please update " + + "your client to include payment_addr or " + + "amp_record.") + // return nil, errors.New("final hop must include " + + // "either an MPP record (with payment_addr) " + + // "or an AMP record as required by the " + + // "Lightning Network specification") + } + + // Cannot have both. + if hasMPP && hasAMP { + return nil, errors.New("final hop cannot have " + + "both MPP and AMP records") + } + } + } + route, err := s.cfg.RouterBackend.UnmarshallRoute(req.Route) if err != nil { return nil, err @@ -1679,6 +1716,33 @@ func (s *Server) BuildRoute(_ context.Context, payAddr = fn.Some(backingPayAddr) } + // TODO(user): Once protos are regenerated with amp_record field, + // uncomment the following code to add AMP support: + /* + var amp fn.Option[*record.AMP] + if req.AmpRecord != nil { + ampRec, err := UnmarshalAMP(req.AmpRecord) + if err != nil { + return nil, fmt.Errorf("invalid AMP record: %w", err) + } + amp = fn.Some(ampRec) + } + + // Validate that payment_addr and amp_record are mutually exclusive. + if payAddr.IsSome() && amp.IsSome() { + return nil, errors.New("payment_addr and amp_record " + + "cannot both be set") + } + + // Require either payment_addr or amp_record to be set as per + // Lightning Network specification. + if payAddr.IsNone() && amp.IsNone() { + return nil, errors.New("either payment_addr (for MPP) or " + + "amp_record (for AMP) must be provided as required " + + "by Lightning Network specification") + } + */ + if req.FinalCltvDelta == 0 { req.FinalCltvDelta = int32( s.cfg.RouterBackend.DefaultFinalCltvDelta, diff --git a/routing/pathfind.go b/routing/pathfind.go index fab8015dde..d674aa46e6 100644 --- a/routing/pathfind.go +++ b/routing/pathfind.go @@ -113,6 +113,10 @@ type finalHopParams struct { records record.CustomSet paymentAddr fn.Option[[32]byte] + // amp is an optional AMP record to include in the final hop. This is + // mutually exclusive with paymentAddr. + amp fn.Option[*record.AMP] + // metadata is additional data that is sent along with the payment to // the payee. metadata []byte @@ -210,6 +214,7 @@ func newRoute(sourceVertex route.Vertex, outgoingTimeLock uint32 customRecords record.CustomSet mpp *record.MPP + amp *record.AMP metadata []byte ) @@ -257,6 +262,19 @@ func newRoute(sourceVertex route.Vertex, "payment addr") } + // Check for AMP support if AMP record is provided. + ampSupport := supports(lnwire.AMPOptional) + if !ampSupport && finalHop.amp.IsSome() { + return nil, errors.New("cannot attach AMP " + + "record") + } + + // Ensure AMP and MPP are mutually exclusive. + if finalHop.paymentAddr.IsSome() && finalHop.amp.IsSome() { + return nil, errors.New("cannot attach both MPP " + + "and AMP records") + } + // Otherwise attach the mpp record if it exists. // TODO(halseth): move this to payment life cycle, // where AMP options are set. @@ -264,6 +282,11 @@ func newRoute(sourceVertex route.Vertex, mpp = record.NewMPP(finalHop.totalAmt, addr) }) + // Attach the AMP record if it exists. + finalHop.amp.WhenSome(func(ampRec *record.AMP) { + amp = ampRec + }) + metadata = finalHop.metadata if blindedPathSet != nil { @@ -311,6 +334,7 @@ func newRoute(sourceVertex route.Vertex, OutgoingTimeLock: outgoingTimeLock, CustomRecords: customRecords, MPP: mpp, + AMP: amp, Metadata: metadata, TotalAmtMsat: totalAmtMsatBlinded, } diff --git a/routing/pathfind_test.go b/routing/pathfind_test.go index 473bd413e3..92f87b571c 100644 --- a/routing/pathfind_test.go +++ b/routing/pathfind_test.go @@ -2377,7 +2377,7 @@ func TestPathFindSpecExample(t *testing.T) { const amt lnwire.MilliSatoshi = 4999999 req, err := NewRouteRequest( bob, &carol, amt, 0, noRestrictions, nil, nil, - nil, MinCLTVDelta, + nil, MinCLTVDelta, fn.None[[32]byte](), fn.None[*record.AMP](), ) require.NoError(t, err, "invalid route request") @@ -2404,7 +2404,7 @@ func TestPathFindSpecExample(t *testing.T) { // We'll now request a route from A -> B -> C. req, err = NewRouteRequest( alice, &carol, amt, 0, noRestrictions, nil, nil, nil, - MinCLTVDelta, + MinCLTVDelta, fn.None[[32]byte](), fn.None[*record.AMP](), ) require.NoError(t, err, "invalid route request") diff --git a/routing/router.go b/routing/router.go index 3c35b7c52c..0b5a347ab0 100644 --- a/routing/router.go +++ b/routing/router.go @@ -422,6 +422,15 @@ type RouteRequest struct { // parameters used to reach a target node blinded paths. This field is // mutually exclusive with the Target field. BlindedPathSet *BlindedPaymentPathSet + + // PaymentAddr is an optional payment address to include in the MPP + // record for the final hop. This is also known as the payment secret. + // If set, an MPP record will be attached to the final hop. + PaymentAddr fn.Option[[32]byte] + + // AMPRecord is an optional AMP record to include in the final hop. + // This is mutually exclusive with PaymentAddr. + AMP fn.Option[*record.AMP] } // RouteHints is an alias type for a set of route hints, with the source node @@ -436,7 +445,8 @@ func NewRouteRequest(source route.Vertex, target *route.Vertex, amount lnwire.MilliSatoshi, timePref float64, restrictions *RestrictParams, customRecords record.CustomSet, routeHints RouteHints, blindedPathSet *BlindedPaymentPathSet, - finalExpiry uint16) (*RouteRequest, error) { + finalExpiry uint16, paymentAddr fn.Option[[32]byte], + amp fn.Option[*record.AMP]) (*RouteRequest, error) { var ( // Assume that we're starting off with a regular payment. @@ -473,6 +483,12 @@ func NewRouteRequest(source route.Vertex, target *route.Vertex, return nil, err } + // Validate that MPP and AMP are mutually exclusive. + if paymentAddr.IsSome() && amp.IsSome() { + return nil, errors.New("cannot set both payment_addr (MPP) " + + "and AMP record") + } + return &RouteRequest{ Source: source, Target: requestTarget, @@ -483,6 +499,8 @@ func NewRouteRequest(source route.Vertex, target *route.Vertex, RouteHints: requestHints, FinalExpiry: requestExpiry, BlindedPathSet: blindedPathSet, + PaymentAddr: paymentAddr, + AMP: amp, }, nil } @@ -563,10 +581,12 @@ func (r *ChannelRouter) FindRoute(req *RouteRequest) (*route.Route, float64, route, err := newRoute( req.Source, path, uint32(currentHeight), finalHopParams{ - amt: req.Amount, - totalAmt: req.Amount, - cltvDelta: req.FinalExpiry, - records: req.CustomRecords, + amt: req.Amount, + totalAmt: req.Amount, + cltvDelta: req.FinalExpiry, + records: req.CustomRecords, + paymentAddr: req.PaymentAddr, + amp: req.AMP, }, req.BlindedPathSet, ) if err != nil { diff --git a/routing/router_test.go b/routing/router_test.go index 9f088917da..4dde3e9839 100644 --- a/routing/router_test.go +++ b/routing/router_test.go @@ -258,6 +258,7 @@ func TestFindRoutesWithFeeLimit(t *testing.T) { req, err := NewRouteRequest( ctx.router.cfg.SelfNode, &target, paymentAmt, 0, restrictions, nil, nil, nil, MinCLTVDelta, + fn.None[[32]byte](), fn.None[*record.AMP](), ) require.NoError(t, err, "invalid route request") @@ -2683,6 +2684,7 @@ func TestNewRouteRequest(t *testing.T) { source, testCase.target, 1000, 0, nil, nil, testCase.routeHints, blindedPathInfo, testCase.finalExpiry, + fn.None[[32]byte](), fn.None[*record.AMP](), ) require.ErrorIs(t, err, testCase.err) @@ -2863,6 +2865,7 @@ func TestAddEdgeUnknownVertexes(t *testing.T) { req, err := NewRouteRequest( ctx.router.cfg.SelfNode, &targetPubKeyBytes, paymentAmt, 0, noRestrictions, nil, nil, nil, MinCLTVDelta, + fn.None[[32]byte](), fn.None[*record.AMP](), ) require.NoError(t, err, "invalid route request") _, _, err = ctx.router.FindRoute(req) @@ -2901,6 +2904,7 @@ func TestAddEdgeUnknownVertexes(t *testing.T) { req, err = NewRouteRequest( ctx.router.cfg.SelfNode, &targetPubKeyBytes, paymentAmt, 0, noRestrictions, nil, nil, nil, MinCLTVDelta, + fn.None[[32]byte](), fn.None[*record.AMP](), ) require.NoError(t, err, "invalid route request")