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 ( + + + + + + {isAmountlessInvoice ? ( + <> + ) => { + const amount = event.target.valueAsNumber; + setCustomAmount(Number.isNaN(amount) ? undefined : amount); + }} + value={customAmount ? `${customAmount}` : ''} + autoFocus + /> + + + {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 = () => { - diff --git a/frontends/web/src/routes/lightning/send/lightning-send-context.tsx b/frontends/web/src/routes/lightning/send/lightning-send-context.tsx deleted file mode 100644 index 74ddddb5a4..0000000000 --- a/frontends/web/src/routes/lightning/send/lightning-send-context.tsx +++ /dev/null @@ -1,188 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -import { ReactNode, createContext, useCallback, useContext, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - TPaymentInputType, - TPaymentInputTypeVariant, - TPreparePaymentResponse, - TSdkError, - getParsePaymentInput, - postPreparePayment, - postSendPayment, -} from '@/api/lightning'; - -type TSendStep = 'select-invoice' | 'edit-invoice' | 'preparing' | 'confirm' | 'sending' | 'success'; - -type TLightningSendContext = { - customAmount?: number; - inputError?: string; - paymentDetails?: TPaymentInputType; - paymentQuote?: TPreparePaymentResponse; - preparePayment: () => Promise; - resetPayment: () => void; - returnToEditInvoice: () => void; - sendError?: string; - sendPayment: () => Promise; - setCustomAmount: (amount?: number) => void; - step: TSendStep; - parsePaymentInput: (rawInput: string) => Promise; -}; - -const isValidCustomAmount = (amount?: number): amount is number => ( - typeof amount === 'number' && Number.isFinite(amount) && Number.isInteger(amount) && amount > 0 -); - -const LightningSendContext = createContext(null); - -type TProps = { - children: ReactNode; -}; - -export const LightningSendProvider = ({ children }: TProps) => { - const { t } = useTranslation(); - const [step, setStep] = useState('select-invoice'); - const [paymentDetails, setPaymentDetails] = useState(); - const [paymentQuote, setPaymentQuote] = useState(); - const [customAmount, setCustomAmount] = useState(); - const [inputError, setInputError] = useState(); - const [sendError, setSendError] = useState(); - - const toErrorMessage = useCallback((error: unknown): string => { - if (error instanceof TSdkError && error.code) { - return t(`error.${error.code}`); - } - return String(error); - }, [t]); - - const resetPayment = useCallback(() => { - setStep('select-invoice'); - setPaymentDetails(undefined); - setPaymentQuote(undefined); - setCustomAmount(undefined); - setInputError(undefined); - setSendError(undefined); - }, []); - - const returnToEditInvoice = useCallback(() => { - setPaymentQuote(undefined); - setSendError(undefined); - setStep('edit-invoice'); - }, []); - - const preparePayment = useCallback(async ( - nextPaymentDetails?: TPaymentInputType, - nextCustomAmount?: number, - prepareErrorMessage?: string, - ) => { - const currentPaymentDetails = nextPaymentDetails || paymentDetails; - if (currentPaymentDetails?.type !== TPaymentInputTypeVariant.BOLT11) { - return; - } - if (!currentPaymentDetails.invoice.amountSat && !isValidCustomAmount(nextCustomAmount)) { - setSendError(t('send.error.invalidAmount')); - return; - } - - setStep('preparing'); - setSendError(prepareErrorMessage); - - try { - const quote = await postPreparePayment({ - bolt11: currentPaymentDetails.invoice.bolt11, - amountSat: nextCustomAmount === undefined ? undefined : nextCustomAmount, - }); - setPaymentQuote(quote); - setStep('confirm'); - } catch (error) { - setStep('select-invoice'); - setPaymentQuote(undefined); - setSendError(toErrorMessage(error)); - } - }, [paymentDetails, toErrorMessage, t]); - - const parsePaymentInput = useCallback(async (rawInput: string) => { - setInputError(undefined); - setSendError(undefined); - setPaymentQuote(undefined); - - try { - const result = await getParsePaymentInput({ s: rawInput }); - setPaymentDetails(result); - - if (result.type === TPaymentInputTypeVariant.BOLT11 && !result.invoice.amountSat) { - setCustomAmount(0); - setStep('edit-invoice'); - return true; - } - - setCustomAmount(undefined); - preparePayment(result); - return true; - } catch (error) { - setInputError(toErrorMessage(error)); - return false; - } - }, [preparePayment, toErrorMessage]); - - const sendPayment = useCallback(async () => { - if (paymentDetails?.type !== TPaymentInputTypeVariant.BOLT11) { - return; - } - if (!paymentQuote) { - return; - } - - if (!paymentDetails.invoice.amountSat && !isValidCustomAmount(customAmount)) { - setSendError(t('send.error.invalidAmount')); - return; - } - - setStep('sending'); - setSendError(undefined); - - try { - await postSendPayment({ - bolt11: paymentDetails.invoice.bolt11, - amountSat: paymentDetails.invoice.amountSat ? undefined : customAmount, - approvedFeeSat: paymentQuote.feeSat, - }); - setStep('success'); - } catch (error) { - if (error instanceof TSdkError && error.code === 'paymentApprovalRequired') { - preparePayment(paymentDetails, customAmount, toErrorMessage(error)); - return; - } - setStep('select-invoice'); - setPaymentQuote(undefined); - setSendError(toErrorMessage(error)); - } - }, [customAmount, paymentDetails, paymentQuote, preparePayment, toErrorMessage, t]); - - return ( - preparePayment(undefined, customAmount), - resetPayment, - returnToEditInvoice, - sendError, - sendPayment, - setCustomAmount, - step, - parsePaymentInput, - }}> - {children} - - ); -}; - -export const useLightningSendContext = () => { - const context = useContext(LightningSendContext); - if (context === null) { - throw new Error('useLightningSendContext must be used within LightningSendProvider'); - } - return context; -}; diff --git a/frontends/web/src/routes/lightning/send/send.module.css b/frontends/web/src/routes/lightning/send/send.module.css index 4ef6607ce1..f662a54b57 100644 --- a/frontends/web/src/routes/lightning/send/send.module.css +++ b/frontends/web/src/routes/lightning/send/send.module.css @@ -37,7 +37,8 @@ } .error { - height: 70px; + margin-bottom: var(--space-default); + min-height: 70px; } .successMessage strong { @@ -56,6 +57,6 @@ } .error { - height: 90px; + min-height: 90px; } } diff --git a/frontends/web/src/routes/lightning/send/send.tsx b/frontends/web/src/routes/lightning/send/send.tsx index 6b2bda9a78..0807e9480b 100644 --- a/frontends/web/src/routes/lightning/send/send.tsx +++ b/frontends/web/src/routes/lightning/send/send.tsx @@ -1,22 +1,56 @@ // SPDX-License-Identifier: Apache-2.0 -import { useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; +import { type TPaymentInputType, TSdkError, getParsePaymentInput } from '@/api/lightning'; import { GuideWrapper, GuidedContent, Header, Main } from '@/components/layout'; -import { Spinner } from '@/components/spinner/Spinner'; -import { Status } from '@/components/status/status'; -import { ConfirmStep } from './components/confirm-step'; -import { EditInvoiceStep } from './components/edit-invoice-step'; +import { ReviewStep } from './components/review-step'; import { SelectInvoiceStep } from './components/select-invoice-step'; -import { SendingSpinner } from './components/sending-spinner'; import { SuccessStep } from './components/success-step'; -import { LightningSendProvider, useLightningSendContext } from './lightning-send-context'; -const SendContent = () => { +type TSendStep = 'select-invoice' | 'review' | 'success'; + +export const Send = () => { const { t } = useTranslation(); const navigate = useNavigate(); - const { sendError, step } = useLightningSendContext(); + const [step, setStep] = useState('select-invoice'); + const [paymentDetails, setPaymentDetails] = useState(); + const [inputError, setInputError] = 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 resetToInvoiceEntry = useCallback((nextInputError?: string) => { + setStep('select-invoice'); + setPaymentDetails(undefined); + setInputError(nextInputError); + }, []); + + const submitPaymentInput = useCallback(async (rawInput: string) => { + setInputError(undefined); + + try { + const result = await getParsePaymentInput({ s: rawInput }); + setPaymentDetails(result); + setStep('review'); + return true; + } catch (error) { + setInputError(toErrorMessage(error)); + return false; + } + }, [toErrorMessage]); + + const showSuccess = useCallback(() => { + setStep('success'); + }, []); useEffect(() => { if (step !== 'success') { @@ -32,23 +66,23 @@ const SendContent = () => {
{t('lightning.send.title')}} /> - - {step === 'select-invoice' && } - {step === 'edit-invoice' && } - {step === 'preparing' && } - {step === 'confirm' && } - {step === 'sending' && } + {step === 'select-invoice' && ( + navigate('/lightning')} + onSubmit={submitPaymentInput} + /> + )} + {step === 'review' && ( + + )} {step === 'success' && }
); }; - -export const Send = () => ( - - - -);