From bde649004283aa5aad8a3ddc8ddbef43401b549e 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 | 35 +++++------ router_client.go | 86 ++++++++++++++++++++++++-- router_client_test.go | 137 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 21 deletions(-) create mode 100644 router_client_test.go diff --git a/macaroon_recipes.go b/macaroon_recipes.go index 2d64e2b..1b44eb3 100644 --- a/macaroon_recipes.go +++ b/macaroon_recipes.go @@ -47,23 +47,24 @@ 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", - "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", + "UpdateChanPolicy": "UpdateChannelPolicy", + "NetworkInfo": "GetNetworkInfo", + "SubscribeGraph": "SubscribeChannelGraph", + "InterceptHtlcs": "HtlcInterceptor", + "ImportMissionControl": "XImportMissionControl", + "EstimateFeeRate": "EstimateFee", + "EstimateFeeToP2WSH": "EstimateFee", + "EstimateRouteFeeWithProbe": "EstimateRouteFee", + "OpenChannelStream": "OpenChannel", + "ListSweepsVerbose": "ListSweeps", + "MinRelayFee": "EstimateFee", + "SignOutputRawKeyLocator": "SignOutputRaw", } // methodOverrides maps lndclient methods to their exact backing RPC diff --git a/router_client.go b/router_client.go index 3971650..51ad977 100644 --- a/router_client.go +++ b/router_client.go @@ -49,6 +49,19 @@ type RouterClient interface { EstimateRouteFee(ctx context.Context, dest route.Vertex, amt btcutil.Amount) (lnwire.MilliSatoshi, error) + // EstimateRouteFeeWithProbe estimates routing costs by probing with an + // invoice. The timeout parameter bounds the probe inside lnd; a zero + // timeout lets lnd choose its default, currently 60 seconds. Do not rely + // on the context deadline or cancellation to bound the probe itself. + // + // A nil error does not imply that a route was found. Callers must check + // that the response FailureReason is FAILURE_REASON_NONE before trusting + // RoutingFee or TimeLockDelay. Zero-amount invoices are rejected by lnd + // and returned as an error. If a probe unexpectedly succeeds, lnd returns + // an RPC error instead of a response. + EstimateRouteFeeWithProbe(ctx context.Context, invoice string, + timeout time.Duration) (*EstimateRouteFeeWithProbeResponse, error) + // SubscribeHtlcEvents subscribes to a stream of htlc events from the // router. SubscribeHtlcEvents(ctx context.Context) (<-chan *routerrpc.HtlcEvent, @@ -319,6 +332,23 @@ type SendPaymentRequest struct { FirstHopCustomRecords map[uint64][]byte } +// EstimateRouteFeeWithProbeResponse is the response of a route fee estimate +// probe. +type EstimateRouteFeeWithProbeResponse 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 { @@ -741,18 +771,66 @@ 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 +} + +// EstimateRouteFeeWithProbe estimates routing costs by probing with an invoice. +// The timeout parameter bounds the probe inside lnd; a zero timeout lets lnd +// choose its default, currently 60 seconds. Do not rely on the context deadline +// or cancellation to bound the probe itself. +// +// A nil error does not imply that a route was found. Callers must check that +// the response FailureReason is FAILURE_REASON_NONE before trusting RoutingFee +// or TimeLockDelay. Zero-amount invoices are rejected by lnd and returned as an +// error. If a probe unexpectedly succeeds, lnd returns an RPC error instead of +// a response. +func (r *routerClient) EstimateRouteFeeWithProbe(ctx context.Context, + invoice string, timeout time.Duration) (*EstimateRouteFeeWithProbeResponse, + error) { + + if timeout < 0 { + return nil, fmt.Errorf("timeout must not be negative") + } + + rpcReq := &routerrpc.RouteFeeRequest{ + PaymentRequest: invoice, + } + 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) (*EstimateRouteFeeWithProbeResponse, + 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 &EstimateRouteFeeWithProbeResponse{ + 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..5a63009 --- /dev/null +++ b/router_client_test.go @@ -0,0 +1,137 @@ +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 +} + +// TestEstimateRouteFeeWithProbe checks that invoice-based route fee estimates +// are mapped to lnd's RouteFeeRequest fields and response values. +func TestEstimateRouteFeeWithProbe(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.EstimateRouteFeeWithProbe( + t.Context(), "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, + ) +} + +// TestEstimateRouteFee checks that destination and amount route fee estimates +// are mapped to lnd's RouteFeeRequest fields. +func TestEstimateRouteFee(t *testing.T) { + t.Parallel() + + dest := testVertex() + mock := &mockRouterRPCClient{ + response: &routerrpc.RouteFeeResponse{ + RoutingFeeMsat: 4321, + }, + } + client := &routerClient{ + client: mock, + } + + fee, err := client.EstimateRouteFee( + t.Context(), 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) +} + +// TestEstimateRouteFeeWithProbeRejectsInvalidTimeout checks that invalid probe +// timeout values are rejected before making the RPC. +func TestEstimateRouteFeeWithProbeRejectsInvalidTimeout(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.EstimateRouteFeeWithProbe( + t.Context(), "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 +}