From e84e6b557ef52bf3c0c5f567b4baadcdab4fedec Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 3 Feb 2026 10:05:06 -0800 Subject: [PATCH 1/3] lnrpc: add ValidatePayReqAmt helper for invoice amount validation In this commit, we introduce a new ValidatePayReqAmt helper function in the lnrpc package that consolidates the logic for validating a caller-specified payment amount against a BOLT11 invoice amount. The helper handles three cases: zero-amount invoices (caller must specify an amount), fixed-amount invoices with no caller override (use invoice amount), and fixed-amount invoices with a caller override (allow overpayment, reject underpayment). This helper will be used in the next commit to fix a bug where amt_msat was silently ignored when paying fixed-amount invoices. --- lnrpc/marshall_utils.go | 40 +++++++++++++++ lnrpc/marshall_utils_test.go | 97 ++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 lnrpc/marshall_utils_test.go diff --git a/lnrpc/marshall_utils.go b/lnrpc/marshall_utils.go index 05a9e9a9027..787376c5f60 100644 --- a/lnrpc/marshall_utils.go +++ b/lnrpc/marshall_utils.go @@ -71,6 +71,46 @@ func UnmarshallAmt(amtSat, amtMsat int64) (lnwire.MilliSatoshi, error) { return lnwire.MilliSatoshi(amtMsat), nil } +// ValidatePayReqAmt validates the amount for a payment that includes a BOLT11 +// payment request. If the invoice includes a fixed amount, the caller may +// optionally specify a larger amount to overpay, but underpayment is rejected. +// For zero-amount invoices, the caller must specify an amount. The returned +// value is the amount that should be used for the payment. +func ValidatePayReqAmt(invoiceMsat *lnwire.MilliSatoshi, + amtSat, amtMsat int64) (lnwire.MilliSatoshi, error) { + + reqAmt, err := UnmarshallAmt(amtSat, amtMsat) + if err != nil { + return 0, err + } + + if invoiceMsat == nil { + // For zero-amount invoices, the caller must specify an amount. + if reqAmt == 0 { + return 0, errors.New("amount must be specified " + + "when paying a zero amount invoice") + } + + return reqAmt, nil + } + + // The invoice has a fixed amount. If the caller also specified an + // amount, allow it as long as it is at least the invoice amount + // (overpayment is permitted by the spec). + if reqAmt != 0 { + if reqAmt < *invoiceMsat { + return 0, fmt.Errorf("payment amount (%v) must "+ + "not be less than invoice amount (%v)", + reqAmt, *invoiceMsat, + ) + } + + return reqAmt, nil + } + + return *invoiceMsat, nil +} + // ParseConfs validates the minimum and maximum confirmation arguments of a // ListUnspent request. func ParseConfs(min, max int32) (int32, int32, error) { diff --git a/lnrpc/marshall_utils_test.go b/lnrpc/marshall_utils_test.go new file mode 100644 index 00000000000..c92b332df43 --- /dev/null +++ b/lnrpc/marshall_utils_test.go @@ -0,0 +1,97 @@ +package lnrpc + +import ( + "testing" + + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" +) + +// TestValidatePayReqAmt tests the ValidatePayReqAmt helper for various +// combinations of invoice amount and caller-specified amount. +func TestValidatePayReqAmt(t *testing.T) { + t.Parallel() + + invoiceAmt := lnwire.MilliSatoshi(1_000_000) + + tests := []struct { + name string + invoiceMsat *lnwire.MilliSatoshi + amtSat int64 + amtMsat int64 + wantAmt lnwire.MilliSatoshi + wantErr string + }{ + { + name: "zero-amount invoice with amt_msat", + invoiceMsat: nil, + amtMsat: 500_000, + wantAmt: 500_000, + }, + { + name: "zero-amount invoice with amt_sat", + invoiceMsat: nil, + amtSat: 500, + wantAmt: 500_000, + }, + { + name: "zero-amount invoice with no amount", + invoiceMsat: nil, + wantErr: "amount must be specified", + }, + { + name: "fixed invoice, no caller amount", + invoiceMsat: &invoiceAmt, + wantAmt: 1_000_000, + }, + { + name: "fixed invoice, exact amount", + invoiceMsat: &invoiceAmt, + amtMsat: 1_000_000, + wantAmt: 1_000_000, + }, + { + name: "fixed invoice, overpayment via msat", + invoiceMsat: &invoiceAmt, + amtMsat: 1_100_000, + wantAmt: 1_100_000, + }, + { + name: "fixed invoice, overpayment via sat", + invoiceMsat: &invoiceAmt, + amtSat: 1100, + wantAmt: 1_100_000, + }, + { + name: "fixed invoice, underpayment rejected", + invoiceMsat: &invoiceAmt, + amtMsat: 999_999, + wantErr: "must not be less than invoice amount", + }, + { + name: "sat and msat mutually exclusive", + invoiceMsat: &invoiceAmt, + amtSat: 1, + amtMsat: 1000, + wantErr: "mutually exclusive", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := ValidatePayReqAmt( + tc.invoiceMsat, tc.amtSat, tc.amtMsat, + ) + + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + require.Equal(t, tc.wantAmt, got) + }) + } +} From e96faa79ec8ae97c37ed211a58c63e7e5ffe577f Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 3 Feb 2026 10:05:24 -0800 Subject: [PATCH 2/3] rpcserver+routerrpc: allow overpayment of fixed-amount invoices Previously, the SendPaymentSync path in rpcserver.go silently ignored the amt_msat field when paying a BOLT11 invoice that already contained a fixed amount, and the SendPaymentV2 path in router_backend.go rejected the request outright with "amount must not be specified when paying a non-zero amount invoice". Both behaviors prevented users from intentionally overpaying an invoice, which is permitted by the BOLT spec. In this commit, we replace the duplicated amount validation logic in both extractPaymentIntent (rpcserver.go) and extractIntentFromSendRequest (router_backend.go) with calls to the new ValidatePayReqAmt helper. When a caller specifies amt_msat alongside a fixed-amount invoice, the payment now proceeds as long as the specified amount is at least the invoice amount. Underpayment is still rejected with a clear error message. Fixes https://github.com/lightningnetwork/lnd/issues/10541 --- lnrpc/routerrpc/router_backend.go | 28 ++++++++-------------------- rpcserver.go | 28 ++++++++-------------------- 2 files changed, 16 insertions(+), 40 deletions(-) diff --git a/lnrpc/routerrpc/router_backend.go b/lnrpc/routerrpc/router_backend.go index d8c8a17c410..e417f12a0bd 100644 --- a/lnrpc/routerrpc/router_backend.go +++ b/lnrpc/routerrpc/router_backend.go @@ -1025,27 +1025,15 @@ func (r *RouterBackend) extractIntentFromSendRequest( "either a payment address or blinded paths") } - // If the amount was not included in the invoice, then we let - // the payer specify the amount of satoshis they wish to send. - // We override the amount to pay with the amount provided from - // the payment request. - if payReq.MilliSat == nil { - if reqAmt == 0 { - return nil, errors.New("amount must be " + - "specified when paying a zero amount " + - "invoice") - } - - payIntent.Amount = reqAmt - } else { - if reqAmt != 0 { - return nil, errors.New("amount must not be " + - "specified when paying a non-zero " + - "amount invoice") - } - - payIntent.Amount = *payReq.MilliSat + // Validate and resolve the payment amount against the + // invoice. Overpayment is allowed, underpayment is not. + payAmt, err := lnrpc.ValidatePayReqAmt( + payReq.MilliSat, rpcPayReq.Amt, rpcPayReq.AmtMsat, + ) + if err != nil { + return nil, err } + payIntent.Amount = payAmt if !payReq.Features.HasFeature(lnwire.MPPOptional) && !payReq.Features.HasFeature(lnwire.AMPOptional) { diff --git a/rpcserver.go b/rpcserver.go index e5d59a31717..d239865248d 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -5768,27 +5768,15 @@ func (r *rpcServer) extractPaymentIntent(rpcPayReq *rpcPaymentRequest) (rpcPayme return payIntent, err } - // If the amount was not included in the invoice, then we let - // the payer specify the amount of satoshis they wish to send. - // We override the amount to pay with the amount provided from - // the payment request. - if payReq.MilliSat == nil { - amt, err := lnrpc.UnmarshallAmt( - rpcPayReq.Amt, rpcPayReq.AmtMsat, - ) - if err != nil { - return payIntent, err - } - if amt == 0 { - return payIntent, errors.New("amount must be " + - "specified when paying a zero amount " + - "invoice") - } - - payIntent.msat = amt - } else { - payIntent.msat = *payReq.MilliSat + // Validate and resolve the payment amount against the + // invoice. Overpayment is allowed, underpayment is not. + amt, err := lnrpc.ValidatePayReqAmt( + payReq.MilliSat, rpcPayReq.Amt, rpcPayReq.AmtMsat, + ) + if err != nil { + return payIntent, err } + payIntent.msat = amt // Calculate the fee limit that should be used for this payment. payIntent.feeLimit = lnrpc.CalculateFeeLimit( From 4c61fb1a75cf70311dd13d20712feadd99a40952 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 3 Feb 2026 10:05:37 -0800 Subject: [PATCH 3/3] itest: add send payment overpay integration test In this commit, we add an integration test that verifies a payer can overpay a fixed-amount BOLT11 invoice by specifying a larger amt_msat in the SendPaymentV2 request. The test creates a 1000 sat invoice, pays it with 1100 sat (1,100,000 msat), and asserts that both the sender's payment record and the receiver's invoice reflect the overpaid amount. --- itest/list_on_test.go | 4 ++ itest/lnd_send_overpayment_test.go | 59 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 itest/lnd_send_overpayment_test.go diff --git a/itest/list_on_test.go b/itest/list_on_test.go index 3dc3ac91de3..65cbe9fa339 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -383,6 +383,10 @@ var allTestCases = []*lntest.TestCase{ Name: "single hop invoice", TestFunc: testSingleHopInvoice, }, + { + Name: "send payment overpay", + TestFunc: testSendPaymentOverpay, + }, { Name: "wipe forwarding packages", TestFunc: testWipeForwardingPackages, diff --git a/itest/lnd_send_overpayment_test.go b/itest/lnd_send_overpayment_test.go new file mode 100644 index 00000000000..9ae920903e8 --- /dev/null +++ b/itest/lnd_send_overpayment_test.go @@ -0,0 +1,59 @@ +package itest + +import ( + "github.com/btcsuite/btcd/btcutil" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" + "github.com/lightningnetwork/lnd/lntest" + "github.com/stretchr/testify/require" +) + +// testSendPaymentOverpay verifies that a payer can intentionally overpay a +// fixed-amount BOLT11 invoice by specifying a larger amt_msat in the send +// request. +func testSendPaymentOverpay(ht *lntest.HarnessTest) { + // Open a channel with 100k satoshis between Alice and Bob. + chanAmt := btcutil.Amount(100_000) + _, nodes := ht.CreateSimpleNetwork( + [][]string{nil, nil}, + lntest.OpenChannelParams{Amt: chanAmt}, + ) + alice, bob := nodes[0], nodes[1] + + const invoiceAmtSat = 1_000 + const overpayAmtSat = 1_100 + const overpayAmtMsat = overpayAmtSat * 1000 + + // Create a fixed-amount invoice on Bob's side. + invoice := bob.RPC.AddInvoice(&lnrpc.Invoice{ + Memo: "overpay-test", + Value: invoiceAmtSat, + }) + + // Alice sends a payment with amt_msat larger than the invoice + // amount. This should succeed and deliver the overpaid amount. + payment := ht.SendPaymentAssertSettled( + alice, &routerrpc.SendPaymentRequest{ + PaymentRequest: invoice.PaymentRequest, + AmtMsat: overpayAmtMsat, + TimeoutSeconds: 60, + FeeLimitMsat: noFeeLimitMsat, + }, + ) + + // The payment value should reflect the overpaid amount. + require.Equal( + ht, int64(overpayAmtMsat), payment.ValueMsat, + "value_msat should equal the overpaid amount", + ) + + // The invoice on Bob's side should show the overpaid amount. + dbInvoice := bob.RPC.LookupInvoice(invoice.RHash) + require.Equal( + ht, lnrpc.Invoice_SETTLED, dbInvoice.State, + ) + require.Equal( + ht, int64(overpayAmtMsat), dbInvoice.AmtPaidMsat, + "Bob should receive the overpaid amount", + ) +}