From 1f63333336e5b789761f58efe765cb045bc1b3a9 Mon Sep 17 00:00:00 2001 From: Nikolas De Giorgis Date: Thu, 21 May 2026 10:56:29 +0100 Subject: [PATCH 1/5] send: extract tx proposal hook Regular send and Lightning top up both need the same transaction proposal lifecycle: debounce user edits, ignore stale proposals, expose fee and amount details, and convert proposed amounts to fiat. Keep that behavior in one hook so future fixes to proposal loading or error handling apply to both flows instead of being copied between screens. --- .../web/src/routes/account/send/send.tsx | 134 +++++----------- .../routes/account/send/use-tx-proposal.ts | 149 ++++++++++++++++++ 2 files changed, 187 insertions(+), 96 deletions(-) create mode 100644 frontends/web/src/routes/account/send/use-tx-proposal.ts diff --git a/frontends/web/src/routes/account/send/send.tsx b/frontends/web/src/routes/account/send/send.tsx index d26b57b7b2..f7fab6e8e5 100644 --- a/frontends/web/src/routes/account/send/send.tsx +++ b/frontends/web/src/routes/account/send/send.tsx @@ -26,8 +26,9 @@ import { CoinInput } from './components/inputs/coin-input'; import { FiatInput } from './components/inputs/fiat-input'; import { NoteInput } from './components/inputs/note-input'; import { FiatValue } from '@/components/amount/fiat-value'; -import { TProposalError, txProposalErrorHandling } from './services'; +import { TProposalError } from './services'; import { CoinControl } from './coin-control'; +import { useTxProposal } from './use-tx-proposal'; import { connectKeystore } from '@/api/keystores'; import { SubTitle } from '@/components/title'; import { RatesContext } from '@/contexts/RatesContext'; @@ -67,9 +68,6 @@ export const Send = ({ const { btcUnit, defaultCurrency } = useContext(RatesContext); const selectedUTXOsRef = useRef({}); const [utxoDialogActive, setUtxoDialogActive] = useState(false); - // in case there are multiple parallel tx proposals we can ignore all other but the last one - const lastProposal = useRef | null>(null); - const proposeTimeout = useRef | null>(null); // state used for the "Receiver address" input - what the user types or the account's address that is selected const [recipientInput, setRecipientInput] = useState(''); @@ -77,18 +75,12 @@ export const Send = ({ const [selectedReceiverAccount, setSelectedReceiverAccount] = useState(null); const [amount, setAmount] = useState(''); const [fiatAmount, setFiatAmount] = useState(''); - const [valid, setValid] = useState(false); const [sendAll, setSendAll] = useState(false); const [isConfirming, setIsConfirming] = useState(false); - const [isUpdatingProposal, setIsUpdatingProposal] = useState(false); const [note, setNote] = useState(''); const [customFee, setCustomFee] = useState(''); const [errorHandling, setErrorHandling] = useState({}); - const [proposedFee, setProposedFee] = useState(); - const [proposedTotal, setProposedTotal] = useState(); - const [proposedAmount, setProposedAmount] = useState(); - const [recipientDisplayAddress, setRecipientDisplayAddress] = useState(''); const [feeTarget, setFeeTarget] = useState(); const [sendResult, setSendResult] = useState(); @@ -98,23 +90,6 @@ export const Send = ({ const balance = useAccountBalance(account.code, btcUnit); - const handleContinue = () => { - setSendAll(false); - setIsConfirming(false); - setRecipientInput(''); - setSelectedReceiverAccount(null); - setProposedAmount(undefined); - setProposedFee(undefined); - setProposedTotal(undefined); - setRecipientDisplayAddress(''); - setFiatAmount(''); - setAmount(''); - setNote(''); - setCustomFee(''); - setSendResult(undefined); - selectedUTXOsRef.current = {}; - }; - const handleRetry = () => { setSendResult(undefined); }; @@ -197,75 +172,42 @@ export const Send = ({ } }, [account.coinCode, defaultCurrency, t]); - const txProposal = useCallback(( - updateFiat: boolean, - result: accountApi.TTxProposalResult, - ) => { - setValid(result.success); - if (result.success) { - setErrorHandling({}); - setProposedFee(result.fee); - setProposedAmount(result.amount); - setProposedTotal(result.total); - setRecipientDisplayAddress(result.recipientDisplayAddress); - setIsUpdatingProposal(false); - if (updateFiat) { - convertToFiat(result.amount.amount); - } - } else { - const errorHandling = txProposalErrorHandling(result.errorCode); - setErrorHandling(errorHandling); - setIsUpdatingProposal(false); - - if ( - errorHandling.amountError - || Object.keys(errorHandling).length === 0 - ) { - setProposedFee(undefined); - } - setRecipientDisplayAddress(''); - } - }, [convertToFiat]); - - const validateAndDisplayFee = useCallback(( - updateFiat: boolean = true, - ) => { - setProposedTotal(undefined); - setErrorHandling({}); - const txInput = getValidTxInputData(); - if (!txInput) { - return; - } - if (proposeTimeout.current) { - clearTimeout(proposeTimeout.current); - proposeTimeout.current = null; - } - setIsUpdatingProposal(true); - // defer the transaction proposal - proposeTimeout.current = setTimeout(async () => { - let proposePromise; - try { - proposePromise = accountApi.proposeTx(account.code, txInput); - // keep this as the last known proposal - lastProposal.current = proposePromise; - const result = await proposePromise; - // continue only if this is the most recent proposal - if (proposePromise === lastProposal.current) { - txProposal(updateFiat, result); - } - } catch (error) { - if (proposePromise === lastProposal.current) { - setValid(false); - console.error('Failed to propose transaction:', error); - } - } finally { - // cleanup regardless of success or failure - if (proposePromise === lastProposal.current) { - lastProposal.current = null; - } - } - }, 400); // Delay the proposal by 400 ms - }, [account.code, getValidTxInputData, txProposal]); + const clearFeeOnError = useCallback((errorHandling: TProposalError) => ( + !!errorHandling.amountError + || Object.keys(errorHandling).length === 0 + ), []); + + const { + clearProposal, + isUpdatingProposal, + proposedAmount, + proposedFee, + proposedTotal, + recipientDisplayAddress, + setRecipientDisplayAddress, + valid, + validateAndDisplayFee, + } = useTxProposal({ + accountCode: account.code, + clearFeeOnError, + getValidTxInputData, + onProposedAmount: convertToFiat, + setErrorHandling, + }); + + const handleContinue = () => { + setSendAll(false); + setIsConfirming(false); + setRecipientInput(''); + setSelectedReceiverAccount(null); + clearProposal(); + setFiatAmount(''); + setAmount(''); + setNote(''); + setCustomFee(''); + setSendResult(undefined); + selectedUTXOsRef.current = {}; + }; useEffect(() => { validateAndDisplayFee(updateFiat); diff --git a/frontends/web/src/routes/account/send/use-tx-proposal.ts b/frontends/web/src/routes/account/send/use-tx-proposal.ts new file mode 100644 index 0000000000..9aa21da4c4 --- /dev/null +++ b/frontends/web/src/routes/account/send/use-tx-proposal.ts @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { Dispatch, SetStateAction } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import * as accountApi from '@/api/account'; +import { TProposalError, txProposalErrorHandling } from './services'; + +type TUseTxProposalProps = { + accountCode?: accountApi.AccountCode; + clearFeeOnError?: (errorHandling: TProposalError) => boolean; + clearOnInvalidInput?: boolean; + getValidTxInputData: () => Required | false; + onProposedAmount?: (amount: string) => void; + setErrorHandling: Dispatch>; +}; + +const clearFeeOnErrorDefault = () => true; + +export const useTxProposal = ({ + accountCode, + clearFeeOnError = clearFeeOnErrorDefault, + clearOnInvalidInput = false, + getValidTxInputData, + onProposedAmount, + setErrorHandling, +}: TUseTxProposalProps) => { + const lastProposal = useRef | null>(null); + const proposeTimeout = useRef | null>(null); + + const [valid, setValid] = useState(false); + const [isUpdatingProposal, setIsUpdatingProposal] = useState(false); + const [proposedFee, setProposedFee] = useState(); + const [proposedTotal, setProposedTotal] = useState(); + const [proposedAmount, setProposedAmount] = useState(); + const [recipientDisplayAddress, setRecipientDisplayAddress] = useState(''); + + const cancelPendingProposal = useCallback(() => { + if (proposeTimeout.current) { + clearTimeout(proposeTimeout.current); + proposeTimeout.current = null; + } + lastProposal.current = null; + setIsUpdatingProposal(false); + }, []); + + useEffect(() => () => { + if (proposeTimeout.current) { + clearTimeout(proposeTimeout.current); + proposeTimeout.current = null; + } + lastProposal.current = null; + }, []); + + const clearProposal = useCallback(() => { + setValid(false); + setIsUpdatingProposal(false); + setProposedAmount(undefined); + setProposedFee(undefined); + setProposedTotal(undefined); + setRecipientDisplayAddress(''); + }, []); + + const handleProposal = useCallback(( + updateFiat: boolean, + result: accountApi.TTxProposalResult, + ) => { + setValid(result.success); + if (result.success) { + setErrorHandling({}); + setProposedFee(result.fee); + setProposedAmount(result.amount); + setProposedTotal(result.total); + setRecipientDisplayAddress(result.recipientDisplayAddress); + setIsUpdatingProposal(false); + if (updateFiat) { + onProposedAmount?.(result.amount.amount); + } + return; + } + + const nextErrorHandling = txProposalErrorHandling(result.errorCode); + setErrorHandling(nextErrorHandling); + setIsUpdatingProposal(false); + + if (clearFeeOnError(nextErrorHandling)) { + setProposedFee(undefined); + } + setRecipientDisplayAddress(''); + }, [clearFeeOnError, onProposedAmount, setErrorHandling]); + + const validateAndDisplayFee = useCallback(( + updateFiat: boolean = true, + ) => { + cancelPendingProposal(); + setProposedTotal(undefined); + setErrorHandling({}); + const txInput = getValidTxInputData(); + if (!txInput || !accountCode) { + if (clearOnInvalidInput) { + clearProposal(); + } + return; + } + setIsUpdatingProposal(true); + proposeTimeout.current = setTimeout(async () => { + let proposePromise: Promise | null = null; + try { + proposePromise = accountApi.proposeTx(accountCode, txInput); + lastProposal.current = proposePromise; + const result = await proposePromise; + if (proposePromise === lastProposal.current) { + handleProposal(updateFiat, result); + } + } catch (error) { + if (proposePromise === lastProposal.current) { + setValid(false); + setIsUpdatingProposal(false); + console.error('Failed to propose transaction:', error); + } + } finally { + if (proposePromise === lastProposal.current) { + lastProposal.current = null; + setIsUpdatingProposal(false); + } + } + }, 400); + }, [ + accountCode, + cancelPendingProposal, + clearOnInvalidInput, + clearProposal, + getValidTxInputData, + handleProposal, + setErrorHandling, + ]); + + return { + cancelPendingProposal, + clearProposal, + isUpdatingProposal, + proposedAmount, + proposedFee, + proposedTotal, + recipientDisplayAddress, + setRecipientDisplayAddress, + valid, + validateAndDisplayFee, + }; +}; From b00fa3479e241ae046a3de5325c67e9e6cdd07a1 Mon Sep 17 00:00:00 2001 From: Nikolas De Giorgis Date: Thu, 21 May 2026 10:56:37 +0100 Subject: [PATCH 2/5] send: make result actions optional Lightning top up reuses the send result component but does not offer a follow-up transaction action. Rendering the secondary button without an onContinue handler creates a visible no-op control. Only render the secondary action when a caller provides onContinue, while leaving the normal send result behavior unchanged. --- .../routes/account/send/components/result.tsx | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/frontends/web/src/routes/account/send/components/result.tsx b/frontends/web/src/routes/account/send/components/result.tsx index 9879677170..e3bdbbb5af 100644 --- a/frontends/web/src/routes/account/send/components/result.tsx +++ b/frontends/web/src/routes/account/send/components/result.tsx @@ -12,9 +12,12 @@ import { CopyableInput } from '@/components/copy/Copy'; type TProps = { children?: ReactNode; code: AccountCode; - onContinue: () => void; + doneRoute?: string; + onContinue?: () => void; onRetry: () => void; result: TSendTx | undefined; + showSuccessActions?: boolean; + successMessage?: string; }; /** @@ -25,12 +28,16 @@ type TProps = { export const SendResult = ({ children, code, - result, + doneRoute, onContinue, + result, onRetry, + showSuccessActions = true, + successMessage, }: TProps) => { const { t } = useTranslation(); const navigate = useNavigate(); + const donePath = doneRoute || `/account/${code}`; if (!result) { return null; @@ -47,7 +54,7 @@ export const SendResult = ({

- - - + {showSuccessActions && ( + + + {onContinue && ( + + )} + + )} ); }; From d2ba7b71cbec0859410b25545c1a8c8178980bf7 Mon Sep 17 00:00:00 2001 From: Nikolas De Giorgis Date: Thu, 21 May 2026 10:56:43 +0100 Subject: [PATCH 3/5] lightning: add top up state hooks The top up screen needs Lightning-specific data and Bitcoin account selection before it can build a transaction proposal. Keeping this in the route mixed data loading with screen flow. Move boarding address loading, Lightning balance loading, source account selection, and draft state into focused hooks. The hooks keep the route small while still using the existing send proposal API. --- .../web/src/routes/lightning/top-up-draft.ts | 217 ++++++++++++++++++ .../web/src/routes/lightning/top-up-hooks.ts | 114 +++++++++ 2 files changed, 331 insertions(+) create mode 100644 frontends/web/src/routes/lightning/top-up-draft.ts create mode 100644 frontends/web/src/routes/lightning/top-up-hooks.ts diff --git a/frontends/web/src/routes/lightning/top-up-draft.ts b/frontends/web/src/routes/lightning/top-up-draft.ts new file mode 100644 index 0000000000..24713c857d --- /dev/null +++ b/frontends/web/src/routes/lightning/top-up-draft.ts @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import * as accountApi from '@/api/account'; +import { convertFromCurrency, convertToCurrency, type BtcUnit } from '@/api/coins'; +import { usePrevious } from '@/hooks/previous'; +import { isBitcoinOnly } from '@/routes/account/utils'; +import { TProposalError } from '@/routes/account/send/services'; +import { useTxProposal } from '@/routes/account/send/use-tx-proposal'; + +type TUseTopUpDraftProps = { + boardingAddress?: string; + btcUnit?: BtcUnit; + defaultCurrency: accountApi.Fiat; + sourceAccount?: accountApi.TAccount; +}; + +export const useTopUpDraft = ({ + boardingAddress, + btcUnit, + defaultCurrency, + sourceAccount, +}: TUseTopUpDraftProps) => { + const { t } = useTranslation(); + const prevDefaultCurrency = usePrevious(defaultCurrency); + const prevBtcUnit = usePrevious(btcUnit); + + const [amount, setAmount] = useState(''); + const [fiatAmount, setFiatAmount] = useState(''); + const [customFee, setCustomFee] = useState(''); + const [errorHandling, setErrorHandling] = useState({}); + const [feeTarget, setFeeTarget] = useState(); + const [updateFiat, setUpdateFiat] = useState(true); + + const convertToFiat = useCallback(async (amount: string) => { + if (!sourceAccount) { + return; + } + if (amount) { + const data = await convertToCurrency({ + amount, + coinCode: sourceAccount.coinCode, + fiatUnit: defaultCurrency, + }); + if (data.success) { + setFiatAmount(data.fiatAmount); + } else { + setErrorHandling({ + amountError: t('send.error.invalidAmount') + }); + } + } else { + setFiatAmount(''); + } + }, [defaultCurrency, sourceAccount, t]); + + const convertFromFiat = useCallback(async (amount: string) => { + if (!sourceAccount) { + return; + } + if (amount) { + const data = await convertFromCurrency({ + amount, + coinCode: sourceAccount.coinCode, + fiatUnit: defaultCurrency, + }); + if (data.success) { + setAmount(data.amount); + setUpdateFiat(false); + } else { + setErrorHandling({ amountError: t('send.error.invalidAmount') }); + } + } else { + setAmount(''); + } + }, [defaultCurrency, sourceAccount, t]); + + const getValidTxInputData = useCallback((): Required | false => { + if ( + !sourceAccount + || !boardingAddress + || feeTarget === undefined + || !amount + || (feeTarget === 'custom' && !customFee) + ) { + return false; + } + return { + address: boardingAddress, + amount, + feeTarget, + customFee, + sendAll: 'no', + selectedUTXOs: [], + paymentRequest: null, + useHighestFee: false + }; + }, [amount, boardingAddress, customFee, feeTarget, sourceAccount]); + + const { + cancelPendingProposal, + clearProposal, + isUpdatingProposal, + proposedAmount, + proposedFee, + proposedTotal, + recipientDisplayAddress, + valid, + validateAndDisplayFee, + } = useTxProposal({ + accountCode: sourceAccount?.code, + clearOnInvalidInput: true, + getValidTxInputData, + onProposedAmount: convertToFiat, + setErrorHandling, + }); + + useEffect(() => { + validateAndDisplayFee(updateFiat); + }, [amount, customFee, feeTarget, updateFiat, validateAndDisplayFee]); + + useEffect(() => { + if (!sourceAccount) { + return; + } + + const currencyChanged = prevDefaultCurrency !== undefined && prevDefaultCurrency !== defaultCurrency; + const btcUnitChanged = prevBtcUnit !== undefined && prevBtcUnit !== btcUnit; + + if (!currencyChanged && !btcUnitChanged) { + return; + } + + if (btcUnitChanged && isBitcoinOnly(sourceAccount.coinCode) && amount) { + const fiatUnit = prevBtcUnit === 'default' ? 'BTC' : 'sat'; + convertFromCurrency({ + amount, + coinCode: sourceAccount.coinCode, + fiatUnit + }).then((data) => { + if (data.success) { + setAmount(data.amount); + setUpdateFiat(false); + } else { + setErrorHandling({ amountError: t('send.error.invalidAmount') }); + } + }).catch(() => { + setErrorHandling({ amountError: t('send.error.invalidAmount') }); + }); + return; + } + + if (currencyChanged) { + convertToFiat(amount); + } + }, [ + amount, + btcUnit, + convertToFiat, + defaultCurrency, + prevBtcUnit, + prevDefaultCurrency, + sourceAccount, + t, + ]); + + const handleCoinAmountChange = (amount: string) => { + setAmount(amount); + convertToFiat(amount); + setUpdateFiat(true); + }; + + const handleFiatInput = (fiatAmount: string) => { + setFiatAmount(fiatAmount); + convertFromFiat(fiatAmount); + }; + + const handleFeeTargetChange = (feeTarget: accountApi.FeeTargetCode) => { + setFeeTarget(feeTarget); + setCustomFee(''); + setUpdateFiat(true); + }; + + const handleCustomFee = (customFee: string) => { + setCustomFee(customFee); + setUpdateFiat(false); + }; + + const resetProposal = () => { + cancelPendingProposal(); + setCustomFee(''); + setFeeTarget(undefined); + clearProposal(); + setUpdateFiat(true); + }; + + return { + amount, + customFee, + errorHandling, + feeTarget, + fiatAmount, + getValidTxInputData, + handleCoinAmountChange, + handleCustomFee, + handleFeeTargetChange, + handleFiatInput, + isUpdatingProposal, + proposedAmount, + proposedFee, + proposedTotal, + recipientDisplayAddress, + resetProposal, + sendDisabled: !getValidTxInputData() || !valid || isUpdatingProposal, + }; +}; diff --git a/frontends/web/src/routes/lightning/top-up-hooks.ts b/frontends/web/src/routes/lightning/top-up-hooks.ts new file mode 100644 index 0000000000..cf78c745db --- /dev/null +++ b/frontends/web/src/routes/lightning/top-up-hooks.ts @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useMemo, useState } from 'react'; +import * as accountApi from '@/api/account'; +import type { TLightningAccount } from '@/api/lightning'; +import { getBoardingAddress, getLightningBalance } from '@/api/lightning'; +import { useLightning } from '@/hooks/lightning'; +import { useMountedRef } from '@/hooks/mount'; +import { findAccount } from '@/routes/account/utils'; + +type TUseTopUpSourceAccountProps = { + activeAccounts: accountApi.TAccount[]; +}; + +const isBitcoinSourceAccount = (account: accountApi.TAccount) => ( + account.coinCode === 'btc' && !account.isToken +); + +const preferredAccountForLightning = ( + accounts: accountApi.TAccount[], + lightningAccount: TLightningAccount | null, +) => { + if (!lightningAccount) { + return accounts[0]; + } + return accounts.find(account => account.keystore.rootFingerprint === lightningAccount.rootFingerprint) + || accounts[0]; +}; + +export const getTopUpSourceAccounts = (activeAccounts: accountApi.TAccount[]) => ( + activeAccounts.filter(isBitcoinSourceAccount) +); + +export const getTopUpAccountToConnect = ( + accounts: accountApi.TAccount[], + activeAccounts: accountApi.TAccount[], + lightningAccount: TLightningAccount | null, +) => { + const bitcoinAccounts = accounts.filter(isBitcoinSourceAccount); + const sourceAccounts = getTopUpSourceAccounts(activeAccounts); + + return preferredAccountForLightning(sourceAccounts, lightningAccount) + || preferredAccountForLightning(bitcoinAccounts, lightningAccount); +}; + +export const useBoardingAddress = () => { + const mounted = useMountedRef(); + const [boardingAddress, setBoardingAddress] = useState(); + const [boardingAddressError, setBoardingAddressError] = useState(); + + useEffect(() => { + getBoardingAddress() + .then((address) => { + if (mounted.current) { + setBoardingAddress(address); + } + }) + .catch((err: any) => { + if (mounted.current) { + setBoardingAddressError(err?.message || err?.errorMessage || String(err)); + } + }); + }, [mounted]); + + return { boardingAddress, boardingAddressError }; +}; + +export const useLightningBalance = () => { + const mounted = useMountedRef(); + const [balance, setBalance] = useState(); + + useEffect(() => { + getLightningBalance() + .then((nextBalance) => { + if (mounted.current) { + setBalance(nextBalance); + } + }) + .catch((err) => console.error('Failed to fetch lightning balance', err)); + }, [mounted]); + + return balance; +}; + +export const useTopUpSourceAccount = ({ + activeAccounts, +}: TUseTopUpSourceAccountProps) => { + const { lightningAccount } = useLightning(); + + const sourceAccounts = useMemo(() => ( + getTopUpSourceAccounts(activeAccounts) + ), [activeAccounts]); + + const preferredAccount = useMemo(() => { + return preferredAccountForLightning(sourceAccounts, lightningAccount); + }, [lightningAccount, sourceAccounts]); + + const [sourceAccountCode, setSourceAccountCode] = useState(preferredAccount?.code); + const sourceAccount = sourceAccountCode ? findAccount(sourceAccounts, sourceAccountCode) : undefined; + + useEffect(() => { + if (sourceAccountCode && sourceAccounts.some(account => account.code === sourceAccountCode)) { + return; + } + setSourceAccountCode(preferredAccount?.code); + }, [preferredAccount?.code, sourceAccountCode, sourceAccounts]); + + return { + setSourceAccountCode, + sourceAccount, + sourceAccountCode, + sourceAccounts, + }; +}; From 1288b55e06fc9d0e61177b343161c1040444a498 Mon Sep 17 00:00:00 2001 From: Nikolas De Giorgis Date: Thu, 21 May 2026 10:56:53 +0100 Subject: [PATCH 4/5] lightning: add top up form The top up UI is separate from route orchestration so the flow can be reviewed in smaller pieces. It still needs the familiar send controls because top up is an on-chain Bitcoin send to a Spark boarding address. Build the form from existing send components for fiat input, fee targets, confirmation, and send result. This keeps the approval path consistent with regular Bitcoin sends. --- .../web/src/routes/lightning/top-up-form.tsx | 237 ++++++++++++++++++ .../src/routes/lightning/top-up.module.css | 51 ++++ 2 files changed, 288 insertions(+) create mode 100644 frontends/web/src/routes/lightning/top-up-form.tsx create mode 100644 frontends/web/src/routes/lightning/top-up.module.css diff --git a/frontends/web/src/routes/lightning/top-up-form.tsx b/frontends/web/src/routes/lightning/top-up-form.tsx new file mode 100644 index 0000000000..80a4a6a2e2 --- /dev/null +++ b/frontends/web/src/routes/lightning/top-up-form.tsx @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useTranslation } from 'react-i18next'; +import * as accountApi from '@/api/account'; +import { AmountWithUnit } from '@/components/amount/amount-with-unit'; +import { BackButton } from '@/components/backbutton/backbutton'; +import { Button } from '@/components/forms'; +import { NumberInput } from '@/components/forms/input-number'; +import { GroupedAccountSelector } from '@/components/groupedaccountselector/groupedaccountselector'; +import { Logo } from '@/components/icon/logo'; +import { Column, ColumnButtons, GuideWrapper, GuidedContent, Header, Main, ResponsiveGrid } from '@/components/layout'; +import { Message } from '@/components/message/message'; +import { View, ViewButtons, ViewContent } from '@/components/view/view'; +import { ConfirmSend } from '@/routes/account/send/components/confirm/confirm'; +import { FiatInput } from '@/routes/account/send/components/inputs/fiat-input'; +import { NoteInput } from '@/routes/account/send/components/inputs/note-input'; +import { SendResult } from '@/routes/account/send/components/result'; +import { FeeTargets } from '@/routes/account/send/feetargets'; +import { TProposalError } from '@/routes/account/send/services'; +import styles from './top-up.module.css'; + +type TProps = { + amount: string; + customFee: string; + defaultCurrency: accountApi.Fiat; + displayedCoinUnit?: string; + errorHandling: TProposalError; + feeTarget?: accountApi.FeeTargetCode; + fiatAmount: string; + isConfirming: boolean; + isUpdatingProposal: boolean; + lightningBalance?: accountApi.TBalance; + note: string; + onBack: () => void; + onCoinAmountChange: (amount: string) => void; + onCustomFee: (customFee: string) => void; + onFeeTargetChange: (feeTarget: accountApi.FeeTargetCode) => void; + onFiatChange: (fiatAmount: string) => void; + onRetry: () => void; + onSend: () => void; + onSourceAccountChange: (code: string) => void; + onNoteChange: (note: string) => void; + proposedAmount?: accountApi.TAmountWithConversions; + proposedFee?: accountApi.TAmountWithConversions; + proposedTotal?: accountApi.TAmountWithConversions; + recipientDisplayAddress: string; + sendDisabled: boolean; + sendResult?: accountApi.TSendTx; + sourceAccount?: accountApi.TAccount; + sourceAccountCode?: accountApi.AccountCode; + sourceAccounts: accountApi.TAccount[]; +}; + +type TEmptyStateProps = { + onBack: () => void; + onManageAccounts: () => void; +}; + +export const TopUpEmptyState = ({ onBack, onManageAccounts }: TEmptyStateProps) => { + const { t } = useTranslation(); + return ( + + +
+
{t('lightning.topUp.title')}} /> + + + + {t('lightning.topUp.noActiveSourceAccount')} + + + + + {t('button.back')} + + + + +
+
+
+ ); +}; + +export const TopUpForm = ({ + amount, + customFee, + defaultCurrency, + displayedCoinUnit, + errorHandling, + feeTarget, + fiatAmount, + isConfirming, + isUpdatingProposal, + lightningBalance, + note, + onBack, + onCoinAmountChange, + onCustomFee, + onFeeTargetChange, + onFiatChange, + onRetry, + onSend, + onSourceAccountChange, + onNoteChange, + proposedAmount, + proposedFee, + proposedTotal, + recipientDisplayAddress, + sendDisabled, + sendResult, + sourceAccount, + sourceAccountCode, + sourceAccounts, +}: TProps) => { + const { t } = useTranslation(); + + return ( + + + + +
+ + +
+
+ +
+ +
+ + {t('lightning.accountLabel')} + + {lightningBalance !== undefined ? ( + + ) : null} + +
+
+
+ + + + + + + + {sourceAccount && ( + + )} + + + + + + + {t('button.back')} + + + +
+
+ + {sourceAccount && ( + + )} +
+ ); +}; diff --git a/frontends/web/src/routes/lightning/top-up.module.css b/frontends/web/src/routes/lightning/top-up.module.css new file mode 100644 index 0000000000..0c2aa1335a --- /dev/null +++ b/frontends/web/src/routes/lightning/top-up.module.css @@ -0,0 +1,51 @@ +.form { + margin: 0 auto; + max-width: 540px; + width: 100%; +} + +.field { + margin-bottom: var(--space-half); + width: 100%; +} + +.field > label { + display: block; + margin-bottom: var(--space-quarter); +} + +.assetField { + align-items: center; + background-color: var(--background-secondary); + border: 1px solid var(--background-quaternary); + border-radius: var(--radius-xl); + color: var(--color-default); + display: flex; + gap: var(--space-quarter); + min-height: var(--input-height); + padding: 0 var(--space-half); +} + +.assetField img { + height: 20px; + width: 20px; +} + +.assetLabel { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.assetBalance { + color: var(--color-secondary); + font-variant-numeric: tabular-nums; + margin-left: auto; + text-transform: uppercase; + white-space: nowrap; +} + +.buttons { + margin-top: var(--space-large); +} From 98efa1faf6c88cb9d5b23177e0764c6e1a3ff9c9 Mon Sep 17 00:00:00 2001 From: Nikolas De Giorgis Date: Thu, 21 May 2026 10:57:01 +0100 Subject: [PATCH 5/5] lightning: wire top up flow Users need a way to fund their Lightning wallet from an active Bitcoin account in the app. The flow should not send them to add an account when a BitBox is disconnected or an existing account only needs to be enabled. Add the route and action entry point, connect the BitBox from the explicit top up action, prefer the Bitcoin account tied to Lightning, and send users to account management when no active Bitcoin account is available. --- backend/handlers/handlers.go | 36 +++++ backend/lightning_topup.go | 137 ++++++++++++++++ backend/lightning_topup_test.go | 120 ++++++++++++++ frontends/web/src/api/lightning.ts | 21 ++- frontends/web/src/locales/en/app.json | 9 ++ .../src/routes/account/send/services.test.ts | 22 ++- .../web/src/routes/account/send/services.ts | 11 ++ .../routes/account/send/use-tx-proposal.ts | 27 +++- .../components/action-buttons.module.css | 12 +- .../components/action-buttons.test.tsx | 89 ++++++++++ .../lightning/components/action-buttons.tsx | 26 ++- .../web/src/routes/lightning/lightning.tsx | 3 - .../web/src/routes/lightning/top-up-draft.ts | 3 +- .../web/src/routes/lightning/top-up-form.tsx | 5 +- .../web/src/routes/lightning/top-up-hooks.ts | 86 ++++------ frontends/web/src/routes/lightning/top-up.tsx | 153 ++++++++++++++++++ frontends/web/src/routes/router.tsx | 4 + 17 files changed, 686 insertions(+), 78 deletions(-) create mode 100644 backend/lightning_topup.go create mode 100644 backend/lightning_topup_test.go create mode 100644 frontends/web/src/routes/lightning/components/action-buttons.test.tsx create mode 100644 frontends/web/src/routes/lightning/top-up.tsx diff --git a/backend/handlers/handlers.go b/backend/handlers/handlers.go index 50ec52b3ac..b408053eda 100644 --- a/backend/handlers/handlers.go +++ b/backend/handlers/handlers.go @@ -70,6 +70,7 @@ type Backend interface { PrepareSwap(buyAccountCode, sellAccountCode accountsTypes.Code, routeID, sellAmount string) (*backend.SwapPreparation, error) SwapAccounts() (backend.SwapAccounts, error) SwapStatus() backend.SwapStatus + LightningTopUpInfo() (backend.LightningTopUpInfo, error) AccountsByKeystore() (backend.KeystoresAccountsListMap, error) AccountsFiatAndCoinBalance(backend.AccountsList, string) (*big.Rat, map[coinpkg.Code]*big.Int, error) Keystore() keystore.Keystore @@ -277,6 +278,7 @@ func NewHandlers( getAPIRouterNoError(apiRouter)("/online", handlers.getOnline).Methods("GET") getAPIRouterNoError(apiRouter)("/keystore/show-backup-banner/{rootFingerprint}", handlers.getKeystoreShowBackupBanner).Methods("GET") + getAPIRouterNoError(apiRouter)("/lightning/top-up/info", handlers.getLightningTopUpInfo).Methods("GET") lightning.NewHandlers( getAPIRouterNoError(apiRouter.PathPrefix("/lightning").Subrouter()), @@ -840,6 +842,40 @@ func (handlers *Handlers) getSwapStatus(*http.Request) interface{} { return handlers.backend.SwapStatus() } +func (handlers *Handlers) getLightningTopUpInfo(*http.Request) interface{} { + type response struct { + Success bool `json:"success"` + ErrorMessage string `json:"errorMessage,omitempty"` + SourceAccounts []accountBaseJSON `json:"sourceAccounts"` + DefaultSourceAccountCode *accountsTypes.Code `json:"defaultSourceAccountCode,omitempty"` + AccountToConnectRootFingerprint jsonp.HexBytes `json:"accountToConnectRootFingerprint,omitempty"` + } + + topUpInfo, err := handlers.backend.LightningTopUpInfo() + if err != nil { + return response{ + Success: false, + ErrorMessage: err.Error(), + } + } + + result := response{ + Success: true, + SourceAccounts: make([]accountBaseJSON, len(topUpInfo.SourceAccounts)), + DefaultSourceAccountCode: topUpInfo.DefaultSourceAccountCode, + AccountToConnectRootFingerprint: topUpInfo.AccountToConnectRootFingerprint, + } + for i, account := range topUpInfo.SourceAccounts { + result.SourceAccounts[i] = newAccountBaseJSON( + account.Keystore, + account.AccountConfig, + account.AccountCoin, + account.KeystoreConnected, + ) + } + return result +} + func (handlers *Handlers) lookupEthAccountCode(r *http.Request) interface{} { var args struct { Address string `json:"address"` diff --git a/backend/lightning_topup.go b/backend/lightning_topup.go new file mode 100644 index 0000000000..3e2842a2e8 --- /dev/null +++ b/backend/lightning_topup.go @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import ( + "bytes" + + accountsTypes "github.com/BitBoxSwiss/bitbox-wallet-app/backend/accounts/types" + coinpkg "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/coin" + "github.com/BitBoxSwiss/bitbox-wallet-app/backend/config" +) + +// LightningTopUpInfo contains the source account choices needed by the Lightning top-up screen. +type LightningTopUpInfo struct { + // SourceAccounts are active, loaded BTC accounts that can fund a top-up. + SourceAccounts []LightningTopUpSourceAccount + DefaultSourceAccountCode *accountsTypes.Code + // AccountToConnectRootFingerprint is set when a matching BTC account exists in the config, + // but is not loaded because its BitBox is not connected. + AccountToConnectRootFingerprint []byte +} + +// LightningTopUpSourceAccount contains the base account data needed by the frontend account selector. +type LightningTopUpSourceAccount struct { + Keystore config.Keystore + KeystoreConnected bool + AccountConfig *config.Account + AccountCoin coinpkg.Coin +} + +// LightningTopUpInfo returns active BTC accounts that can source a Lightning top-up. +func (backend *Backend) LightningTopUpInfo() (LightningTopUpInfo, error) { + sourceAccounts, err := backend.lightningTopUpSourceAccounts() + if err != nil { + return LightningTopUpInfo{}, err + } + + lightningAccount := backend.lightning.Account() + result := LightningTopUpInfo{ + SourceAccounts: sourceAccounts, + DefaultSourceAccountCode: lightningTopUpDefaultSourceAccount(sourceAccounts, lightningAccount), + } + + if len(sourceAccounts) == 0 && backend.Keystore() == nil && lightningAccount != nil && + backend.hasConfiguredLightningTopUpSourceAccount(lightningAccount.RootFingerprint) { + // Let the frontend open the shared connect prompt immediately. If no matching + // configured BTC account exists, the user needs account management instead. + result.AccountToConnectRootFingerprint = append([]byte(nil), lightningAccount.RootFingerprint...) + } + + return result, nil +} + +func (backend *Backend) lightningTopUpSourceAccounts() ([]LightningTopUpSourceAccount, error) { + connectedKeystore, err := backend.connectedKeystoreConfig() + if err != nil { + return nil, err + } + + persistedAccounts := backend.config.AccountsConfig() + sourceAccounts := []LightningTopUpSourceAccount{} + for _, account := range backend.Accounts() { + // Use loaded accounts for the actual selector. Disconnected non-watch accounts are not + // loaded, while inactive accounts should stay unavailable until enabled by the user. + accountConfig := account.Config().Config + if !isLightningTopUpSourceAccount(accountConfig) { + continue + } + + rootFingerprint, err := accountConfig.SigningConfigurations.RootFingerprint() + if err != nil { + backend.log.WithField("code", accountConfig.Code).Error("could not identify root fingerprint") + continue + } + keystore, err := persistedAccounts.LookupKeystore(rootFingerprint) + if err != nil { + backend.log.WithField("code", accountConfig.Code).Error("could not find keystore of account") + continue + } + + keystoreConnected := connectedKeystore != nil && + bytes.Equal(rootFingerprint, connectedKeystore.RootFingerprint) + sourceAccounts = append(sourceAccounts, LightningTopUpSourceAccount{ + Keystore: *keystore, + KeystoreConnected: keystoreConnected, + AccountConfig: accountConfig, + AccountCoin: account.Coin(), + }) + } + return sourceAccounts, nil +} + +func isLightningTopUpSourceAccount(account *config.Account) bool { + // Top-up is a regular on-chain BTC send to the Lightning boarding address. + // Inactive accounts are omitted so the UI can direct users to Manage accounts. + return account.CoinCode == coinpkg.CodeBTC && + !account.HiddenBecauseUnused && + !account.Inactive +} + +func lightningTopUpDefaultSourceAccount( + sourceAccounts []LightningTopUpSourceAccount, + lightningAccount *config.LightningAccountConfig, +) *accountsTypes.Code { + if len(sourceAccounts) == 0 { + return nil + } + if lightningAccount != nil { + // Prefer the BTC account from the BitBox that created the Lightning account. + for _, account := range sourceAccounts { + rootFingerprint, err := account.AccountConfig.SigningConfigurations.RootFingerprint() + if err != nil { + continue + } + if bytes.Equal(rootFingerprint, lightningAccount.RootFingerprint) { + code := account.AccountConfig.Code + return &code + } + } + } + code := sourceAccounts[0].AccountConfig.Code + return &code +} + +func (backend *Backend) hasConfiguredLightningTopUpSourceAccount(rootFingerprint []byte) bool { + // This checks persisted config, not loaded accounts, so an unplugged BitBox can still + // produce a connect prompt when it has an active BTC account configured. + for _, account := range backend.config.AccountsConfig().Accounts { + if !isLightningTopUpSourceAccount(account) { + continue + } + if account.SigningConfigurations.ContainsRootFingerprint(rootFingerprint) { + return true + } + } + return false +} diff --git a/backend/lightning_topup_test.go b/backend/lightning_topup_test.go new file mode 100644 index 0000000000..8bdfaabebc --- /dev/null +++ b/backend/lightning_topup_test.go @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: Apache-2.0 + +package backend + +import ( + "testing" + + accountsTypes "github.com/BitBoxSwiss/bitbox-wallet-app/backend/accounts/types" + "github.com/BitBoxSwiss/bitbox-wallet-app/backend/config" + "github.com/stretchr/testify/require" +) + +func setTestLightningAccount(t *testing.T, b *Backend, rootFingerprint []byte) { + t.Helper() + require.NoError(t, b.Lightning().SetAccount(&config.LightningAccountConfig{ + Mnemonic: "test mnemonic", + RootFingerprint: append([]byte(nil), rootFingerprint...), + Code: accountsTypes.Code("v0-lightning-ln-0"), + Number: 0, + })) +} + +func lightningTopUpSourceAccountCodes(accounts []LightningTopUpSourceAccount) []accountsTypes.Code { + codes := make([]accountsTypes.Code, 0, len(accounts)) + for _, account := range accounts { + codes = append(codes, account.AccountConfig.Code) + } + return codes +} + +func TestLightningTopUpInfoDisconnectedBitBoxReturnsConnectFingerprint(t *testing.T) { + b := newBackend(t, testnetDisabled, regtestDisabled) + defer b.Close() + + ks := makeBitBox02Multi() + ks.RootFingerprintFunc = func() ([]byte, error) { + return rootFingerprint1, nil + } + b.registerKeystore(ks) + setTestLightningAccount(t, b, rootFingerprint1) + b.DeregisterKeystore() + + topUpInfo, err := b.LightningTopUpInfo() + require.NoError(t, err) + require.Empty(t, topUpInfo.SourceAccounts) + require.Nil(t, topUpInfo.DefaultSourceAccountCode) + require.Equal(t, rootFingerprint1, topUpInfo.AccountToConnectRootFingerprint) +} + +func TestLightningTopUpInfoConnectedBitBoxReturnsActiveBTCSourceAccount(t *testing.T) { + b := newBackend(t, testnetDisabled, regtestDisabled) + defer b.Close() + + ks := makeBitBox02Multi() + ks.RootFingerprintFunc = func() ([]byte, error) { + return rootFingerprint1, nil + } + b.registerKeystore(ks) + setTestLightningAccount(t, b, rootFingerprint1) + + topUpInfo, err := b.LightningTopUpInfo() + require.NoError(t, err) + require.Equal(t, []accountsTypes.Code{"v0-55555555-btc-0"}, lightningTopUpSourceAccountCodes(topUpInfo.SourceAccounts)) + require.NotNil(t, topUpInfo.DefaultSourceAccountCode) + require.Equal(t, accountsTypes.Code("v0-55555555-btc-0"), *topUpInfo.DefaultSourceAccountCode) + require.Empty(t, topUpInfo.AccountToConnectRootFingerprint) + require.True(t, topUpInfo.SourceAccounts[0].KeystoreConnected) +} + +func TestLightningTopUpInfoOmitsInactiveBTCAccountWithoutConnectTargetWhenConnected(t *testing.T) { + b := newBackend(t, testnetDisabled, regtestDisabled) + defer b.Close() + + ks := makeBitBox02Multi() + ks.RootFingerprintFunc = func() ([]byte, error) { + return rootFingerprint1, nil + } + b.registerKeystore(ks) + setTestLightningAccount(t, b, rootFingerprint1) + + btcAccountCode := accountsTypes.Code("v0-55555555-btc-0") + require.NoError(t, b.SetAccountActive(btcAccountCode, false)) + + topUpInfo, err := b.LightningTopUpInfo() + require.NoError(t, err) + require.Empty(t, topUpInfo.SourceAccounts) + require.Nil(t, topUpInfo.DefaultSourceAccountCode) + require.Empty(t, topUpInfo.AccountToConnectRootFingerprint) +} + +func TestLightningTopUpInfoDefaultPrefersLightningRootFingerprint(t *testing.T) { + b := newBackend(t, testnetDisabled, regtestDisabled) + defer b.Close() + + ks1 := makeBitBox02Multi() + ks1.RootFingerprintFunc = func() ([]byte, error) { + return rootFingerprint1, nil + } + b.registerKeystore(ks1) + setTestLightningAccount(t, b, rootFingerprint1) + require.NoError(t, b.SetWatchonly(rootFingerprint1, true)) + b.DeregisterKeystore() + + ks2Helper := keystoreHelper2() + ks2 := makeBitBox02Multi() + ks2.RootFingerprintFunc = func() ([]byte, error) { + return rootFingerprint2, nil + } + ks2.ExtendedPublicKeyFunc = ks2Helper.ExtendedPublicKey + ks2.BTCXPubsFunc = ks2Helper.BTCXPubs + b.registerKeystore(ks2) + + topUpInfo, err := b.LightningTopUpInfo() + require.NoError(t, err) + require.Contains(t, lightningTopUpSourceAccountCodes(topUpInfo.SourceAccounts), accountsTypes.Code("v0-55555555-btc-0")) + require.Contains(t, lightningTopUpSourceAccountCodes(topUpInfo.SourceAccounts), accountsTypes.Code("v0-66666666-btc-0")) + require.NotNil(t, topUpInfo.DefaultSourceAccountCode) + require.Equal(t, accountsTypes.Code("v0-55555555-btc-0"), *topUpInfo.DefaultSourceAccountCode) + require.Empty(t, topUpInfo.AccountToConnectRootFingerprint) +} diff --git a/frontends/web/src/api/lightning.ts b/frontends/web/src/api/lightning.ts index c73379ec41..0b49ad226f 100644 --- a/frontends/web/src/api/lightning.ts +++ b/frontends/web/src/api/lightning.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { apiGet, apiPost } from '../utils/request'; -import { AccountCode, TAmountWithConversions, TBalance, TTransactionStatus } from './account'; +import { AccountCode, TAccountBase, TAmountWithConversions, TBalance, TTransactionStatus } from './account'; import { TSubscriptionCallback, TUnsubscribe, subscribeEndpoint } from './subscribe'; export type TLightningResponse = @@ -21,6 +21,21 @@ export type TLightningAccount = { num: number; }; +export type TTopUpSourceAccount = TAccountBase & { + coinCode: 'btc'; + isToken: false; +}; + +export type TTopUpInfo = { + success: true; + sourceAccounts: TTopUpSourceAccount[]; + defaultSourceAccountCode?: AccountCode; + accountToConnectRootFingerprint?: string; +} | { + success: false; + errorMessage: string; +}; + export type TLightningInvoice = { bolt11: string; description?: string; @@ -150,6 +165,10 @@ export const getBoardingAddress = async (): Promise => { return getApiResponse('lightning/boarding-address', 'Error calling getBoardingAddress'); }; +export const getTopUpInfo = async (): Promise => { + return apiGet('lightning/top-up/info'); +}; + export const postPreparePayment = async (data: TPreparePaymentRequest): Promise => { return postApiResponse( 'lightning/prepare-payment', diff --git a/frontends/web/src/locales/en/app.json b/frontends/web/src/locales/en/app.json index 5837bc5fbe..c6f64ae169 100644 --- a/frontends/web/src/locales/en/app.json +++ b/frontends/web/src/locales/en/app.json @@ -1644,6 +1644,15 @@ "message": "Transaction confirmed and sent!" }, "title": "Send Lightning" + }, + "topUp": { + "button": "Top up", + "from": "From", + "noActiveSourceAccount": "You need at least one active Bitcoin account to top up your lightning wallet.", + "success": { + "message": "Top up created!" + }, + "title": "Top up lightning wallet" } }, "loading": "loading…", diff --git a/frontends/web/src/routes/account/send/services.test.ts b/frontends/web/src/routes/account/send/services.test.ts index 4e879d07ca..2a8abcd84a 100644 --- a/frontends/web/src/routes/account/send/services.test.ts +++ b/frontends/web/src/routes/account/send/services.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { i18n as interfacei18n } from 'i18next'; -import { txProposalErrorHandling } from './services'; +import { txProposalErrorHandling, txProposalExceptionHandling } from './services'; import { alertUser } from '@/components/alert/Alert'; vi.mock('i18next', async () => { @@ -33,12 +33,12 @@ describe('send services', () => { it('returns invalid amount message on invalidAmount error', () => { const result = txProposalErrorHandling('invalidAmount'); - expect(result).toEqual({ amountError: 'send.error.invalidAmount', proposedFee: undefined }); + expect(result).toEqual({ amountError: 'send.error.invalidAmount' }); }); it('returns insufficient funds message on insufficientFunds error', () => { const result = txProposalErrorHandling('insufficientFunds'); - expect(result).toEqual({ amountError: 'send.error.insufficientFunds', proposedFee: undefined }); + expect(result).toEqual({ amountError: 'send.error.insufficientFunds' }); }); it('returns fee too low message on feeTooLow error', () => { @@ -53,9 +53,23 @@ describe('send services', () => { it('returns proposed fee undefined and alerts the user when error is unknown', () => { const result = txProposalErrorHandling('unknownError'); - expect(result).toEqual({ proposedFee: undefined }); + expect(result).toEqual({}); expect(alertUser).toHaveBeenCalledWith('unknownError'); }); }); + + describe('txProposalExceptionHandling', () => { + + it('returns thrown error message as fee error', () => { + const result = txProposalExceptionHandling(new Error('proposal failed')); + expect(result).toEqual({ feeError: 'proposal failed' }); + }); + + it('falls back to generic error for unknown thrown values', () => { + const result = txProposalExceptionHandling({}); + expect(result).toEqual({ feeError: 'genericError' }); + }); + + }); }); diff --git a/frontends/web/src/routes/account/send/services.ts b/frontends/web/src/routes/account/send/services.ts index 6f880a940e..f09aa42f0b 100644 --- a/frontends/web/src/routes/account/send/services.ts +++ b/frontends/web/src/routes/account/send/services.ts @@ -27,3 +27,14 @@ export const txProposalErrorHandling = (errorCode?: string): TProposalError => { return {}; } }; + +export const txProposalExceptionHandling = (error: unknown): TProposalError => { + const { t } = i18n; + if (error instanceof Error && error.message) { + return { feeError: error.message }; + } + if (typeof error === 'string' && error) { + return { feeError: error }; + } + return { feeError: t('genericError') }; +}; diff --git a/frontends/web/src/routes/account/send/use-tx-proposal.ts b/frontends/web/src/routes/account/send/use-tx-proposal.ts index 9aa21da4c4..72ef3e6719 100644 --- a/frontends/web/src/routes/account/send/use-tx-proposal.ts +++ b/frontends/web/src/routes/account/send/use-tx-proposal.ts @@ -3,7 +3,7 @@ import type { Dispatch, SetStateAction } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import * as accountApi from '@/api/account'; -import { TProposalError, txProposalErrorHandling } from './services'; +import { TProposalError, txProposalErrorHandling, txProposalExceptionHandling } from './services'; type TUseTxProposalProps = { accountCode?: accountApi.AccountCode; @@ -26,6 +26,8 @@ export const useTxProposal = ({ }: TUseTxProposalProps) => { const lastProposal = useRef | null>(null); const proposeTimeout = useRef | null>(null); + const accountCodeRef = useRef(accountCode); + const getValidTxInputDataRef = useRef(getValidTxInputData); const [valid, setValid] = useState(false); const [isUpdatingProposal, setIsUpdatingProposal] = useState(false); @@ -34,6 +36,9 @@ export const useTxProposal = ({ const [proposedAmount, setProposedAmount] = useState(); const [recipientDisplayAddress, setRecipientDisplayAddress] = useState(''); + accountCodeRef.current = accountCode; + getValidTxInputDataRef.current = getValidTxInputData; + const cancelPendingProposal = useCallback(() => { if (proposeTimeout.current) { clearTimeout(proposeTimeout.current); @@ -94,8 +99,8 @@ export const useTxProposal = ({ cancelPendingProposal(); setProposedTotal(undefined); setErrorHandling({}); - const txInput = getValidTxInputData(); - if (!txInput || !accountCode) { + const txInput = getValidTxInputDataRef.current(); + if (!txInput || !accountCodeRef.current) { if (clearOnInvalidInput) { clearProposal(); } @@ -105,7 +110,15 @@ export const useTxProposal = ({ proposeTimeout.current = setTimeout(async () => { let proposePromise: Promise | null = null; try { - proposePromise = accountApi.proposeTx(accountCode, txInput); + const latestAccountCode = accountCodeRef.current; + const latestTxInput = getValidTxInputDataRef.current(); + if (!latestTxInput || !latestAccountCode) { + if (clearOnInvalidInput) { + clearProposal(); + } + return; + } + proposePromise = accountApi.proposeTx(latestAccountCode, latestTxInput); lastProposal.current = proposePromise; const result = await proposePromise; if (proposePromise === lastProposal.current) { @@ -113,8 +126,8 @@ export const useTxProposal = ({ } } catch (error) { if (proposePromise === lastProposal.current) { - setValid(false); - setIsUpdatingProposal(false); + setErrorHandling(txProposalExceptionHandling(error)); + clearProposal(); console.error('Failed to propose transaction:', error); } } finally { @@ -125,11 +138,9 @@ export const useTxProposal = ({ } }, 400); }, [ - accountCode, cancelPendingProposal, clearOnInvalidInput, clearProposal, - getValidTxInputData, handleProposal, setErrorHandling, ]); diff --git a/frontends/web/src/routes/lightning/components/action-buttons.module.css b/frontends/web/src/routes/lightning/components/action-buttons.module.css index 3afb0ab96b..b037d6ed57 100644 --- a/frontends/web/src/routes/lightning/components/action-buttons.module.css +++ b/frontends/web/src/routes/lightning/components/action-buttons.module.css @@ -10,7 +10,8 @@ } .receive, -.send { +.send, +.topUp { background-color: var(--color-blue); border-radius: 2px; color: var(--color-alt); @@ -28,13 +29,15 @@ } .receive, -.send { +.send, +.topUp { margin-left: var(--space-quarter); } .buy:hover, .receive:hover, -.send:not(.disabled):hover { +.send:not(.disabled):hover, +.topUp:hover { background-color: var(--color-lightblue); } @@ -45,7 +48,8 @@ @media (max-width: 768px) { .send, - .receive { + .receive, + .topUp { font-size: var(--size-small); } } diff --git a/frontends/web/src/routes/lightning/components/action-buttons.test.tsx b/frontends/web/src/routes/lightning/components/action-buttons.test.tsx new file mode 100644 index 0000000000..2019bc2776 --- /dev/null +++ b/frontends/web/src/routes/lightning/components/action-buttons.test.tsx @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 + +import '../../../../__mocks__/i18n'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/i18n/i18n'); +vi.mock('@/api/lightning', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getTopUpInfo: vi.fn(), + }; +}); +vi.mock('@/api/keystores', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + connectKeystore: vi.fn(), + }; +}); + +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import * as lightningApi from '@/api/lightning'; +import * as keystoresApi from '@/api/keystores'; +import { ActionButtons } from './action-buttons'; + +const Location = () => { + const location = useLocation(); + return
{location.pathname}
; +}; + +const renderActionButtons = () => render( + + + + + + + )} /> + } /> + + +); + +describe('routes/lightning/components/action-buttons', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + vi.mocked(keystoresApi.connectKeystore).mockResolvedValue({ success: true }); + }); + + it('connects backend-provided top-up fingerprint before navigation', async () => { + vi.mocked(lightningApi.getTopUpInfo).mockResolvedValue({ + success: true, + sourceAccounts: [], + accountToConnectRootFingerprint: 'f23ab988', + }); + const user = userEvent.setup(); + + renderActionButtons(); + await user.click(screen.getByRole('link', { name: /top/i })); + + await waitFor(() => { + expect(lightningApi.getTopUpInfo).toHaveBeenCalledTimes(1); + expect(keystoresApi.connectKeystore).toHaveBeenCalledWith('f23ab988'); + expect(screen.getByTestId('location')).toHaveTextContent('/lightning/top-up'); + }); + }); + + it('navigates directly when backend has no connect target', async () => { + vi.mocked(lightningApi.getTopUpInfo).mockResolvedValue({ + success: true, + sourceAccounts: [], + }); + const user = userEvent.setup(); + + renderActionButtons(); + await user.click(screen.getByRole('link', { name: /top/i })); + + await waitFor(() => { + expect(lightningApi.getTopUpInfo).toHaveBeenCalledTimes(1); + expect(keystoresApi.connectKeystore).not.toHaveBeenCalled(); + expect(screen.getByTestId('location')).toHaveTextContent('/lightning/top-up'); + }); + }); +}); diff --git a/frontends/web/src/routes/lightning/components/action-buttons.tsx b/frontends/web/src/routes/lightning/components/action-buttons.tsx index 669b3d6298..f6ca5b0f26 100644 --- a/frontends/web/src/routes/lightning/components/action-buttons.tsx +++ b/frontends/web/src/routes/lightning/components/action-buttons.tsx @@ -1,7 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; +import type { MouseEvent } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { connectKeystore } from '@/api/keystores'; +import { getTopUpInfo } from '@/api/lightning'; import style from './action-buttons.module.css'; type TProps = { @@ -10,6 +13,24 @@ type TProps = { export const ActionButtons = ({ canSend }: TProps) => { const { t } = useTranslation(); + const navigate = useNavigate(); + + const handleTopUpClick = async (event: MouseEvent) => { + event.preventDefault(); + try { + const topUpInfo = await getTopUpInfo(); + if (topUpInfo.success && topUpInfo.accountToConnectRootFingerprint) { + const connectResult = await connectKeystore(topUpInfo.accountToConnectRootFingerprint); + if (!connectResult.success) { + return; + } + } + } catch (err) { + console.error('Failed to fetch Lightning top-up info', err); + } + navigate('/lightning/top-up'); + }; + return (
{canSend ? ( @@ -24,6 +45,9 @@ export const ActionButtons = ({ canSend }: TProps) => { {t('generic.receiveWithoutCoinCode')} + + {t('lightning.topUp.button')} +
); }; diff --git a/frontends/web/src/routes/lightning/lightning.tsx b/frontends/web/src/routes/lightning/lightning.tsx index 4f03aa6b4b..635e46da45 100644 --- a/frontends/web/src/routes/lightning/lightning.tsx +++ b/frontends/web/src/routes/lightning/lightning.tsx @@ -9,7 +9,6 @@ import { getLightningBalance, getListPayments, subscribeListPayments, - getBoardingAddress, } from '../../api/lightning'; import { Balance } from '../../components/balance/balance'; import { ContentWrapper } from '@/components/contentwrapper/contentwrapper'; @@ -37,7 +36,6 @@ export const Lightning = () => { const [error, setError] = useState(); const [detailID, setDetailID] = useState(null); const devices = useLoad(getDeviceList); - const boardingAddress = useLoad(getBoardingAddress); const onStateChange = useCallback(async () => { try { @@ -113,7 +111,6 @@ export const Lightning = () => { - { <>Boarding address: {boardingAddress ?? ''} } {offlineErrorTextLines.length || !hasDataLoaded ? ( ) : ( diff --git a/frontends/web/src/routes/lightning/top-up-draft.ts b/frontends/web/src/routes/lightning/top-up-draft.ts index 24713c857d..b8b7a26add 100644 --- a/frontends/web/src/routes/lightning/top-up-draft.ts +++ b/frontends/web/src/routes/lightning/top-up-draft.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import * as accountApi from '@/api/account'; import { convertFromCurrency, convertToCurrency, type BtcUnit } from '@/api/coins'; +import type { TTopUpSourceAccount } from '@/api/lightning'; import { usePrevious } from '@/hooks/previous'; import { isBitcoinOnly } from '@/routes/account/utils'; import { TProposalError } from '@/routes/account/send/services'; @@ -13,7 +14,7 @@ type TUseTopUpDraftProps = { boardingAddress?: string; btcUnit?: BtcUnit; defaultCurrency: accountApi.Fiat; - sourceAccount?: accountApi.TAccount; + sourceAccount?: TTopUpSourceAccount; }; export const useTopUpDraft = ({ diff --git a/frontends/web/src/routes/lightning/top-up-form.tsx b/frontends/web/src/routes/lightning/top-up-form.tsx index 80a4a6a2e2..e2ffc74834 100644 --- a/frontends/web/src/routes/lightning/top-up-form.tsx +++ b/frontends/web/src/routes/lightning/top-up-form.tsx @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'; import * as accountApi from '@/api/account'; +import type { TTopUpSourceAccount } from '@/api/lightning'; import { AmountWithUnit } from '@/components/amount/amount-with-unit'; import { BackButton } from '@/components/backbutton/backbutton'; import { Button } from '@/components/forms'; @@ -46,9 +47,9 @@ type TProps = { recipientDisplayAddress: string; sendDisabled: boolean; sendResult?: accountApi.TSendTx; - sourceAccount?: accountApi.TAccount; + sourceAccount?: TTopUpSourceAccount; sourceAccountCode?: accountApi.AccountCode; - sourceAccounts: accountApi.TAccount[]; + sourceAccounts: TTopUpSourceAccount[]; }; type TEmptyStateProps = { diff --git a/frontends/web/src/routes/lightning/top-up-hooks.ts b/frontends/web/src/routes/lightning/top-up-hooks.ts index cf78c745db..90749f5a95 100644 --- a/frontends/web/src/routes/lightning/top-up-hooks.ts +++ b/frontends/web/src/routes/lightning/top-up-hooks.ts @@ -1,48 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import * as accountApi from '@/api/account'; -import type { TLightningAccount } from '@/api/lightning'; -import { getBoardingAddress, getLightningBalance } from '@/api/lightning'; -import { useLightning } from '@/hooks/lightning'; +import { getBoardingAddress, getLightningBalance, getTopUpInfo, type TTopUpInfo } from '@/api/lightning'; import { useMountedRef } from '@/hooks/mount'; import { findAccount } from '@/routes/account/utils'; -type TUseTopUpSourceAccountProps = { - activeAccounts: accountApi.TAccount[]; -}; - -const isBitcoinSourceAccount = (account: accountApi.TAccount) => ( - account.coinCode === 'btc' && !account.isToken -); - -const preferredAccountForLightning = ( - accounts: accountApi.TAccount[], - lightningAccount: TLightningAccount | null, -) => { - if (!lightningAccount) { - return accounts[0]; - } - return accounts.find(account => account.keystore.rootFingerprint === lightningAccount.rootFingerprint) - || accounts[0]; -}; - -export const getTopUpSourceAccounts = (activeAccounts: accountApi.TAccount[]) => ( - activeAccounts.filter(isBitcoinSourceAccount) -); - -export const getTopUpAccountToConnect = ( - accounts: accountApi.TAccount[], - activeAccounts: accountApi.TAccount[], - lightningAccount: TLightningAccount | null, -) => { - const bitcoinAccounts = accounts.filter(isBitcoinSourceAccount); - const sourceAccounts = getTopUpSourceAccounts(activeAccounts); - - return preferredAccountForLightning(sourceAccounts, lightningAccount) - || preferredAccountForLightning(bitcoinAccounts, lightningAccount); -}; - export const useBoardingAddress = () => { const mounted = useMountedRef(); const [boardingAddress, setBoardingAddress] = useState(); @@ -82,33 +45,48 @@ export const useLightningBalance = () => { return balance; }; -export const useTopUpSourceAccount = ({ - activeAccounts, -}: TUseTopUpSourceAccountProps) => { - const { lightningAccount } = useLightning(); - - const sourceAccounts = useMemo(() => ( - getTopUpSourceAccounts(activeAccounts) - ), [activeAccounts]); +export const useTopUpSourceAccount = () => { + const mounted = useMountedRef(); + const [topUpInfo, setTopUpInfo] = useState(); + const [topUpInfoError, setTopUpInfoError] = useState(); + const [sourceAccountCode, setSourceAccountCode] = useState(); - const preferredAccount = useMemo(() => { - return preferredAccountForLightning(sourceAccounts, lightningAccount); - }, [lightningAccount, sourceAccounts]); + useEffect(() => { + getTopUpInfo() + .then((info) => { + if (mounted.current) { + setTopUpInfo(info); + if (!info.success) { + setTopUpInfoError(info.errorMessage); + } + } + }) + .catch((err: any) => { + if (mounted.current) { + setTopUpInfoError(err?.message || err?.errorMessage || String(err)); + } + }); + }, [mounted]); - const [sourceAccountCode, setSourceAccountCode] = useState(preferredAccount?.code); - const sourceAccount = sourceAccountCode ? findAccount(sourceAccounts, sourceAccountCode) : undefined; + const sourceAccounts = topUpInfo?.success ? topUpInfo.sourceAccounts : undefined; + const defaultSourceAccountCode = topUpInfo?.success ? topUpInfo.defaultSourceAccountCode : undefined; + const sourceAccount = sourceAccountCode && sourceAccounts ? findAccount(sourceAccounts, sourceAccountCode) : undefined; useEffect(() => { + if (!sourceAccounts) { + return; + } if (sourceAccountCode && sourceAccounts.some(account => account.code === sourceAccountCode)) { return; } - setSourceAccountCode(preferredAccount?.code); - }, [preferredAccount?.code, sourceAccountCode, sourceAccounts]); + setSourceAccountCode(defaultSourceAccountCode); + }, [defaultSourceAccountCode, sourceAccountCode, sourceAccounts]); return { setSourceAccountCode, sourceAccount, sourceAccountCode, sourceAccounts, + topUpInfoError, }; }; diff --git a/frontends/web/src/routes/lightning/top-up.tsx b/frontends/web/src/routes/lightning/top-up.tsx new file mode 100644 index 0000000000..6fc4a0ab6f --- /dev/null +++ b/frontends/web/src/routes/lightning/top-up.tsx @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useCallback, useContext, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import * as accountApi from '@/api/account'; +import { connectKeystore } from '@/api/keystores'; +import { GuideWrapper, GuidedContent, Header, Main } from '@/components/layout'; +import { Spinner } from '@/components/spinner/Spinner'; +import { View, ViewHeader } from '@/components/view/view'; +import { RatesContext } from '@/contexts/RatesContext'; +import { getDisplayedCoinUnit } from '@/routes/account/utils'; +import { useTopUpDraft } from './top-up-draft'; +import { TopUpEmptyState, TopUpForm } from './top-up-form'; +import { useBoardingAddress, useLightningBalance, useTopUpSourceAccount } from './top-up-hooks'; + +export const TopUp = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { btcUnit, defaultCurrency } = useContext(RatesContext); + const { boardingAddress, boardingAddressError } = useBoardingAddress(); + const lightningBalance = useLightningBalance(); + const { + setSourceAccountCode, + sourceAccount, + sourceAccountCode, + sourceAccounts, + topUpInfoError, + } = useTopUpSourceAccount(); + + const [isConfirming, setIsConfirming] = useState(false); + const [note, setNote] = useState(''); + const [sendResult, setSendResult] = useState(); + const draft = useTopUpDraft({ + boardingAddress, + btcUnit, + defaultCurrency, + sourceAccount, + }); + + useEffect(() => { + if (!sendResult?.success) { + return undefined; + } + const timeout = window.setTimeout(() => navigate('/lightning'), 1000); + return () => window.clearTimeout(timeout); + }, [navigate, sendResult]); + + const handleRetry = () => { + setSendResult(undefined); + }; + + const handleSourceAccountChange = (code: string) => { + setSourceAccountCode(code); + draft.resetProposal(); + }; + + const handleSend = useCallback(async () => { + if (!sourceAccount) { + return; + } + const connectResult = await connectKeystore(sourceAccount.keystore.rootFingerprint); + if (!connectResult.success) { + return; + } + setIsConfirming(true); + try { + setSendResult(await accountApi.sendTx(sourceAccount.code, note)); + } catch (err) { + console.error(err); + } finally { + setIsConfirming(false); + } + }, [note, sourceAccount]); + + if (topUpInfoError) { + return ( + + + + ); + } + + if (sourceAccounts === undefined) { + return ; + } + + if (sourceAccounts.length === 0) { + return ( + navigate('/lightning')} + onManageAccounts={() => navigate('/settings/manage-accounts')} + /> + ); + } + + if (boardingAddressError) { + return ( + + + + ); + } + + if (!boardingAddress) { + return ; + } + + const displayedCoinUnit = sourceAccount + ? getDisplayedCoinUnit(sourceAccount.coinCode, sourceAccount.coinUnit, btcUnit) + : undefined; + + return ( + + +
+
{t('lightning.topUp.title')}} /> + navigate('/lightning')} + onCoinAmountChange={draft.handleCoinAmountChange} + onCustomFee={draft.handleCustomFee} + onFeeTargetChange={draft.handleFeeTargetChange} + onFiatChange={draft.handleFiatInput} + onNoteChange={setNote} + onRetry={handleRetry} + onSend={handleSend} + onSourceAccountChange={handleSourceAccountChange} + proposedAmount={draft.proposedAmount} + proposedFee={draft.proposedFee} + proposedTotal={draft.proposedTotal} + recipientDisplayAddress={draft.recipientDisplayAddress} + sendDisabled={draft.sendDisabled} + sendResult={sendResult} + sourceAccount={sourceAccount} + sourceAccountCode={sourceAccountCode} + sourceAccounts={sourceAccounts} + /> +
+
+
+ ); +}; diff --git a/frontends/web/src/routes/router.tsx b/frontends/web/src/routes/router.tsx index e78142368a..2f03471c0e 100644 --- a/frontends/web/src/routes/router.tsx +++ b/frontends/web/src/routes/router.tsx @@ -47,6 +47,7 @@ import { LightningActivate } from './lightning/activate'; import { LightningDeactivate } from './lightning/deactivate'; import { Send as LightningSend } from './lightning/send/send'; import { Receive as LightningReceive } from './lightning/receive/receive'; +import { TopUp as LightningTopUp } from './lightning/top-up'; type TAppRouterProps = { devices: TDevices; @@ -268,6 +269,8 @@ export const AppRouter = ({ devices, devicesKey, accounts, activeAccounts }: TAp const AllAccountsEl = ; + const LightningTopUpEl = ; + return ( @@ -313,6 +316,7 @@ export const AppRouter = ({ devices, devicesKey, accounts, activeAccounts }: TAp } /> } /> } /> +