multi: pay invoices with allowlisted components

Build outgoing requests from validated invoice fields so encoded server
invoices are never forwarded directly to LND.
This commit is contained in:
Boris Nagaev 2026-08-08 18:12:20 -05:00
parent c977dedc99
commit 3bb9ab1170
No known key found for this signature in database
9 changed files with 108 additions and 60 deletions

View file

@ -11,10 +11,10 @@ import (
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcutil/v2"
"github.com/btcsuite/btcd/txscript/v2"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/instantout/reservation"
"github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/payment"
"github.com/lightninglabs/loop/swap"
"github.com/lightninglabs/loop/swapserverrpc"
"github.com/lightningnetwork/lnd/lnrpc"
@ -150,17 +150,19 @@ func (f *FSM) InitInstantOutAction(ctx context.Context,
if err != nil {
return f.HandleError(err)
}
// Decode the invoice to check if the hash is valid.
payReq, err := f.cfg.LndClient.DecodePaymentRequest(
ctx, instantOutResponse.SwapInvoice,
// Decode and validate the invoice before storing it for payment.
paymentRequest, err := payment.RequestFromInvoice(
f.cfg.Network, instantOutResponse.SwapInvoice,
f.clck.Now(),
)
if err != nil {
return f.HandleError(err)
}
if swapHash != payReq.Hash {
invoiceHash := *paymentRequest.PaymentHash
if swapHash != invoiceHash {
return f.HandleError(fmt.Errorf("invalid swap invoice hash: "+
"expected %x got %x", preimage.Hash(), payReq.Hash))
"expected %x got %x", preimage.Hash(), invoiceHash))
}
serverPubkey, err := btcec.ParsePubKey(instantOutResponse.SenderKey)
if err != nil {
@ -212,6 +214,16 @@ func (f *FSM) InitInstantOutAction(ctx context.Context,
func (f *FSM) PollPaymentAcceptedAction(ctx context.Context,
_ fsm.EventContext) fsm.EventType {
paymentRequest, err := payment.RequestFromInvoice(
f.cfg.Network, f.InstantOut.swapInvoice, f.clck.Now(),
)
if err != nil {
return f.HandleError(err)
}
paymentRequest.Timeout = defaultSendpaymentTimeout
paymentRequest.MaxParts = defaultMaxParts
paymentRequest.MaxFee = getMaxRoutingFee(f.InstantOut.Value)
// Now that we're doing the swap, we first lock the reservations
// so that they can't be used for other swaps.
for _, reservation := range f.InstantOut.Reservations {
@ -225,13 +237,7 @@ func (f *FSM) PollPaymentAcceptedAction(ctx context.Context,
// Now we send the payment to the server.
payChan, paymentErrChan, err := f.cfg.RouterClient.SendPayment(
ctx,
lndclient.SendPaymentRequest{
Invoice: f.InstantOut.swapInvoice,
Timeout: defaultSendpaymentTimeout,
MaxParts: defaultMaxParts,
MaxFee: getMaxRoutingFee(f.InstantOut.Value),
},
ctx, paymentRequest,
)
if err != nil {
f.Errorf("error sending payment: %v", err)

View file

@ -148,7 +148,7 @@ type Config struct {
// Store is used to store the instant out.
Store InstantLoopOutStore
// LndClient is used to decode the swap invoice.
// LndClient is used to query lnd.
LndClient lndclient.LightningClient
// RouterClient is used to send the offchain payment to the server.

View file

@ -17,6 +17,7 @@ import (
"github.com/btcsuite/btcd/wire/v2"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/payment"
"github.com/lightninglabs/loop/swap"
"github.com/lightninglabs/loop/swapserverrpc"
"github.com/lightninglabs/loop/sweep"
@ -772,15 +773,18 @@ func (s *loopOutSwap) payInvoiceAsync(ctx context.Context,
pluginType RoutingPluginType, reportPluginResult bool, rfqId []byte) (
*lndclient.PaymentStatus, error) {
// Extract hash from payment request. Unfortunately the request
// components aren't available directly.
chainParams := s.lnd.ChainParams
target, routeHints, hash, amt, err := swap.DecodeInvoice(
chainParams, invoice,
// Decode and validate the invoice before copying its supported fields
// into a component-based payment request.
req, err := payment.RequestFromInvoice(
s.lnd.ChainParams, invoice, s.clock.Now(),
)
if err != nil {
return nil, err
}
target := req.Target
routeHints := req.RouteHints
hash := *req.PaymentHash
amt := req.AmountMsat.ToSatoshis()
maxRetries := 1
totalPaymentTimeout := s.executeConfig.totalPaymentTimeout
@ -824,13 +828,10 @@ func (s *loopOutSwap) payInvoiceAsync(ctx context.Context,
paymentTimeout = totalPaymentTimeout
}
req := lndclient.SendPaymentRequest{
MaxFee: maxFee,
Invoice: invoice,
OutgoingChanIds: outgoingChanIds,
Timeout: paymentTimeout,
MaxParts: s.executeConfig.loopOutMaxParts,
}
req.MaxFee = maxFee
req.OutgoingChanIds = outgoingChanIds
req.Timeout = paymentTimeout
req.MaxParts = s.executeConfig.loopOutMaxParts
// If we want an asset swap, we'll need to set the custom first hop
// data to the rfq id. This will then allow LND to route the payment
@ -1667,11 +1668,11 @@ trackChanLoop:
// resumeLoopOutPayment attempts to resume the loop out payment for the
// specified swap.
func (m *resumeManager) resumeLoopOutPayment(ctx context.Context,
swap *loopdb.LoopOut) error {
pendingSwap *loopdb.LoopOut) error {
swapRes, err := m.swapClient.NewLoopOutSwap(
ctx, &swapserverrpc.ServerLoopOutRequest{
SwapHash: swap.Hash[:],
SwapHash: pendingSwap.Hash[:],
UserAgent: ResumeSwapInitiator,
},
)
@ -1679,33 +1680,35 @@ func (m *resumeManager) resumeLoopOutPayment(ctx context.Context,
return err
}
paymentReq := swapRes.SwapInvoice
// Verify the payment request before attempting payment.
inv, err := m.lnd.Client.DecodePaymentRequest(ctx, paymentReq)
// Verify the payment request before attempting payment and copy only
// supported invoice fields into the outgoing request.
paymentRequest, err := payment.RequestFromInvoice(
m.lnd.ChainParams, swapRes.SwapInvoice, m.clock.Now(),
)
if err != nil {
return fmt.Errorf("failed to decode loop out invoice: %v", err)
}
if swap.Hash != inv.Hash {
invoiceHash := *paymentRequest.PaymentHash
if pendingSwap.Hash != invoiceHash {
return fmt.Errorf("invoice payment hash %v does not match "+
"swap hash %v", inv.Hash, swap.Hash)
"swap hash %v", invoiceHash, pendingSwap.Hash)
}
amtRequested := swap.Contract.AmountRequested
amtRequested := pendingSwap.Contract.AmountRequested
invoiceAmount := paymentRequest.AmountMsat.ToSatoshis()
if inv.Value.ToSatoshis() > swap.Contract.MaxSwapFee*2+amtRequested {
if invoiceAmount > pendingSwap.Contract.MaxSwapFee*2+amtRequested {
return fmt.Errorf("invoice amount %v exceeds max "+
"allowed %v", inv.Value.ToSatoshis(),
swap.Contract.MaxSwapFee+amtRequested)
"allowed %v", invoiceAmount,
pendingSwap.Contract.MaxSwapFee+amtRequested)
}
paymentRequest.Timeout = time.Hour
paymentRequest.MaxFee = pendingSwap.Contract.MaxSwapFee
payChan, errChan, err := m.lnd.Router.SendPayment(
ctx,
lndclient.SendPaymentRequest{
Invoice: paymentReq,
Timeout: time.Hour,
MaxFee: swap.Contract.MaxSwapFee,
})
ctx, paymentRequest,
)
if err != nil {
return err
}
@ -1716,7 +1719,7 @@ func (m *resumeManager) resumeLoopOutPayment(ctx context.Context,
return fmt.Errorf("payment error: %v", payResp.FailureReason)
}
if payResp.State == lnrpc.Payment_SUCCEEDED {
cost := swap.LastUpdate().Cost
cost := pendingSwap.LastUpdate().Cost
cost.Server = payResp.Value.ToSatoshis() - amtRequested
cost.Offchain = payResp.Fee.ToSatoshis()
// Payment succeeded.
@ -1724,11 +1727,11 @@ func (m *resumeManager) resumeLoopOutPayment(ctx context.Context,
// Update state in store.
err = m.swapStore.UpdateLoopOut(
ctx, swap.Hash, updateTime,
ctx, pendingSwap.Hash, updateTime,
loopdb.SwapStateData{
State: loopdb.StateSuccess,
Cost: cost,
HtlcTxHash: swap.LastUpdate().HtlcTxHash,
HtlcTxHash: pendingSwap.LastUpdate().HtlcTxHash,
},
)
if err != nil {

View file

@ -127,7 +127,7 @@ func testLoopOutPaymentParameters(t *testing.T) {
// Find the swap payment.
var swapPayment test.RouterPaymentChannelMessage
for _, p := range payments {
if p.Invoice == swap.SwapInvoice {
if p.PaymentHash != nil && *p.PaymentHash == swap.hash {
swapPayment = p
}
}
@ -894,7 +894,7 @@ func testFailedOffChainCancellation(t *testing.T) {
// We want to fail our swap payment and succeed the prepayment, so we send
// a failure update to the payment that has the larger amount.
if pmt1.Amount > pmt2.Amount {
if pmt1.AmountMsat > pmt2.AmountMsat {
pmt1.TrackPaymentMessage.Updates <- failUpdate
pmt2.TrackPaymentMessage.Updates <- successUpdate
} else {

View file

@ -108,6 +108,19 @@ func (s *serverMock) NewLoopOutSwap(_ context.Context, swapHash lntypes.Hash,
return nil, err
}
// Store the remote invoices in the test fixture so component-based
// payment assertions can identify them by payment hash.
s.lnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
Memo: swapInvoiceDesc,
PaymentRequest: swapPayReqString,
})
s.lnd.SetInvoice(&lndclient.Invoice{
Hash: s.prepayHash,
Memo: prepayInvoiceDesc,
PaymentRequest: prePayReqString,
})
var senderKeyArray [33]byte
copy(senderKeyArray[:], senderKey.SerializeCompressed())

View file

@ -161,12 +161,15 @@ func (ctx *Context) AssertPaid(
expectedMemo)
}
payReq := ctx.DecodeInvoice(swapPayment.SendPaymentRequest.Invoice)
paymentHash := swapPayment.SendPaymentRequest.PaymentHash
require.NotNil(ctx.T, paymentHash)
invoice, ok := ctx.Lnd.LookupInvoice(*paymentHash)
require.True(ctx.T, ok, "unknown payment hash: %v", paymentHash)
_, ok := ctx.PaidInvoices[*payReq.Description]
_, ok = ctx.PaidInvoices[invoice.Memo]
require.False(
ctx.T, ok,
"duplicate invoice paid: %v", *payReq.Description,
"duplicate invoice paid: %v", invoice.Memo,
)
done := func(result error) {
@ -184,9 +187,9 @@ func (ctx *Context) AssertPaid(
}
}
ctx.PaidInvoices[*payReq.Description] = done
ctx.PaidInvoices[invoice.Memo] = done
if *payReq.Description == expectedMemo {
if invoice.Memo == expectedMemo {
return done
}
}

View file

@ -29,13 +29,6 @@ type mockLightningClient struct {
wg sync.WaitGroup
}
// DecodePaymentRequest returns a non-nil payment request.
func (h *mockLightningClient) DecodePaymentRequest(_ context.Context,
_ string) (*lndclient.PaymentRequest, error) {
return &lndclient.PaymentRequest{}, nil
}
func (h *mockLightningClient) WaitForFinished() {
h.wg.Wait()
}

View file

@ -234,6 +234,22 @@ func (s *LndMockServices) SetInvoice(invoice *lndclient.Invoice) {
s.Invoices[invoice.Hash] = &invoiceCopy
}
// LookupInvoice returns a copy of the invoice for the payment hash.
func (s *LndMockServices) LookupInvoice(hash lntypes.Hash) (
*lndclient.Invoice, bool) {
s.lock.Lock()
defer s.lock.Unlock()
invoice, ok := s.Invoices[hash]
if !ok {
return nil, false
}
invoiceCopy := *invoice
return &invoiceCopy, true
}
// IsDone checks whether all channels have been fully emptied. If not this may
// indicate unexpected behaviour of the code under test.
func (s *LndMockServices) IsDone() error {

View file

@ -120,8 +120,22 @@ func createClientTestContext(t *testing.T,
serverMock := newServerMock(clientLnd)
store := loopdb.NewStoreMock(t)
registerInvoice := func(encoded string) {
invoice, err := clientLnd.DecodeInvoice(encoded)
require.NoError(t, err)
require.NotNil(t, invoice.PaymentHash)
require.NotNil(t, invoice.Description)
clientLnd.SetInvoice(&lndclient.Invoice{
Hash: lntypes.Hash(*invoice.PaymentHash),
Memo: *invoice.Description,
PaymentRequest: encoded,
})
}
for _, s := range pendingSwaps {
store.LoopOutSwaps[s.Hash] = s.Contract
registerInvoice(s.Contract.SwapInvoice)
registerInvoice(s.Contract.PrepayInvoice)
updates := []loopdb.SwapStateData{}
for _, e := range s.Events {