payment: build requests from invoice components

Decode and validate signed invoices before constructing payment
requests. Reject invoice semantics that the component API cannot safely
preserve.
This commit is contained in:
Boris Nagaev 2026-08-08 17:42:27 -05:00
parent 76a7e9c729
commit c977dedc99
No known key found for this signature in database
2 changed files with 370 additions and 0 deletions

157
payment/invoice.go Normal file
View file

@ -0,0 +1,157 @@
// Package payment provides Loop-specific construction and validation of
// outgoing Lightning payments.
package payment
import (
"errors"
"fmt"
"math"
"sort"
"time"
"github.com/btcsuite/btcd/chaincfg/v2"
"github.com/lightninglabs/lndclient"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/lightningnetwork/lnd/zpay32"
)
// RequestFromInvoice decodes and verifies an encoded BOLT 11 invoice, then
// builds a component-based payment request from its supported fields.
func RequestFromInvoice(chainParams *chaincfg.Params, encoded string,
now time.Time) (lndclient.SendPaymentRequest, error) {
invoice, err := zpay32.Decode(encoded, chainParams)
if err != nil {
return lndclient.SendPaymentRequest{},
fmt.Errorf("decode invoice: %w", err)
}
request, err := requestFromDecodedInvoice(invoice, now)
if err != nil {
return lndclient.SendPaymentRequest{},
fmt.Errorf("invalid invoice: %w", err)
}
return request, nil
}
// requestFromDecodedInvoice builds an allowlisted component payment request
// from a decoded invoice.
func requestFromDecodedInvoice(invoice *zpay32.Invoice,
now time.Time) (lndclient.SendPaymentRequest, error) {
if invoice == nil {
return lndclient.SendPaymentRequest{}, errors.New("invoice is nil")
}
if invoice.Metadata != nil {
return lndclient.SendPaymentRequest{}, errors.New(
"invoice metadata is not supported",
)
}
if len(invoice.BlindedPaymentPaths) != 0 {
return lndclient.SendPaymentRequest{}, errors.New(
"blinded payment paths are not supported",
)
}
if invoice.Features == nil {
return lndclient.SendPaymentRequest{}, errors.New(
"invoice features are missing",
)
}
if invoice.Features.HasFeature(lnwire.AMPOptional) {
return lndclient.SendPaymentRequest{}, errors.New(
"AMP invoices are not supported",
)
}
if now.After(invoice.Timestamp.Add(invoice.Expiry())) {
return lndclient.SendPaymentRequest{}, errors.New(
"invoice is expired",
)
}
if invoice.MilliSat == nil || *invoice.MilliSat <= 0 {
return lndclient.SendPaymentRequest{}, errors.New(
"invoice amount must be greater than zero",
)
}
if invoice.PaymentHash == nil {
return lndclient.SendPaymentRequest{}, errors.New(
"invoice payment hash is missing",
)
}
if invoice.Destination == nil {
return lndclient.SendPaymentRequest{}, errors.New(
"invoice destination is missing",
)
}
finalCltvDelta := invoice.MinFinalCLTVExpiry()
if finalCltvDelta > math.MaxUint16 {
return lndclient.SendPaymentRequest{}, fmt.Errorf(
"invoice final CLTV delta %d exceeds maximum %d",
finalCltvDelta, uint64(math.MaxUint16),
)
}
destFeatures := make(
[]lnrpc.FeatureBit, 0, len(invoice.Features.Features()),
)
for feature := range invoice.Features.Features() {
if !supportedInvoiceFeature(feature) {
return lndclient.SendPaymentRequest{}, fmt.Errorf(
"invoice feature bit %d is not supported", feature,
)
}
destFeatures = append(destFeatures, lnrpc.FeatureBit(feature))
}
sort.Slice(destFeatures, func(i, j int) bool {
return destFeatures[i] < destFeatures[j]
})
paymentHash := lntypes.Hash(*invoice.PaymentHash)
request := lndclient.SendPaymentRequest{
Target: route.NewVertex(invoice.Destination),
AmountMsat: *invoice.MilliSat,
PaymentHash: &paymentHash,
FinalCLTVDelta: uint16(finalCltvDelta),
RouteHints: invoice.RouteHints,
DestFeatures: destFeatures,
}
invoice.PaymentAddr.WhenSome(func(addr [32]byte) {
request.PaymentAddr = &addr
})
return request, nil
}
// supportedInvoiceFeature returns true for invoice feature bits that are
// represented by a component-based SendPayment request.
func supportedInvoiceFeature(feature lnwire.FeatureBit) bool {
switch feature {
case lnwire.TLVOnionPayloadRequired,
lnwire.TLVOnionPayloadOptional,
lnwire.PaymentAddrRequired,
lnwire.PaymentAddrOptional,
lnwire.MPPRequired,
lnwire.MPPOptional,
lnwire.RouteBlindingRequired,
lnwire.RouteBlindingOptional:
return true
default:
return false
}
}

