From 9e0a75889ee396bd858058497318c2c16935dd71 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 16 Sep 2025 17:13:05 -0400 Subject: [PATCH 1/5] cache account balances and poll for updates --- extension/e2e-tests/helpers/stubs.ts | 6 +- extension/e2e-tests/loadAccount.test.ts | 130 +++++++++++++++++- extension/e2e-tests/sendPayment.test.ts | 49 +++++-- .../src/helpers/hooks/useGetBalances.tsx | 4 +- .../hooks/useSubmitTxData.tsx | 28 +++- .../hooks/useChangeTrust.tsx | 26 +++- extension/src/popup/ducks/cache.ts | 11 +- .../views/Account/hooks/useGetAccountData.tsx | 50 ++++++- extension/src/popup/views/Account/index.tsx | 14 +- .../popup/views/__tests__/Account.test.tsx | 65 +++++++++ 10 files changed, 357 insertions(+), 26 deletions(-) diff --git a/extension/e2e-tests/helpers/stubs.ts b/extension/e2e-tests/helpers/stubs.ts index 15ecf00298..576b680016 100644 --- a/extension/e2e-tests/helpers/stubs.ts +++ b/extension/e2e-tests/helpers/stubs.ts @@ -134,7 +134,7 @@ export const stubTokenPrices = async (page: Page | BrowserContext) => { }); }; -export const stubAccountBalances = async (page: Page) => { +export const stubAccountBalances = async (page: Page, xlmBalance?: string) => { await page.route("**/account-balances/**", async (route) => { const json = { balances: { @@ -143,8 +143,8 @@ export const stubAccountBalances = async (page: Page) => { type: "native", code: "XLM", }, - total: "9697.8556678", - available: "9697.8556678", + total: xlmBalance || "9697.8556678", + available: xlmBalance || "9697.8556678", sellingLiabilities: "0", buyingLiabilities: "0", minimumBalance: "1", diff --git a/extension/e2e-tests/loadAccount.test.ts b/extension/e2e-tests/loadAccount.test.ts index 3d9e603c34..a2e3795008 100644 --- a/extension/e2e-tests/loadAccount.test.ts +++ b/extension/e2e-tests/loadAccount.test.ts @@ -1,5 +1,5 @@ import { test, expect, expectPageToHaveScreenshot } from "./test-fixtures"; -import { loginToTestAccount } from "./helpers/login"; +import { loginToTestAccount, loginAndFund } from "./helpers/login"; import { stubAccountBalances, stubAccountHistory, @@ -76,6 +76,134 @@ test("Switches account and fetches correct balances", async ({ await expect(account1XlmBalance).not.toEqual(account2XlmBalance); }); +test("Switches network and fetches correct balances", async ({ + page, + extensionId, + context, +}) => { + await stubTokenDetails(page); + await stubAccountHistory(page); + await stubTokenPrices(page); + await stubScanDapp(context); + + await page.route("**/account-balances/**", async (route) => { + let json = {}; + + if (route.request().url().includes("TESTNET")) { + json = { + balances: { + native: { + token: { + type: "native", + code: "XLM", + }, + total: "2", + available: "2", + sellingLiabilities: "0", + buyingLiabilities: "0", + minimumBalance: "1", + blockaidData: { + result_type: "Benign", + malicious_score: "0.0", + attack_types: {}, + chain: "stellar", + address: "", + metadata: { + type: "", + }, + fees: {}, + features: [], + trading_limits: {}, + financial_stats: {}, + }, + }, + }, + isFunded: true, + subentryCount: 0, + error: { + horizon: null, + soroban: null, + }, + }; + } else { + json = { + balances: { + native: { + token: { + type: "native", + code: "XLM", + }, + total: "1", + available: "1", + sellingLiabilities: "0", + buyingLiabilities: "0", + minimumBalance: "1", + blockaidData: { + result_type: "Benign", + malicious_score: "0.0", + attack_types: {}, + chain: "stellar", + address: "", + metadata: { + type: "", + }, + fees: {}, + features: [], + trading_limits: {}, + financial_stats: {}, + }, + }, + }, + isFunded: true, + subentryCount: 0, + error: { + horizon: null, + soroban: null, + }, + }; + } + + await route.fulfill({ json }); + }); + + test.slow(); + await loginToTestAccount({ page, extensionId }); + await expect(page.getByTestId("account-assets")).toContainText("XLM"); + await expect(page.getByTestId("asset-amount")).toHaveText("2"); + + await page.getByTestId("network-selector-open").click(); + await page.getByText("Main Net").click(); + + await expect(page.getByTestId("asset-amount")).toHaveText("1"); +}); + +test("Account Balances should be loaded once and cached", async ({ + page, + extensionId, + context, +}) => { + await stubTokenDetails(page); + await stubAccountBalances(page); + await stubAccountHistory(page); + await stubTokenPrices(page); + await stubScanDapp(context); + + test.slow(); + await loginToTestAccount({ page, extensionId }); + + let accountBalancesRequestWasMade = false; + page.on("request", (request) => { + if (request.url().includes("/account-balances/")) { + accountBalancesRequestWasMade = true; + } + }); + + await page.getByTestId("account-options-dropdown").click(); + await page.getByText("Settings").click(); + await page.getByTestId("BackButton").click(); + await expect(accountBalancesRequestWasMade).toBeFalsy(); +}); + test("Switches account without password prompt", async ({ page, extensionId, diff --git a/extension/e2e-tests/sendPayment.test.ts b/extension/e2e-tests/sendPayment.test.ts index cd2873a226..71c961d974 100644 --- a/extension/e2e-tests/sendPayment.test.ts +++ b/extension/e2e-tests/sendPayment.test.ts @@ -9,6 +9,9 @@ import { stubTokenPrices, } from "./helpers/stubs"; +const MUXED_ACCOUNT_ADDRESS = + "MCCRNOIMODZT7HVQA3NM46YMLOBAXMSMHK3XXIN62CTWIPPP3J2E4AAAAAAAAAAAAEIAM"; + test("Swap doesn't throw error when account is unfunded", async ({ page, extensionId, @@ -202,11 +205,7 @@ test("Send doesn't throw error when creating muxed account", async ({ await page.getByTestId("nav-link-send").click({ force: true }); await expect(page.getByText("Send")).toBeVisible(); - await page - .getByTestId("send-to-input") - .fill( - "MAUPPMNJUS76SG5NA6UXVCSO5HYVAJT422LBISV6LMCX37OIEPDJGAAAAAAAAAAAAF54C", - ); + await page.getByTestId("send-to-input").fill(MUXED_ACCOUNT_ADDRESS); await expect( page.getByText("The destination account doesn’t exist."), ).toBeVisible(); @@ -227,11 +226,7 @@ test("Send can review formatted inputs", async ({ page, extensionId }) => { await page.getByTestId("nav-link-send").click({ force: true }); await expect(page.getByText("Send")).toBeVisible(); - await page - .getByTestId("send-to-input") - .fill( - "MAUPPMNJUS76SG5NA6UXVCSO5HYVAJT422LBISV6LMCX37OIEPDJGAAAAAAAAAAAAF54C", - ); + await page.getByTestId("send-to-input").fill(MUXED_ACCOUNT_ADDRESS); await expect( page.getByText("The destination account doesn’t exist."), ).toBeVisible(); @@ -298,9 +293,17 @@ test("Send XLM payments to recent federated addresses", async ({ await submitAction.waitFor({ state: "visible" }); await submitAction.click({ force: true }); + let accountBalancesRequestWasMade = false; + page.on("request", (request) => { + if (request.url().includes("/account-balances/")) { + accountBalancesRequestWasMade = true; + } + }); + await expect(page.getByText("Sent!")).toBeVisible({ timeout: 60000, }); + expect(accountBalancesRequestWasMade).toBeTruthy(); }); test("Send XLM payment to C address", async ({ page, extensionId }) => { @@ -329,9 +332,17 @@ test("Send XLM payment to C address", async ({ page, extensionId }) => { await page.getByTestId(`SubmitAction`).click({ force: true }); + let accountBalancesRequestWasMade = false; + page.on("request", (request) => { + if (request.url().includes("/account-balances/")) { + accountBalancesRequestWasMade = true; + } + }); + await expect(page.getByText("Sent!")).toBeVisible({ timeout: 60000, }); + expect(accountBalancesRequestWasMade).toBeTruthy(); }); test("Send XLM payment to M address", async ({ page, extensionId }) => { @@ -362,9 +373,18 @@ test("Send XLM payment to M address", async ({ page, extensionId }) => { await submitButton.scrollIntoViewIfNeeded(); await submitButton.click(); + let accountBalancesRequestWasMade = false; + page.on("request", (request) => { + if (request.url().includes("/account-balances/")) { + accountBalancesRequestWasMade = true; + } + }); + await expect(page.getByText("Sent!")).toBeVisible({ timeout: 60000, }); + + expect(accountBalancesRequestWasMade).toBeTruthy(); }); test.skip("Send SAC to C address", async ({ page, extensionId }) => { @@ -492,9 +512,18 @@ test("Send token payment to C address", async ({ page, extensionId }) => { await page.getByTestId(`SubmitAction`).click({ force: true }); + let accountBalancesRequestWasMade = false; + page.on("request", (request) => { + if (request.url().includes("/account-balances/")) { + accountBalancesRequestWasMade = true; + } + }); + await expect(page.getByText("Sent!")).toBeVisible({ timeout: 60000, }); + + expect(accountBalancesRequestWasMade).toBeTruthy(); }); test.afterAll(async ({ page, extensionId }) => { diff --git a/extension/src/helpers/hooks/useGetBalances.tsx b/extension/src/helpers/hooks/useGetBalances.tsx index 6ae1d9a329..2d0822d0ae 100644 --- a/extension/src/helpers/hooks/useGetBalances.tsx +++ b/extension/src/helpers/hooks/useGetBalances.tsx @@ -80,7 +80,8 @@ function useGetBalances(options: { ): Promise => { dispatch({ type: "FETCH_DATA_START" }); try { - const cachedBalanceData = cachedBalances[publicKey]; + const cachedBalanceData = + cachedBalances[networkDetails.network]?.[publicKey]; const accountBalances = useCache && cachedBalanceData ? cachedBalanceData @@ -121,6 +122,7 @@ function useGetBalances(options: { saveBalancesForAccount({ publicKey, balances: accountBalances, + networkDetails, }), ); dispatch({ type: "FETCH_DATA_SUCCESS", payload }); diff --git a/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx b/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx index 22eb035151..8200d40ac2 100644 --- a/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx +++ b/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx @@ -1,7 +1,8 @@ import { useReducer } from "react"; import { useDispatch, useSelector } from "react-redux"; +import { captureException } from "@sentry/browser"; -import { initialState, reducer } from "helpers/request"; +import { initialState, reducer, isError } from "helpers/request"; import { AppDispatch } from "popup/App"; import { addRecentAddress, @@ -9,10 +10,11 @@ import { submitFreighterTransaction, transactionSubmissionSelector, } from "popup/ducks/transactionSubmission"; +import { AccountBalances, useGetBalances } from "helpers/hooks/useGetBalances"; import { NetworkDetails } from "@shared/constants/stellar"; import { emitMetric } from "helpers/metrics"; import { METRIC_NAMES } from "popup/constants/metricsNames"; -import { getAssetFromCanonical } from "helpers/stellar"; +import { getAssetFromCanonical, isMainnet } from "helpers/stellar"; import { AssetIcons } from "@shared/api/types"; interface SubmitTxData { @@ -38,6 +40,11 @@ function useSubmitTxData({ initialState, ); const submission = useSelector(transactionSubmissionSelector); + const { fetchData: fetchBalances } = useGetBalances({ + showHidden: false, + includeIcons: false, + }); + const { transactionData: { asset, destination, federationAddress }, transactionSimulation, @@ -84,6 +91,23 @@ function useSubmitTxData({ emitMetric(METRIC_NAMES.sendPaymentSuccess, { sourceAsset: sourceAsset.code, }); + + const balancesResult = await fetchBalances( + publicKey, + isMainnet(networkDetails), + networkDetails, + false, + ); + + if (isError(balancesResult)) { + // we don't want to throw an error if balances fail to fetch as this doesn't affect the tx submission + // let's simply log the error and continue - the user will need to refresh the Account page or wait for polling to refresh the balances + captureException( + `Failed to fetch balances after ${isSwap ? "swap" : "send"} tx submission - ${JSON.stringify( + balancesResult.message, + )} ${networkDetails.network}`, + ); + } } dispatch({ type: "FETCH_DATA_SUCCESS", payload }); diff --git a/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/hooks/useChangeTrust.tsx b/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/hooks/useChangeTrust.tsx index eb52e84bb7..73cbe7e0a8 100644 --- a/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/hooks/useChangeTrust.tsx +++ b/extension/src/popup/components/manageAssets/ManageAssetRows/ChangeTrustInternal/hooks/useChangeTrust.tsx @@ -1,13 +1,16 @@ import { useReducer } from "react"; import { useDispatch } from "react-redux"; +import { captureException } from "@sentry/browser"; -import { initialState, reducer } from "helpers/request"; +import { initialState, reducer, isError } from "helpers/request"; import { AppDispatch } from "popup/App"; import { signFreighterTransaction, submitFreighterTransaction, } from "popup/ducks/transactionSubmission"; import { NetworkDetails } from "@shared/constants/stellar"; +import { AccountBalances, useGetBalances } from "helpers/hooks/useGetBalances"; +import { isMainnet } from "helpers/stellar"; export interface ChangeTrustData { status: "success" | "error"; @@ -21,6 +24,10 @@ function useGetChangeTrust() { initialState, ); const reduxDispatch = useDispatch(); + const { fetchData: fetchBalances } = useGetBalances({ + showHidden: false, + includeIcons: false, + }); const fetchData = async ({ publicKey, @@ -62,6 +69,23 @@ function useGetChangeTrust() { if (submitFreighterTransaction.fulfilled.match(submitResp)) { payload.status = "success"; payload.txHash = submitResp.payload.hash; + + const balancesResult = await fetchBalances( + publicKey, + isMainnet(networkDetails), + networkDetails, + false, + ); + + if (isError(balancesResult)) { + // we don't want to throw an error if balances fail to fetch as this doesn't affect the tx submission + // let's simply log the error and continue - the user will need to refresh the Account page or wait for polling to refresh the balances + captureException( + `Failed to fetch balances after change trust tx submission - ${JSON.stringify( + balancesResult.message, + )} ${networkDetails.network}`, + ); + } } } diff --git a/extension/src/popup/ducks/cache.ts b/extension/src/popup/ducks/cache.ts index 52c233f943..26618678bf 100644 --- a/extension/src/popup/ducks/cache.ts +++ b/extension/src/popup/ducks/cache.ts @@ -1,5 +1,6 @@ import { createSelector, createSlice } from "@reduxjs/toolkit"; import { AccountBalancesInterface } from "@shared/api/types/backend-api"; +import { NetworkDetails } from "@shared/constants/stellar"; import { AssetListResponse } from "@shared/constants/soroban/asset-list"; type AssetCode = string; @@ -10,6 +11,7 @@ type HomeDomain = string; interface SaveBalancesPayload { publicKey: PublicKey; balances: AccountBalancesInterface; + networkDetails: NetworkDetails; } interface SaveIconsPayload { @@ -21,7 +23,9 @@ type SaveDomainPayload = Record; type SaveTokenLists = AssetListResponse[]; interface InitialState { - balanceData: Record; + balanceData: { + [network: string]: Record; + }; icons: Record; homeDomains: Record; tokenLists: AssetListResponse[]; @@ -47,7 +51,10 @@ const cacheSlice = createSlice({ saveBalancesForAccount(state, action: { payload: SaveBalancesPayload }) { state.balanceData = { ...state.balanceData, - [action.payload.publicKey]: action.payload.balances, + [action.payload.networkDetails.network]: { + ...state.balanceData[action.payload.networkDetails.network], + [action.payload.publicKey]: action.payload.balances, + }, }; }, saveIconsForBalances(state, action: { payload: SaveIconsPayload }) { diff --git a/extension/src/popup/views/Account/hooks/useGetAccountData.tsx b/extension/src/popup/views/Account/hooks/useGetAccountData.tsx index 927e46e819..072649429f 100644 --- a/extension/src/popup/views/Account/hooks/useGetAccountData.tsx +++ b/extension/src/popup/views/Account/hooks/useGetAccountData.tsx @@ -1,4 +1,5 @@ import { useEffect, useReducer, useState } from "react"; +import { useSelector } from "react-redux"; import { RequestState } from "constants/request"; import { initialState, isError, reducer } from "helpers/request"; @@ -19,6 +20,7 @@ import { useDispatch } from "react-redux"; import { AppDispatch } from "popup/App"; import { makeAccountActive } from "popup/ducks/accountServices"; import { changeNetwork } from "popup/ducks/settings"; +import { balancesSelector } from "popup/ducks/cache"; export const getTokenPrices = async ({ balances, @@ -67,13 +69,20 @@ function useGetAccountData(options: { const { fetchData: fetchBalances } = useGetBalances(options); const { fetchData: fetchHistory } = useGetHistory(); - const fetchData = async ( + const cachedBalances = useSelector(balancesSelector); + + const fetchData = async ({ useAppDataCache = true, + updatedAppData, + shouldForceBalancesRefresh, + }: { + useAppDataCache: boolean; updatedAppData?: { publicKey?: string; network?: NetworkDetails; - }, - ) => { + }; + shouldForceBalancesRefresh?: boolean; + }) => { dispatch({ type: "FETCH_DATA_START" }); try { if (updatedAppData && updatedAppData.publicKey) { @@ -98,11 +107,19 @@ function useGetAccountData(options: { const networkDetails = appData.settings.networkDetails; const allowList = appData.settings.allowList; const isMainnetNetwork = isMainnet(networkDetails); + + const hasBalanceCache = + cachedBalances && + Object.keys(cachedBalances[networkDetails.network]?.[publicKey] || {}) + .length > 0; + const balancesResult = await fetchBalances( publicKey, isMainnetNetwork, networkDetails, + hasBalanceCache && !shouldForceBalancesRefresh, ); + const history = await fetchHistory(publicKey, networkDetails); if (isError(balancesResult)) { @@ -196,6 +213,33 @@ function useGetAccountData(options: { return () => clearInterval(interval); }, [_isMainnet, state.data]); + useEffect(() => { + // refresh balances every 30 seconds + + if (!state.data || state.data.type === AppDataType.REROUTE) { + return; + } + const resolvedData = state.data; + + const interval = setInterval(async () => { + const publicKey = resolvedData.publicKey; + const networkDetails = resolvedData.networkDetails; + const balancesResult = await fetchBalances( + publicKey, + _isMainnet, + networkDetails, + false, + ); + + const payload = { + ...state.data, + balances: balancesResult, + } as AccountData; + dispatch({ type: "FETCH_DATA_SUCCESS", payload }); + }, 30000); + return () => clearInterval(interval); + }, [_isMainnet, state.data, fetchBalances]); + return { state, fetchData, diff --git a/extension/src/popup/views/Account/index.tsx b/extension/src/popup/views/Account/index.tsx index 6a21757995..06bf3edb1d 100644 --- a/extension/src/popup/views/Account/index.tsx +++ b/extension/src/popup/views/Account/index.tsx @@ -50,7 +50,7 @@ export const Account = () => { useEffect(() => { const getData = async () => { - await fetchData(false); + await fetchData({ useAppDataCache: false }); }; getData(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -133,7 +133,10 @@ export const Account = () => { publicKey?: string; network?: NetworkDetails; }) => { - await fetchData(false, updatedValues); + await fetchData({ + useAppDataCache: false, + updatedAppData: updatedValues, + }); }} roundedTotalBalanceUsd={roundedTotalBalanceUsd} isFunded={!!resolvedData?.balances?.isFunded} @@ -217,7 +220,12 @@ export const Account = () => { + fetchData({ + useAppDataCache: true, + shouldForceBalancesRefresh: true, + }) + } /> )} diff --git a/extension/src/popup/views/__tests__/Account.test.tsx b/extension/src/popup/views/__tests__/Account.test.tsx index 4b5fcaf585..dc374fc001 100644 --- a/extension/src/popup/views/__tests__/Account.test.tsx +++ b/extension/src/popup/views/__tests__/Account.test.tsx @@ -689,6 +689,71 @@ describe("Account view", () => { }); }); + it("polls for account balances", async () => { + jest.useFakeTimers(); + jest.spyOn(ApiInternal, "loadSettings").mockImplementation(() => + Promise.resolve({ + networkDetails: MAINNET_NETWORK_DETAILS, + networksList: DEFAULT_NETWORKS, + hiddenAssets: {}, + allowList: ApiInternal.DEFAULT_ALLOW_LIST, + error: "", + isDataSharingAllowed: false, + isMemoValidationEnabled: false, + isHideDustEnabled: true, + settingsState: SettingsState.SUCCESS, + isSorobanPublicEnabled: false, + isRpcHealthy: true, + userNotification: { + enabled: false, + message: "", + }, + isExperimentalModeEnabled: false, + isHashSigningEnabled: false, + isNonSSLEnabled: false, + experimentalFeaturesState: SettingsState.SUCCESS, + assetsLists: DEFAULT_ASSETS_LISTS, + }), + ); + const getAccountBalancesSpy = jest + .spyOn(ApiInternal, "getAccountBalances") + .mockImplementation(() => Promise.resolve(mockBalances)); + + jest.spyOn(ApiInternal, "getTokenPrices").mockImplementation(() => { + throw new Error("Failed to fetch prices"); + }); + + render( + + + , + ); + + await waitFor(async () => { + const assetNodes = screen.getAllByTestId("account-assets-item"); + expect(assetNodes.length).toEqual(3); + expect(getAccountBalancesSpy).toHaveBeenCalledTimes(1); + }); + + // Fast-forward 30 seconds + jest.advanceTimersByTime(30000); + expect(getAccountBalancesSpy).toHaveBeenCalledTimes(2); + }); + it("handles abandoned onboarding in password created step", async () => { jest.spyOn(ApiInternal, "loadAccount").mockImplementation(() => Promise.resolve({ From ba9a7b292651295054a7381dcba27aff3808febc Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 16 Sep 2025 17:53:55 -0400 Subject: [PATCH 2/5] fix CI tests --- .../helpers/__tests__/useGetAssetDomainsWithBalances.test.tsx | 4 +++- extension/src/helpers/__tests__/useGetBalances.test.tsx | 4 +++- extension/src/popup/views/__tests__/Account.test.tsx | 4 ++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/extension/src/helpers/__tests__/useGetAssetDomainsWithBalances.test.tsx b/extension/src/helpers/__tests__/useGetAssetDomainsWithBalances.test.tsx index 01080c65dc..fdb362872a 100644 --- a/extension/src/helpers/__tests__/useGetAssetDomainsWithBalances.test.tsx +++ b/extension/src/helpers/__tests__/useGetAssetDomainsWithBalances.test.tsx @@ -67,7 +67,9 @@ describe("useGetAssetDomainsWithBalances (cached path)", () => { publicKey: TEST_PUBLIC_KEY, }, cache: { - balanceData: { [publicKey]: cachedBalanceData }, + balanceData: { + [TESTNET_NETWORK_DETAILS.network]: { [publicKey]: cachedBalanceData }, + }, icons: cachedIcons, tokenLists: tokenListData, homeDomains: { diff --git a/extension/src/helpers/__tests__/useGetBalances.test.tsx b/extension/src/helpers/__tests__/useGetBalances.test.tsx index febf5eec4d..f374ec9f79 100644 --- a/extension/src/helpers/__tests__/useGetBalances.test.tsx +++ b/extension/src/helpers/__tests__/useGetBalances.test.tsx @@ -52,7 +52,9 @@ describe("useGetBalances (cached path)", () => { const preloadedState = { cache: { - balanceData: { [publicKey]: cachedBalanceData }, + balanceData: { + [TESTNET_NETWORK_DETAILS.network]: { [publicKey]: cachedBalanceData }, + }, icons: cachedIcons, tokenLists: tokenListData, }, diff --git a/extension/src/popup/views/__tests__/Account.test.tsx b/extension/src/popup/views/__tests__/Account.test.tsx index dc374fc001..9dcec5b3e0 100644 --- a/extension/src/popup/views/__tests__/Account.test.tsx +++ b/extension/src/popup/views/__tests__/Account.test.tsx @@ -247,6 +247,10 @@ describe("Account view", () => { afterAll(() => { jest.clearAllMocks(); }); + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); it("renders", async () => { render( From a88c762f977cc74e61a4a112718fd729f362de87 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 17 Sep 2025 11:10:46 -0400 Subject: [PATCH 3/5] rm `force:true` which was causing action to happen too fast --- extension/e2e-tests/loadAccount.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extension/e2e-tests/loadAccount.test.ts b/extension/e2e-tests/loadAccount.test.ts index a2e3795008..aae9119c2f 100644 --- a/extension/e2e-tests/loadAccount.test.ts +++ b/extension/e2e-tests/loadAccount.test.ts @@ -1,5 +1,5 @@ import { test, expect, expectPageToHaveScreenshot } from "./test-fixtures"; -import { loginToTestAccount, loginAndFund } from "./helpers/login"; +import { loginToTestAccount } from "./helpers/login"; import { stubAccountBalances, stubAccountHistory, @@ -222,7 +222,7 @@ test("Switches account without password prompt", async ({ await page.getByText("Account 2").click(); await page.getByTestId("account-options-dropdown").click(); - await page.getByText("Manage assets").click({ force: true }); + await page.getByText("Manage assets").click(); await expect(page.getByText("Your assets")).toBeVisible(); }); From e7a440a956f8c2946380931f20f9e5fef02f0393 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 17 Sep 2025 12:55:24 -0400 Subject: [PATCH 4/5] do a fresh balance fetch on account/network change --- extension/e2e-tests/loadAccount.test.ts | 97 ++++++++++++++++++++- extension/src/popup/views/Account/index.tsx | 1 + extension/src/popup/views/Wallets/index.tsx | 17 +++- 3 files changed, 111 insertions(+), 4 deletions(-) diff --git a/extension/e2e-tests/loadAccount.test.ts b/extension/e2e-tests/loadAccount.test.ts index aae9119c2f..f1e7741534 100644 --- a/extension/e2e-tests/loadAccount.test.ts +++ b/extension/e2e-tests/loadAccount.test.ts @@ -49,7 +49,7 @@ test("Load accounts on standalone network", async ({ await expect(page.getByTestId("account-assets")).toContainText("XLM"); }); -test("Switches account and fetches correct balances", async ({ +test("Switches account and fetches correct balances while clearing cache", async ({ page, extensionId, context, @@ -74,9 +74,58 @@ test("Switches account and fetches correct balances", async ({ .textContent(); await expect(account1XlmBalance).not.toEqual(account2XlmBalance); + + // go back to account 1 and make sure we do a fresh balance fetch + await page.route("**/account-balances/**", async (route) => { + const json = { + balances: { + native: { + token: { + type: "native", + code: "XLM", + }, + total: "999111", + available: "99911", + sellingLiabilities: "0", + buyingLiabilities: "0", + minimumBalance: "1", + blockaidData: { + result_type: "Benign", + malicious_score: "0.0", + attack_types: {}, + chain: "stellar", + address: "", + metadata: { + type: "", + }, + fees: {}, + features: [], + trading_limits: {}, + financial_stats: {}, + }, + }, + }, + isFunded: true, + subentryCount: 0, + error: { + horizon: null, + soroban: null, + }, + }; + + await route.fulfill({ json }); + }); + + await page.getByTestId("account-view-account-name").click(); + await page.getByText("Account 1").click(); + const updatedAccount1XlmBalance = await page + .getByTestId("asset-amount") + .textContent(); + await expect(updatedAccount1XlmBalance).not.toEqual(account1XlmBalance); + await expect(updatedAccount1XlmBalance).toEqual("999,111"); }); -test("Switches network and fetches correct balances", async ({ +test("Switches network and fetches correct balances while clearing cache", async ({ page, extensionId, context, @@ -175,6 +224,50 @@ test("Switches network and fetches correct balances", async ({ await page.getByText("Main Net").click(); await expect(page.getByTestId("asset-amount")).toHaveText("1"); + + // now go back to Testnet and make sure we do a fresh balance fetch + await page.route("**/account-balances/**", async (route) => { + const json = { + balances: { + native: { + token: { + type: "native", + code: "XLM", + }, + total: "999111", + available: "99911", + sellingLiabilities: "0", + buyingLiabilities: "0", + minimumBalance: "1", + blockaidData: { + result_type: "Benign", + malicious_score: "0.0", + attack_types: {}, + chain: "stellar", + address: "", + metadata: { + type: "", + }, + fees: {}, + features: [], + trading_limits: {}, + financial_stats: {}, + }, + }, + }, + isFunded: true, + subentryCount: 0, + error: { + horizon: null, + soroban: null, + }, + }; + + await route.fulfill({ json }); + }); + await page.getByTestId("network-selector-open").click(); + await page.getByText("Test Net").click(); + await expect(page.getByTestId("asset-amount")).toHaveText("999,111"); }); test("Account Balances should be loaded once and cached", async ({ diff --git a/extension/src/popup/views/Account/index.tsx b/extension/src/popup/views/Account/index.tsx index 06bf3edb1d..b98f6e699c 100644 --- a/extension/src/popup/views/Account/index.tsx +++ b/extension/src/popup/views/Account/index.tsx @@ -136,6 +136,7 @@ export const Account = () => { await fetchData({ useAppDataCache: false, updatedAppData: updatedValues, + shouldForceBalancesRefresh: true, }); }} roundedTotalBalanceUsd={roundedTotalBalanceUsd} diff --git a/extension/src/popup/views/Wallets/index.tsx b/extension/src/popup/views/Wallets/index.tsx index 94ef50e752..2862fe1057 100644 --- a/extension/src/popup/views/Wallets/index.tsx +++ b/extension/src/popup/views/Wallets/index.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { useDispatch } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { Link, Navigate, useNavigate } from "react-router-dom"; import { Button, @@ -24,8 +24,9 @@ import { makeAccountActive, updateAccountName, } from "popup/ducks/accountServices"; +import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import IconEllipsis from "popup/assets/icon-ellipsis.svg"; -import { truncatedPublicKey } from "helpers/stellar"; +import { truncatedPublicKey, isMainnet } from "helpers/stellar"; import { getColorPubKey } from "helpers/stellarIdenticon"; import { METRIC_NAMES } from "popup/constants/metricsNames"; import { emitMetric } from "helpers/metrics"; @@ -40,6 +41,7 @@ import { reRouteOnboarding } from "popup/helpers/route"; import "./styles.scss"; import { WalletType } from "@shared/constants/hardwareWallet"; +import { useGetBalances } from "helpers/hooks/useGetBalances"; interface AddWalletProps { onBack: () => void; @@ -283,6 +285,11 @@ export const Wallets = () => { const [activeOptionsPublicKey, setActiveOptionsPublicKey] = React.useState(""); const { state: dataState, fetchData } = useGetWalletsData(); + const { fetchData: fetchBalances } = useGetBalances({ + showHidden: true, + includeIcons: false, + }); + const networkDetails = useSelector(settingsNetworkDetailsSelector); useEffect(() => { const getData = async () => { @@ -399,6 +406,12 @@ export const Wallets = () => { isSelected={isSelected} onClick={async (publicKey) => { await dispatch(makeAccountActive(publicKey)); + await fetchBalances( + publicKey, + isMainnet(networkDetails), + networkDetails, + false, + ); navigateTo(ROUTES.account, navigate); }} setOptionsOpen={setActiveOptionsPublicKey} From f564c2fd2cde6d1676385afbae064f5a6ba339f5 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 17 Sep 2025 16:48:51 -0400 Subject: [PATCH 5/5] pr comments --- extension/src/popup/ducks/cache.ts | 11 +++++++++++ .../views/Account/hooks/useGetAccountData.tsx | 11 +---------- extension/src/popup/views/Wallets/index.tsx | 15 ++++----------- 3 files changed, 16 insertions(+), 21 deletions(-) diff --git a/extension/src/popup/ducks/cache.ts b/extension/src/popup/ducks/cache.ts index 26618678bf..4ace6c7759 100644 --- a/extension/src/popup/ducks/cache.ts +++ b/extension/src/popup/ducks/cache.ts @@ -14,6 +14,11 @@ interface SaveBalancesPayload { networkDetails: NetworkDetails; } +interface ClearBalancesPayload { + publicKey: PublicKey; + networkDetails: NetworkDetails; +} + interface SaveIconsPayload { icons: Record; } @@ -57,6 +62,11 @@ const cacheSlice = createSlice({ }, }; }, + clearBalancesForAccount(state, action: { payload: ClearBalancesPayload }) { + delete state.balanceData[action.payload.networkDetails.network][ + action.payload.publicKey + ]; + }, saveIconsForBalances(state, action: { payload: SaveIconsPayload }) { state.icons = { ...state.icons, @@ -93,4 +103,5 @@ export const { saveIconsForBalances, saveDomainForIssuer, saveTokenLists, + clearBalancesForAccount, } = cacheSlice.actions; diff --git a/extension/src/popup/views/Account/hooks/useGetAccountData.tsx b/extension/src/popup/views/Account/hooks/useGetAccountData.tsx index 072649429f..f15b7b79c6 100644 --- a/extension/src/popup/views/Account/hooks/useGetAccountData.tsx +++ b/extension/src/popup/views/Account/hooks/useGetAccountData.tsx @@ -1,5 +1,4 @@ import { useEffect, useReducer, useState } from "react"; -import { useSelector } from "react-redux"; import { RequestState } from "constants/request"; import { initialState, isError, reducer } from "helpers/request"; @@ -20,7 +19,6 @@ import { useDispatch } from "react-redux"; import { AppDispatch } from "popup/App"; import { makeAccountActive } from "popup/ducks/accountServices"; import { changeNetwork } from "popup/ducks/settings"; -import { balancesSelector } from "popup/ducks/cache"; export const getTokenPrices = async ({ balances, @@ -69,8 +67,6 @@ function useGetAccountData(options: { const { fetchData: fetchBalances } = useGetBalances(options); const { fetchData: fetchHistory } = useGetHistory(); - const cachedBalances = useSelector(balancesSelector); - const fetchData = async ({ useAppDataCache = true, updatedAppData, @@ -108,16 +104,11 @@ function useGetAccountData(options: { const allowList = appData.settings.allowList; const isMainnetNetwork = isMainnet(networkDetails); - const hasBalanceCache = - cachedBalances && - Object.keys(cachedBalances[networkDetails.network]?.[publicKey] || {}) - .length > 0; - const balancesResult = await fetchBalances( publicKey, isMainnetNetwork, networkDetails, - hasBalanceCache && !shouldForceBalancesRefresh, + !shouldForceBalancesRefresh, ); const history = await fetchHistory(publicKey, networkDetails); diff --git a/extension/src/popup/views/Wallets/index.tsx b/extension/src/popup/views/Wallets/index.tsx index 2862fe1057..8becd2fd19 100644 --- a/extension/src/popup/views/Wallets/index.tsx +++ b/extension/src/popup/views/Wallets/index.tsx @@ -25,8 +25,9 @@ import { updateAccountName, } from "popup/ducks/accountServices"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; +import { clearBalancesForAccount } from "popup/ducks/cache"; import IconEllipsis from "popup/assets/icon-ellipsis.svg"; -import { truncatedPublicKey, isMainnet } from "helpers/stellar"; +import { truncatedPublicKey } from "helpers/stellar"; import { getColorPubKey } from "helpers/stellarIdenticon"; import { METRIC_NAMES } from "popup/constants/metricsNames"; import { emitMetric } from "helpers/metrics"; @@ -41,7 +42,6 @@ import { reRouteOnboarding } from "popup/helpers/route"; import "./styles.scss"; import { WalletType } from "@shared/constants/hardwareWallet"; -import { useGetBalances } from "helpers/hooks/useGetBalances"; interface AddWalletProps { onBack: () => void; @@ -285,10 +285,6 @@ export const Wallets = () => { const [activeOptionsPublicKey, setActiveOptionsPublicKey] = React.useState(""); const { state: dataState, fetchData } = useGetWalletsData(); - const { fetchData: fetchBalances } = useGetBalances({ - showHidden: true, - includeIcons: false, - }); const networkDetails = useSelector(settingsNetworkDetailsSelector); useEffect(() => { @@ -406,11 +402,8 @@ export const Wallets = () => { isSelected={isSelected} onClick={async (publicKey) => { await dispatch(makeAccountActive(publicKey)); - await fetchBalances( - publicKey, - isMainnet(networkDetails), - networkDetails, - false, + await dispatch( + clearBalancesForAccount({ publicKey, networkDetails }), ); navigateTo(ROUTES.account, navigate); }}