mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
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:
parent
76a7e9c729
commit
c977dedc99
2 changed files with 370 additions and 0 deletions
157
payment/invoice.go
Normal file
157
payment/invoice.go
Normal 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
213
payment/invoice_test.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue