From 9449856a96cf1d1c661a581e1be306dedaacf0e7 Mon Sep 17 00:00:00 2001 From: thisconnect Date: Mon, 11 May 2026 07:29:14 +0200 Subject: [PATCH 1/4] frontend: add useaccountsynced hook Fixed BTC Direct widget loading forever if accounts are not fully synced yet. How to test: 1) unlock device 2) stop make servewallet-mainnet 3) rm -rf appfolder.dev/cache/account-v0-* 4) rm appfolder.dev/accounts.json 5) make servewallet-mainnet 6) navigate to Market / BTC Direct 7) iframe should load after account is synced --- frontends/web/src/hooks/accounts.ts | 36 +++++++++++++++++++ frontends/web/src/routes/market/btcdirect.tsx | 4 ++- 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 frontends/web/src/hooks/accounts.ts diff --git a/frontends/web/src/hooks/accounts.ts b/frontends/web/src/hooks/accounts.ts new file mode 100644 index 0000000000..6418c1d61d --- /dev/null +++ b/frontends/web/src/hooks/accounts.ts @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { syncdone } from '@/api/accountsync'; +import { useMountedRef } from './mount'; + +export const useAccountSynced = ( + code: string, + apiCall: () => Promise, +): T | undefined => { + const isMounted = useMountedRef(); + const apiRequestId = useRef(0); + const [result, setResult] = useState(undefined); + + const callApi = useCallback(async () => { + const requestId = ++apiRequestId.current; + try { + const response = await apiCall(); + if ( + isMounted.current + && requestId === apiRequestId.current + ) { + setResult(response); + } + } catch (error) { + console.error(error); + } + }, [apiCall, isMounted]); + + useEffect(() => { + callApi(); + return syncdone(code, callApi); + }, [code, callApi]); + + return result; +}; diff --git a/frontends/web/src/routes/market/btcdirect.tsx b/frontends/web/src/routes/market/btcdirect.tsx index 7e33d7e174..c974700b24 100644 --- a/frontends/web/src/routes/market/btcdirect.tsx +++ b/frontends/web/src/routes/market/btcdirect.tsx @@ -8,6 +8,7 @@ import { parseExternalBtcAmount } from '@/api/coins'; import { AppContext } from '@/contexts/AppContext'; import { AccountCode, TAccount, proposeTx, sendTx, TTxInput } from '@/api/account'; import { useLoad } from '@/hooks/api'; +import { useAccountSynced } from '@/hooks/accounts'; import { useDarkmode } from '@/hooks/darkmode'; import { UseDisableBackButton } from '@/hooks/backbutton'; import { getConfig } from '@/utils/config'; @@ -48,7 +49,8 @@ export const BTCDirect = ({ const { isDarkMode } = useDarkmode(); const navigate = useNavigate(); - const btcdirectInfo = useLoad(() => getBTCDirectInfo(action, code)); + const fetchBTCDirectInfo = useCallback(() => getBTCDirectInfo(action, code), [action, code]); + const btcdirectInfo = useAccountSynced(code, fetchBTCDirectInfo); const [blocking, setBlocking] = useState(false); From 38b8e48ee8708485a1264f14cd427c637f4487ec Mon Sep 17 00:00:00 2001 From: thisconnect Date: Mon, 11 May 2026 08:25:08 +0200 Subject: [PATCH 2/4] frontend: show syncing accounts message in btcdirect In case the account is still syncing show a 'syncing account' message instead of just 'loading' to give the user better feedback. Ensure that the account is properly synced and call BTC Direct info once it is synced. This fixes a bug where BTC Direct hangs with loading spinner in case the account was not fully synced. --- frontends/web/src/api/market.ts | 2 +- frontends/web/src/components/aopp/aopp.tsx | 2 +- frontends/web/src/hooks/account.test.ts | 149 ++++++++++++++++++ .../web/src/hooks/{accounts.ts => account.ts} | 4 +- frontends/web/src/locales/en/app.json | 2 +- frontends/web/src/routes/market/btcdirect.tsx | 12 +- 6 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 frontends/web/src/hooks/account.test.ts rename frontends/web/src/hooks/{accounts.ts => account.ts} (90%) diff --git a/frontends/web/src/api/market.ts b/frontends/web/src/api/market.ts index 96831b2e70..cd92398166 100644 --- a/frontends/web/src/api/market.ts +++ b/frontends/web/src/api/market.ts @@ -87,7 +87,7 @@ export type TBTCDirectInfoResponse = { address?: string; } | { success: false; - errorMessage: string; + errorMessage: string; // TODO: add 'syncInProgress' }; export const getBTCDirectInfo = async (action: TMarketAction, code: string): Promise => { diff --git a/frontends/web/src/components/aopp/aopp.tsx b/frontends/web/src/components/aopp/aopp.tsx index 5b2fc002a9..86da56ab4e 100644 --- a/frontends/web/src/components/aopp/aopp.tsx +++ b/frontends/web/src/components/aopp/aopp.tsx @@ -164,7 +164,7 @@ export const Aopp = () => { -

{t('aopp.syncing')}

+

{t('account.syncing')}

diff --git a/frontends/web/src/hooks/account.test.ts b/frontends/web/src/hooks/account.test.ts new file mode 100644 index 0000000000..81d6dd0f44 --- /dev/null +++ b/frontends/web/src/hooks/account.test.ts @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { useState } from 'react'; +import { useAccountSynced } from './account'; +import { syncdone } from '@/api/accountsync'; +import * as utils from './mount'; + +vi.mock('@/api/accountsync', () => ({ + syncdone: vi.fn(() => () => {}), +})); + +const mockSyncdone = vi.mocked(syncdone); + +const useMountedRefSpy = vi.spyOn(utils, 'useMountedRef'); + +describe('useAccountSynced', () => { + beforeEach(() => { + useMountedRefSpy.mockReturnValue({ current: true }); + vi.clearAllMocks(); + }); + + it('returns undefined while loading', () => { + const mockApiCall = vi.fn().mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => ( + useAccountSynced('account-code-1' as any, mockApiCall) + )); + + expect(result.current).toBeUndefined(); + }); + + it('returns api result on success', async () => { + const mockApiCall = vi.fn().mockResolvedValue('result'); + + const { result } = renderHook(() => ( + useAccountSynced('account-code-1' as any, mockApiCall) + )); + + await waitFor(() => expect(result.current).toBe('result')); + + expect(mockApiCall).toHaveBeenCalledTimes(1); + expect(mockSyncdone).toHaveBeenCalledWith( + 'account-code-1', + expect.any(Function), + ); + }); + + it('clears previous result when code changes', async () => { + let resolveApiCall: ((value: string) => void) | undefined; + + const mockApiCall = vi.fn().mockImplementation(() => ( + new Promise((resolve) => { + resolveApiCall = resolve; + }) + )); + + const { result } = renderHook(() => { + const [code, setCode] = useState('account-code-1'); + + const value = useAccountSynced( + code as any, + mockApiCall, + ); + + return { + value, + setCode, + }; + }); + + act(() => { + resolveApiCall?.('result-1'); + }); + + await waitFor(() => { + expect(result.current.value).toBe('result-1'); + }); + + act(() => { + result.current.setCode('account-code-2'); + }); + + expect(result.current.value).toBeUndefined(); + + act(() => { + resolveApiCall?.('result-2'); + }); + + await waitFor(() => { + expect(result.current.value).toBe('result-2'); + }); + + expect(mockApiCall).toHaveBeenCalledTimes(2); + }); + + it('ignores stale api responses', async () => { + let resolveFirst: ((value: string) => void) | undefined; + let resolveSecond: ((value: string) => void) | undefined; + + const mockApiCall = vi + .fn() + .mockImplementationOnce(() => ( + new Promise((resolve) => { + resolveFirst = resolve; + }) + )) + .mockImplementationOnce(() => ( + new Promise((resolve) => { + resolveSecond = resolve; + }) + )); + + const { result } = renderHook(() => { + const [code, setCode] = useState('account-code-1'); + + const value = useAccountSynced( + code as any, + mockApiCall, + ); + + return { + value, + setCode, + }; + }); + + act(() => { + result.current.setCode('account-code-2'); + }); + + act(() => { + resolveFirst?.('stale-result'); + }); + + expect(result.current.value).toBeUndefined(); + + act(() => { + resolveSecond?.('fresh-result'); + }); + + await waitFor(() => { + expect(result.current.value).toBe('fresh-result'); + }); + + expect(result.current.value).not.toBe('stale-result'); + }); +}); diff --git a/frontends/web/src/hooks/accounts.ts b/frontends/web/src/hooks/account.ts similarity index 90% rename from frontends/web/src/hooks/accounts.ts rename to frontends/web/src/hooks/account.ts index 6418c1d61d..52b2af101f 100644 --- a/frontends/web/src/hooks/accounts.ts +++ b/frontends/web/src/hooks/account.ts @@ -1,11 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 import { useCallback, useEffect, useRef, useState } from 'react'; +import type { AccountCode } from '@/api/account'; import { syncdone } from '@/api/accountsync'; import { useMountedRef } from './mount'; export const useAccountSynced = ( - code: string, + code: AccountCode, apiCall: () => Promise, ): T | undefined => { const isMounted = useMountedRef(); @@ -28,6 +29,7 @@ export const useAccountSynced = ( }, [apiCall, isMounted]); useEffect(() => { + setResult(undefined); callApi(); return syncdone(code, callApi); }, [code, callApi]); diff --git a/frontends/web/src/locales/en/app.json b/frontends/web/src/locales/en/app.json index 655c4f8e3e..25f1f5bacb 100644 --- a/frontends/web/src/locales/en/app.json +++ b/frontends/web/src/locales/en/app.json @@ -14,6 +14,7 @@ "maybeProxyError": "Tor proxy enabled. Ensure that your Tor proxy is running properly, or disable the proxy setting.", "reconnecting": "Lost connection, trying to reconnect…", "syncedAddressesCount": "Scanned {{count}} addresses", + "syncing": "Syncing the account, please wait…", "uncoveredFunds": "You have coins on the following uncovered address types of your {{name}} account: {{uncovered}}.\nSince the account is insured, only coins received via the Native Segwit address type are covered. Coins on different address types, even if they are on the same account, are not insured.\nPlease move all your coins from the unsupported address types to the Native Segwit address type, so all your coins on this account are insured.", "uncoveredFundsLink": "Follow this guide on how to move your coins.", "warning": "Warning!" @@ -122,7 +123,6 @@ "message": "Proceed on {{host}}", "title": "Address successfully sent" }, - "syncing": "Syncing the account, please wait.", "title": "Address request", "xpubRequested": "Sharing your xpub lets external services see your account addresses. Your coins are still safe and remain in your control." }, diff --git a/frontends/web/src/routes/market/btcdirect.tsx b/frontends/web/src/routes/market/btcdirect.tsx index c974700b24..17faa42508 100644 --- a/frontends/web/src/routes/market/btcdirect.tsx +++ b/frontends/web/src/routes/market/btcdirect.tsx @@ -8,7 +8,7 @@ import { parseExternalBtcAmount } from '@/api/coins'; import { AppContext } from '@/contexts/AppContext'; import { AccountCode, TAccount, proposeTx, sendTx, TTxInput } from '@/api/account'; import { useLoad } from '@/hooks/api'; -import { useAccountSynced } from '@/hooks/accounts'; +import { useAccountSynced } from '@/hooks/account'; import { useDarkmode } from '@/hooks/darkmode'; import { UseDisableBackButton } from '@/hooks/backbutton'; import { getConfig } from '@/utils/config'; @@ -211,6 +211,8 @@ export const BTCDirect = ({ t('generic.sell', { context: translationContext }) ); + const syncInProgress = !btcdirectInfo?.success && btcdirectInfo?.errorMessage === 'syncInProgress'; + return (
@@ -232,7 +234,13 @@ export const BTCDirect = ({ ) : (
- {!iframeLoaded && } + {!iframeLoaded && ( + syncInProgress ? ( + + ) : ( + + ) + )} {blocking && (
)} From 3c7fa1124876c09fe84fee41ff8b82ec1711f08f Mon Sep 17 00:00:00 2001 From: thisconnect Date: Wed, 13 May 2026 15:30:49 +0200 Subject: [PATCH 3/4] frontend: ensure account is synced before loading pocket widget --- frontends/web/src/routes/market/pocket.tsx | 59 ++++++++++++---------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/frontends/web/src/routes/market/pocket.tsx b/frontends/web/src/routes/market/pocket.tsx index 455cc41145..4c64140512 100644 --- a/frontends/web/src/routes/market/pocket.tsx +++ b/frontends/web/src/routes/market/pocket.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { RequestAddressV0Message, MessageVersion, parseMessage, serializeMessage, V0MessageType, PaymentRequestV0Message } from 'request-address'; @@ -22,6 +22,8 @@ import { convertScriptType } from '@/utils/request-addess'; import { parseExternalBtcAmount } from '@/api/coins'; import { FirmwareUpgradeRequiredDialog } from '@/components/dialog/firmware-upgrade-required-dialog'; import { useVendorIframeResizeHeight, useVendorTerms } from '@/hooks/vendor-iframe'; +import { useAccountSynced } from '@/hooks/account'; +import { Message } from '@/components/message/message'; import style from './iframe.module.css'; type TProps = { @@ -43,7 +45,6 @@ export const Pocket = ({ const [blocking, setBlocking] = useState(false); const [verifying, setVerifying] = useState(false); - const [iframeURL, setIframeUrl] = useState(''); const config = useLoad(getConfig); const accountInfo = useLoad(getInfo(code)); @@ -51,15 +52,7 @@ export const Pocket = ({ const { agreedTerms, setAgreedTerms } = useVendorTerms(!!config?.frontend?.skipPocketDisclaimer); const signingRef = useRef(false); - useEffect(() => { - getPocketURL(action).then(response => { - if (response.success) { - setIframeUrl(response.url); - } else { - alertUser(t('unknownError', { errorMessage: response.errorMessage })); - } - }); - }, [action, t]); + const pocketInfo = useAccountSynced(code, useCallback(() => getPocketURL(action), [action])); useEffect(() => { // enable paymentRequestError only when the action is sell. @@ -263,11 +256,11 @@ export const Pocket = ({ }; const onMessage = (m: MessageEvent) => { - if (!iframeURL || !code) { + if (!pocketInfo?.success || !code) { return; } // verify the origin of the received message - if (m.origin !== new URL(iframeURL).origin) { + if (m.origin !== new URL(pocketInfo.url).origin) { return; } @@ -323,23 +316,35 @@ export const Pocket = ({ ) : (
- {!iframeLoaded && } + {!iframeLoaded && ( + + )} {blocking && (
)} - + { pocketInfo?.success ? ( + + ) : ( + pocketInfo?.success === false && ( + + {pocketInfo?.errorMessage + ? pocketInfo.errorMessage + : t('genericError')} + + ) + )}
)} Date: Wed, 13 May 2026 15:46:23 +0200 Subject: [PATCH 4/4] frontend: ensure account is synced before loading bitrefill widget --- frontends/web/src/routes/market/bitrefill.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/frontends/web/src/routes/market/bitrefill.tsx b/frontends/web/src/routes/market/bitrefill.tsx index e92ec3b098..48cc26a64b 100644 --- a/frontends/web/src/routes/market/bitrefill.tsx +++ b/frontends/web/src/routes/market/bitrefill.tsx @@ -20,6 +20,7 @@ import { getURLOrigin } from '@/utils/url'; import { ConfirmBitrefill } from './bitrefill-confirm'; import { AppContext } from '@/contexts/AppContext'; import { useVendorIframeResizeHeight, useVendorTerms } from '@/hooks/vendor-iframe'; +import { useAccountSynced } from '@/hooks/account'; import style from './iframe.module.css'; // Map coins supported by Bitrefill @@ -48,7 +49,9 @@ export const Bitrefill = ({ const { isDevServers } = useContext(AppContext); const account = findAccount(accounts, code); - const bitrefillInfo = useLoad(() => getBitrefillInfo('spend', code)); + const fetchBitrefillInfo = useCallback(() => getBitrefillInfo('spend', code), [code]); + const bitrefillInfo = useAccountSynced(code, fetchBitrefillInfo); + const config = useLoad(getConfig); const { containerRef, height, iframeLoaded, iframeRef, onIframeLoad } = useVendorIframeResizeHeight(); @@ -189,12 +192,7 @@ export const Bitrefill = ({ }; }, [handleMessage]); - if ( - !account - || !config - || !bitrefillInfo?.success - || !bitrefillInfo.address - ) { + if (!account || !config) { return null; } @@ -222,7 +220,9 @@ export const Bitrefill = ({ /> ) : (
- {!iframeLoaded && } + {!iframeLoaded && ( + + )} { bitrefillInfo?.success && (