diff --git a/backend/lightning/payments.go b/backend/lightning/payments.go
index 32d1463a96..d2adc7c596 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,28 @@ 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())
+ }
+ 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 +276,21 @@ func (lightning *Lightning) PreparePayment(paymentInvoice string, amountSat *uin
}
prepareResponse, err := lightning.sdkService.PrepareSendPayment(prepareSendPaymentRequest(paymentInvoice, amountSat))
if err != nil {
- return nil, err
+ lightning.log.WithError(err).Error("Prepare lightning payment failed")
+ 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,7 +307,8 @@ func (lightning *Lightning) SendPayment(paymentInvoice string, amount *uint64, a
prepareResponse, err := lightning.sdkService.PrepareSendPayment(prepareSendPaymentRequest(paymentInvoice, amount))
if err != nil {
- return err
+ lightning.log.WithError(err).Error("Prepare send lightning payment failed")
+ return lightningPaymentError(err)
}
fee, err := preparedPaymentFee(prepareResponse)
@@ -293,7 +330,8 @@ func (lightning *Lightning) SendPayment(paymentInvoice string, amount *uint64, a
_, err = lightning.sdkService.SendPayment(payRequest)
if err != nil {
- return err
+ lightning.log.WithError(err).Error("Send lightning payment failed")
+ return lightningPaymentError(err)
}
return nil
}
diff --git a/backend/lightning/payments_test.go b/backend/lightning/payments_test.go
index c5972b86c4..fe747c97d1 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,83 @@ 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 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/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
deleted file mode 100644
index eb16b8d237..0000000000
--- a/frontends/web/src/routes/lightning/send/components/confirm-step.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-
-import { useTranslation } from 'react-i18next';
-import { useNavigate } from 'react-router-dom';
-import { Button } from '@/components/forms';
-import { Column, Grid } from '@/components/layout';
-import { View, ViewButtons, ViewContent } from '@/components/view/view';
-import { useLightningSendContext } from '../lightning-send-context';
-import { PaymentDetails } from './invoice-details';
-
-export const ConfirmStep = () => {
- const { t } = useTranslation();
- const navigate = useNavigate();
- const {
- paymentDetails,
- paymentQuote,
- returnToEditInvoice,
- sendPayment,
- } = useLightningSendContext();
-
- if (!paymentDetails || !paymentQuote) {
- return null;
- }
-
- const handleBack = () => {
- if (!paymentDetails.invoice.amountSat) {
- returnToEditInvoice();
- return;
- }
- navigate(-1);
- };
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
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
deleted file mode 100644
index d1b69e3db4..0000000000
--- a/frontends/web/src/routes/lightning/send/components/edit-invoice-step.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-
-import { ChangeEvent } from 'react';
-import { useTranslation } from 'react-i18next';
-import { TPaymentInputTypeVariant } from '@/api/lightning';
-import { Button, Input } from '@/components/forms';
-import { Column, Grid } from '@/components/layout';
-import { View, ViewButtons, ViewContent } from '@/components/view/view';
-import { useLightningSendContext } from '../lightning-send-context';
-
-export const EditInvoiceStep = () => {
- const { t } = useTranslation();
- const {
- customAmount,
- paymentDetails,
- preparePayment,
- resetPayment,
- setCustomAmount,
- } = useLightningSendContext();
-
- if (paymentDetails?.type !== TPaymentInputTypeVariant.BOLT11) {
- return null;
- }
-
- return (
-
-
-
-
- ) => setCustomAmount(event.target.valueAsNumber)}
- value={customAmount ? `${customAmount}` : ''}
- autoFocus
- />
-
-
-
-
-
-
-
-
-
- );
-};
diff --git a/frontends/web/src/routes/lightning/send/components/invoice-details.tsx b/frontends/web/src/routes/lightning/send/components/invoice-details.tsx
index 36c2643bb3..cdc8af2ad0 100644
--- a/frontends/web/src/routes/lightning/send/components/invoice-details.tsx
+++ b/frontends/web/src/routes/lightning/send/components/invoice-details.tsx
@@ -56,39 +56,80 @@ const AmountValue = ({ amount, showFiat = false }: TAmountValueProps) => {
);
};
+const satsAmount = (amountSat?: number): TAmountWithConversions | undefined => {
+ if (amountSat === undefined) {
+ return undefined;
+ }
+ return {
+ amount: amountSat.toString(),
+ unit: 'sat',
+ estimated: false,
+ };
+};
+
+type TProps = {
+ fees?: TPreparePaymentResponse;
+ totalWithFiat?: boolean;
+};
+
+type TPaymentAmountDetailsProps = {
+ amountSat?: number;
+};
+
type TPaymentDetailsProps = {
input: TPaymentInputType;
- quote: TPreparePaymentResponse;
+ fees: TPreparePaymentResponse;
};
-export const PaymentDetails = ({ input, quote }: TPaymentDetailsProps) => {
+export const PaymentAmountDetails = ({ amountSat }: TPaymentAmountDetailsProps) => {
const { t } = useTranslation();
- const { invoice } = input;
- const invoiceAmount = useInvoiceAmount(quote.amountSat);
- const feeAmount = useInvoiceAmount(quote.feeSat);
- const totalDebitAmount = useInvoiceAmount(quote.totalDebitSat);
+ const invoiceAmount = useInvoiceAmount(amountSat);
+
+ return (
+
+
{t('lightning.send.confirm.amount')}
+
+
+ );
+};
+
+export const PaymentFeeDetails = ({ fees, totalWithFiat = false }: TProps) => {
+ const { t } = useTranslation();
+ const feeAmount = satsAmount(fees?.feeSat);
+ const totalDebitAmountSat = satsAmount(fees?.totalDebitSat);
+ const convertedTotalDebitAmount = useInvoiceAmount(totalWithFiat ? fees?.totalDebitSat : undefined);
+ const totalDebitAmount = totalWithFiat ? convertedTotalDebitAmount || totalDebitAmountSat : totalDebitAmountSat;
+ const showTotalFiat = totalWithFiat && convertedTotalDebitAmount !== undefined;
return (
<>
- {t('lightning.send.confirm.title')}
-
{t('lightning.send.confirm.amount')}
-
+
{t('send.fee.label')}
+
+
+
{t('send.confirm.total')}
+
+
+ >
+ );
+};
+
+export const PaymentDetails = ({ input, fees }: TPaymentDetailsProps) => {
+ const { t } = useTranslation();
+ const { invoice } = input;
+
+ return (
+ <>
+ {t('lightning.send.confirm.title')}
+
{invoice.description && (
{t('lightning.send.confirm.memo')}
{invoice.description}
)}
-
-
{t('send.fee.label')}
-
-
-
-
{t('send.confirm.total')}
-
-
+
>
);
};
diff --git a/frontends/web/src/routes/lightning/send/components/review-step.tsx b/frontends/web/src/routes/lightning/send/components/review-step.tsx
new file mode 100644
index 0000000000..c091cb7229
--- /dev/null
+++ b/frontends/web/src/routes/lightning/send/components/review-step.tsx
@@ -0,0 +1,316 @@
+// SPDX-License-Identifier: Apache-2.0
+
+import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { TPaymentInputTypeVariant, type TPaymentInputType, type TPreparePaymentResponse, postPreparePayment, postSendPayment, TSdkError } from '@/api/lightning';
+import { Button, Input } from '@/components/forms';
+import { Column, Grid } from '@/components/layout';
+import { Spinner } from '@/components/spinner/Spinner';
+import { Status } from '@/components/status/status';
+import { View, ViewButtons, ViewContent } from '@/components/view/view';
+import { useDebounce } from '@/hooks/debounce';
+import { useMountedRef } from '@/hooks/mount';
+import { PaymentAmountDetails, PaymentDetails, PaymentFeeDetails } from './invoice-details';
+import { SendingSpinner } from './sending-spinner';
+
+const isValidCustomAmount = (amount?: number): amount is number => (
+ typeof amount === 'number' && Number.isFinite(amount) && Number.isInteger(amount) && amount > 0
+);
+
+type TProps = {
+ paymentDetails?: TPaymentInputType;
+ backToSelectInvoice: (nextInputError?: string) => void;
+ onSuccess: () => void;
+};
+
+export const ReviewStep = ({
+ paymentDetails,
+ backToSelectInvoice,
+ onSuccess,
+}: TProps) => {
+ const { t } = useTranslation();
+ const mounted = useMountedRef();
+ const customAmountRef = useRef();
+ const invoice = paymentDetails?.type === TPaymentInputTypeVariant.BOLT11 ? paymentDetails.invoice : undefined;
+ const [customAmount, setCustomAmount] = useState();
+ const debouncedCustomAmount = useDebounce(customAmount, 300);
+ const [fees, setFees] = useState();
+ const [preparedCustomAmount, setPreparedCustomAmount] = useState();
+ const [prepareError, setPrepareError] = useState();
+ const [isPreparing, setIsPreparing] = useState(false);
+ const [isSending, setIsSending] = useState(false);
+ const [sendError, setSendError] = useState();
+
+ const toErrorMessage = useCallback((error: unknown): string => {
+ if (error instanceof TSdkError) {
+ if (error.code) {
+ return t(`error.${error.code}`);
+ }
+ return error.message;
+ }
+ return String(error);
+ }, [t]);
+
+ const resetCustomAmountFees = useCallback(() => {
+ setFees(undefined);
+ setPreparedCustomAmount(undefined);
+ setPrepareError(undefined);
+ setIsPreparing(false);
+ }, []);
+
+ const startPreparingFees = useCallback((amountSat?: number) => {
+ setIsPreparing(true);
+ setFees(undefined);
+ setPreparedCustomAmount(amountSat);
+ setPrepareError(undefined);
+ }, []);
+
+ const applyPreparedFees = useCallback((
+ nextFees: TPreparePaymentResponse,
+ amountSat?: number,
+ ) => {
+ setFees(nextFees);
+ setPreparedCustomAmount(amountSat);
+ setPrepareError(undefined);
+ setIsPreparing(false);
+ }, []);
+
+ const applyPrepareError = useCallback((errorMessage: string, amountSat: number) => {
+ setFees(undefined);
+ setPreparedCustomAmount(amountSat);
+ setPrepareError(errorMessage);
+ setIsPreparing(false);
+ setSendError(undefined);
+ }, []);
+
+ const getActiveFees = useCallback((amountSat?: number) => (
+ invoice?.amountSat === undefined
+ ? preparedCustomAmount === amountSat ? fees : undefined
+ : fees
+ ), [fees, invoice?.amountSat, preparedCustomAmount]);
+
+ const preparePayment = useCallback(async (amountSat?: number) => {
+ if (!invoice) {
+ return;
+ }
+ if (invoice.amountSat === undefined && !isValidCustomAmount(amountSat)) {
+ return;
+ }
+ const isCustomAmount = amountSat !== undefined;
+
+ startPreparingFees(amountSat);
+
+ try {
+ const fees = await postPreparePayment({
+ bolt11: invoice.bolt11,
+ amountSat,
+ });
+
+ const discardFees = isCustomAmount && customAmountRef.current !== amountSat;
+ if (!mounted.current || discardFees) {
+ return;
+ }
+
+ applyPreparedFees(fees, amountSat);
+ } catch (error) {
+ const discardFees = isCustomAmount && customAmountRef.current !== amountSat;
+ if (!mounted.current || discardFees) {
+ return;
+ }
+
+ if (!isCustomAmount) {
+ setIsPreparing(false);
+ backToSelectInvoice(toErrorMessage(error));
+ return;
+ }
+
+ applyPrepareError(toErrorMessage(error), amountSat);
+ }
+ }, [
+ invoice,
+ mounted,
+ applyPrepareError,
+ applyPreparedFees,
+ backToSelectInvoice,
+ startPreparingFees,
+ toErrorMessage,
+ ]);
+
+ const retryPreparePayment = useCallback(async (errorMessage: string, amountSat?: number) => {
+ setSendError(errorMessage);
+ await preparePayment(amountSat);
+ }, [preparePayment]);
+
+ const sendPayment = useCallback(async () => {
+ if (!invoice) {
+ return;
+ }
+ const isAmountlessInvoice = invoice.amountSat === undefined;
+ if (isAmountlessInvoice && !isValidCustomAmount(customAmount)) {
+ setSendError(t('send.error.invalidAmount'));
+ return;
+ }
+
+ const activeFees = getActiveFees(customAmount);
+ if (!activeFees) {
+ return;
+ }
+
+ setIsSending(true);
+ setSendError(undefined);
+
+ try {
+ await postSendPayment({
+ bolt11: invoice.bolt11,
+ amountSat: invoice.amountSat === undefined ? customAmount : undefined,
+ approvedFeeSat: activeFees.feeSat,
+ });
+ onSuccess();
+ } catch (error) {
+ if (mounted.current) {
+ setIsSending(false);
+ }
+
+ const errorMessage = toErrorMessage(error);
+ if (error instanceof TSdkError && error.code === 'lightningInvoiceAlreadyUsed') {
+ backToSelectInvoice(errorMessage);
+ return;
+ }
+ if (
+ error instanceof TSdkError
+ && error.code === 'paymentApprovalRequired'
+ ) {
+ await retryPreparePayment(errorMessage, isAmountlessInvoice ? customAmount : undefined);
+ return;
+ }
+
+ setSendError(errorMessage);
+ }
+ }, [
+ customAmount,
+ backToSelectInvoice,
+ getActiveFees,
+ mounted,
+ onSuccess,
+ invoice,
+ retryPreparePayment,
+ t,
+ toErrorMessage,
+ ]);
+
+ // Reset all review-local state when a new invoice enters the review step.
+ useEffect(() => {
+ setCustomAmount(undefined);
+ resetCustomAmountFees();
+ setIsSending(false);
+ setSendError(undefined);
+
+ if (!invoice || invoice.amountSat === undefined) {
+ return;
+ }
+
+ void preparePayment();
+ }, [invoice, preparePayment, resetCustomAmountFees]);
+
+ // Clear the current custom-amount fees whenever the typed amount changes.
+ useEffect(() => {
+ if (!invoice || invoice.amountSat !== undefined) {
+ return;
+ }
+
+ customAmountRef.current = customAmount;
+ setSendError(undefined);
+ resetCustomAmountFees();
+ }, [customAmount, invoice, resetCustomAmountFees]);
+
+ // Prepare fees for the custom amount after the debounced amount settles on a valid value.
+ useEffect(() => {
+ if (!invoice || invoice.amountSat !== undefined) {
+ return;
+ }
+
+ if (debouncedCustomAmount !== customAmount) {
+ return;
+ }
+
+ void preparePayment(debouncedCustomAmount);
+ }, [customAmount, debouncedCustomAmount, invoice, preparePayment]);
+
+
+ if (!invoice || !paymentDetails || paymentDetails.type !== TPaymentInputTypeVariant.BOLT11) {
+ return null;
+ }
+
+ if (isSending) {
+ return ;
+ }
+
+ const isAmountlessInvoice = invoice.amountSat === undefined;
+ const paymentFees = getActiveFees(customAmount);
+ const canSend = !!paymentFees;
+ const showCustomAmountFees = isPreparing || paymentFees;
+
+ return (
+
+
+
+
+
+ {sendError}
+
+ {isAmountlessInvoice ? (
+ <>
+ ) => {
+ const amount = event.target.valueAsNumber;
+ setCustomAmount(Number.isNaN(amount) ? undefined : amount);
+ }}
+ value={customAmount ? `${customAmount}` : ''}
+ autoFocus
+ />
+
+
+ {prepareError}
+
+ {showCustomAmountFees && (
+ <>
+
+
+ >
+ )}
+ >
+ ) : (
+ paymentFees
+ ?
+ : isPreparing &&
+ )}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontends/web/src/routes/lightning/send/components/select-invoice-step.tsx b/frontends/web/src/routes/lightning/send/components/select-invoice-step.tsx
index 98fa840cec..e1342d8087 100644
--- a/frontends/web/src/routes/lightning/send/components/select-invoice-step.tsx
+++ b/frontends/web/src/routes/lightning/send/components/select-invoice-step.tsx
@@ -2,28 +2,34 @@
import { ChangeEvent, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { useNavigate } from 'react-router-dom';
import { Button, Input } from '@/components/forms';
import { Column, Grid } from '@/components/layout';
import { Status } from '@/components/status/status';
import { View, ViewButtons, ViewContent, ViewHeader } from '@/components/view/view';
import { ScanQRVideo } from '@/routes/account/send/components/inputs/scan-qr-video';
import { runningInAndroid, runningInIOS } from '@/utils/env';
-import { useLightningSendContext } from '../lightning-send-context';
import styles from '../send.module.css';
-export const SelectInvoiceStep = () => {
+type TProps = {
+ inputError?: string;
+ onCancel: () => void;
+ onSubmit: (input: string) => Promise;
+};
+
+export const SelectInvoiceStep = ({
+ inputError,
+ onCancel,
+ onSubmit,
+}: TProps) => {
const { t } = useTranslation();
- const navigate = useNavigate();
- const { inputError, parsePaymentInput } = useLightningSendContext();
const [invoiceInput, setInvoiceInput] = useState('');
const scanQRVideo = useMemo(() => (
-
- ), [parsePaymentInput]);
+ void onSubmit(result)} />
+ ), [onSubmit]);
const submitInvoiceInput = async () => {
- const success = await parsePaymentInput(invoiceInput);
+ const success = await onSubmit(invoiceInput);
if (success) {
setInvoiceInput('');
}
@@ -52,7 +58,7 @@ export const SelectInvoiceStep = () => {
-