diff --git a/backend/lightning/handlers.go b/backend/lightning/handlers.go index 57527e0aa2..2d6c2f67fe 100644 --- a/backend/lightning/handlers.go +++ b/backend/lightning/handlers.go @@ -149,6 +149,9 @@ func (lightning *Lightning) PostPreparePayment(r *http.Request) interface{} { fee, err := lightning.PreparePayment(jsonBody.Bolt11, jsonBody.AmountSat) if err != nil { + if fee != nil && jsonBody.AmountSat != nil && errp.Cause(err) == errLightningInsufficientFunds { + return responseDto{Success: false, ErrorCode: string(errLightningInsufficientFunds), Data: fee} + } return errorResponse(err) } diff --git a/backend/lightning/payments.go b/backend/lightning/payments.go index 32d1463a96..b71f1001e4 100644 --- a/backend/lightning/payments.go +++ b/backend/lightning/payments.go @@ -3,8 +3,10 @@ package lightning import ( + "errors" "math/big" "strconv" + "strings" "time" "github.com/BitBoxSwiss/bitbox-wallet-app/backend/accounts" @@ -13,7 +15,11 @@ import ( "github.com/breez/breez-sdk-spark-go/breez_sdk_spark" ) -const errPaymentApprovalRequired errp.ErrorCode = "paymentApprovalRequired" +const ( + errPaymentApprovalRequired errp.ErrorCode = "paymentApprovalRequired" + errLightningInsufficientFunds errp.ErrorCode = "lightningInsufficientFunds" + errLightningInvoiceAlreadyUsed errp.ErrorCode = "lightningInvoiceAlreadyUsed" +) type lightningInvoice struct { Bolt11 string `json:"bolt11"` @@ -241,6 +247,32 @@ func checkApprovedPaymentFee(fee uint64, approvedFee uint64) error { return nil } +func checkPaymentBalance(fee *paymentFee, balance *accounts.Balance) error { + if new(big.Int).SetUint64(fee.TotalDebitSat).Cmp(balance.Available().BigInt()) > 0 { + return errLightningInsufficientFunds + } + return nil +} + +func lightningPaymentError(err error) error { + if err == nil { + return nil + } + if errors.Is(err, breez_sdk_spark.ErrSdkErrorInsufficientFunds) { + return errp.WithMessage(errLightningInsufficientFunds, err.Error()) + } + errString := strings.ToLower(err.Error()) + if strings.Contains(errString, "preimage request already exists") || + (strings.Contains(errString, "duplicate_operation") && strings.Contains(errString, "paymenthash")) { + return errp.WithMessage(errLightningInvoiceAlreadyUsed, err.Error()) + } + // Spark currently wraps insufficient funds as SdkErrorSparkError with this text. + if strings.Contains(errString, "insufficient funds") { + return errp.WithMessage(errLightningInsufficientFunds, err.Error()) + } + return err +} + // PreparePayment computes the fee quote for the provided payment request. func (lightning *Lightning) PreparePayment(paymentInvoice string, amountSat *uint64) (*paymentFee, error) { if err := lightning.CheckActive(); err != nil { @@ -248,13 +280,20 @@ func (lightning *Lightning) PreparePayment(paymentInvoice string, amountSat *uin } prepareResponse, err := lightning.sdkService.PrepareSendPayment(prepareSendPaymentRequest(paymentInvoice, amountSat)) if err != nil { - return nil, err + return nil, lightningPaymentError(err) } fee, err := preparedPaymentFee(prepareResponse) if err != nil { return nil, err } + balance, err := lightning.Balance() + if err != nil { + return nil, err + } + if err := checkPaymentBalance(fee, balance); err != nil { + return fee, err + } lightning.log.Printf("Lightning Fee: %v sats", fee.FeeSat) return fee, nil } @@ -271,13 +310,21 @@ func (lightning *Lightning) SendPayment(paymentInvoice string, amount *uint64, a prepareResponse, err := lightning.sdkService.PrepareSendPayment(prepareSendPaymentRequest(paymentInvoice, amount)) if err != nil { - return err + return lightningPaymentError(err) } fee, err := preparedPaymentFee(prepareResponse) if err != nil { return err } + balance, err := lightning.Balance() + if err != nil { + return err + } + // Re-check the balance because funds or the prepared fee can change between quote approval and send. + if err := checkPaymentBalance(fee, balance); err != nil { + return err + } if err := checkApprovedPaymentFee(fee.FeeSat, approvedFee); err != nil { return err } @@ -293,7 +340,7 @@ func (lightning *Lightning) SendPayment(paymentInvoice string, amount *uint64, a _, err = lightning.sdkService.SendPayment(payRequest) if err != nil { - return err + return lightningPaymentError(err) } return nil } diff --git a/backend/lightning/payments_test.go b/backend/lightning/payments_test.go index c5972b86c4..082e5492d8 100644 --- a/backend/lightning/payments_test.go +++ b/backend/lightning/payments_test.go @@ -3,6 +3,7 @@ package lightning import ( + "errors" "math/big" "testing" @@ -283,3 +284,89 @@ func coinAmountWithConversions(amount string) coin.FormattedAmountWithConversion func stringPointer(value string) *string { return &value } + +func TestCheckPaymentBalance(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + totalDebitSat uint64 + availableSat int64 + expectedErr error + }{ + { + name: "total debit below available balance", + totalDebitSat: 99, + availableSat: 100, + }, + { + name: "total debit equals available balance", + totalDebitSat: 100, + availableSat: 100, + }, + { + name: "total debit exceeds available balance", + totalDebitSat: 101, + availableSat: 100, + expectedErr: errLightningInsufficientFunds, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + balance := accounts.NewBalance(coin.NewAmountFromInt64(testCase.availableSat), coin.NewAmountFromInt64(0)) + err := checkPaymentBalance(&paymentFee{TotalDebitSat: testCase.totalDebitSat}, balance) + require.Equal(t, testCase.expectedErr, errp.Cause(err)) + }) + } +} + +func TestLightningPaymentError(t *testing.T) { + t.Parallel() + + unrelatedErr := errors.New("network unavailable") + testCases := []struct { + name string + err error + expectedErr error + expectedErrorContains []string + }{ + { + name: "typed SDK insufficient funds", + err: breez_sdk_spark.NewSdkErrorInsufficientFunds(), + expectedErr: errLightningInsufficientFunds, + expectedErrorContains: []string{"SdkError: InsufficientFunds", "lightningInsufficientFunds"}, + }, + { + name: "Spark insufficient funds", + err: breez_sdk_spark.NewSdkErrorSparkError("Tree service error: insufficient funds"), + expectedErr: errLightningInsufficientFunds, + expectedErrorContains: []string{"Tree service error: insufficient funds", "lightningInsufficientFunds"}, + }, + { + name: "Spark already used invoice", + err: breez_sdk_spark.NewSdkErrorSparkError("Service error: status: AlreadyExists, message: preimage request already exists for paymentHash abc, details: DUPLICATE_OPERATION"), + expectedErr: errLightningInvoiceAlreadyUsed, + expectedErrorContains: []string{"preimage request already exists", "lightningInvoiceAlreadyUsed"}, + }, + { + name: "unrelated error", + err: unrelatedErr, + expectedErr: unrelatedErr, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + err := lightningPaymentError(testCase.err) + require.Equal(t, testCase.expectedErr, errp.Cause(err)) + for _, expectedText := range testCase.expectedErrorContains { + require.Contains(t, err.Error(), expectedText) + } + }) + } +} diff --git a/frontends/web/src/api/lightning.ts b/frontends/web/src/api/lightning.ts index c73379ec41..a7a2138311 100644 --- a/frontends/web/src/api/lightning.ts +++ b/frontends/web/src/api/lightning.ts @@ -1,8 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 -import { apiGet, apiPost } from '../utils/request'; -import { AccountCode, TAmountWithConversions, TBalance, TTransactionStatus } from './account'; -import { TSubscriptionCallback, TUnsubscribe, subscribeEndpoint } from './subscribe'; +import type { AccountCode, TAmountWithConversions, TBalance, TTransactionStatus } from '@/api/account'; +import type { TSubscriptionCallback, TUnsubscribe } from '@/api/subscribe'; +import { subscribeEndpoint } from '@/api/subscribe'; +import { apiGet, apiPost } from '@/utils/request'; export type TLightningResponse = | { @@ -11,6 +12,7 @@ export type TLightningResponse = } | { success: false; + data?: T; errorMessage?: string; errorCode?: string; }; @@ -79,12 +81,14 @@ export type TParsePaymentInputRequest = { s: string; }; -export class TSdkError extends Error { +export class TSdkError extends Error { code?: string; + data?: T; - constructor(message: string, code?: string) { + constructor(message: string, code?: string, data?: T) { super(message); this.code = code; + this.data = data; Object.setPrototypeOf(this, TSdkError.prototype); } @@ -103,7 +107,7 @@ const queryString = (params: Record) const getApiResponse = async (url: string, defaultError: string = 'Error'): Promise => { const response: TLightningResponse = await apiGet(url); if (!response.success) { - throw new TSdkError(response.errorMessage || defaultError, response.errorCode); + throw new TSdkError(response.errorMessage || defaultError, response.errorCode, response.data); } if (response.data === undefined) { throw new TSdkError(defaultError); @@ -114,7 +118,7 @@ const getApiResponse = async (url: string, defaultError: string = 'Error'): P const postApiResponse = async (url: string, data: C, defaultError: string = 'Error'): Promise => { const response: TLightningResponse = await apiPost(url, data); if (!response.success) { - throw new TSdkError(response.errorMessage || defaultError, response.errorCode); + throw new TSdkError(response.errorMessage || defaultError, response.errorCode, response.data); } if (response.data === undefined) { return undefined as T; diff --git a/frontends/web/src/locales/en/app.json b/frontends/web/src/locales/en/app.json index 5837bc5fbe..cb47ef8ca8 100644 --- a/frontends/web/src/locales/en/app.json +++ b/frontends/web/src/locales/en/app.json @@ -916,6 +916,8 @@ "aoppUnsupportedKeystore": "The connected device cannot sign messages for this asset.", "aoppVersion": "Unknown version.", "keystoreTimeout": "Wallet request expired. Please try again.", + "lightningInsufficientFunds": "Insufficient funds in your Lightning wallet.", + "lightningInvoiceAlreadyUsed": "This Lightning invoice was already used. Please request a new invoice.", "paymentApprovalRequired": "Payment fee increased. Please review and approve again.", "wrongKeystore": "Wrong wallet connected. Please make sure to insert the correct device matching this account.", "wrongKeystore2": " If you are using the optional passphrase, make sure you have entered the correct passphrase for the account." diff --git a/frontends/web/src/routes/lightning/send/components/confirm-step.tsx b/frontends/web/src/routes/lightning/send/components/confirm-step.tsx index eb16b8d237..e63f86cab2 100644 --- a/frontends/web/src/routes/lightning/send/components/confirm-step.tsx +++ b/frontends/web/src/routes/lightning/send/components/confirm-step.tsx @@ -14,7 +14,6 @@ export const ConfirmStep = () => { const { paymentDetails, paymentQuote, - returnToEditInvoice, sendPayment, } = useLightningSendContext(); @@ -23,10 +22,6 @@ export const ConfirmStep = () => { } const handleBack = () => { - if (!paymentDetails.invoice.amountSat) { - returnToEditInvoice(); - return; - } navigate(-1); }; diff --git a/frontends/web/src/routes/lightning/send/components/edit-invoice-step.tsx b/frontends/web/src/routes/lightning/send/components/edit-invoice-step.tsx index d1b69e3db4..9f06d0440a 100644 --- a/frontends/web/src/routes/lightning/send/components/edit-invoice-step.tsx +++ b/frontends/web/src/routes/lightning/send/components/edit-invoice-step.tsx @@ -5,16 +5,19 @@ import { useTranslation } from 'react-i18next'; import { TPaymentInputTypeVariant } from '@/api/lightning'; import { Button, Input } from '@/components/forms'; import { Column, Grid } from '@/components/layout'; +import { Status } from '@/components/status/status'; import { View, ViewButtons, ViewContent } from '@/components/view/view'; import { useLightningSendContext } from '../lightning-send-context'; +import { PaymentFeeDetails } from './invoice-details'; export const EditInvoiceStep = () => { const { t } = useTranslation(); const { customAmount, + customPrepareState, paymentDetails, - preparePayment, resetPayment, + sendPayment, setCustomAmount, } = useLightningSendContext(); @@ -22,6 +25,19 @@ export const EditInvoiceStep = () => { return null; } + const currentQuote = customPrepareState.status === 'success' && customPrepareState.amountSat === customAmount + ? customPrepareState.quote + : undefined; + const errorQuote = customPrepareState.status === 'error' && customPrepareState.amountSat === customAmount + ? customPrepareState.quote + : undefined; + const isPreparing = customPrepareState.status === 'loading' && customPrepareState.amountSat === customAmount; + const prepareError = customPrepareState.status === 'error' && customPrepareState.amountSat === customAmount + ? customPrepareState.error + : undefined; + const displayedQuote = currentQuote || errorQuote; + const showQuote = isPreparing || displayedQuote; + return ( @@ -33,7 +49,10 @@ export const EditInvoiceStep = () => { label={t('lightning.receive.amountSats.label')} placeholder={t('lightning.receive.amountSats.placeholder')} id="amountSatsInput" - onInput={(event: ChangeEvent) => setCustomAmount(event.target.valueAsNumber)} + onInput={(event: ChangeEvent) => { + const amount = event.target.valueAsNumber; + setCustomAmount(Number.isNaN(amount) ? undefined : amount); + }} value={customAmount ? `${customAmount}` : ''} autoFocus /> @@ -46,15 +65,19 @@ export const EditInvoiceStep = () => { disabled value={paymentDetails.invoice.description || ''} /> + + {showQuote && }