213
payment/invoice_test.go Normal file
View file

@ -0,0 +1,213 @@
package payment
import (
"testing"
"time"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/ecdsa"
"github.com/btcsuite/btcd/chaincfg/v2"
"github.com/btcsuite/btcd/chainhash/v2"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/lightningnetwork/lnd/zpay32"
"github.com/stretchr/testify/require"
)
// TestRequestFromInvoice checks that a signed invoice's supported semantics
// are copied into a component-based payment request.
func TestRequestFromInvoice(t *testing.T) {
t.Parallel()
now := time.Unix(123456789, 0)
paymentHash := [32]byte{1, 2, 3}
paymentAddr := [32]byte{4, 5, 6}
privateKey, destination := btcec.PrivKeyFromBytes([]byte{7, 8, 9})
_, hintNode := btcec.PrivKeyFromBytes([]byte{10, 11, 12})
routeHints := [][]zpay32.HopHint{{{
NodeID: hintNode,
ChannelID: 123,
FeeBaseMSat: 456,
FeeProportionalMillionths: 789,
CLTVExpiryDelta: 40,
}}}
features := lnwire.NewFeatureVector(
lnwire.NewRawFeatureVector(
lnwire.TLVOnionPayloadRequired,
lnwire.PaymentAddrRequired,
lnwire.MPPOptional,
lnwire.RouteBlindingOptional,
),
lnwire.Features,
)
invoice, err := zpay32.NewInvoice(
&chaincfg.TestNet3Params, paymentHash, now,
zpay32.Description("test"),
zpay32.Amount(123456),
zpay32.Destination(destination),
zpay32.PaymentAddr(paymentAddr),
zpay32.CLTVExpiry(144),
zpay32.RouteHint(routeHints[0]),
zpay32.Features(features),
)
require.NoError(t, err)
encoded := encodeInvoice(t, invoice, privateKey)
request, err := RequestFromInvoice(
&chaincfg.TestNet3Params, encoded, now.Add(time.Minute),
)
require.NoError(t, err)
require.Empty(t, request.Invoice)
require.Equal(t, route.NewVertex(destination), request.Target)
require.Equal(t, lnwire.MilliSatoshi(123456), request.AmountMsat)
require.Zero(t, request.Amount)
require.Equal(t, lntypes.Hash(paymentHash), *request.PaymentHash)
require.Equal(t, paymentAddr, *request.PaymentAddr)
require.Equal(t, uint16(144), request.FinalCLTVDelta)
require.Equal(t, routeHints, request.RouteHints)
require.Equal(t, []lnrpc.FeatureBit{
lnrpc.FeatureBit_TLV_ONION_REQ,
lnrpc.FeatureBit_PAYMENT_ADDR_REQ,
lnrpc.FeatureBit_MPP_OPT,
lnrpc.FeatureBit_ROUTE_BLINDING_OPTIONAL,
}, request.DestFeatures)
}
// TestRequestFromInvoiceRejectsTampering checks that decoding and integrity
// validation are part of constructing a payment request.
func TestRequestFromInvoiceRejectsTampering(t *testing.T) {
t.Parallel()
now := time.Unix(123456789, 0)
paymentHash := [32]byte{1, 2, 3}
privateKey, _ := btcec.PrivKeyFromBytes([]byte{7, 8, 9})
invoice, err := zpay32.NewInvoice(
&chaincfg.TestNet3Params, paymentHash, now,
zpay32.Description("test"), zpay32.Amount(123456),
)
require.NoError(t, err)
encoded := encodeInvoice(t, invoice, privateKey)
replacement := byte('q')
if encoded[len(encoded)-1] == replacement {
replacement = 'p'
}
encoded = encoded[:len(encoded)-1] + string(replacement)
_, err = RequestFromInvoice(
&chaincfg.TestNet3Params, encoded, now.Add(time.Minute),
)
require.ErrorContains(t, err, "decode invoice")
}
// TestRequestFromDecodedInvoiceRejectsUnsupported checks that invoice
// semantics which cannot be represented safely are rejected.
func TestRequestFromDecodedInvoiceRejectsUnsupported(t *testing.T) {
t.Parallel()
now := time.Unix(123456789, 0)
newInvoice := func(t *testing.T) *zpay32.Invoice {
t.Helper()
paymentHash := [32]byte{1, 2, 3}
_, destination := btcec.PrivKeyFromBytes([]byte{7, 8, 9})
invoice, err := zpay32.NewInvoice(
&chaincfg.TestNet3Params, paymentHash, now,
zpay32.Description("test"),
zpay32.Amount(123456),
zpay32.Destination(destination),
zpay32.Expiry(time.Hour),
)
require.NoError(t, err)
return invoice
}
testCases := []struct {
name string
mutate func(*zpay32.Invoice)
now time.Time
err string
}{
{
name: "expired",
now: now.Add(time.Hour + time.Second),
err: "invoice is expired",
},
{
name: "metadata",
mutate: func(invoice *zpay32.Invoice) {
invoice.Metadata = []byte{}
},
err: "invoice metadata is not supported",
},
{
name: "blinded payment path",
mutate: func(invoice *zpay32.Invoice) {
invoice.BlindedPaymentPaths =
[]*zpay32.BlindedPaymentPath{{}}
},
err: "blinded payment paths are not supported",
},
{
name: "AMP",
mutate: func(invoice *zpay32.Invoice) {
invoice.Features = lnwire.NewFeatureVector(
lnwire.NewRawFeatureVector(
lnwire.AMPOptional,
),
lnwire.Features,
)
},
err: "AMP invoices are not supported",
},
{
name: "unknown feature",
mutate: func(invoice *zpay32.Invoice) {
invoice.Features = lnwire.NewFeatureVector(
lnwire.NewRawFeatureVector(999),
lnwire.Features,
)
},
err: "invoice feature bit 999 is not supported",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
invoice := newInvoice(t)
if tc.mutate != nil {
tc.mutate(invoice)
}
checkTime := now.Add(time.Minute)
if !tc.now.IsZero() {
checkTime = tc.now
}
_, err := requestFromDecodedInvoice(invoice, checkTime)
require.ErrorContains(t, err, tc.err)
})
}
}
// encodeInvoice signs and encodes an invoice for testing.
func encodeInvoice(t *testing.T, invoice *zpay32.Invoice,
privateKey *btcec.PrivateKey) string {
t.Helper()
encoded, err := invoice.Encode(zpay32.MessageSigner{
SignCompact: func(message []byte) ([]byte, error) {
hash := chainhash.HashB(message)
return ecdsa.SignCompact(privateKey, hash, true), nil
},
})
require.NoError(t, err)
return encoded
}