From c977dedc99dc1da9855cbd6154cdb3559e8e9c5d Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sat, 8 Aug 2026 17:42:27 -0500 Subject: [PATCH] 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. --- payment/invoice.go | 157 +++++++++++++++++++++++++++++ payment/invoice_test.go | 213 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 payment/invoice.go create mode 100644 payment/invoice_test.go diff --git a/payment/invoice.go b/payment/invoice.go new file mode 100644 index 00000000..53193dcd --- /dev/null +++ b/payment/invoice.go @@ -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 + } +} diff --git a/payment/invoice_test.go b/payment/invoice_test.go new file mode 100644 index 00000000..4c1f7ecd --- /dev/null +++ b/payment/invoice_test.go @@ -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 +}