From 767763e9c39a4456ebed1ebaa13320da2c1d44a9 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Mon, 4 May 2026 10:55:25 +0200 Subject: [PATCH] routerclient: add payment request route fee estimates --- macaroon_recipes.go | 37 ++++++------ router_client.go | 69 ++++++++++++++++++++-- router_client_test.go | 131 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+), 22 deletions(-) create mode 100644 router_client_test.go diff --git a/macaroon_recipes.go b/macaroon_recipes.go index 371788a..2911469 100644 --- a/macaroon_recipes.go +++ b/macaroon_recipes.go @@ -28,24 +28,25 @@ var ( // implemented in lndclient and the value is the original name of the // RPC method defined in the proto. renames = map[string]string{ - "ChannelBackup": "ExportChannelBackup", - "ChannelBackups": "ExportAllChannelBackups", - "ConfirmedWalletBalance": "WalletBalance", - "Connect": "ConnectPeer", - "DecodePaymentRequest": "DecodePayReq", - "ListTransactions": "GetTransactions", - "PayInvoice": "SendPaymentSync", - "UpdateChanPolicy": "UpdateChannelPolicy", - "NetworkInfo": "GetNetworkInfo", - "SubscribeGraph": "SubscribeChannelGraph", - "InterceptHtlcs": "HtlcInterceptor", - "ImportMissionControl": "XImportMissionControl", - "EstimateFeeRate": "EstimateFee", - "EstimateFeeToP2WSH": "EstimateFee", - "OpenChannelStream": "OpenChannel", - "ListSweepsVerbose": "ListSweeps", - "MinRelayFee": "EstimateFee", - "SignOutputRawKeyLocator": "SignOutputRaw", + "ChannelBackup": "ExportChannelBackup", + "ChannelBackups": "ExportAllChannelBackups", + "ConfirmedWalletBalance": "WalletBalance", + "Connect": "ConnectPeer", + "DecodePaymentRequest": "DecodePayReq", + "ListTransactions": "GetTransactions", + "PayInvoice": "SendPaymentSync", + "UpdateChanPolicy": "UpdateChannelPolicy", + "NetworkInfo": "GetNetworkInfo", + "SubscribeGraph": "SubscribeChannelGraph", + "InterceptHtlcs": "HtlcInterceptor", + "ImportMissionControl": "XImportMissionControl", + "EstimateFeeRate": "EstimateFee", + "EstimateFeeToP2WSH": "EstimateFee", + "EstimateRouteFeeWithPaymentRequest": "EstimateRouteFee", + "OpenChannelStream": "OpenChannel", + "ListSweepsVerbose": "ListSweeps", + "MinRelayFee": "EstimateFee", + "SignOutputRawKeyLocator": "SignOutputRaw", } // ignores is a list of method names on the client implementations that diff --git a/router_client.go b/router_client.go index d603be9..799f769 100644 --- a/router_client.go +++ b/router_client.go @@ -48,6 +48,12 @@ type RouterClient interface { EstimateRouteFee(ctx context.Context, dest route.Vertex, amt btcutil.Amount) (lnwire.MilliSatoshi, error) + // EstimateRouteFeeWithPaymentRequest estimates routing costs by probing + // with a payment request. + EstimateRouteFeeWithPaymentRequest(ctx context.Context, + paymentRequest string, + timeout time.Duration) (*EstimateRouteFeeResponse, error) + // SubscribeHtlcEvents subscribes to a stream of htlc events from the // router. SubscribeHtlcEvents(ctx context.Context) (<-chan *routerrpc.HtlcEvent, @@ -318,6 +324,22 @@ type SendPaymentRequest struct { FirstHopCustomRecords map[uint64][]byte } +// EstimateRouteFeeResponse is the response of a route fee estimate. +type EstimateRouteFeeResponse struct { + // RoutingFee is a lower bound of the estimated fee to the target + // destination within the network. + RoutingFee lnwire.MilliSatoshi + + // TimeLockDelay is an estimate of the worst-case time delay that can + // occur. Callers still need to factor in the final CLTV delta of the + // last hop into this value. + TimeLockDelay int64 + + // FailureReason indicates whether a probing payment succeeded or + // whether and why it failed. FAILURE_REASON_NONE indicates success. + FailureReason lnrpc.PaymentFailureReason +} + // InterceptedHtlc contains information about a htlc that was intercepted in // lnd's switch. type InterceptedHtlc struct { @@ -607,18 +629,57 @@ func (r *routerClient) trackPayment(ctx context.Context, func (r *routerClient) EstimateRouteFee(ctx context.Context, dest route.Vertex, amt btcutil.Amount) (lnwire.MilliSatoshi, error) { - rpcCtx := r.routerKitMac.WithMacaroonAuth(ctx) - rpcReq := &routerrpc.RouteFeeRequest{ + res, err := r.estimateRouteFee(ctx, &routerrpc.RouteFeeRequest{ Dest: dest[:], AmtSat: int64(amt), + }) + if err != nil { + return 0, err } + return res.RoutingFee, nil +} + +// EstimateRouteFeeWithPaymentRequest estimates routing costs by probing with a +// payment request. +func (r *routerClient) EstimateRouteFeeWithPaymentRequest(ctx context.Context, + paymentRequest string, timeout time.Duration) (*EstimateRouteFeeResponse, + error) { + + if timeout < 0 { + return nil, fmt.Errorf("timeout must not be negative") + } + + rpcReq := &routerrpc.RouteFeeRequest{ + PaymentRequest: paymentRequest, + } + if timeout > 0 { + const maxTimeout = time.Duration(^uint32(0)) * time.Second + if timeout > maxTimeout { + return nil, fmt.Errorf("timeout exceeds maximum of %v", + maxTimeout) + } + + rpcReq.Timeout = uint32((timeout + time.Second - 1) / time.Second) + } + + return r.estimateRouteFee(ctx, rpcReq) +} + +func (r *routerClient) estimateRouteFee(ctx context.Context, + rpcReq *routerrpc.RouteFeeRequest) (*EstimateRouteFeeResponse, error) { + + rpcCtx := r.routerKitMac.WithMacaroonAuth(ctx) rpcRes, err := r.client.EstimateRouteFee(rpcCtx, rpcReq) if err != nil { - return 0, err + return nil, err } - return lnwire.MilliSatoshi(rpcRes.RoutingFeeMsat), nil + return &EstimateRouteFeeResponse{ + RoutingFee: lnwire.MilliSatoshi(rpcRes.RoutingFeeMsat), + TimeLockDelay: rpcRes.TimeLockDelay, + FailureReason: rpcRes.FailureReason, + }, nil } // unmarshallPaymentStatus converts an rpc status update to the PaymentStatus diff --git a/router_client_test.go b/router_client_test.go new file mode 100644 index 0000000..4d8a326 --- /dev/null +++ b/router_client_test.go @@ -0,0 +1,131 @@ +package lndclient + +import ( + "context" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +type mockRouterRPCClient struct { + routerrpc.RouterClient + + request *routerrpc.RouteFeeRequest + response *routerrpc.RouteFeeResponse + err error +} + +func (m *mockRouterRPCClient) EstimateRouteFee(_ context.Context, + request *routerrpc.RouteFeeRequest, _ ...grpc.CallOption) ( + *routerrpc.RouteFeeResponse, error) { + + m.request = request + return m.response, m.err +} + +func TestEstimateRouteFeeWithPaymentRequest(t *testing.T) { + t.Parallel() + + mock := &mockRouterRPCClient{ + response: &routerrpc.RouteFeeResponse{ + RoutingFeeMsat: 987, + TimeLockDelay: 654, + FailureReason: lnrpc.PaymentFailureReason_FAILURE_REASON_NONE, + }, + } + client := &routerClient{ + client: mock, + } + + resp, err := client.EstimateRouteFeeWithPaymentRequest( + context.Background(), "lnbc1...", 1500*time.Millisecond, + ) + require.NoError(t, err) + + require.Empty(t, mock.request.Dest) + require.Zero(t, mock.request.AmtSat) + require.Equal(t, "lnbc1...", mock.request.PaymentRequest) + require.Equal(t, uint32(2), mock.request.Timeout) + require.Equal(t, lnwire.MilliSatoshi(987), resp.RoutingFee) + require.Equal(t, int64(654), resp.TimeLockDelay) + require.Equal( + t, lnrpc.PaymentFailureReason_FAILURE_REASON_NONE, + resp.FailureReason, + ) +} + +func TestEstimateRouteFee(t *testing.T) { + t.Parallel() + + dest := testVertex() + mock := &mockRouterRPCClient{ + response: &routerrpc.RouteFeeResponse{ + RoutingFeeMsat: 4321, + }, + } + client := &routerClient{ + client: mock, + } + + fee, err := client.EstimateRouteFee( + context.Background(), dest, btcutil.Amount(1000), + ) + require.NoError(t, err) + + require.Equal(t, dest[:], mock.request.Dest) + require.Equal(t, int64(1000), mock.request.AmtSat) + require.Equal(t, lnwire.MilliSatoshi(4321), fee) +} + +func TestEstimateRouteFeeWithPaymentRequestRejectsInvalidTimeout(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + timeout time.Duration + err string + }{ + { + name: "negative", + timeout: -time.Second, + err: "timeout must not be negative", + }, + { + name: "too large", + timeout: time.Duration(^uint32(0))*time.Second + + time.Nanosecond, + err: "timeout exceeds maximum", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mock := &mockRouterRPCClient{} + client := &routerClient{ + client: mock, + } + + _, err := client.EstimateRouteFeeWithPaymentRequest( + context.Background(), "lnbc1...", tc.timeout, + ) + require.ErrorContains(t, err, tc.err) + require.Nil(t, mock.request) + }) + } +} + +func testVertex() route.Vertex { + var vertex route.Vertex + for i := range vertex { + vertex[i] = byte(i + 1) + } + + return vertex +}