From 6f15e2c56372bea665d0ce7d12853d0c6f003178 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Tue, 14 Apr 2026 16:31:20 -0500 Subject: [PATCH 1/6] lndclient: preserve addinvoice route hints Marshal explicit route hints in lightningClient.AddInvoice so the standard invoice path matches AddHoldInvoice. Add regression coverage for direct AddInvoice route hint encoding and for route hint parity between AddInvoice and AddHoldInvoice. --- invoices_client_test.go | 157 +++++++++++++++++++++++++++++++++++++++ lightning_client.go | 8 ++ lightning_client_test.go | 70 ++++++++++++++++- 3 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 invoices_client_test.go diff --git a/invoices_client_test.go b/invoices_client_test.go new file mode 100644 index 0000000..6d48b05 --- /dev/null +++ b/invoices_client_test.go @@ -0,0 +1,157 @@ +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, + }, + }, + } +} + +// 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...) +} + +// TestInvoiceClientAddInvoiceRouteHintParity ensures AddInvoice and +// AddHoldInvoice encode the same route hints for the same invoice input. +func TestInvoiceClientAddInvoiceRouteHintParity(t *testing.T) { + var validPreimage lntypes.Preimage + copy(validPreimage[:], "valid preimage") + + var validRHash lntypes.Hash + copy(validRHash[:], "valid hash") + + invoice := &invoicesrpc.AddInvoiceData{ + Memo: "fake memo", + Preimage: &validPreimage, + Hash: &validRHash, + Value: lnwire.MilliSatoshi(500000), + DescriptionHash: []byte("fake 32 byte hash"), + Expiry: 123, + CltvExpiry: 456, + RouteHints: testInvoiceRouteHints(), + } + + 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(), invoice) + require.NoError(t, err) + + _, err = invoices.AddHoldInvoice(t.Context(), invoice) + require.NoError(t, err) + + require.Len(t, lightningRPC.addInvoiceArgs, 1) + require.Len(t, holdRPC.addHoldInvoiceArgs, 1) + + require.Equal( + t, holdRPC.addHoldInvoiceArgs[0].in.RouteHints, + lightningRPC.addInvoiceArgs[0].in.RouteHints, + ) +} diff --git a/lightning_client.go b/lightning_client.go index 35a70cb..f63b6e8 100644 --- a/lightning_client.go +++ b/lightning_client.go @@ -1665,6 +1665,13 @@ func (s *lightningClient) AddInvoice(ctx context.Context, rpcCtx, cancel := context.WithTimeout(ctx, s.timeout) defer cancel() + 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), @@ -1672,6 +1679,7 @@ func (s *lightningClient) AddInvoice(ctx context.Context, Expiry: in.Expiry, CltvExpiry: in.CltvExpiry, Private: in.Private, + RouteHints: routeHints, } if in.Preimage != nil { diff --git a/lightning_client_test.go b/lightning_client_test.go index 44ec327..f5df568 100644 --- a/lightning_client_test.go +++ b/lightning_client_test.go @@ -40,6 +40,37 @@ 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.CltvExpiry, got[i].in.CltvExpiry, + ) + require.Equal(t, want[i].in.Private, got[i].in.Private) + + 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,6 +79,9 @@ 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, @@ -100,6 +134,22 @@ func TestLightningClientAddInvoice(t *testing.T) { {in: privateInvoice}, } + routeHintAddInvoiceData := *validAddInvoiceData + routeHintAddInvoiceData.RouteHints = validRouteHints + routeHintInvoice := &lnrpc.Invoice{ + Memo: validAddInvoiceData.Memo, + RPreimage: validAddInvoiceData.Preimage[:], + RHash: validAddInvoiceData.Hash[:], + 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 +197,18 @@ func TestLightningClientAddInvoice(t *testing.T) { 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 +229,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 +254,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, ) }) } From 157b89e68ac4d26e82aee52e6e704f51acaacced Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Tue, 14 Apr 2026 17:21:36 -0500 Subject: [PATCH 2/6] lndclient: preserve addinvoice fallback address Set FallbackAddr on the standard AddInvoice RPC request so the lightning client preserves explicit on-chain fallback addresses. Extend the existing invoice tests with direct AddInvoice coverage and parity checks against AddHoldInvoice. --- invoices_client_test.go | 50 +++++++++++++++++++++++++++++++--------- lightning_client.go | 1 + lightning_client_test.go | 31 +++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 11 deletions(-) diff --git a/invoices_client_test.go b/invoices_client_test.go index 6d48b05..93706ed 100644 --- a/invoices_client_test.go +++ b/invoices_client_test.go @@ -50,6 +50,9 @@ func testInvoiceRouteHints() [][]zpay32.HopHint { } } +// 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() @@ -92,26 +95,51 @@ func (m *mockInvoicesRPCClient) AddHoldInvoice(ctx context.Context, return m.addHoldInvoice(in, opts...) } -// TestInvoiceClientAddInvoiceRouteHintParity ensures AddInvoice and -// AddHoldInvoice encode the same route hints for the same invoice input. -func TestInvoiceClientAddInvoiceRouteHintParity(t *testing.T) { +// 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") - invoice := &invoicesrpc.AddInvoiceData{ + sharedInvoice := invoicesrpc.AddInvoiceData{ Memo: "fake memo", - Preimage: &validPreimage, - Hash: &validRHash, 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, @@ -141,17 +169,17 @@ func TestInvoiceClientAddInvoiceRouteHintParity(t *testing.T) { client: holdRPC, } - _, _, err := lightning.AddInvoice(t.Context(), invoice) + _, _, err := lightning.AddInvoice(t.Context(), &lightningInvoice) require.NoError(t, err) - _, err = invoices.AddHoldInvoice(t.Context(), invoice) + _, err = invoices.AddHoldInvoice(t.Context(), &holdInvoice) require.NoError(t, err) require.Len(t, lightningRPC.addInvoiceArgs, 1) require.Len(t, holdRPC.addHoldInvoiceArgs, 1) - require.Equal( - t, holdRPC.addHoldInvoiceArgs[0].in.RouteHints, - lightningRPC.addInvoiceArgs[0].in.RouteHints, + assertInvoiceRequestParity( + t, lightningRPC.addInvoiceArgs[0].in, + holdRPC.addHoldInvoiceArgs[0].in, ) } diff --git a/lightning_client.go b/lightning_client.go index f63b6e8..4405837 100644 --- a/lightning_client.go +++ b/lightning_client.go @@ -1677,6 +1677,7 @@ func (s *lightningClient) AddInvoice(ctx context.Context, ValueMsat: int64(in.Value), DescriptionHash: in.DescriptionHash, Expiry: in.Expiry, + FallbackAddr: in.FallbackAddr, CltvExpiry: in.CltvExpiry, Private: in.Private, RouteHints: routeHints, diff --git a/lightning_client_test.go b/lightning_client_test.go index f5df568..9704b8b 100644 --- a/lightning_client_test.go +++ b/lightning_client_test.go @@ -57,6 +57,9 @@ func assertAddInvoiceArgs(t *testing.T, want, got []addInvoiceArg) { 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, ) @@ -134,6 +137,22 @@ func TestLightningClientAddInvoice(t *testing.T) { {in: privateInvoice}, } + fallbackAddrAddInvoiceData := *validAddInvoiceData + fallbackAddrAddInvoiceData.FallbackAddr = fallbackAddr + fallbackAddrInvoice := &lnrpc.Invoice{ + Memo: validAddInvoiceData.Memo, + RPreimage: validAddInvoiceData.Preimage[:], + RHash: validAddInvoiceData.Hash[:], + ValueMsat: int64(validAddInvoiceData.Value), + DescriptionHash: validAddInvoiceData.DescriptionHash, + Expiry: validAddInvoiceData.Expiry, + FallbackAddr: fallbackAddrAddInvoiceData.FallbackAddr, + CltvExpiry: validAddInvoiceData.CltvExpiry, + } + fallbackAddrAddInvoiceArgs := []addInvoiceArg{ + {in: fallbackAddrInvoice}, + } + routeHintAddInvoiceData := *validAddInvoiceData routeHintAddInvoiceData.RouteHints = validRouteHints routeHintInvoice := &lnrpc.Invoice{ @@ -197,6 +216,18 @@ 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: "invoice with route hints", client: mockRPCClient{ From 9c1b4f432b7f3438e770d1015dec3f210d047b3e Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Tue, 14 Apr 2026 17:22:17 -0500 Subject: [PATCH 3/6] lndclient: preserve addinvoice amp flag Set IsAmp on the standard AddInvoice RPC request so callers can request AMP invoices through lightningClient.AddInvoice. Extend the existing AddInvoice unit test to assert AMP invoice requests preserve the flag on the outgoing RPC payload. --- lightning_client.go | 1 + lightning_client_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/lightning_client.go b/lightning_client.go index 4405837..85f8c64 100644 --- a/lightning_client.go +++ b/lightning_client.go @@ -1680,6 +1680,7 @@ func (s *lightningClient) AddInvoice(ctx context.Context, FallbackAddr: in.FallbackAddr, CltvExpiry: in.CltvExpiry, Private: in.Private, + IsAmp: in.Amp, RouteHints: routeHints, } diff --git a/lightning_client_test.go b/lightning_client_test.go index 9704b8b..0e63f49 100644 --- a/lightning_client_test.go +++ b/lightning_client_test.go @@ -64,6 +64,7 @@ func assertAddInvoiceArgs(t *testing.T, want, got []addInvoiceArg) { 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) if len(want[i].in.RouteHints) == 0 { require.Empty(t, got[i].in.RouteHints) @@ -153,6 +154,26 @@ func TestLightningClientAddInvoice(t *testing.T) { {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}, + } + routeHintAddInvoiceData := *validAddInvoiceData routeHintAddInvoiceData.RouteHints = validRouteHints routeHintInvoice := &lnrpc.Invoice{ @@ -228,6 +249,18 @@ func TestLightningClientAddInvoice(t *testing.T) { payRequest: validPayReq, }, }, + { + name: "amp invoice", + client: mockRPCClient{ + addInvoice: validAddInvoice, + }, + invoice: ampAddInvoiceData, + expect: expect{ + addInvoiceArgs: ampAddInvoiceArgs, + hash: validRHash, + payRequest: validPayReq, + }, + }, { name: "invoice with route hints", client: mockRPCClient{ From db55732973fff6064691c3d6630564e47d726501 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Tue, 14 Apr 2026 17:49:13 -0500 Subject: [PATCH 4/6] lndclient: ignore hold invoice args in addinvoice Stop forwarding Hash on the standard AddInvoice path and warn when Hash or HodlInvoice are provided, since callers should use InvoicesClient.AddHoldInvoice for hold invoices. Extend the existing AddInvoice test to assert the standard invoice request omits hold-invoice-only arguments. --- lightning_client.go | 9 ++++++--- lightning_client_test.go | 32 +++++++++++++++++++++++++++----- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/lightning_client.go b/lightning_client.go index 85f8c64..36b705f 100644 --- a/lightning_client.go +++ b/lightning_client.go @@ -1665,6 +1665,12 @@ 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( @@ -1687,9 +1693,6 @@ func (s *lightningClient) AddInvoice(ctx context.Context, if in.Preimage != nil { rpcIn.RPreimage = in.Preimage[:] } - if in.Hash != nil { - rpcIn.RHash = in.Hash[:] - } rpcCtx = s.adminMac.WithMacaroonAuth(rpcCtx) resp, err := s.client.AddInvoice(rpcCtx, rpcIn) diff --git a/lightning_client_test.go b/lightning_client_test.go index 0e63f49..87b8a3d 100644 --- a/lightning_client_test.go +++ b/lightning_client_test.go @@ -89,7 +89,6 @@ func TestLightningClientAddInvoice(t *testing.T) { validAddInvoiceData := &invoicesrpc.AddInvoiceData{ Memo: "fake memo", Preimage: &validPreimage, - Hash: &validRHash, Value: lnwire.MilliSatoshi(500000), DescriptionHash: []byte("fake 32 byte hash"), Expiry: 123, @@ -99,7 +98,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, @@ -127,7 +125,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, @@ -143,7 +140,6 @@ func TestLightningClientAddInvoice(t *testing.T) { fallbackAddrInvoice := &lnrpc.Invoice{ Memo: validAddInvoiceData.Memo, RPreimage: validAddInvoiceData.Preimage[:], - RHash: validAddInvoiceData.Hash[:], ValueMsat: int64(validAddInvoiceData.Value), DescriptionHash: validAddInvoiceData.DescriptionHash, Expiry: validAddInvoiceData.Expiry, @@ -174,12 +170,26 @@ func TestLightningClientAddInvoice(t *testing.T) { {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}, + } + routeHintAddInvoiceData := *validAddInvoiceData routeHintAddInvoiceData.RouteHints = validRouteHints routeHintInvoice := &lnrpc.Invoice{ Memo: validAddInvoiceData.Memo, RPreimage: validAddInvoiceData.Preimage[:], - RHash: validAddInvoiceData.Hash[:], ValueMsat: int64(validAddInvoiceData.Value), DescriptionHash: validAddInvoiceData.DescriptionHash, Expiry: validAddInvoiceData.Expiry, @@ -261,6 +271,18 @@ func TestLightningClientAddInvoice(t *testing.T) { 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: "invoice with route hints", client: mockRPCClient{ From ec7246e9f21d83c4199f1c561b5e762666496491 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Tue, 14 Apr 2026 17:50:04 -0500 Subject: [PATCH 5/6] lndclient: map addinvoice blinded path config Translate BlindedPathCfg into the standard AddInvoice RPC by setting IsBlinded and forwarding MinNumPathHops as the compatible NumHops override. Warn when non-overlapping blinded path settings are provided, and extend the AddInvoice unit test to assert blinded invoice requests preserve the translated RPC fields. --- lightning_client.go | 19 +++++++++++ lightning_client_test.go | 70 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/lightning_client.go b/lightning_client.go index 36b705f..03cab16 100644 --- a/lightning_client.go +++ b/lightning_client.go @@ -1693,6 +1693,25 @@ func (s *lightningClient) AddInvoice(ctx context.Context, if in.Preimage != nil { rpcIn.RPreimage = in.Preimage[:] } + 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) resp, err := s.client.AddInvoice(rpcCtx, rpcIn) diff --git a/lightning_client_test.go b/lightning_client_test.go index 87b8a3d..b2451ef 100644 --- a/lightning_client_test.go +++ b/lightning_client_test.go @@ -65,6 +65,16 @@ func assertAddInvoiceArgs(t *testing.T, want, got []addInvoiceArg) { ) 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) @@ -185,6 +195,42 @@ func TestLightningClientAddInvoice(t *testing.T) { {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{ @@ -283,6 +329,30 @@ func TestLightningClientAddInvoice(t *testing.T) { 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{ From e3748c1a23dab82e8df2be5eae1da50731456740 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Tue, 14 Apr 2026 18:33:43 -0500 Subject: [PATCH 6/6] lndclient: warn on unsupported hold invoice fields Warn when AddHoldInvoice is called with Amp or BlindedPathCfg, since the hold-invoice RPC cannot represent those inputs. Add coverage to document that the wrapper still forwards the supported request fields unchanged when those unsupported inputs are present. --- invoices_client.go | 6 +++++ invoices_client_test.go | 58 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) 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 index 93706ed..ee5bef6 100644 --- a/invoices_client_test.go +++ b/invoices_client_test.go @@ -183,3 +183,61 @@ func TestInvoiceClientAddInvoiceParity(t *testing.T) { 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) +}