Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 19 additions & 18 deletions macaroon_recipes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 65 additions & 4 deletions router_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown
Contributor

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.

paymentRequest string,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 paymentRequest to invoice which is more common and understandable name. And response type to EstimateRouteFeeWithProbeResponse.

Also update the rename map:

"EstimateRouteFeeWithProbe": "EstimateRouteFee"

and tests:

TestEstimateRouteFeeWithProbe, …WithProbeRejectsInvalidTimeout

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 err != nil, not in the struct.


// SubscribeHtlcEvents subscribes to a stream of htlc events from the
// router.
SubscribeHtlcEvents(ctx context.Context) (<-chan *routerrpc.HtlcEvent,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 DefaultPaymentTimeout=60 seconds.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
Expand Down
131 changes: 131 additions & 0 deletions router_client_test.go
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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
}
Loading