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/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 && ( + + )} + + )} ); }; 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/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 new file mode 100644 index 0000000000..72ef3e6719 --- /dev/null +++ b/frontends/web/src/routes/account/send/use-tx-proposal.ts @@ -0,0 +1,160 @@ +// 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, txProposalExceptionHandling } 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 accountCodeRef = useRef(accountCode); + const getValidTxInputDataRef = useRef(getValidTxInputData); + + 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(''); + + accountCodeRef.current = accountCode; + getValidTxInputDataRef.current = getValidTxInputData; + + 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 = getValidTxInputDataRef.current(); + if (!txInput || !accountCodeRef.current) { + if (clearOnInvalidInput) { + clearProposal(); + } + return; + } + setIsUpdatingProposal(true); + proposeTimeout.current = setTimeout(async () => { + let proposePromise: Promise | null = null; + try { + 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) { + handleProposal(updateFiat, result); + } + } catch (error) { + if (proposePromise === lastProposal.current) { + setErrorHandling(txProposalExceptionHandling(error)); + clearProposal(); + console.error('Failed to propose transaction:', error); + } + } finally { + if (proposePromise === lastProposal.current) { + lastProposal.current = null; + setIsUpdatingProposal(false); + } + } + }, 400); + }, [ + cancelPendingProposal, + clearOnInvalidInput, + clearProposal, + handleProposal, + setErrorHandling, + ]); + + return { + cancelPendingProposal, + clearProposal, + isUpdatingProposal, + proposedAmount, + proposedFee, + proposedTotal, + recipientDisplayAddress, + setRecipientDisplayAddress, + valid, + validateAndDisplayFee, + }; +}; 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 new file mode 100644 index 0000000000..b8b7a26add --- /dev/null +++ b/frontends/web/src/routes/lightning/top-up-draft.ts @@ -0,0 +1,218 @@ +// 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 type { TTopUpSourceAccount } from '@/api/lightning'; +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?: TTopUpSourceAccount; +}; + +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-form.tsx b/frontends/web/src/routes/lightning/top-up-form.tsx new file mode 100644 index 0000000000..e2ffc74834 --- /dev/null +++ b/frontends/web/src/routes/lightning/top-up-form.tsx @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: Apache-2.0 + +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'; +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?: TTopUpSourceAccount; + sourceAccountCode?: accountApi.AccountCode; + sourceAccounts: TTopUpSourceAccount[]; +}; + +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-hooks.ts b/frontends/web/src/routes/lightning/top-up-hooks.ts new file mode 100644 index 0000000000..90749f5a95 --- /dev/null +++ b/frontends/web/src/routes/lightning/top-up-hooks.ts @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useState } from 'react'; +import * as accountApi from '@/api/account'; +import { getBoardingAddress, getLightningBalance, getTopUpInfo, type TTopUpInfo } from '@/api/lightning'; +import { useMountedRef } from '@/hooks/mount'; +import { findAccount } from '@/routes/account/utils'; + +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 = () => { + const mounted = useMountedRef(); + const [topUpInfo, setTopUpInfo] = useState(); + const [topUpInfoError, setTopUpInfoError] = useState(); + const [sourceAccountCode, setSourceAccountCode] = useState(); + + 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 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(defaultSourceAccountCode); + }, [defaultSourceAccountCode, sourceAccountCode, sourceAccounts]); + + return { + setSourceAccountCode, + sourceAccount, + sourceAccountCode, + sourceAccounts, + topUpInfoError, + }; +}; 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); +} 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 } /> } /> } /> +