From b377434de01888a3680f6ab7c19ee5b180c0928a Mon Sep 17 00:00:00 2001 From: strmci Date: Mon, 18 May 2026 14:25:18 +0200 Subject: [PATCH 1/2] frontend: improve insufficient funds error in lightning wallet - prepare custom-amount invoices while editing the amount - show fee, total, and insufficient-funds errors in the edit step - validate prepared payments against the available balance - map Lightning insufficient-funds SDK errors to an app error code - map already-used Lightning invoice SDK errors to an app error code --- backend/lightning/handlers.go | 3 + backend/lightning/payments.go | 55 ++- backend/lightning/payments_test.go | 87 ++++ frontends/web/src/api/lightning.ts | 18 +- frontends/web/src/locales/en/app.json | 2 + .../send/components/confirm-step.tsx | 5 - .../send/components/edit-invoice-step.tsx | 33 +- .../send/components/invoice-details.tsx | 49 ++- .../send/lightning-send-context.test.ts | 407 ++++++++++++++++++ .../lightning/send/lightning-send-context.tsx | 162 +++++-- .../src/routes/lightning/send/send.module.css | 5 +- 11 files changed, 768 insertions(+), 58 deletions(-) create mode 100644 frontends/web/src/routes/lightning/send/lightning-send-context.test.ts 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 && } - - - - ); -}; 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 9f06d0440a..0000000000 --- a/frontends/web/src/routes/lightning/send/components/edit-invoice-step.tsx +++ /dev/null @@ -1,88 +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 { 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, - resetPayment, - sendPayment, - setCustomAmount, - } = useLightningSendContext(); - - if (paymentDetails?.type !== TPaymentInputTypeVariant.BOLT11) { - 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 ( - - - - - ) => { - const amount = event.target.valueAsNumber; - setCustomAmount(Number.isNaN(amount) ? undefined : amount); - }} - value={customAmount ? `${customAmount}` : ''} - autoFocus - /> - - - {showQuote && } - - - - - - - - - ); -}; 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 2ca1647c4e..cdc8af2ad0 100644 --- a/frontends/web/src/routes/lightning/send/components/invoice-details.tsx +++ b/frontends/web/src/routes/lightning/send/components/invoice-details.tsx @@ -67,21 +67,37 @@ const satsAmount = (amountSat?: number): TAmountWithConversions | undefined => { }; }; +type TProps = { + fees?: TPreparePaymentResponse; + totalWithFiat?: boolean; +}; + +type TPaymentAmountDetailsProps = { + amountSat?: number; +}; + type TPaymentDetailsProps = { input: TPaymentInputType; - quote: TPreparePaymentResponse; + fees: TPreparePaymentResponse; }; -type TProps = { - quote?: TPreparePaymentResponse; - totalWithFiat?: boolean; +export const PaymentAmountDetails = ({ amountSat }: TPaymentAmountDetailsProps) => { + const { t } = useTranslation(); + const invoiceAmount = useInvoiceAmount(amountSat); + + return ( +
+

{t('lightning.send.confirm.amount')}

+ +
+ ); }; -export const PaymentFeeDetails = ({ quote, totalWithFiat = false }: TProps) => { +export const PaymentFeeDetails = ({ fees, totalWithFiat = false }: TProps) => { const { t } = useTranslation(); - const feeAmount = satsAmount(quote?.feeSat); - const totalDebitAmountSat = satsAmount(quote?.totalDebitSat); - const convertedTotalDebitAmount = useInvoiceAmount(totalWithFiat ? quote?.totalDebitSat : undefined); + 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; @@ -99,25 +115,21 @@ export const PaymentFeeDetails = ({ quote, totalWithFiat = false }: TProps) => { ); }; -export const PaymentDetails = ({ input, quote }: TPaymentDetailsProps) => { +export const PaymentDetails = ({ input, fees }: TPaymentDetailsProps) => { const { t } = useTranslation(); const { invoice } = input; - const invoiceAmount = useInvoiceAmount(quote.amountSat); return ( <>

{t('lightning.send.confirm.title')}

-
-

{t('lightning.send.confirm.amount')}

- -
+ {invoice.description && (

{t('lightning.send.confirm.memo')}

{invoice.description}
)} - + ); }; 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.test.ts b/frontends/web/src/routes/lightning/send/lightning-send-context.test.ts deleted file mode 100644 index 834369c446..0000000000 --- a/frontends/web/src/routes/lightning/send/lightning-send-context.test.ts +++ /dev/null @@ -1,407 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -import '../../../../__mocks__/i18n'; -import { act, renderHook } from '@testing-library/react'; -import { createElement, type ReactNode } from 'react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('@/api/lightning', () => { - class TSdkError extends Error { - code?: string; - data?: unknown; - - constructor(message: string, code?: string, data?: unknown) { - super(message); - this.code = code; - this.data = data; - } - } - - return { - TPaymentInputTypeVariant: { - BOLT11: 'bolt11', - }, - TSdkError, - getParsePaymentInput: vi.fn(), - postPreparePayment: vi.fn(), - postSendPayment: vi.fn(), - }; -}); - -import { - TPaymentInputTypeVariant, - TSdkError, - getParsePaymentInput, - postPreparePayment, - postSendPayment, -} from '@/api/lightning'; -import { LightningSendProvider, useLightningSendContext } from './lightning-send-context'; - -const amountlessInvoice = { - type: TPaymentInputTypeVariant.BOLT11, - invoice: { - bolt11: 'lnbc1invoice', - description: 'invoice description', - }, -}; - -const quote = (amountSat: number, feeSat: number) => ({ - amountSat, - feeSat, - totalDebitSat: amountSat + feeSat, -}); - -const deferred = () => { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve; - reject = promiseReject; - }); - return { promise, resolve, reject }; -}; - -type TProps = { - children: ReactNode; -}; - -const wrapper = ({ children }: TProps) => ( - createElement(LightningSendProvider, null, children) -); - -const renderContext = () => renderHook(() => useLightningSendContext(), { wrapper }); - -const flushPromises = async () => { - await act(async () => { - await Promise.resolve(); - }); -}; - -describe('lightning send context', () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.resetAllMocks(); - vi.mocked(getParsePaymentInput).mockResolvedValue(amountlessInvoice); - vi.mocked(postSendPayment).mockResolvedValue(undefined); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('does not prepare custom invoices until the amount is valid', async () => { - const { result } = renderContext(); - - await act(async () => { - await result.current.parsePaymentInput('lnbc1invoice'); - }); - act(() => { - result.current.setCustomAmount(0); - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(300); - }); - - expect(postPreparePayment).not.toHaveBeenCalled(); - expect(result.current.customPrepareState.status).toBe('idle'); - }); - - it('debounces custom invoice prepare requests and stores the matching quote', async () => { - vi.mocked(postPreparePayment).mockResolvedValue(quote(123, 4)); - const { result } = renderContext(); - - await act(async () => { - await result.current.parsePaymentInput('lnbc1invoice'); - }); - act(() => { - result.current.setCustomAmount(123); - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(299); - }); - - expect(postPreparePayment).not.toHaveBeenCalled(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(1); - }); - await flushPromises(); - - expect(postPreparePayment).toHaveBeenCalledWith({ - bolt11: 'lnbc1invoice', - amountSat: 123, - }); - expect(result.current.customPrepareState).toEqual({ - status: 'success', - amountSat: 123, - quote: quote(123, 4), - }); - }); - - it('ignores stale custom invoice prepare responses', async () => { - const firstPrepare = deferred>(); - const secondPrepare = deferred>(); - vi.mocked(postPreparePayment) - .mockReturnValueOnce(firstPrepare.promise) - .mockReturnValueOnce(secondPrepare.promise); - const { result } = renderContext(); - - await act(async () => { - await result.current.parsePaymentInput('lnbc1invoice'); - }); - act(() => { - result.current.setCustomAmount(100); - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(300); - }); - act(() => { - result.current.setCustomAmount(200); - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(300); - }); - - await act(async () => { - firstPrepare.resolve(quote(100, 1)); - }); - - expect(result.current.customPrepareState).toEqual({ - status: 'loading', - amountSat: 200, - }); - - await act(async () => { - secondPrepare.resolve(quote(200, 2)); - }); - await flushPromises(); - - expect(result.current.customPrepareState).toEqual({ - status: 'success', - amountSat: 200, - quote: quote(200, 2), - }); - }); - - it('stores custom invoice prepare errors inline', async () => { - vi.mocked(postPreparePayment).mockRejectedValue(new TSdkError( - 'insufficient funds', - 'lightningInsufficientFunds', - quote(123, 4) - )); - const { result } = renderContext(); - - await act(async () => { - await result.current.parsePaymentInput('lnbc1invoice'); - }); - act(() => { - result.current.setCustomAmount(123); - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(300); - }); - await flushPromises(); - - expect(result.current.customPrepareState).toEqual({ - status: 'error', - amountSat: 123, - error: 'error.lightningInsufficientFunds', - quote: quote(123, 4), - }); - expect(result.current.sendError).toBeUndefined(); - - await act(async () => { - await result.current.sendPayment(); - }); - - expect(postSendPayment).not.toHaveBeenCalled(); - }); - - it('ignores malformed quote data in custom invoice prepare errors', async () => { - vi.mocked(postPreparePayment).mockRejectedValue(new TSdkError( - 'insufficient funds', - 'lightningInsufficientFunds', - { feeSat: 4 } - )); - const { result } = renderContext(); - - await act(async () => { - await result.current.parsePaymentInput('lnbc1invoice'); - }); - act(() => { - result.current.setCustomAmount(123); - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(300); - }); - await flushPromises(); - - expect(result.current.customPrepareState).toEqual({ - status: 'error', - amountSat: 123, - error: 'error.lightningInsufficientFunds', - }); - }); - - it('sends custom invoices with the visible quote fee', async () => { - vi.mocked(postPreparePayment).mockResolvedValue(quote(123, 4)); - const { result } = renderContext(); - - await act(async () => { - await result.current.parsePaymentInput('lnbc1invoice'); - }); - act(() => { - result.current.setCustomAmount(123); - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(300); - }); - await flushPromises(); - expect(result.current.customPrepareState.status).toBe('success'); - - await act(async () => { - await result.current.sendPayment(); - }); - - expect(postSendPayment).toHaveBeenCalledWith({ - bolt11: 'lnbc1invoice', - amountSat: 123, - approvedFeeSat: 4, - }); - }); - - it('refreshes the custom quote in the edit step when fee approval is stale', async () => { - vi.mocked(postPreparePayment) - .mockResolvedValueOnce(quote(123, 4)) - .mockResolvedValueOnce(quote(123, 5)); - vi.mocked(postSendPayment).mockRejectedValue(new TSdkError('approval required', 'paymentApprovalRequired')); - const { result } = renderContext(); - - await act(async () => { - await result.current.parsePaymentInput('lnbc1invoice'); - }); - act(() => { - result.current.setCustomAmount(123); - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(300); - }); - await flushPromises(); - expect(result.current.customPrepareState.status).toBe('success'); - - await act(async () => { - await result.current.sendPayment(); - }); - await flushPromises(); - - expect(postPreparePayment).toHaveBeenCalledTimes(2); - expect(result.current.customPrepareState).toEqual({ - status: 'success', - amountSat: 123, - quote: quote(123, 5), - }); - expect(result.current.step).toBe('edit-invoice'); - expect(result.current.sendError).toBe('error.paymentApprovalRequired'); - }); - - it('keeps custom invoice insufficient funds errors in the edit step after send', async () => { - vi.mocked(postPreparePayment) - .mockResolvedValueOnce(quote(123, 4)) - .mockRejectedValueOnce(new TSdkError( - 'insufficient funds', - 'lightningInsufficientFunds', - quote(123, 4) - )); - vi.mocked(postSendPayment).mockRejectedValue(new TSdkError('insufficient funds', 'lightningInsufficientFunds')); - const { result } = renderContext(); - - await act(async () => { - await result.current.parsePaymentInput('lnbc1invoice'); - }); - act(() => { - result.current.setCustomAmount(123); - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(300); - }); - await flushPromises(); - expect(result.current.customPrepareState.status).toBe('success'); - - await act(async () => { - await result.current.sendPayment(); - }); - await flushPromises(); - - expect(postPreparePayment).toHaveBeenCalledTimes(2); - expect(result.current.customPrepareState).toEqual({ - status: 'error', - amountSat: 123, - error: 'error.lightningInsufficientFunds', - quote: quote(123, 4), - }); - expect(result.current.step).toBe('edit-invoice'); - expect(result.current.sendError).toBeUndefined(); - }); - - it('keeps generic custom invoice send errors in the edit step', async () => { - vi.mocked(postPreparePayment).mockResolvedValue(quote(123, 4)); - vi.mocked(postSendPayment).mockRejectedValue(new TSdkError('duplicate operation')); - const { result } = renderContext(); - - await act(async () => { - await result.current.parsePaymentInput('lnbc1invoice'); - }); - act(() => { - result.current.setCustomAmount(123); - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(300); - }); - await flushPromises(); - expect(result.current.customPrepareState.status).toBe('success'); - - await act(async () => { - await result.current.sendPayment(); - }); - - expect(result.current.step).toBe('edit-invoice'); - expect(result.current.sendError).toBe('duplicate operation'); - expect(result.current.customPrepareState).toEqual({ - status: 'success', - amountSat: 123, - quote: quote(123, 4), - }); - }); - - it('returns already used custom invoices to invoice entry', async () => { - vi.mocked(postPreparePayment).mockResolvedValue(quote(123, 4)); - vi.mocked(postSendPayment).mockRejectedValue(new TSdkError( - 'already used', - 'lightningInvoiceAlreadyUsed' - )); - const { result } = renderContext(); - - await act(async () => { - await result.current.parsePaymentInput('lnbc1invoice'); - }); - act(() => { - result.current.setCustomAmount(123); - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(300); - }); - await flushPromises(); - expect(result.current.customPrepareState.status).toBe('success'); - - await act(async () => { - await result.current.sendPayment(); - }); - - expect(result.current.step).toBe('select-invoice'); - expect(result.current.inputError).toBe('error.lightningInvoiceAlreadyUsed'); - expect(result.current.paymentDetails).toBeUndefined(); - expect(result.current.customAmount).toBeUndefined(); - expect(result.current.customPrepareState).toEqual({ status: 'idle' }); - expect(result.current.sendError).toBeUndefined(); - }); -}); 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 564f340946..0000000000 --- a/frontends/web/src/routes/lightning/send/lightning-send-context.tsx +++ /dev/null @@ -1,300 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -import { ReactNode, createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - type TPaymentInputType, - TPaymentInputTypeVariant, - type TPreparePaymentResponse, - TSdkError, - getParsePaymentInput, - postPreparePayment, - postSendPayment, -} from '@/api/lightning'; -import { useDebounce } from '@/hooks/debounce'; -import { useMountedRef } from '@/hooks/mount'; - -type TSendStep = 'select-invoice' | 'edit-invoice' | 'preparing' | 'confirm' | 'sending' | 'success'; - -type TCustomPrepareState = - | { status: 'idle' } - | { status: 'loading'; amountSat: number } - | { status: 'success'; amountSat: number; quote: TPreparePaymentResponse } - | { status: 'error'; amountSat: number; error: string; quote?: TPreparePaymentResponse }; - -type TLightningSendContext = { - customAmount?: number; - customPrepareState: TCustomPrepareState; - inputError?: string; - paymentDetails?: TPaymentInputType; - paymentQuote?: TPreparePaymentResponse; - resetPayment: () => 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 isPreparePaymentResponse = (data: unknown): data is TPreparePaymentResponse => { - if (typeof data !== 'object' || data === null) { - return false; - } - const response = data as Partial; - return ( - typeof response.amountSat === 'number' - && typeof response.feeSat === 'number' - && typeof response.totalDebitSat === 'number' - ); -}; - -const LightningSendContext = createContext(null); - -type TProps = { - children: ReactNode; -}; - -export const LightningSendProvider = ({ children }: TProps) => { - const { t } = useTranslation(); - const mounted = useMountedRef(); - const customPrepareRequestID = useRef(0); - const [step, setStep] = useState('select-invoice'); - const [paymentDetails, setPaymentDetails] = useState(); - const [paymentQuote, setPaymentQuote] = useState(); - const [customAmount, setCustomAmount] = useState(); - const debouncedCustomAmount = useDebounce(customAmount, 300); - const [customPrepareState, setCustomPrepareState] = useState({ status: 'idle' }); - const [inputError, setInputError] = useState(); - 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 resetPayment = useCallback(() => { - customPrepareRequestID.current += 1; - setStep('select-invoice'); - setPaymentDetails(undefined); - setPaymentQuote(undefined); - setCustomAmount(undefined); - setCustomPrepareState({ status: 'idle' }); - setInputError(undefined); - setSendError(undefined); - }, []); - - const updateCustomAmount = useCallback((amount?: number) => { - customPrepareRequestID.current += 1; - setCustomAmount(amount); - setPaymentQuote(undefined); - setCustomPrepareState({ status: 'idle' }); - setSendError(undefined); - }, []); - - const prepareCustomPayment = useCallback(async ( - currentPaymentDetails: TPaymentInputType | undefined, - amountSat: number | undefined, - prepareErrorMessage?: string, - ) => { - if ( - currentPaymentDetails?.type !== TPaymentInputTypeVariant.BOLT11 - || currentPaymentDetails.invoice.amountSat - || !isValidCustomAmount(amountSat) - ) { - return; - } - - const requestID = ++customPrepareRequestID.current; - setStep('edit-invoice'); - setCustomPrepareState({ status: 'loading', amountSat }); - setSendError(prepareErrorMessage); - - try { - const quote = await postPreparePayment({ - bolt11: currentPaymentDetails.invoice.bolt11, - amountSat, - }); - if (!mounted.current || requestID !== customPrepareRequestID.current) { - return; - } - setCustomPrepareState({ status: 'success', amountSat, quote }); - } catch (error) { - if (!mounted.current || requestID !== customPrepareRequestID.current) { - return; - } - const quote = error instanceof TSdkError && isPreparePaymentResponse(error.data) ? error.data : undefined; - setCustomPrepareState(quote === undefined - ? { status: 'error', amountSat, error: toErrorMessage(error) } - : { status: 'error', amountSat, error: toErrorMessage(error), quote }); - setSendError(undefined); - } - }, [mounted, toErrorMessage]); - - const preparePayment = useCallback(async ( - nextPaymentDetails?: TPaymentInputType, - prepareErrorMessage?: string, - ) => { - const currentPaymentDetails = nextPaymentDetails || paymentDetails; - if (currentPaymentDetails?.type !== TPaymentInputTypeVariant.BOLT11) { - return; - } - if (!currentPaymentDetails.invoice.amountSat) { - return; - } - - setStep('preparing'); - setSendError(prepareErrorMessage); - - try { - const quote = await postPreparePayment({ - bolt11: currentPaymentDetails.invoice.bolt11, - }); - setPaymentQuote(quote); - setStep('confirm'); - } catch (error) { - setStep('select-invoice'); - setPaymentQuote(undefined); - setSendError(toErrorMessage(error)); - } - }, [paymentDetails, toErrorMessage]); - - useEffect(() => { - if (paymentDetails?.type !== TPaymentInputTypeVariant.BOLT11 || paymentDetails.invoice.amountSat) { - return; - } - - if (debouncedCustomAmount !== customAmount) { - return; - } - - if (!isValidCustomAmount(debouncedCustomAmount)) { - customPrepareRequestID.current += 1; - setPaymentQuote(undefined); - setCustomPrepareState({ status: 'idle' }); - return; - } - - prepareCustomPayment(paymentDetails, debouncedCustomAmount); - }, [customAmount, debouncedCustomAmount, paymentDetails, prepareCustomPayment]); - - const parsePaymentInput = useCallback(async (rawInput: string) => { - customPrepareRequestID.current += 1; - setInputError(undefined); - setSendError(undefined); - setPaymentQuote(undefined); - setCustomPrepareState({ status: 'idle' }); - - try { - const result = await getParsePaymentInput({ s: rawInput }); - setPaymentDetails(result); - - if (result.type === TPaymentInputTypeVariant.BOLT11 && !result.invoice.amountSat) { - setCustomAmount(undefined); - 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; - } - - const activePaymentQuote = paymentDetails.invoice.amountSat - ? paymentQuote - : customPrepareState.status === 'success' && customPrepareState.amountSat === customAmount - ? customPrepareState.quote - : undefined; - - if (!activePaymentQuote) { - 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: activePaymentQuote.feeSat, - }); - setStep('success'); - } catch (error) { - const errorMessage = toErrorMessage(error); - if (error instanceof TSdkError && error.code === 'lightningInvoiceAlreadyUsed') { - customPrepareRequestID.current += 1; - setStep('select-invoice'); - setPaymentDetails(undefined); - setPaymentQuote(undefined); - setCustomAmount(undefined); - setCustomPrepareState({ status: 'idle' }); - setInputError(errorMessage); - setSendError(undefined); - return; - } - if ( - error instanceof TSdkError - && !paymentDetails.invoice.amountSat - && (error.code === 'paymentApprovalRequired' || error.code === 'lightningInsufficientFunds') - ) { - prepareCustomPayment(paymentDetails, customAmount, errorMessage); - return; - } - if (error instanceof TSdkError && error.code === 'paymentApprovalRequired') { - preparePayment(paymentDetails, errorMessage); - return; - } - setStep(paymentDetails.invoice.amountSat ? 'confirm' : 'edit-invoice'); - setSendError(errorMessage); - } - }, [customAmount, customPrepareState, paymentDetails, paymentQuote, prepareCustomPayment, preparePayment, toErrorMessage, t]); - - return ( - - {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.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 = () => ( - - - -);