diff --git a/api/models.go b/api/models.go index 1b98f962..0680bad2 100644 --- a/api/models.go +++ b/api/models.go @@ -35,7 +35,7 @@ type API interface { RedeemOnchainFunds(ctx context.Context, toAddress string) (*RedeemOnchainFundsResponse, error) GetBalances(ctx context.Context) (*BalancesResponse, error) ListTransactions(ctx context.Context, limit uint64, offset uint64) (*ListTransactionsResponse, error) - SendPayment(ctx context.Context, invoice string) (*SendPaymentResponse, error) + SendPayment(ctx context.Context, invoice string, amount *uint64) (*SendPaymentResponse, error) CreateInvoice(ctx context.Context, amount int64, description string) (*MakeInvoiceResponse, error) LookupInvoice(ctx context.Context, paymentHash string) (*LookupInvoiceResponse, error) RequestMempoolApi(endpoint string) (interface{}, error) diff --git a/api/transactions.go b/api/transactions.go index 4dab3fab..d84a1d35 100644 --- a/api/transactions.go +++ b/api/transactions.go @@ -51,11 +51,11 @@ func (api *api) ListTransactions(ctx context.Context, limit uint64, offset uint6 return &apiTransactions, nil } -func (api *api) SendPayment(ctx context.Context, invoice string) (*SendPaymentResponse, error) { +func (api *api) SendPayment(ctx context.Context, invoice string, amount *uint64) (*SendPaymentResponse, error) { if api.svc.GetLNClient() == nil { return nil, errors.New("LNClient not started") } - transaction, err := api.svc.GetTransactionsService().SendPaymentSync(ctx, invoice, api.svc.GetLNClient(), nil, nil) + transaction, err := api.svc.GetTransactionsService().SendPaymentSync(ctx, invoice, amount, api.svc.GetLNClient(), nil, nil) if err != nil { return nil, err } diff --git a/frontend/src/screens/wallet/Send.tsx b/frontend/src/screens/wallet/Send.tsx index f8adb185..5c43070b 100644 --- a/frontend/src/screens/wallet/Send.tsx +++ b/frontend/src/screens/wallet/Send.tsx @@ -42,7 +42,10 @@ export default function Send() { const { data: csrf } = useCSRF(); const { toast } = useToast(); const [isLoading, setLoading] = React.useState(false); + const [invoice, setInvoice] = React.useState(""); + const [offer, setOffer] = React.useState(); + const [amount, setAmount] = React.useState(); const [invoiceDetails, setInvoiceDetails] = React.useState( null ); @@ -56,6 +59,11 @@ export default function Send() { const handleContinue = () => { try { + if (invoice.startsWith("lno1")) { + setOffer(invoice); + return; + } + setInvoiceDetails(new Invoice({ pr: invoice })); } catch (error) { toast({ @@ -75,7 +83,7 @@ export default function Send() { } setLoading(true); const payInvoiceResponse = await request( - `/api/payments/${invoice}`, + `/api/payments/${invoice}?amount=${parseInt(amount || "0") * 1000}`, { method: "POST", headers: { @@ -87,7 +95,6 @@ export default function Send() { if (payInvoiceResponse) { setPayResponse(payInvoiceResponse); setPaymentDone(true); - setInvoice(""); toast({ title: "Successfully paid invoice", }); @@ -97,10 +104,12 @@ export default function Send() { variant: "destructive", title: "Failed to send: " + e, }); - setInvoice(""); - setInvoiceDetails(null); console.error(e); } + setInvoice(""); + setInvoiceDetails(null); + setOffer(undefined); + setAmount(undefined); setLoading(false); }; @@ -205,6 +214,39 @@ export default function Send() { + ) : offer ? ( +
+
+

Bolt12 Offer

+ { + setAmount(e.target.value.trim()); + }} + /> +
+
+ + Confirm Payment + + +
+
) : (
diff --git a/http/http_service.go b/http/http_service.go index 949f9819..b93b67f2 100644 --- a/http/http_service.go +++ b/http/http_service.go @@ -407,7 +407,15 @@ func (httpSvc *HttpService) balancesHandler(c echo.Context) error { func (httpSvc *HttpService) sendPaymentHandler(c echo.Context) error { ctx := c.Request().Context() - paymentResponse, err := httpSvc.api.SendPayment(ctx, c.Param("invoice")) + var amount *uint64 + + if amountParam := c.QueryParam("amount"); amountParam != "" { + if parsedAmount, err := strconv.ParseUint(amountParam, 10, 64); err == nil { + amount = &parsedAmount + } + } + + paymentResponse, err := httpSvc.api.SendPayment(ctx, c.Param("invoice"), amount) if err != nil { return c.JSON(http.StatusInternalServerError, ErrorResponse{ diff --git a/lnclient/breez/breez.go b/lnclient/breez/breez.go index 7ad178f4..e36740dd 100644 --- a/lnclient/breez/breez.go +++ b/lnclient/breez/breez.go @@ -491,3 +491,7 @@ func (bs *BreezService) GetSupportedNIP47NotificationTypes() []string { func (bs *BreezService) GetPubkey() string { return bs.pubkey } + +func (bs *BreezService) PayOfferSync(ctx context.Context, offer string, amount uint64) (string, *lnclient.PayOfferResponse, error) { + return "", nil, errors.New("not supported") +} diff --git a/lnclient/cashu/cashu.go b/lnclient/cashu/cashu.go index 206ea379..e3278aef 100644 --- a/lnclient/cashu/cashu.go +++ b/lnclient/cashu/cashu.go @@ -356,3 +356,7 @@ func (cs *CashuService) GetSupportedNIP47NotificationTypes() []string { func (svc *CashuService) GetPubkey() string { return "" } + +func (svc *CashuService) PayOfferSync(ctx context.Context, offer string, amount uint64) (string, *lnclient.PayOfferResponse, error) { + return "", nil, errors.New("not supported") +} diff --git a/lnclient/greenlight/greenlight.go b/lnclient/greenlight/greenlight.go index 3abcbf84..15b8a197 100644 --- a/lnclient/greenlight/greenlight.go +++ b/lnclient/greenlight/greenlight.go @@ -692,3 +692,7 @@ func (gs *GreenlightService) GetSupportedNIP47NotificationTypes() []string { func (gs *GreenlightService) GetPubkey() string { return gs.pubkey } + +func (gs *GreenlightService) PayOfferSync(ctx context.Context, offer string, amount uint64) (string, *lnclient.PayOfferResponse, error) { + return "", nil, errors.New("not supported") +} diff --git a/lnclient/ldk/ldk.go b/lnclient/ldk/ldk.go index 7ab03afb..e7ceec57 100644 --- a/lnclient/ldk/ldk.go +++ b/lnclient/ldk/ldk.go @@ -306,6 +306,14 @@ func NewLDKService(ctx context.Context, cfg config.Config, eventPublisher events } }() + offer, err := ls.node.Bolt12Payment().ReceiveVariableAmount("Pay to alby hub") + if err != nil { + logger.Logger.WithError(err).Error("Failed to make Bolt12 offer") + } else { + + logger.Logger.WithField("offer", offer).Info("My offer") + } + return &ls, nil } @@ -409,6 +417,122 @@ func (ls *LDKService) resetRouterInternal() { } } +// TODO: make amount optional +// TODO: payer note +func (ls *LDKService) PayOfferSync(ctx context.Context, offer string, amount uint64) (string, *lnclient.PayOfferResponse, error) { + payerNote := "Hello from Alby Hub" + + // TODO: send liquidity event if amount too large + + paymentStart := time.Now() + ldkEventSubscription := ls.ldkEventBroadcaster.Subscribe() + defer ls.ldkEventBroadcaster.CancelSubscription(ldkEventSubscription) + + paymentId, err := ls.node.Bolt12Payment().SendUsingAmount(offer, &payerNote, amount) + if err != nil { + logger.Logger.WithError(err).Error("Failed to initiate BOLT12 variable amount payment") + } + + fee := uint64(0) + preimage := "" + + payment := ls.node.Payment(paymentId) + if payment == nil { + return "", nil, errors.New("payment not found by payment ID") + } + + paymentHash := "" + + for start := time.Now(); time.Since(start) < time.Second*60; { + event := <-ldkEventSubscription + + eventPaymentSuccessful, isEventPaymentSuccessfulEvent := (*event).(ldk_node.EventPaymentSuccessful) + eventPaymentFailed, isEventPaymentFailedEvent := (*event).(ldk_node.EventPaymentFailed) + + if isEventPaymentSuccessfulEvent && eventPaymentSuccessful.PaymentId != nil && *eventPaymentSuccessful.PaymentId == paymentId { + logger.Logger.Info("Got payment success event") + payment := ls.node.Payment(paymentId) + if payment == nil { + logger.Logger.Errorf("Couldn't find payment by payment ID: %v", paymentId) + return paymentHash, nil, errors.New("Payment not found") + } + + bolt12PaymentKind, ok := payment.Kind.(ldk_node.PaymentKindBolt12Offer) + + if !ok { + logger.Logger.WithFields(logrus.Fields{ + "payment": payment, + }).Error("Payment is not a bolt12 offer kind") + return paymentHash, nil, errors.New("payment is not a bolt12 offer") + } + + if bolt12PaymentKind.Preimage == nil { + logger.Logger.Errorf("No payment preimage for payment ID: %v", paymentId) + return paymentHash, nil, errors.New("payment preimage not found") + } + preimage = *bolt12PaymentKind.Preimage + + if bolt12PaymentKind.Hash == nil { + logger.Logger.Errorf("No payment hash for payment ID: %v", paymentId) + return "", nil, errors.New("payment hash not found") + } + paymentHash = *bolt12PaymentKind.Hash + + if eventPaymentSuccessful.FeePaidMsat != nil { + fee = *eventPaymentSuccessful.FeePaidMsat + } + break + } + if isEventPaymentFailedEvent && eventPaymentFailed.PaymentId != nil && *eventPaymentFailed.PaymentId == paymentId { + var failureReason ldk_node.PaymentFailureReason + var failureReasonMessage string + if eventPaymentFailed.Reason != nil { + failureReason = *eventPaymentFailed.Reason + } + switch failureReason { + case ldk_node.PaymentFailureReasonRecipientRejected: + failureReasonMessage = "RecipientRejected" + case ldk_node.PaymentFailureReasonUserAbandoned: + failureReasonMessage = "UserAbandoned" + case ldk_node.PaymentFailureReasonRetriesExhausted: + failureReasonMessage = "RetriesExhausted" + case ldk_node.PaymentFailureReasonPaymentExpired: + failureReasonMessage = "PaymentExpired" + case ldk_node.PaymentFailureReasonRouteNotFound: + failureReasonMessage = "RouteNotFound" + case ldk_node.PaymentFailureReasonUnexpectedError: + failureReasonMessage = "UnexpectedError" + default: + failureReasonMessage = "UnknownError" + } + + logger.Logger.WithFields(logrus.Fields{ + "payment_id": paymentId, + "failure_reason": failureReason, + "failure_reason_message": failureReasonMessage, + }).Error("Received payment failed event") + + return paymentHash, nil, fmt.Errorf("received payment failed event: %v %s", failureReason, failureReasonMessage) + } + } + if preimage == "" { + logger.Logger.WithFields(logrus.Fields{ + "payment_id": paymentId, + }).Warn("Timed out waiting for payment to be sent") + return paymentHash, nil, lnclient.NewTimeoutError() + } + + logger.Logger.WithFields(logrus.Fields{ + "duration": time.Since(paymentStart).Milliseconds(), + "fee": fee, + }).Info("Successful payment") + + return paymentHash, &lnclient.PayOfferResponse{ + Preimage: preimage, + Fee: &fee, + }, nil +} + func (ls *LDKService) SendPaymentSync(ctx context.Context, invoice string) (*lnclient.PayInvoiceResponse, error) { paymentRequest, err := decodepay.Decodepay(invoice) if err != nil { @@ -464,6 +588,7 @@ func (ls *LDKService) SendPaymentSync(ctx context.Context, invoice string) (*lnc logger.Logger.WithFields(logrus.Fields{ "payment": payment, }).Error("Payment is not a bolt11 kind") + return nil, errors.New("payment is not a bolt11 kind") } if bolt11PaymentKind.Preimage == nil { @@ -1104,6 +1229,26 @@ func (ls *LDKService) ldkPaymentToTransaction(payment *ldk_node.PaymentDetails) paymentHash = bolt11PaymentKind.Hash } + bolt12PaymentKind, isBolt12PaymentKind := payment.Kind.(ldk_node.PaymentKindBolt12Offer) + + if isBolt12PaymentKind { + logger.Logger.WithField("bolt12", bolt12PaymentKind).WithField("payment", payment).Info("Received Bolt12 payment!") + createdAt = int64(payment.CreatedAt) + + paymentHash = *bolt12PaymentKind.Hash + // TODO: get description by decoding offer (how to get the offer from the offer ID?) + //description = paymentRequest.Description + //descriptionHash = paymentRequest.DescriptionHash + + // TODO: get payer note from BOLT12 payment (how?) + + if payment.Status == ldk_node.PaymentStatusSucceeded { + preimage = *bolt12PaymentKind.Preimage + lastUpdate := int64(payment.LatestUpdateTimestamp) + settledAt = &lastUpdate + } + } + spontaneousPaymentKind, isSpontaneousPaymentKind := payment.Kind.(ldk_node.PaymentKindSpontaneous) if isSpontaneousPaymentKind { // keysend payment diff --git a/lnclient/lnd/lnd.go b/lnclient/lnd/lnd.go index 9148736e..ca8550a9 100644 --- a/lnclient/lnd/lnd.go +++ b/lnclient/lnd/lnd.go @@ -916,3 +916,7 @@ func (svc *LNDService) GetSupportedNIP47NotificationTypes() []string { func (svc *LNDService) GetPubkey() string { return svc.pubkey } + +func (svc *LNDService) PayOfferSync(ctx context.Context, offer string, amount uint64) (string, *lnclient.PayOfferResponse, error) { + return "", nil, errors.New("not supported") +} diff --git a/lnclient/models.go b/lnclient/models.go index caf864b5..8cf051cd 100644 --- a/lnclient/models.go +++ b/lnclient/models.go @@ -45,6 +45,7 @@ type NodeConnectionInfo struct { type LNClient interface { SendPaymentSync(ctx context.Context, payReq string) (*PayInvoiceResponse, error) + PayOfferSync(ctx context.Context, offer string, amount uint64) (string, *PayOfferResponse, error) SendKeysend(ctx context.Context, amount uint64, destination string, customRecords []TLVRecord) (paymentHash string, preimage string, fee uint64, err error) GetBalance(ctx context.Context) (balance int64, err error) GetPubkey() string @@ -157,6 +158,8 @@ type PayInvoiceResponse struct { Fee *uint64 `json:"fee"` } +type PayOfferResponse = PayInvoiceResponse + type BalancesResponse struct { Onchain OnchainBalanceResponse `json:"onchain"` Lightning LightningBalanceResponse `json:"lightning"` diff --git a/lnclient/phoenixd/phoenixd.go b/lnclient/phoenixd/phoenixd.go index b89a184e..7782eb70 100644 --- a/lnclient/phoenixd/phoenixd.go +++ b/lnclient/phoenixd/phoenixd.go @@ -542,3 +542,7 @@ func (svc *PhoenixService) GetSupportedNIP47NotificationTypes() []string { func (svc *PhoenixService) GetPubkey() string { return svc.pubkey } + +func (svc *PhoenixService) PayOfferSync(ctx context.Context, offer string, amount uint64) (string, *lnclient.PayOfferResponse, error) { + return "", nil, errors.New("not supported") +} diff --git a/nip47/controllers/pay_invoice_controller.go b/nip47/controllers/pay_invoice_controller.go index 62efb5a7..e5959eee 100644 --- a/nip47/controllers/pay_invoice_controller.go +++ b/nip47/controllers/pay_invoice_controller.go @@ -57,7 +57,7 @@ func (controller *nip47Controller) pay(ctx context.Context, bolt11 string, payme "bolt11": bolt11, }).Info("Sending payment") - response, err := controller.transactionsService.SendPaymentSync(ctx, bolt11, controller.lnClient, &app.ID, &requestEventId) + response, err := controller.transactionsService.SendPaymentSync(ctx, bolt11, nil, controller.lnClient, &app.ID, &requestEventId) if err != nil { logger.Logger.WithFields(logrus.Fields{ "request_event_id": requestEventId, diff --git a/tests/mock_ln_client.go b/tests/mock_ln_client.go index a9043625..a5629193 100644 --- a/tests/mock_ln_client.go +++ b/tests/mock_ln_client.go @@ -2,6 +2,7 @@ package tests import ( "context" + "errors" "time" "github.com/getAlby/hub/lnclient" @@ -185,3 +186,7 @@ func (mln *MockLn) GetPubkey() string { return "123pubkey" } + +func (mln *MockLn) PayOfferSync(ctx context.Context, offer string, amount uint64) (string, *lnclient.PayOfferResponse, error) { + return "", nil, errors.New("not supported") +} diff --git a/transactions/app_payments_test.go b/transactions/app_payments_test.go index 023c7d07..3ece21a6 100644 --- a/transactions/app_payments_test.go +++ b/transactions/app_payments_test.go @@ -26,7 +26,7 @@ func TestSendPaymentSync_App_NoPermission(t *testing.T) { assert.NoError(t, err) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, nil, svc.LNClient, &app.ID, &dbRequestEvent.ID) assert.Error(t, err) assert.Equal(t, "app does not have pay_invoice scope", err.Error()) @@ -55,7 +55,7 @@ func TestSendPaymentSync_App_WithPermission(t *testing.T) { assert.NoError(t, err) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, nil, svc.LNClient, &app.ID, &dbRequestEvent.ID) assert.NoError(t, err) assert.Equal(t, uint64(123000), transaction.AmountMsat) @@ -89,7 +89,7 @@ func TestSendPaymentSync_App_BudgetExceeded(t *testing.T) { assert.NoError(t, err) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, nil, svc.LNClient, &app.ID, &dbRequestEvent.ID) assert.Error(t, err) assert.ErrorIs(t, err, NewQuotaExceededError()) @@ -129,7 +129,7 @@ func TestSendPaymentSync_App_BudgetExceeded_SettledPayment(t *testing.T) { assert.NoError(t, err) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, nil, svc.LNClient, &app.ID, &dbRequestEvent.ID) assert.Error(t, err) assert.ErrorIs(t, err, NewQuotaExceededError()) @@ -168,7 +168,7 @@ func TestSendPaymentSync_App_BudgetExceeded_UnsettledPayment(t *testing.T) { assert.NoError(t, err) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, nil, svc.LNClient, &app.ID, &dbRequestEvent.ID) assert.Error(t, err) assert.ErrorIs(t, err, NewQuotaExceededError()) @@ -208,7 +208,7 @@ func TestSendPaymentSync_App_BudgetNotExceeded_FailedPayment(t *testing.T) { assert.NoError(t, err) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, nil, svc.LNClient, &app.ID, &dbRequestEvent.ID) assert.NoError(t, err) assert.Equal(t, uint64(123000), transaction.AmountMsat) diff --git a/transactions/isolated_app_payments_test.go b/transactions/isolated_app_payments_test.go index bb1ad6d4..50a1a0a7 100644 --- a/transactions/isolated_app_payments_test.go +++ b/transactions/isolated_app_payments_test.go @@ -35,7 +35,7 @@ func TestSendPaymentSync_IsolatedApp_NoBalance(t *testing.T) { assert.NoError(t, err) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, nil, svc.LNClient, &app.ID, &dbRequestEvent.ID) assert.Error(t, err) assert.ErrorIs(t, err, NewInsufficientBalanceError()) @@ -74,7 +74,7 @@ func TestSendPaymentSync_IsolatedApp_BalanceInsufficient(t *testing.T) { }) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, nil, svc.LNClient, &app.ID, &dbRequestEvent.ID) assert.Error(t, err) assert.ErrorIs(t, err, NewInsufficientBalanceError()) @@ -113,7 +113,7 @@ func TestSendPaymentSync_IsolatedApp_BalanceSufficient(t *testing.T) { }) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, nil, svc.LNClient, &app.ID, &dbRequestEvent.ID) assert.NoError(t, err) assert.Equal(t, uint64(123000), transaction.AmountMsat) @@ -162,7 +162,7 @@ func TestSendPaymentSync_IsolatedApp_BalanceInsufficient_OutstandingPayment(t *t }) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, nil, svc.LNClient, &app.ID, &dbRequestEvent.ID) assert.Error(t, err) assert.ErrorIs(t, err, NewInsufficientBalanceError()) @@ -208,7 +208,7 @@ func TestSendPaymentSync_IsolatedApp_BalanceInsufficient_SettledPayment(t *testi }) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, nil, svc.LNClient, &app.ID, &dbRequestEvent.ID) assert.Error(t, err) assert.ErrorIs(t, err, NewInsufficientBalanceError()) @@ -253,7 +253,7 @@ func TestSendPaymentSync_IsolatedApp_BalanceSufficient_UnrelatedPayment(t *testi }) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, nil, svc.LNClient, &app.ID, &dbRequestEvent.ID) assert.NoError(t, err) assert.Equal(t, uint64(123000), transaction.AmountMsat) @@ -301,7 +301,7 @@ func TestSendPaymentSync_IsolatedApp_BalanceSufficient_FailedPayment(t *testing. }) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, nil, svc.LNClient, &app.ID, &dbRequestEvent.ID) assert.NoError(t, err) assert.Equal(t, uint64(123000), transaction.AmountMsat) diff --git a/transactions/payments_test.go b/transactions/payments_test.go index ba75a03e..81bea044 100644 --- a/transactions/payments_test.go +++ b/transactions/payments_test.go @@ -19,7 +19,7 @@ func TestSendPaymentSync_NoApp(t *testing.T) { assert.NoError(t, err) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, nil, nil) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, nil, svc.LNClient, nil, nil) assert.NoError(t, err) assert.Equal(t, uint64(123000), transaction.AmountMsat) @@ -39,7 +39,7 @@ func TestSendPaymentSync_FailedRemovesFeeReserve(t *testing.T) { svc.LNClient.(*tests.MockLn).PayInvoiceResponses = append(svc.LNClient.(*tests.MockLn).PayInvoiceResponses, nil) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, nil, nil) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, nil, svc.LNClient, nil, nil) assert.Error(t, err) assert.Nil(t, transaction) @@ -66,7 +66,7 @@ func TestSendPaymentSync_PendingHasFeeReserve(t *testing.T) { svc.LNClient.(*tests.MockLn).PayInvoiceResponses = append(svc.LNClient.(*tests.MockLn).PayInvoiceResponses, nil) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, nil, nil) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, nil, svc.LNClient, nil, nil) assert.Error(t, err) assert.Nil(t, transaction) diff --git a/transactions/self_payments_test.go b/transactions/self_payments_test.go index 9c573d0d..c94ec6be 100644 --- a/transactions/self_payments_test.go +++ b/transactions/self_payments_test.go @@ -32,7 +32,7 @@ func TestSendPaymentSync_SelfPayment_NoAppToNoApp(t *testing.T) { }) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, svc.LNClient, nil, nil) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, nil, svc.LNClient, nil, nil) assert.NoError(t, err) assert.Equal(t, uint64(123000), transaction.AmountMsat) @@ -84,7 +84,7 @@ func TestSendPaymentSync_SelfPayment_NoAppToIsolatedApp(t *testing.T) { }) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, svc.LNClient, nil, nil) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, nil, svc.LNClient, nil, nil) assert.NoError(t, err) assert.Equal(t, uint64(123000), transaction.AmountMsat) @@ -137,7 +137,7 @@ func TestSendPaymentSync_SelfPayment_NoAppToApp(t *testing.T) { }) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, svc.LNClient, nil, nil) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, nil, svc.LNClient, nil, nil) assert.NoError(t, err) assert.Equal(t, uint64(123000), transaction.AmountMsat) @@ -206,7 +206,7 @@ func TestSendPaymentSync_SelfPayment_IsolatedAppToNoApp(t *testing.T) { }) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, svc.LNClient, &app.ID, &dbRequestEvent.ID) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, nil, svc.LNClient, &app.ID, &dbRequestEvent.ID) assert.NoError(t, err) assert.Equal(t, uint64(123000), transaction.AmountMsat) @@ -281,7 +281,7 @@ func TestSendPaymentSync_SelfPayment_IsolatedAppToApp(t *testing.T) { }) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, svc.LNClient, &app.ID, &dbRequestEvent.ID) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, nil, svc.LNClient, &app.ID, &dbRequestEvent.ID) assert.NoError(t, err) assert.Equal(t, uint64(123000), transaction.AmountMsat) @@ -360,7 +360,7 @@ func TestSendPaymentSync_SelfPayment_IsolatedAppToIsolatedApp(t *testing.T) { }) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, svc.LNClient, &app.ID, &dbRequestEvent.ID) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, nil, svc.LNClient, &app.ID, &dbRequestEvent.ID) assert.NoError(t, err) assert.Equal(t, uint64(123000), transaction.AmountMsat) @@ -434,7 +434,7 @@ func TestSendPaymentSync_SelfPayment_IsolatedAppToSelf(t *testing.T) { }) transactionsService := NewTransactionsService(svc.DB) - transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, svc.LNClient, &app.ID, &dbRequestEvent.ID) + transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, nil, svc.LNClient, &app.ID, &dbRequestEvent.ID) assert.NoError(t, err) assert.Equal(t, uint64(123000), transaction.AmountMsat) diff --git a/transactions/transactions_service.go b/transactions/transactions_service.go index 8bd65a4f..9c085c78 100644 --- a/transactions/transactions_service.go +++ b/transactions/transactions_service.go @@ -29,7 +29,7 @@ type TransactionsService interface { MakeInvoice(ctx context.Context, amount int64, description string, descriptionHash string, expiry int64, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error) LookupTransaction(ctx context.Context, paymentHash string, transactionType *string, lnClient lnclient.LNClient, appId *uint) (*Transaction, error) ListTransactions(ctx context.Context, from, until, limit, offset uint64, unpaid bool, transactionType *string, lnClient lnclient.LNClient, appId *uint) (transactions []Transaction, err error) - SendPaymentSync(ctx context.Context, payReq string, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error) + SendPaymentSync(ctx context.Context, payReq string, amount *uint64, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error) SendKeysend(ctx context.Context, amount uint64, destination string, customRecords []lnclient.TLVRecord, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error) } @@ -124,8 +124,114 @@ func (svc *transactionsService) MakeInvoice(ctx context.Context, amount int64, d return &dbTransaction, nil } -func (svc *transactionsService) SendPaymentSync(ctx context.Context, payReq string, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error) { +func (svc *transactionsService) PayOffer(ctx context.Context, offer string, amount uint64, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error) { + + var dbTransaction db.Transaction + + err := svc.db.Transaction(func(tx *gorm.DB) error { + err := svc.validateCanPay(tx, appId, amount) + if err != nil { + return err + } + + feeReserve := svc.calculateFeeReserve(amount) + dbTransaction = db.Transaction{ + AppId: appId, + RequestEventId: requestEventId, + Type: constants.TRANSACTION_TYPE_OUTGOING, + State: constants.TRANSACTION_STATE_PENDING, + FeeReserveMsat: &feeReserve, + AmountMsat: uint64(amount), + PaymentRequest: offer, // TODO: should this be a new field? - offer will generate an invoice? + //PaymentHash: paymentRequest.PaymentHash, + //Description: paymentRequest.Description, + //DescriptionHash: paymentRequest.DescriptionHash, + //ExpiresAt: expiresAt, + //SelfPayment: selfPayment, + // Metadata: metadata, + } + err = tx.Create(&dbTransaction).Error + return err + }) + + if err != nil { + logger.Logger.WithFields(logrus.Fields{ + "offer": offer, + }).WithError(err).Error("Failed to create DB transaction") + return nil, err + } + + paymentHash, response, err := lnClient.PayOfferSync(ctx, offer, amount) + + if err != nil { + logger.Logger.WithFields(logrus.Fields{ + "offer": offer, + }).WithError(err).Error("Failed to pay offer") + + if errors.Is(err, lnclient.NewTimeoutError()) { + logger.Logger.WithFields(logrus.Fields{ + "offer": offer, + }).WithError(err).Error("Timed out waiting for payment to be sent. It may still succeed. Skipping update of transaction status") + + dbErr := svc.db.Model(&dbTransaction).Updates(&db.Transaction{ + PaymentHash: paymentHash, + }).Error + if dbErr != nil { + logger.Logger.WithFields(logrus.Fields{ + "offer": offer, + }).WithError(dbErr).Error("Failed to update DB transaction") + } + // we cannot update the payment to failed as it still might succeed. + // we'll need to check the status of it later + return nil, err + } + + // As the LNClient did not return a timeout error, we assume the payment definitely failed + feeReserve := uint64(0) + dbErr := svc.db.Model(&dbTransaction).Updates(&db.Transaction{ + State: constants.TRANSACTION_STATE_FAILED, + FeeReserveMsat: &feeReserve, + PaymentHash: paymentHash, + }).Error + if dbErr != nil { + logger.Logger.WithFields(logrus.Fields{ + "offer": offer, + }).WithError(dbErr).Error("Failed to update DB transaction") + } + + return nil, err + } + + // the payment definitely succeeded + feeReserve := uint64(0) + now := time.Now() + dbErr := svc.db.Model(&dbTransaction).Updates(&db.Transaction{ + PaymentHash: paymentHash, + State: constants.TRANSACTION_STATE_SETTLED, + Preimage: &response.Preimage, + FeeMsat: response.Fee, + FeeReserveMsat: &feeReserve, + SettledAt: &now, + }).Error + if dbErr != nil { + logger.Logger.WithFields(logrus.Fields{ + "offer": offer, + }).WithError(dbErr).Error("Failed to update DB transaction") + } + + return &dbTransaction, nil +} + +func (svc *transactionsService) SendPaymentSync(ctx context.Context, payReq string, amount *uint64, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error) { payReq = strings.ToLower(payReq) + + if strings.HasPrefix(payReq, "lno1") { + if amount == nil { + return nil, errors.New("no amount provided") + } + return svc.PayOffer(ctx, payReq, *amount, lnClient, appId, requestEventId) + } + paymentRequest, err := decodepay.Decodepay(payReq) if err != nil { logger.Logger.WithFields(logrus.Fields{ @@ -229,7 +335,6 @@ func (svc *transactionsService) SendPaymentSync(ctx context.Context, payReq stri }).WithError(dbErr).Error("Failed to update DB transaction") } - // TODO: check the fields are updated here return &dbTransaction, nil } diff --git a/wails/wails_handlers.go b/wails/wails_handlers.go index d5e27969..7095b582 100644 --- a/wails/wails_handlers.go +++ b/wails/wails_handlers.go @@ -242,7 +242,8 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string switch { case len(invoiceMatch) > 1: invoice := invoiceMatch[1] - paymentResponse, err := app.api.SendPayment(ctx, invoice) + // TODO: support amount param + paymentResponse, err := app.api.SendPayment(ctx, invoice, nil) if err != nil { return WailsRequestRouterResponse{Body: nil, Error: err.Error()} }