diff --git a/invoices_client.go b/invoices_client.go index 5946419..a1a2501 100644 --- a/invoices_client.go +++ b/invoices_client.go @@ -239,6 +239,12 @@ func (s *invoicesClient) AddHoldInvoice(ctx context.Context, rpcCtx, cancel := context.WithTimeout(ctx, s.timeout) defer cancel() + if in.Amp || in.BlindedPathCfg != nil { + log.Warnf("invoicesClient.AddHoldInvoice ignores Amp/" + + "BlindedPathCfg; hold invoice RPC does not support " + + "those fields") + } + routeHints, err := marshallRouteHints(in.RouteHints) if err != nil { return "", fmt.Errorf("failed to marshal route hints: %v", err) diff --git a/invoices_client_test.go b/invoices_client_test.go new file mode 100644 index 0000000..ee5bef6 --- /dev/null +++ b/invoices_client_test.go @@ -0,0 +1,243 @@ +package lndclient + +import ( + "bytes" + "context" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/zpay32" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +// testInvoiceRouteHints returns deterministic route hints for invoice tests. +func testInvoiceRouteHints() [][]zpay32.HopHint { + _, pubKey1 := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{1}, 32)) + _, pubKey2 := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{2}, 32)) + _, pubKey3 := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{3}, 32)) + + return [][]zpay32.HopHint{ + { + { + NodeID: pubKey1, + ChannelID: 101, + FeeBaseMSat: 1001, + FeeProportionalMillionths: 2001, + CLTVExpiryDelta: 40, + }, + { + NodeID: pubKey2, + ChannelID: 102, + FeeBaseMSat: 1002, + FeeProportionalMillionths: 2002, + CLTVExpiryDelta: 41, + }, + }, + { + { + NodeID: pubKey3, + ChannelID: 103, + FeeBaseMSat: 1003, + FeeProportionalMillionths: 2003, + CLTVExpiryDelta: 42, + }, + }, + } +} + +// fallbackAddr is just a Bitcoin address used for tests of FallbackAddr field. +const fallbackAddr = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080" + +// testRPCRouteHints returns the RPC form of the deterministic route hints. +func testRPCRouteHints(t *testing.T) []*lnrpc.RouteHint { + t.Helper() + + rpcRouteHints, err := marshallRouteHints(testInvoiceRouteHints()) + require.NoError(t, err) + + return rpcRouteHints +} + +// addHoldInvoiceArg records the args used in +// mockInvoicesRPCClient.AddHoldInvoice. +type addHoldInvoiceArg struct { + in *invoicesrpc.AddHoldInvoiceRequest + opts []grpc.CallOption +} + +// mockInvoicesRPCClient implements invoicesrpc.InvoicesClient with a dynamic +// AddHoldInvoice implementation and call spying. +type mockInvoicesRPCClient struct { + invoicesrpc.InvoicesClient + + addHoldInvoice func(in *invoicesrpc.AddHoldInvoiceRequest, + opts ...grpc.CallOption) (*invoicesrpc.AddHoldInvoiceResp, + error) + + addHoldInvoiceArgs []addHoldInvoiceArg +} + +// AddHoldInvoice records the call and forwards it to the test hook. +func (m *mockInvoicesRPCClient) AddHoldInvoice(ctx context.Context, + in *invoicesrpc.AddHoldInvoiceRequest, + opts ...grpc.CallOption) (*invoicesrpc.AddHoldInvoiceResp, error) { + + m.addHoldInvoiceArgs = append(m.addHoldInvoiceArgs, addHoldInvoiceArg{ + in: in, + opts: opts, + }) + + return m.addHoldInvoice(in, opts...) +} + +// assertInvoiceRequestParity verifies the shared fields that should be encoded +// identically by AddInvoice and AddHoldInvoice. +func assertInvoiceRequestParity(t *testing.T, add *lnrpc.Invoice, + hold *invoicesrpc.AddHoldInvoiceRequest) { + + t.Helper() + + require.Equal(t, add.Memo, hold.Memo) + require.Equal(t, add.ValueMsat, hold.ValueMsat) + require.Equal(t, add.DescriptionHash, hold.DescriptionHash) + require.Equal(t, add.Expiry, hold.Expiry) + require.Equal(t, add.FallbackAddr, hold.FallbackAddr) + require.Equal(t, add.CltvExpiry, hold.CltvExpiry) + require.Equal(t, add.Private, hold.Private) + require.Equal(t, add.RouteHints, hold.RouteHints) +} + +// TestInvoiceClientAddInvoiceParity ensures AddInvoice and AddHoldInvoice +// encode the same explicit invoice fields for the same invoice input. +func TestInvoiceClientAddInvoiceParity(t *testing.T) { + var validPreimage lntypes.Preimage + copy(validPreimage[:], "valid preimage") + + var validRHash lntypes.Hash + copy(validRHash[:], "valid hash") + + sharedInvoice := invoicesrpc.AddInvoiceData{ + Memo: "fake memo", + Value: lnwire.MilliSatoshi(500000), + DescriptionHash: []byte("fake 32 byte hash"), + Expiry: 123, + FallbackAddr: fallbackAddr, + CltvExpiry: 456, + Private: true, + RouteHints: testInvoiceRouteHints(), + } + + // The two wrappers use different invoice creation RPCs, so we provide + // path-specific fixtures for their mutually exclusive fields. + lightningInvoice := sharedInvoice + lightningInvoice.Preimage = &validPreimage + + holdInvoice := sharedInvoice + holdInvoice.Hash = &validRHash + + lightningRPC := &mockRPCClient{ + addInvoice: func(_ *lnrpc.Invoice, + _ ...grpc.CallOption) (*lnrpc.AddInvoiceResponse, + error) { + + return &lnrpc.AddInvoiceResponse{ + RHash: validRHash[:], + PaymentRequest: "swap invoice", + }, nil + }, + } + holdRPC := &mockInvoicesRPCClient{ + addHoldInvoice: func(_ *invoicesrpc.AddHoldInvoiceRequest, + _ ...grpc.CallOption) (*invoicesrpc.AddHoldInvoiceResp, + error) { + + return &invoicesrpc.AddHoldInvoiceResp{ + PaymentRequest: "probe invoice", + }, nil + }, + } + + lightning := &lightningClient{ + client: lightningRPC, + } + invoices := &invoicesClient{ + client: holdRPC, + } + + _, _, err := lightning.AddInvoice(t.Context(), &lightningInvoice) + require.NoError(t, err) + + _, err = invoices.AddHoldInvoice(t.Context(), &holdInvoice) + require.NoError(t, err) + + require.Len(t, lightningRPC.addInvoiceArgs, 1) + require.Len(t, holdRPC.addHoldInvoiceArgs, 1) + + assertInvoiceRequestParity( + t, lightningRPC.addInvoiceArgs[0].in, + holdRPC.addHoldInvoiceArgs[0].in, + ) +} + +// TestInvoicesClientAddHoldInvoiceIgnoresUnsupportedFields ensures the +// AddHoldInvoice wrapper still forwards the supported request fields when AMP +// or blinded-path-only inputs are provided. +func TestInvoicesClientAddHoldInvoiceIgnoresUnsupportedFields(t *testing.T) { + var validRHash lntypes.Hash + copy(validRHash[:], "valid hash") + + invoice := &invoicesrpc.AddInvoiceData{ + Memo: "fake memo", + Hash: &validRHash, + Value: lnwire.MilliSatoshi(500000), + DescriptionHash: []byte("fake 32 byte hash"), + Expiry: 123, + FallbackAddr: fallbackAddr, + CltvExpiry: 456, + Private: true, + Amp: true, + BlindedPathCfg: &invoicesrpc.BlindedPathConfig{ + MinNumPathHops: 5, + }, + RouteHints: testInvoiceRouteHints(), + } + + rpcRouteHints := testRPCRouteHints(t) + expectedRequest := &invoicesrpc.AddHoldInvoiceRequest{ + Memo: invoice.Memo, + Hash: invoice.Hash[:], + ValueMsat: int64(invoice.Value), + DescriptionHash: invoice.DescriptionHash, + Expiry: invoice.Expiry, + FallbackAddr: invoice.FallbackAddr, + CltvExpiry: invoice.CltvExpiry, + Private: invoice.Private, + RouteHints: rpcRouteHints, + } + + holdRPC := &mockInvoicesRPCClient{ + addHoldInvoice: func(_ *invoicesrpc.AddHoldInvoiceRequest, + _ ...grpc.CallOption) (*invoicesrpc.AddHoldInvoiceResp, + error) { + + return &invoicesrpc.AddHoldInvoiceResp{ + PaymentRequest: "probe invoice", + }, nil + }, + } + + invoices := &invoicesClient{ + client: holdRPC, + } + + _, err := invoices.AddHoldInvoice(t.Context(), invoice) + require.NoError(t, err) + + require.Len(t, holdRPC.addHoldInvoiceArgs, 1) + require.Equal(t, expectedRequest, holdRPC.addHoldInvoiceArgs[0].in) +} diff --git a/lightning_client.go b/lightning_client.go index 35a70cb..03cab16 100644 --- a/lightning_client.go +++ b/lightning_client.go @@ -1665,20 +1665,52 @@ func (s *lightningClient) AddInvoice(ctx context.Context, rpcCtx, cancel := context.WithTimeout(ctx, s.timeout) defer cancel() + if in.Hash != nil || in.HodlInvoice { + log.Warnf("lightningClient.AddInvoice ignores " + + "Hash/HodlInvoice; use InvoicesClient.AddHoldInvoice " + + "for hold invoices") + } + + routeHints, err := marshallRouteHints(in.RouteHints) + if err != nil { + return lntypes.Hash{}, "", fmt.Errorf( + "failed to marshal route hints: %v", err, + ) + } + rpcIn := &lnrpc.Invoice{ Memo: in.Memo, ValueMsat: int64(in.Value), DescriptionHash: in.DescriptionHash, Expiry: in.Expiry, + FallbackAddr: in.FallbackAddr, CltvExpiry: in.CltvExpiry, Private: in.Private, + IsAmp: in.Amp, + RouteHints: routeHints, } if in.Preimage != nil { rpcIn.RPreimage = in.Preimage[:] } - if in.Hash != nil { - rpcIn.RHash = in.Hash[:] + if in.BlindedPathCfg != nil { + rpcIn.IsBlinded = true + + if in.BlindedPathCfg.MinNumPathHops != 0 { + numHops := uint32(in.BlindedPathCfg.MinNumPathHops) + rpcIn.BlindedPathConfig = &lnrpc.BlindedPathConfig{ + NumHops: &numHops, + } + } + + if in.BlindedPathCfg.RoutePolicyIncrMultiplier != 0 || + in.BlindedPathCfg.RoutePolicyDecrMultiplier != 0 || + in.BlindedPathCfg.DefaultDummyHopPolicy != nil { + + log.Warnf("lightningClient.AddInvoice only forwards " + + "MinNumPathHops from BlindedPathCfg; other " + + "blinded path settings use lnd defaults") + } } rpcCtx = s.adminMac.WithMacaroonAuth(rpcCtx) diff --git a/lightning_client_test.go b/lightning_client_test.go index 44ec327..b2451ef 100644 --- a/lightning_client_test.go +++ b/lightning_client_test.go @@ -40,6 +40,51 @@ func (m *mockRPCClient) AddInvoice(ctx context.Context, in *lnrpc.Invoice, return m.addInvoice(in, opts...) } +// assertAddInvoiceArgs verifies the recorded AddInvoice RPC calls. +func assertAddInvoiceArgs(t *testing.T, want, got []addInvoiceArg) { + t.Helper() + + require.Len(t, got, len(want)) + + for i := range want { + require.Equal(t, want[i].opts, got[i].opts) + require.Equal(t, want[i].in.Memo, got[i].in.Memo) + require.Equal(t, want[i].in.RPreimage, got[i].in.RPreimage) + require.Equal(t, want[i].in.RHash, got[i].in.RHash) + require.Equal(t, want[i].in.ValueMsat, got[i].in.ValueMsat) + require.Equal( + t, want[i].in.DescriptionHash, + got[i].in.DescriptionHash, + ) + require.Equal(t, want[i].in.Expiry, got[i].in.Expiry) + require.Equal( + t, want[i].in.FallbackAddr, got[i].in.FallbackAddr, + ) + require.Equal( + t, want[i].in.CltvExpiry, got[i].in.CltvExpiry, + ) + require.Equal(t, want[i].in.Private, got[i].in.Private) + require.Equal(t, want[i].in.IsAmp, got[i].in.IsAmp) + require.Equal(t, want[i].in.IsBlinded, got[i].in.IsBlinded) + + if want[i].in.BlindedPathConfig == nil { + require.Nil(t, got[i].in.BlindedPathConfig) + } else { + require.Equal( + t, want[i].in.BlindedPathConfig, + got[i].in.BlindedPathConfig, + ) + } + + if len(want[i].in.RouteHints) == 0 { + require.Empty(t, got[i].in.RouteHints) + continue + } + + require.Equal(t, want[i].in.RouteHints, got[i].in.RouteHints) + } +} + // TestLightningClientAddInvoice ensures that adding an invoice via // lightningClient is completed as expected. func TestLightningClientAddInvoice(t *testing.T) { @@ -48,10 +93,12 @@ func TestLightningClientAddInvoice(t *testing.T) { copy(validPreimage[:], "valid preimage") var validRHash lntypes.Hash copy(validRHash[:], "valid hash") + validRouteHints := testInvoiceRouteHints() + validRPCRouteHints := testRPCRouteHints(t) + validAddInvoiceData := &invoicesrpc.AddInvoiceData{ Memo: "fake memo", Preimage: &validPreimage, - Hash: &validRHash, Value: lnwire.MilliSatoshi(500000), DescriptionHash: []byte("fake 32 byte hash"), Expiry: 123, @@ -61,7 +108,6 @@ func TestLightningClientAddInvoice(t *testing.T) { validInvoice := &lnrpc.Invoice{ Memo: validAddInvoiceData.Memo, RPreimage: validAddInvoiceData.Preimage[:], - RHash: validAddInvoiceData.Hash[:], ValueMsat: int64(validAddInvoiceData.Value), DescriptionHash: validAddInvoiceData.DescriptionHash, Expiry: validAddInvoiceData.Expiry, @@ -89,7 +135,6 @@ func TestLightningClientAddInvoice(t *testing.T) { privateInvoice := &lnrpc.Invoice{ Memo: validAddInvoiceData.Memo, RPreimage: validAddInvoiceData.Preimage[:], - RHash: validAddInvoiceData.Hash[:], ValueMsat: int64(validAddInvoiceData.Value), DescriptionHash: validAddInvoiceData.DescriptionHash, Expiry: validAddInvoiceData.Expiry, @@ -100,6 +145,107 @@ func TestLightningClientAddInvoice(t *testing.T) { {in: privateInvoice}, } + fallbackAddrAddInvoiceData := *validAddInvoiceData + fallbackAddrAddInvoiceData.FallbackAddr = fallbackAddr + fallbackAddrInvoice := &lnrpc.Invoice{ + Memo: validAddInvoiceData.Memo, + RPreimage: validAddInvoiceData.Preimage[:], + ValueMsat: int64(validAddInvoiceData.Value), + DescriptionHash: validAddInvoiceData.DescriptionHash, + Expiry: validAddInvoiceData.Expiry, + FallbackAddr: fallbackAddrAddInvoiceData.FallbackAddr, + CltvExpiry: validAddInvoiceData.CltvExpiry, + } + fallbackAddrAddInvoiceArgs := []addInvoiceArg{ + {in: fallbackAddrInvoice}, + } + + ampAddInvoiceData := &invoicesrpc.AddInvoiceData{ + Memo: validAddInvoiceData.Memo, + Value: validAddInvoiceData.Value, + DescriptionHash: validAddInvoiceData.DescriptionHash, + Expiry: validAddInvoiceData.Expiry, + CltvExpiry: validAddInvoiceData.CltvExpiry, + Amp: true, + } + ampInvoice := &lnrpc.Invoice{ + Memo: ampAddInvoiceData.Memo, + ValueMsat: int64(ampAddInvoiceData.Value), + DescriptionHash: ampAddInvoiceData.DescriptionHash, + Expiry: ampAddInvoiceData.Expiry, + CltvExpiry: ampAddInvoiceData.CltvExpiry, + IsAmp: true, + } + ampAddInvoiceArgs := []addInvoiceArg{ + {in: ampInvoice}, + } + + hashAddInvoiceData := *validAddInvoiceData + hashAddInvoiceData.Preimage = nil + hashAddInvoiceData.Hash = &validRHash + hashAddInvoiceData.HodlInvoice = true + hashInvoice := &lnrpc.Invoice{ + Memo: validAddInvoiceData.Memo, + ValueMsat: int64(validAddInvoiceData.Value), + DescriptionHash: validAddInvoiceData.DescriptionHash, + Expiry: validAddInvoiceData.Expiry, + CltvExpiry: validAddInvoiceData.CltvExpiry, + } + hashAddInvoiceArgs := []addInvoiceArg{ + {in: hashInvoice}, + } + + blindedAddInvoiceData := *validAddInvoiceData + blindedAddInvoiceData.BlindedPathCfg = &invoicesrpc.BlindedPathConfig{ + MinNumPathHops: 5, + } + numHops := uint32(blindedAddInvoiceData.BlindedPathCfg.MinNumPathHops) + blindedInvoice := &lnrpc.Invoice{ + Memo: validAddInvoiceData.Memo, + RPreimage: validAddInvoiceData.Preimage[:], + ValueMsat: int64(validAddInvoiceData.Value), + DescriptionHash: validAddInvoiceData.DescriptionHash, + Expiry: validAddInvoiceData.Expiry, + CltvExpiry: validAddInvoiceData.CltvExpiry, + IsBlinded: true, + BlindedPathConfig: &lnrpc.BlindedPathConfig{ + NumHops: &numHops, + }, + } + blindedAddInvoiceArgs := []addInvoiceArg{ + {in: blindedInvoice}, + } + + blindedZeroHopAddInvoiceData := *validAddInvoiceData + blindedZeroHopAddInvoiceData.BlindedPathCfg = &invoicesrpc.BlindedPathConfig{} + blindedZeroHopInvoice := &lnrpc.Invoice{ + Memo: validAddInvoiceData.Memo, + RPreimage: validAddInvoiceData.Preimage[:], + ValueMsat: int64(validAddInvoiceData.Value), + DescriptionHash: validAddInvoiceData.DescriptionHash, + Expiry: validAddInvoiceData.Expiry, + CltvExpiry: validAddInvoiceData.CltvExpiry, + IsBlinded: true, + } + blindedZeroHopAddInvoiceArgs := []addInvoiceArg{ + {in: blindedZeroHopInvoice}, + } + + routeHintAddInvoiceData := *validAddInvoiceData + routeHintAddInvoiceData.RouteHints = validRouteHints + routeHintInvoice := &lnrpc.Invoice{ + Memo: validAddInvoiceData.Memo, + RPreimage: validAddInvoiceData.Preimage[:], + ValueMsat: int64(validAddInvoiceData.Value), + DescriptionHash: validAddInvoiceData.DescriptionHash, + Expiry: validAddInvoiceData.Expiry, + CltvExpiry: validAddInvoiceData.CltvExpiry, + RouteHints: validRPCRouteHints, + } + routeHintAddInvoiceArgs := []addInvoiceArg{ + {in: routeHintInvoice}, + } + errorAddInvoice := func(in *lnrpc.Invoice, opts ...grpc.CallOption) ( *lnrpc.AddInvoiceResponse, error) { @@ -147,6 +293,78 @@ func TestLightningClientAddInvoice(t *testing.T) { payRequest: validPayReq, }, }, + { + name: "invoice with fallback address", + client: mockRPCClient{ + addInvoice: validAddInvoice, + }, + invoice: &fallbackAddrAddInvoiceData, + expect: expect{ + addInvoiceArgs: fallbackAddrAddInvoiceArgs, + hash: validRHash, + payRequest: validPayReq, + }, + }, + { + name: "amp invoice", + client: mockRPCClient{ + addInvoice: validAddInvoice, + }, + invoice: ampAddInvoiceData, + expect: expect{ + addInvoiceArgs: ampAddInvoiceArgs, + hash: validRHash, + payRequest: validPayReq, + }, + }, + { + name: "invoice with hash uses standard invoice path", + client: mockRPCClient{ + addInvoice: validAddInvoice, + }, + invoice: &hashAddInvoiceData, + expect: expect{ + addInvoiceArgs: hashAddInvoiceArgs, + hash: validRHash, + payRequest: validPayReq, + }, + }, + { + name: "blinded invoice", + client: mockRPCClient{ + addInvoice: validAddInvoice, + }, + invoice: &blindedAddInvoiceData, + expect: expect{ + addInvoiceArgs: blindedAddInvoiceArgs, + hash: validRHash, + payRequest: validPayReq, + }, + }, + { + name: "blinded invoice with zero min path hops", + client: mockRPCClient{ + addInvoice: validAddInvoice, + }, + invoice: &blindedZeroHopAddInvoiceData, + expect: expect{ + addInvoiceArgs: blindedZeroHopAddInvoiceArgs, + hash: validRHash, + payRequest: validPayReq, + }, + }, + { + name: "invoice with route hints", + client: mockRPCClient{ + addInvoice: validAddInvoice, + }, + invoice: &routeHintAddInvoiceData, + expect: expect{ + addInvoiceArgs: routeHintAddInvoiceArgs, + hash: validRHash, + payRequest: validPayReq, + }, + }, { name: "rpc client error", client: mockRPCClient{ @@ -167,7 +385,7 @@ func TestLightningClientAddInvoice(t *testing.T) { } hash, payRequest, err := ln.AddInvoice( - context.Background(), test.invoice, + t.Context(), test.invoice, ) // Check if an error (or no error) was received as @@ -192,9 +410,9 @@ func TestLightningClientAddInvoice(t *testing.T) { // Check if the expected args were passed to the RPC // client call. - require.Equal(t, test.client.addInvoiceArgs, - test.expect.addInvoiceArgs, - "rpc client call was not made as expected", + assertAddInvoiceArgs( + t, test.expect.addInvoiceArgs, + test.client.addInvoiceArgs, ) }) }