From 3bb9ab1170b4f19399210645ac46104c755cac8e Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sat, 8 Aug 2026 18:12:20 -0500 Subject: [PATCH] multi: pay invoices with allowlisted components Build outgoing requests from validated invoice fields so encoded server invoices are never forwarded directly to LND. --- instantout/actions.go | 32 ++++++++++------- instantout/fsm.go | 2 +- loopout.go | 67 ++++++++++++++++++----------------- loopout_test.go | 4 +-- server_mock_test.go | 13 +++++++ test/context.go | 13 ++++--- test/lightning_client_mock.go | 7 ---- test/lnd_services_mock.go | 16 +++++++++ testcontext_test.go | 14 ++++++++ 9 files changed, 108 insertions(+), 60 deletions(-) diff --git a/instantout/actions.go b/instantout/actions.go index 6e866577..75810eed 100644 --- a/instantout/actions.go +++ b/instantout/actions.go @@ -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) diff --git a/instantout/fsm.go b/instantout/fsm.go index 54583ee0..8f117639 100644 --- a/instantout/fsm.go +++ b/instantout/fsm.go @@ -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. diff --git a/loopout.go b/loopout.go index 71312401..618e72e4 100644 --- a/loopout.go +++ b/loopout.go @@ -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 { diff --git a/loopout_test.go b/loopout_test.go index 9377a217..4861975b 100644 --- a/loopout_test.go +++ b/loopout_test.go @@ -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 { diff --git a/server_mock_test.go b/server_mock_test.go index 3b2c9f30..20cee2f2 100644 --- a/server_mock_test.go +++ b/server_mock_test.go @@ -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()) diff --git a/test/context.go b/test/context.go index f44ee9c0..9661581c 100644 --- a/test/context.go +++ b/test/context.go @@ -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 } } diff --git a/test/lightning_client_mock.go b/test/lightning_client_mock.go index f94e31e6..cd7e56f3 100644 --- a/test/lightning_client_mock.go +++ b/test/lightning_client_mock.go @@ -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() } diff --git a/test/lnd_services_mock.go b/test/lnd_services_mock.go index c8257f39..49e3a68a 100644 --- a/test/lnd_services_mock.go +++ b/test/lnd_services_mock.go @@ -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 { diff --git a/testcontext_test.go b/testcontext_test.go index 23b9f632..c23c740c 100644 --- a/testcontext_test.go +++ b/testcontext_test.go @@ -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 {