-
Notifications
You must be signed in to change notification settings - Fork 49
routerclient: add payment request route fee estimates #273
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Zero-amount invoices are rejected server-side (router_server.go:530, "amount must be greater than 0") and surface as an error — fine, but undocumented. |
||
| timeout time.Duration) (*EstimateRouteFeeResponse, error) | ||
|
Comment on lines
+51
to
+55
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A method name ending with "Request" is confusing, because normally it is a request type which follows this naming convention. Since the main differentiator of this method is an active probing based on this payment request, I propose to rename it: EstimateRouteFeeWithProbe(ctx context.Context, invoice string,
timeout time.Duration) (*EstimateRouteFeeWithProbeResponse, error)I also renamed Also update the rename map: and tests:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A probe that unexpectedly succeeds returns a gRPC error warning that funds are lost (router_server.go:983). Astronomically unlikely (random 256-bit hash); just be aware it arrives as |
||
|
|
||
| // 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I propose to document that timeout=0 means LND chooses its default, which is |
||
| 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 | ||
|
Comment on lines
+672
to
+682
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On a probe that fails to reach the destination, lnd returns a successful RPC with a zeroed fee and the reason in-band. So the idiomatic resp, err := EstimateRouteFee…()
if err != nil {
…
} else {
use(resp.RoutingFee)
}silently reads RoutingFee == 0 and treats an unroutable destination as a free route. The struct field is documented, but the method doc says nothing, so the trap is easy to fall into. Fix: call it out in the method doc ("a nil error does not imply success - check FailureReason == FAILURE_REASON_NONE before trusting RoutingFee/TimeLockDelay"), and consider whether a non-NONE reason should be surfaced as an error rather than a zero-value struct. Passing it through is a defensible choice, but it must be loud. |
||
| } | ||
|
|
||
| // unmarshallPaymentStatus converts an rpc status update to the PaymentStatus | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add godocs in this file |
||
| 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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. use t.Context() here and below |
||
| ) | ||
| 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 | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Context can't bound the call: worth one doc line so callers don't rely on ctx deadline/cancel for the probe.