From db91844ccac8b505db5f914c1896731369025917 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 24 Jun 2026 11:30:32 -0600 Subject: [PATCH 01/11] feat(token-prices): migrate getTokenPrices to v2 indexer endpoint Point getTokenPrices at INDEXER_V2_URL and pass the active network as a query param. The v2 endpoint only supports pubnet and testnet, so skip the request on any other network (Futurenet, custom) and return {} to avoid a guaranteed error and Sentry noise. Thread networkDetails through every fetchTokenPrices caller (account, send, swap, wallets, dest asset) and update the useGetTokenPrices hook signature accordingly. Widen the e2e token-prices route stub to match the new query string, and update tests to assert the network argument and the unsupported-network skip behavior. --- @shared/api/__tests__/internal.test.ts | 42 +++++++++++++++---- @shared/api/internal.ts | 14 ++++++- extension/e2e-tests/helpers/stubs.ts | 2 +- .../__tests__/useGetTokenPrices.test.tsx | 7 +++- .../src/helpers/hooks/useGetTokenPrices.tsx | 8 +++- .../SendAmount/hooks/useSendAmountData.tsx | 1 + .../hooks/useGetDestAssetData.tsx | 1 + .../SwapAmount/hooks/useGetSwapAmountData.tsx | 1 + .../swap/SwapAsset/hooks/useSwapFromData.tsx | 1 + .../src/popup/locales/en/translation.json | 2 + .../src/popup/locales/pt/translation.json | 2 + .../views/Account/hooks/useGetAccountData.tsx | 2 + .../__tests__/useGetWalletsData.test.tsx | 14 ++++--- .../views/Wallets/hooks/useGetWalletsData.tsx | 1 + 14 files changed, 80 insertions(+), 18 deletions(-) diff --git a/@shared/api/__tests__/internal.test.ts b/@shared/api/__tests__/internal.test.ts index 4687db43da..794a923f87 100644 --- a/@shared/api/__tests__/internal.test.ts +++ b/@shared/api/__tests__/internal.test.ts @@ -1,4 +1,4 @@ -import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { NETWORKS, TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; import * as GetLedgerKeyAccounts from "../helpers/getLedgerKeyAccounts"; import * as internalApi from "../internal"; @@ -146,11 +146,14 @@ describe("internalApi", () => { it("excludes contract-ID issuers from the indexer request", async () => { const fetchSpy = mockFetchOk(); - await internalApi.getTokenPrices([ - "native", - "USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", - "DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ", - ]); + await internalApi.getTokenPrices( + [ + "native", + "USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", + "DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ", + ], + NETWORKS.PUBLIC, + ); const requestInit = fetchSpy.mock.calls[0][1] as RequestInit; const body = JSON.parse(requestInit.body as string); @@ -163,11 +166,36 @@ describe("internalApi", () => { it("excludes liquidity-pool IDs from the indexer request", async () => { const fetchSpy = mockFetchOk(); - await internalApi.getTokenPrices(["native", "abc123:lp"]); + await internalApi.getTokenPrices( + ["native", "abc123:lp"], + NETWORKS.PUBLIC, + ); const requestInit = fetchSpy.mock.calls[0][1] as RequestInit; const body = JSON.parse(requestInit.body as string); expect(body.tokens).toEqual(["native"]); }); + + it("targets the v2 endpoint with the network query param", async () => { + const fetchSpy = mockFetchOk(); + + await internalApi.getTokenPrices(["native"], NETWORKS.TESTNET); + + const requestUrl = fetchSpy.mock.calls[0][0] as string; + expect(requestUrl).toContain("/token-prices"); + expect(requestUrl).toContain("network=TESTNET"); + }); + + it("skips the request on unsupported networks", async () => { + const fetchSpy = mockFetchOk(); + + const prices = await internalApi.getTokenPrices( + ["native"], + NETWORKS.FUTURENET, + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(prices).toEqual({}); + }); }); }); diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index e2180b6a63..995bdc59a5 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -599,13 +599,23 @@ export const getAccountIndexerBalances = async ({ }; }; -export const getTokenPrices = async (tokens: string[]) => { +export const getTokenPrices = async ( + tokens: string[], + network: NETWORKS, +): Promise => { + // The v2 token-prices endpoint only supports pubnet and testnet (Futurenet + // and custom networks are rejected), so skip the call entirely on anything + // else to avoid a guaranteed error and Sentry noise. + if (network !== NETWORKS.PUBLIC && network !== NETWORKS.TESTNET) { + return {}; + } // NOTE: API does not accept LP IDs or custom tokens const filteredTokens = tokens.filter((tokenId) => { const asset = getAssetFromCanonical(tokenId); return !tokenId.includes(":lp") && !isContractId(asset.issuer); }); - const url = new URL(`${INDEXER_URL}/token-prices`); + const url = new URL(`${INDEXER_V2_URL}/token-prices`); + url.searchParams.append("network", network); const options = { method: "POST", headers: { diff --git a/extension/e2e-tests/helpers/stubs.ts b/extension/e2e-tests/helpers/stubs.ts index 76762a49f1..e8d0b2ec77 100644 --- a/extension/e2e-tests/helpers/stubs.ts +++ b/extension/e2e-tests/helpers/stubs.ts @@ -676,7 +676,7 @@ export const stubTokenDetails = async (page: Page | BrowserContext) => { }; export const stubTokenPrices = async (page: Page | BrowserContext) => { - await page.route("**/token-prices", async (route) => { + await page.route("**/token-prices*", async (route) => { const request = route.request(); let tokenIds = [] as string[]; diff --git a/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx b/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx index ca1da1a991..7012c54300 100644 --- a/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx +++ b/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx @@ -6,6 +6,7 @@ import { useGetTokenPrices } from "../hooks/useGetTokenPrices"; import { makeDummyStore } from "popup/__testHelpers__"; import { defaultBlockaidScanAssetResult } from "@shared/helpers/stellar"; import { RequestState } from "constants/request"; +import { MAINNET_NETWORK_DETAILS, NETWORKS } from "@shared/constants/stellar"; import * as ApiInternal from "@shared/api/internal"; describe("useGetTokenPrices", () => { @@ -51,9 +52,10 @@ describe("useGetTokenPrices", () => { blockaidData: defaultBlockaidScanAssetResult, }, ], + networkDetails: MAINNET_NETWORK_DETAILS, } as any); }); - expect(getTokenPricesSpy).toHaveBeenCalledWith(["native"]); + expect(getTokenPricesSpy).toHaveBeenCalledWith(["native"], NETWORKS.PUBLIC); expect(result.current.state.state).toBe(RequestState.SUCCESS); expect(result.current.state.data?.tokenPrices).toEqual({ native: { @@ -109,9 +111,10 @@ describe("useGetTokenPrices", () => { blockaidData: defaultBlockaidScanAssetResult, }, ], + networkDetails: MAINNET_NETWORK_DETAILS, } as any); }); - expect(getTokenPricesSpy).toHaveBeenCalledWith(["native"]); + expect(getTokenPricesSpy).toHaveBeenCalledWith(["native"], NETWORKS.PUBLIC); expect(result.current.state.state).toBe(RequestState.SUCCESS); expect(result.current.state.data?.tokenPrices).toEqual({ native: { diff --git a/extension/src/helpers/hooks/useGetTokenPrices.tsx b/extension/src/helpers/hooks/useGetTokenPrices.tsx index ff2d3074a0..2654745654 100644 --- a/extension/src/helpers/hooks/useGetTokenPrices.tsx +++ b/extension/src/helpers/hooks/useGetTokenPrices.tsx @@ -7,6 +7,7 @@ import { initialState, isCacheValid, reducer } from "helpers/request"; import { AppDataType } from "helpers/hooks/useGetAppData"; import { getCanonicalFromAsset } from "helpers/stellar"; import { ApiTokenPrices } from "@shared/api/types"; +import { NETWORKS, NetworkDetails } from "@shared/constants/stellar"; import { saveTokenPrices } from "popup/ducks/cache"; import { AppDispatch } from "popup/App"; import { AccountBalances } from "./useGetBalances"; @@ -27,10 +28,12 @@ export function useGetTokenPrices() { const fetchData = async ({ publicKey, balances, + networkDetails, useCache = false, }: { publicKey: string; balances: AccountBalances["balances"]; + networkDetails: NetworkDetails; useCache: boolean; }): Promise => { dispatch({ type: "FETCH_DATA_START" }); @@ -61,7 +64,10 @@ export function useGetTokenPrices() { ), ); if (assetIds.length) { - const fetchedTokenPrices = await getTokenPrices(assetIds); + const fetchedTokenPrices = await getTokenPrices( + assetIds, + networkDetails.network as NETWORKS, + ); reduxDispatch( saveTokenPrices({ publicKey, tokenPrices: fetchedTokenPrices }), ); diff --git a/extension/src/popup/components/send/SendAmount/hooks/useSendAmountData.tsx b/extension/src/popup/components/send/SendAmount/hooks/useSendAmountData.tsx index 702f873a2b..27acce8353 100644 --- a/extension/src/popup/components/send/SendAmount/hooks/useSendAmountData.tsx +++ b/extension/src/popup/components/send/SendAmount/hooks/useSendAmountData.tsx @@ -91,6 +91,7 @@ function useGetSendAmountData( const fetchedTokenPrices = await fetchTokenPrices({ publicKey: userDomains.publicKey, balances: userDomains.balances.balances, + networkDetails: userDomains.networkDetails, useCache: true, }); diff --git a/extension/src/popup/components/send/SendDestinationAsset/hooks/useGetDestAssetData.tsx b/extension/src/popup/components/send/SendDestinationAsset/hooks/useGetDestAssetData.tsx index f63a6dc23e..5dc8f5f98f 100644 --- a/extension/src/popup/components/send/SendDestinationAsset/hooks/useGetDestAssetData.tsx +++ b/extension/src/popup/components/send/SendDestinationAsset/hooks/useGetDestAssetData.tsx @@ -74,6 +74,7 @@ export function useGetDestAssetData(getBalancesOptions: { const fetchedTokenPrices = await fetchTokenPrices({ publicKey, balances: balances.balances, + networkDetails, useCache: true, }); diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx index 1c6f68599d..44b9325a45 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx @@ -85,6 +85,7 @@ function useGetSwapAmountData( const fetchedTokenPrices = await fetchTokenPrices({ publicKey: userDomains.publicKey, balances: destinationBalances.balances, + networkDetails: userDomains.networkDetails, useCache: true, }); tokenPrices = fetchedTokenPrices.tokenPrices || {}; diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapFromData.tsx b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapFromData.tsx index b6ab94033b..22d61aa8dc 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapFromData.tsx +++ b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapFromData.tsx @@ -69,6 +69,7 @@ export function useGetSwapFromData(getBalancesOptions: { const fetchedTokenPrices = await fetchTokenPrices({ publicKey, balances: balances.balances, + networkDetails, useCache: true, }); diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 52339ec4bb..0ddd30cdd0 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -77,6 +77,8 @@ "Authorizations": "Authorizations", "Authorize": "Authorize", "Authorized address": "Authorized address", + "Auto-lock timer": "Auto-lock timer", + "Auto-Lock Timer": "Auto-Lock Timer", "available": "available", "Back": "Back", "Balance": "Balance", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index db2c569c1c..a0cb327877 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -77,6 +77,8 @@ "Authorizations": "Autorizações", "Authorize": "Autorizar", "Authorized address": "Endereço autorizado", + "Auto-lock timer": "Auto-lock timer", + "Auto-Lock Timer": "Auto-Lock Timer", "available": "disponível", "Back": "Voltar", "Balance": "Saldo", diff --git a/extension/src/popup/views/Account/hooks/useGetAccountData.tsx b/extension/src/popup/views/Account/hooks/useGetAccountData.tsx index 59bd869f46..0ab007ca90 100644 --- a/extension/src/popup/views/Account/hooks/useGetAccountData.tsx +++ b/extension/src/popup/views/Account/hooks/useGetAccountData.tsx @@ -120,6 +120,7 @@ function useGetAccountData(options: { const fetchedTokenPrices = await fetchTokenPrices({ publicKey, balances: balancesResult.balances, + networkDetails, useCache: true, }); payload.tokenPrices = fetchedTokenPrices.tokenPrices; @@ -213,6 +214,7 @@ function useGetAccountData(options: { const fetchedTokenPrices = await fetchTokenPrices({ publicKey: resolvedData.publicKey, balances: resolvedData.balances.balances, + networkDetails: resolvedData.networkDetails, useCache: false, }); const payload = { diff --git a/extension/src/popup/views/Wallets/hooks/__tests__/useGetWalletsData.test.tsx b/extension/src/popup/views/Wallets/hooks/__tests__/useGetWalletsData.test.tsx index eab97eb26d..510f93e084 100644 --- a/extension/src/popup/views/Wallets/hooks/__tests__/useGetWalletsData.test.tsx +++ b/extension/src/popup/views/Wallets/hooks/__tests__/useGetWalletsData.test.tsx @@ -13,6 +13,7 @@ import { RequestState } from "constants/request"; import * as ApiInternal from "@shared/api/internal"; import { MAINNET_NETWORK_DETAILS, + NETWORKS, TESTNET_NETWORK_DETAILS, } from "@shared/constants/stellar"; import { AppDataType } from "helpers/hooks/useGetAppData"; @@ -221,11 +222,14 @@ describe("useGetWalletsData", () => { await result.current.fetchData(true); }); expect(result.current.state.state).toBe(RequestState.SUCCESS); - expect(getTokenPricesSpy).toHaveBeenCalledWith([ - "native", - "DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ", - "USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", - ]); + expect(getTokenPricesSpy).toHaveBeenCalledWith( + [ + "native", + "DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ", + "USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", + ], + NETWORKS.PUBLIC, + ); expect(result.current.state.data).toEqual({ accountValue: { G1: "$250.00", diff --git a/extension/src/popup/views/Wallets/hooks/useGetWalletsData.tsx b/extension/src/popup/views/Wallets/hooks/useGetWalletsData.tsx index ef902e1fff..1f2baa003f 100644 --- a/extension/src/popup/views/Wallets/hooks/useGetWalletsData.tsx +++ b/extension/src/popup/views/Wallets/hooks/useGetWalletsData.tsx @@ -78,6 +78,7 @@ function useGetWalletsData() { const prices = await fetchTokenPrices({ publicKey: account.publicKey, balances: balances.balances, + networkDetails, useCache: true, }); const totalPriceUsd = getTotalUsd( From 2abe2bb8659396fb70d82c9281ae5d67d5b9349a Mon Sep 17 00:00:00 2001 From: aristides Date: Wed, 24 Jun 2026 11:44:15 -0600 Subject: [PATCH 02/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- extension/src/popup/locales/pt/translation.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index a0cb327877..3d3a492ff1 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -77,8 +77,8 @@ "Authorizations": "Autorizações", "Authorize": "Autorizar", "Authorized address": "Endereço autorizado", - "Auto-lock timer": "Auto-lock timer", - "Auto-Lock Timer": "Auto-Lock Timer", + "Auto-lock timer": "Temporizador de bloqueio automático", + "Auto-Lock Timer": "Temporizador de bloqueio automático", "available": "disponível", "Back": "Voltar", "Balance": "Saldo", From 3cd273f9fa9836e6b4da6ddd9226410414242f4c Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 24 Jun 2026 11:51:25 -0600 Subject: [PATCH 03/11] fix(token-prices): key the price cache by network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The token-price request is now scoped by network, but the cache still read and wrote entries keyed by publicKey alone. Reusing the same account across networks (e.g. Futurenet/Testnet then Mainnet) could serve the other network's cached prices — or the empty result cached for an unsupported network — for up to the 3-minute TTL, leaving USD values missing or wrong. Nest tokenPrices under [network][publicKey], matching the existing pattern used by balanceData, historyData, homeDomains, and collections. Thread networkDetails through saveTokenPrices and clearBalancesForAccount, and update both cache readers (useGetTokenPrices and AssetDetail) to look up by network. Add a regression test asserting a populated PUBLIC cache is not reused for a TESTNET fetch on the same account. --- .../__tests__/useGetTokenPrices.test.tsx | 99 ++++++++++++++++--- .../src/helpers/hooks/useGetTokenPrices.tsx | 9 +- .../components/__tests__/AssetDetail.test.tsx | 14 +-- .../components/account/AssetDetail/index.tsx | 3 +- extension/src/popup/ducks/cache.ts | 23 +++-- 5 files changed, 122 insertions(+), 26 deletions(-) diff --git a/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx b/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx index 7012c54300..1abcf45ed9 100644 --- a/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx +++ b/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx @@ -6,7 +6,11 @@ import { useGetTokenPrices } from "../hooks/useGetTokenPrices"; import { makeDummyStore } from "popup/__testHelpers__"; import { defaultBlockaidScanAssetResult } from "@shared/helpers/stellar"; import { RequestState } from "constants/request"; -import { MAINNET_NETWORK_DETAILS, NETWORKS } from "@shared/constants/stellar"; +import { + MAINNET_NETWORK_DETAILS, + NETWORKS, + TESTNET_NETWORK_DETAILS, +} from "@shared/constants/stellar"; import * as ApiInternal from "@shared/api/internal"; describe("useGetTokenPrices", () => { @@ -78,12 +82,14 @@ describe("useGetTokenPrices", () => { const preloadedState = { cache: { tokenPrices: { - G123: { - native: { - currentPrice: "1", - percentagePriceChange24h: ".5", + [NETWORKS.PUBLIC]: { + G123: { + native: { + currentPrice: "1", + percentagePriceChange24h: ".5", + }, + updatedAt: Date.now() - 60000, }, - updatedAt: Date.now() - 60000, }, }, }, @@ -137,12 +143,14 @@ describe("useGetTokenPrices", () => { const preloadedState = { cache: { tokenPrices: { - G123: { - native: { - currentPrice: "1", - percentagePriceChange24h: ".5", + [NETWORKS.PUBLIC]: { + G123: { + native: { + currentPrice: "1", + percentagePriceChange24h: ".5", + }, + updatedAt: Date.now(), }, - updatedAt: Date.now(), }, }, }, @@ -170,6 +178,7 @@ describe("useGetTokenPrices", () => { blockaidData: defaultBlockaidScanAssetResult, }, ], + networkDetails: MAINNET_NETWORK_DETAILS, useCache: true, } as any); }); @@ -182,6 +191,73 @@ describe("useGetTokenPrices", () => { }, }); }); + it("does not reuse another network's cache for the same account", async () => { + // Cache holds a fresh PUBLIC entry for G123. A cached request on TESTNET + // for the same account must ignore it and hit the network. + const getTokenPricesSpy = jest + .spyOn(ApiInternal, "getTokenPrices") + .mockImplementationOnce(() => + Promise.resolve({ + native: { + currentPrice: "2", + percentagePriceChange24h: ".75", + }, + }), + ); + const preloadedState = { + cache: { + tokenPrices: { + [NETWORKS.PUBLIC]: { + G123: { + native: { + currentPrice: "1", + percentagePriceChange24h: ".5", + }, + updatedAt: Date.now(), + }, + }, + }, + }, + }; + + const store = makeDummyStore(preloadedState); + const Wrapper = + (store: ReturnType) => + ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useGetTokenPrices(), { + wrapper: Wrapper(store), + }); + + await act(async () => { + await result.current.fetchData({ + publicKey: "G123", + balances: [ + { + token: { type: "native", code: "XLM" }, + total: new BigNumber("50"), + available: new BigNumber("50"), + blockaidData: defaultBlockaidScanAssetResult, + }, + ], + networkDetails: TESTNET_NETWORK_DETAILS, + useCache: true, + } as any); + }); + expect(getTokenPricesSpy).toHaveBeenCalledWith( + ["native"], + NETWORKS.TESTNET, + ); + expect(result.current.state.state).toBe(RequestState.SUCCESS); + expect(result.current.state.data?.tokenPrices).toEqual({ + native: { + currentPrice: "2", + percentagePriceChange24h: ".75", + }, + }); + }); it("should return no token prices for no balances", async () => { const getTokenPricesSpy = jest .spyOn(ApiInternal, "getTokenPrices") @@ -214,6 +290,7 @@ describe("useGetTokenPrices", () => { await result.current.fetchData({ publicKey: "G123", balances: [], + networkDetails: MAINNET_NETWORK_DETAILS, } as any); }); expect(getTokenPricesSpy).not.toHaveBeenCalled(); diff --git a/extension/src/helpers/hooks/useGetTokenPrices.tsx b/extension/src/helpers/hooks/useGetTokenPrices.tsx index 2654745654..444dcfd914 100644 --- a/extension/src/helpers/hooks/useGetTokenPrices.tsx +++ b/extension/src/helpers/hooks/useGetTokenPrices.tsx @@ -39,7 +39,8 @@ export function useGetTokenPrices() { dispatch({ type: "FETCH_DATA_START" }); let tokenPrices = {} as ApiTokenPrices; - const publicKeyTokenPrices = cachedTokenPrices[publicKey]; + const publicKeyTokenPrices = + cachedTokenPrices[networkDetails.network]?.[publicKey]; const payload = { type: AppDataType.RESOLVED, tokenPrices, @@ -69,7 +70,11 @@ export function useGetTokenPrices() { networkDetails.network as NETWORKS, ); reduxDispatch( - saveTokenPrices({ publicKey, tokenPrices: fetchedTokenPrices }), + saveTokenPrices({ + publicKey, + networkDetails, + tokenPrices: fetchedTokenPrices, + }), ); payload.tokenPrices = fetchedTokenPrices; } diff --git a/extension/src/popup/components/__tests__/AssetDetail.test.tsx b/extension/src/popup/components/__tests__/AssetDetail.test.tsx index 5a98f0138c..c2a85ba60e 100644 --- a/extension/src/popup/components/__tests__/AssetDetail.test.tsx +++ b/extension/src/popup/components/__tests__/AssetDetail.test.tsx @@ -283,12 +283,14 @@ describe("AssetDetail", () => { "test-img-src", }, tokenPrices: { - G1: { - "BAZ:GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF": - { - price: 1, - timestamp: 1718236800, - }, + [TESTNET_NETWORK_DETAILS.network]: { + G1: { + "BAZ:GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF": + { + price: 1, + timestamp: 1718236800, + }, + }, }, }, }, diff --git a/extension/src/popup/components/account/AssetDetail/index.tsx b/extension/src/popup/components/account/AssetDetail/index.tsx index c299e2cc06..1a7626a6d4 100644 --- a/extension/src/popup/components/account/AssetDetail/index.tsx +++ b/extension/src/popup/components/account/AssetDetail/index.tsx @@ -120,7 +120,8 @@ export const AssetDetail = ({ const [optionsOpen, setOptionsOpen] = React.useState(false); const activeOptionsRef = useRef(null); const isNative = selectedAsset === "native"; - const tokenPrices = cachedTokenPrices[publicKey] || null; + const tokenPrices = + cachedTokenPrices[networkDetails.network]?.[publicKey] || null; useEffect(() => { function handleClickOutside(event: MouseEvent) { diff --git a/extension/src/popup/ducks/cache.ts b/extension/src/popup/ducks/cache.ts index 93ccaeaca0..223c533434 100644 --- a/extension/src/popup/ducks/cache.ts +++ b/extension/src/popup/ducks/cache.ts @@ -43,6 +43,7 @@ type SaveTokenDetailsPayload = { contractId: string } & TokenDetailsResponse; type SaveTokenPricesPayload = { publicKey: string; + networkDetails: NetworkDetails; tokenPrices: ApiTokenPrices; }; @@ -69,7 +70,10 @@ interface InitialState { [network: string]: Record; }; tokenPrices: { - [publicKey: string]: ApiTokenPrices & { updatedAt: number }; + [network: string]: Record< + PublicKey, + ApiTokenPrices & { updatedAt: number } + >; }; collections: { [network: string]: Record }; } @@ -125,7 +129,11 @@ const cacheSlice = createSlice({ action.payload.publicKey ]; } - delete state.tokenPrices[action.payload.publicKey]; + if (state.tokenPrices[action.payload.networkDetails.network]) { + delete state.tokenPrices[action.payload.networkDetails.network][ + action.payload.publicKey + ]; + } }, saveIconsForBalances(state, action: { payload: SaveIconsPayload }) { state.icons = { @@ -154,10 +162,13 @@ const cacheSlice = createSlice({ saveTokenPrices(state, action: { payload: SaveTokenPricesPayload }) { state.tokenPrices = { ...state.tokenPrices, - [action.payload.publicKey]: { - ...action.payload.tokenPrices, - updatedAt: Date.now(), - } as ApiTokenPrices & { updatedAt: number }, + [action.payload.networkDetails.network]: { + ...state.tokenPrices[action.payload.networkDetails.network], + [action.payload.publicKey]: { + ...action.payload.tokenPrices, + updatedAt: Date.now(), + } as ApiTokenPrices & { updatedAt: number }, + }, }; }, saveCollections(state, action: { payload: SaveCollectionsPayload }) { From e820c360a486f3bfe32d071305e60853747c51a9 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 24 Jun 2026 11:55:49 -0600 Subject: [PATCH 04/11] refactor(token-prices): type getTokenPrices network param as string getTokenPrices was typed to accept only NETWORKS, but the implementation deliberately accepts any network string and short-circuits anything other than pubnet/testnet. Typing the parameter as NetworkDetails["network"] reflects that behavior and removes the unsafe `as NETWORKS` cast at the useGetTokenPrices call site (and its now-unused import). --- @shared/api/internal.ts | 2 +- extension/src/helpers/hooks/useGetTokenPrices.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index 995bdc59a5..ac17700c4c 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -601,7 +601,7 @@ export const getAccountIndexerBalances = async ({ export const getTokenPrices = async ( tokens: string[], - network: NETWORKS, + network: NetworkDetails["network"], ): Promise => { // The v2 token-prices endpoint only supports pubnet and testnet (Futurenet // and custom networks are rejected), so skip the call entirely on anything diff --git a/extension/src/helpers/hooks/useGetTokenPrices.tsx b/extension/src/helpers/hooks/useGetTokenPrices.tsx index 444dcfd914..b56c2500ca 100644 --- a/extension/src/helpers/hooks/useGetTokenPrices.tsx +++ b/extension/src/helpers/hooks/useGetTokenPrices.tsx @@ -7,7 +7,7 @@ import { initialState, isCacheValid, reducer } from "helpers/request"; import { AppDataType } from "helpers/hooks/useGetAppData"; import { getCanonicalFromAsset } from "helpers/stellar"; import { ApiTokenPrices } from "@shared/api/types"; -import { NETWORKS, NetworkDetails } from "@shared/constants/stellar"; +import { NetworkDetails } from "@shared/constants/stellar"; import { saveTokenPrices } from "popup/ducks/cache"; import { AppDispatch } from "popup/App"; import { AccountBalances } from "./useGetBalances"; @@ -67,7 +67,7 @@ export function useGetTokenPrices() { if (assetIds.length) { const fetchedTokenPrices = await getTokenPrices( assetIds, - networkDetails.network as NETWORKS, + networkDetails.network, ); reduxDispatch( saveTokenPrices({ From f1723ea671df7b593189f4a8b8cd588946aa6295 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 24 Jun 2026 11:59:27 -0600 Subject: [PATCH 05/11] fix(token-prices): skip request when all tokens are filtered out getTokenPrices filters out LP IDs and contract-ID issuers before calling the indexer. When that leaves nothing, it was still POSTing {tokens: []}, an unnecessary call that risks a 4xx the hook would surface as an error. Return {} early when filteredTokens is empty. --- @shared/api/__tests__/internal.test.ts | 15 +++++++++++++++ @shared/api/internal.ts | 5 +++++ 2 files changed, 20 insertions(+) diff --git a/@shared/api/__tests__/internal.test.ts b/@shared/api/__tests__/internal.test.ts index 794a923f87..a5fced5db7 100644 --- a/@shared/api/__tests__/internal.test.ts +++ b/@shared/api/__tests__/internal.test.ts @@ -197,5 +197,20 @@ describe("internalApi", () => { expect(fetchSpy).not.toHaveBeenCalled(); expect(prices).toEqual({}); }); + + it("skips the request when every token is filtered out", async () => { + const fetchSpy = mockFetchOk(); + + const prices = await internalApi.getTokenPrices( + [ + "abc123:lp", + "DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ", + ], + NETWORKS.PUBLIC, + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(prices).toEqual({}); + }); }); }); diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index ac17700c4c..f6c3ee2c66 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -614,6 +614,11 @@ export const getTokenPrices = async ( const asset = getAssetFromCanonical(tokenId); return !tokenId.includes(":lp") && !isContractId(asset.issuer); }); + // Nothing priceable left after filtering, so skip the request rather than + // POST an empty tokens array and risk a 4xx that surfaces as an error. + if (!filteredTokens.length) { + return {}; + } const url = new URL(`${INDEXER_V2_URL}/token-prices`); url.searchParams.append("network", network); const options = { From 5cb7d2e9920aff84cae5d3708b75a93a3f5553a9 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 24 Jun 2026 12:58:48 -0600 Subject: [PATCH 06/11] fix(token-prices): derive supported price network from passphrase A custom network sharing the pubnet/testnet passphrase is stored with network "STANDALONE", but isMainnet() (passphrase-based) still routes it into price loading. The previous guard keyed off networkDetails.network and returned {} for STANDALONE, regressing USD prices on custom Mainnet/Testnet configs that previously fetched them. Pass networkDetails into getTokenPrices and derive the price network from networkPassphrase (pubnet -> PUBLIC, testnet -> TESTNET, else skip), so custom networks resolve to the correct supported network and query the endpoint with the right network param. --- @shared/api/__tests__/internal.test.ts | 34 +++++++++++++++---- @shared/api/internal.ts | 19 +++++++---- .../__tests__/useGetTokenPrices.test.tsx | 12 +++++-- .../src/helpers/hooks/useGetTokenPrices.tsx | 2 +- .../__tests__/useGetWalletsData.test.tsx | 3 +- 5 files changed, 52 insertions(+), 18 deletions(-) diff --git a/@shared/api/__tests__/internal.test.ts b/@shared/api/__tests__/internal.test.ts index a5fced5db7..c38f43f6c1 100644 --- a/@shared/api/__tests__/internal.test.ts +++ b/@shared/api/__tests__/internal.test.ts @@ -1,4 +1,9 @@ -import { NETWORKS, TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { Networks } from "stellar-sdk"; +import { + FUTURENET_NETWORK_DETAILS, + MAINNET_NETWORK_DETAILS, + TESTNET_NETWORK_DETAILS, +} from "@shared/constants/stellar"; import * as GetLedgerKeyAccounts from "../helpers/getLedgerKeyAccounts"; import * as internalApi from "../internal"; @@ -152,7 +157,7 @@ describe("internalApi", () => { "USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", "DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ", ], - NETWORKS.PUBLIC, + MAINNET_NETWORK_DETAILS, ); const requestInit = fetchSpy.mock.calls[0][1] as RequestInit; @@ -168,7 +173,7 @@ describe("internalApi", () => { await internalApi.getTokenPrices( ["native", "abc123:lp"], - NETWORKS.PUBLIC, + MAINNET_NETWORK_DETAILS, ); const requestInit = fetchSpy.mock.calls[0][1] as RequestInit; @@ -179,19 +184,36 @@ describe("internalApi", () => { it("targets the v2 endpoint with the network query param", async () => { const fetchSpy = mockFetchOk(); - await internalApi.getTokenPrices(["native"], NETWORKS.TESTNET); + await internalApi.getTokenPrices(["native"], TESTNET_NETWORK_DETAILS); const requestUrl = fetchSpy.mock.calls[0][0] as string; expect(requestUrl).toContain("/token-prices"); expect(requestUrl).toContain("network=TESTNET"); }); + it("derives the price network from the passphrase for custom networks", async () => { + const fetchSpy = mockFetchOk(); + + // Custom network stored as STANDALONE but sharing the pubnet passphrase + // must still resolve to PUBLIC and hit the endpoint. + await internalApi.getTokenPrices(["native"], { + ...MAINNET_NETWORK_DETAILS, + network: "STANDALONE", + networkName: "Custom Pubnet", + networkPassphrase: Networks.PUBLIC, + }); + + expect(fetchSpy).toHaveBeenCalled(); + const requestUrl = fetchSpy.mock.calls[0][0] as string; + expect(requestUrl).toContain("network=PUBLIC"); + }); + it("skips the request on unsupported networks", async () => { const fetchSpy = mockFetchOk(); const prices = await internalApi.getTokenPrices( ["native"], - NETWORKS.FUTURENET, + FUTURENET_NETWORK_DETAILS, ); expect(fetchSpy).not.toHaveBeenCalled(); @@ -206,7 +228,7 @@ describe("internalApi", () => { "abc123:lp", "DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ", ], - NETWORKS.PUBLIC, + MAINNET_NETWORK_DETAILS, ); expect(fetchSpy).not.toHaveBeenCalled(); diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index f6c3ee2c66..8d806aa2ec 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -601,12 +601,19 @@ export const getAccountIndexerBalances = async ({ export const getTokenPrices = async ( tokens: string[], - network: NetworkDetails["network"], + networkDetails: NetworkDetails, ): Promise => { - // The v2 token-prices endpoint only supports pubnet and testnet (Futurenet - // and custom networks are rejected), so skip the call entirely on anything - // else to avoid a guaranteed error and Sentry noise. - if (network !== NETWORKS.PUBLIC && network !== NETWORKS.TESTNET) { + // The v2 token-prices endpoint only supports pubnet and testnet. Derive the + // price network from the passphrase rather than networkDetails.network so + // that custom networks sharing the pubnet/testnet passphrase (stored as + // STANDALONE) still resolve to the correct supported network. Anything else + // (Futurenet, custom passphrases) is skipped to avoid a guaranteed error and + // Sentry noise. + const priceNetwork = { + [Networks.PUBLIC]: NETWORKS.PUBLIC, + [Networks.TESTNET]: NETWORKS.TESTNET, + }[networkDetails.networkPassphrase]; + if (!priceNetwork) { return {}; } // NOTE: API does not accept LP IDs or custom tokens @@ -620,7 +627,7 @@ export const getTokenPrices = async ( return {}; } const url = new URL(`${INDEXER_V2_URL}/token-prices`); - url.searchParams.append("network", network); + url.searchParams.append("network", priceNetwork); const options = { method: "POST", headers: { diff --git a/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx b/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx index 1abcf45ed9..fb5892f12c 100644 --- a/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx +++ b/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx @@ -59,7 +59,10 @@ describe("useGetTokenPrices", () => { networkDetails: MAINNET_NETWORK_DETAILS, } as any); }); - expect(getTokenPricesSpy).toHaveBeenCalledWith(["native"], NETWORKS.PUBLIC); + expect(getTokenPricesSpy).toHaveBeenCalledWith( + ["native"], + MAINNET_NETWORK_DETAILS, + ); expect(result.current.state.state).toBe(RequestState.SUCCESS); expect(result.current.state.data?.tokenPrices).toEqual({ native: { @@ -120,7 +123,10 @@ describe("useGetTokenPrices", () => { networkDetails: MAINNET_NETWORK_DETAILS, } as any); }); - expect(getTokenPricesSpy).toHaveBeenCalledWith(["native"], NETWORKS.PUBLIC); + expect(getTokenPricesSpy).toHaveBeenCalledWith( + ["native"], + MAINNET_NETWORK_DETAILS, + ); expect(result.current.state.state).toBe(RequestState.SUCCESS); expect(result.current.state.data?.tokenPrices).toEqual({ native: { @@ -248,7 +254,7 @@ describe("useGetTokenPrices", () => { }); expect(getTokenPricesSpy).toHaveBeenCalledWith( ["native"], - NETWORKS.TESTNET, + TESTNET_NETWORK_DETAILS, ); expect(result.current.state.state).toBe(RequestState.SUCCESS); expect(result.current.state.data?.tokenPrices).toEqual({ diff --git a/extension/src/helpers/hooks/useGetTokenPrices.tsx b/extension/src/helpers/hooks/useGetTokenPrices.tsx index b56c2500ca..a29895e517 100644 --- a/extension/src/helpers/hooks/useGetTokenPrices.tsx +++ b/extension/src/helpers/hooks/useGetTokenPrices.tsx @@ -67,7 +67,7 @@ export function useGetTokenPrices() { if (assetIds.length) { const fetchedTokenPrices = await getTokenPrices( assetIds, - networkDetails.network, + networkDetails, ); reduxDispatch( saveTokenPrices({ diff --git a/extension/src/popup/views/Wallets/hooks/__tests__/useGetWalletsData.test.tsx b/extension/src/popup/views/Wallets/hooks/__tests__/useGetWalletsData.test.tsx index 510f93e084..93226353df 100644 --- a/extension/src/popup/views/Wallets/hooks/__tests__/useGetWalletsData.test.tsx +++ b/extension/src/popup/views/Wallets/hooks/__tests__/useGetWalletsData.test.tsx @@ -13,7 +13,6 @@ import { RequestState } from "constants/request"; import * as ApiInternal from "@shared/api/internal"; import { MAINNET_NETWORK_DETAILS, - NETWORKS, TESTNET_NETWORK_DETAILS, } from "@shared/constants/stellar"; import { AppDataType } from "helpers/hooks/useGetAppData"; @@ -228,7 +227,7 @@ describe("useGetWalletsData", () => { "DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ", "USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", ], - NETWORKS.PUBLIC, + MAINNET_NETWORK_DETAILS, ); expect(result.current.state.data).toEqual({ accountValue: { From 226e0c88fe1e7782023e692335689d58667eb195 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 24 Jun 2026 13:37:00 -0600 Subject: [PATCH 07/11] fix(token-prices): key price cache by passphrase, not network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The request network is derived from the passphrase, but the cache still keyed entries by networkDetails.network — the shared "STANDALONE" value for every custom network. Within the cache TTL, switching the same account between two custom networks (pubnet/testnet/unsupported) made useCache:true reuse the stale {} or prices and skip the correctly parameterized v2 request, so USD values went missing or came from the wrong network. Key tokenPrices by networkPassphrase, which is exactly what determines the price response. Networks sharing a passphrase share a cache entry (correct); custom pubnet vs custom testnet no longer collide; switching from an unsupported custom network to a supported one is now a cache miss and fetches. --- .../__tests__/useGetTokenPrices.test.tsx | 81 ++++++++++++++++++- .../src/helpers/hooks/useGetTokenPrices.tsx | 2 +- .../components/__tests__/AssetDetail.test.tsx | 2 +- .../components/account/AssetDetail/index.tsx | 2 +- extension/src/popup/ducks/cache.ts | 18 +++-- 5 files changed, 91 insertions(+), 14 deletions(-) diff --git a/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx b/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx index fb5892f12c..57dc9ab2ab 100644 --- a/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx +++ b/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx @@ -8,7 +8,6 @@ import { defaultBlockaidScanAssetResult } from "@shared/helpers/stellar"; import { RequestState } from "constants/request"; import { MAINNET_NETWORK_DETAILS, - NETWORKS, TESTNET_NETWORK_DETAILS, } from "@shared/constants/stellar"; import * as ApiInternal from "@shared/api/internal"; @@ -85,7 +84,7 @@ describe("useGetTokenPrices", () => { const preloadedState = { cache: { tokenPrices: { - [NETWORKS.PUBLIC]: { + [MAINNET_NETWORK_DETAILS.networkPassphrase]: { G123: { native: { currentPrice: "1", @@ -149,7 +148,7 @@ describe("useGetTokenPrices", () => { const preloadedState = { cache: { tokenPrices: { - [NETWORKS.PUBLIC]: { + [MAINNET_NETWORK_DETAILS.networkPassphrase]: { G123: { native: { currentPrice: "1", @@ -213,7 +212,7 @@ describe("useGetTokenPrices", () => { const preloadedState = { cache: { tokenPrices: { - [NETWORKS.PUBLIC]: { + [MAINNET_NETWORK_DETAILS.networkPassphrase]: { G123: { native: { currentPrice: "1", @@ -264,6 +263,80 @@ describe("useGetTokenPrices", () => { }, }); }); + it("does not reuse one custom network's cache for another", async () => { + // Both custom networks share the STANDALONE network value but differ by + // passphrase. A fresh entry cached for the pubnet-passphrase custom network + // must not be reused for a testnet-passphrase custom network. + const customPubnet = { + ...MAINNET_NETWORK_DETAILS, + network: "STANDALONE", + networkName: "Custom Pubnet", + }; + const customTestnet = { + ...TESTNET_NETWORK_DETAILS, + network: "STANDALONE", + networkName: "Custom Testnet", + }; + const getTokenPricesSpy = jest + .spyOn(ApiInternal, "getTokenPrices") + .mockImplementationOnce(() => + Promise.resolve({ + native: { + currentPrice: "2", + percentagePriceChange24h: ".75", + }, + }), + ); + const preloadedState = { + cache: { + tokenPrices: { + [customPubnet.networkPassphrase]: { + G123: { + native: { + currentPrice: "1", + percentagePriceChange24h: ".5", + }, + updatedAt: Date.now(), + }, + }, + }, + }, + }; + + const store = makeDummyStore(preloadedState); + const Wrapper = + (store: ReturnType) => + ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useGetTokenPrices(), { + wrapper: Wrapper(store), + }); + + await act(async () => { + await result.current.fetchData({ + publicKey: "G123", + balances: [ + { + token: { type: "native", code: "XLM" }, + total: new BigNumber("50"), + available: new BigNumber("50"), + blockaidData: defaultBlockaidScanAssetResult, + }, + ], + networkDetails: customTestnet, + useCache: true, + } as any); + }); + expect(getTokenPricesSpy).toHaveBeenCalledWith(["native"], customTestnet); + expect(result.current.state.data?.tokenPrices).toEqual({ + native: { + currentPrice: "2", + percentagePriceChange24h: ".75", + }, + }); + }); it("should return no token prices for no balances", async () => { const getTokenPricesSpy = jest .spyOn(ApiInternal, "getTokenPrices") diff --git a/extension/src/helpers/hooks/useGetTokenPrices.tsx b/extension/src/helpers/hooks/useGetTokenPrices.tsx index a29895e517..d2da6d8d31 100644 --- a/extension/src/helpers/hooks/useGetTokenPrices.tsx +++ b/extension/src/helpers/hooks/useGetTokenPrices.tsx @@ -40,7 +40,7 @@ export function useGetTokenPrices() { let tokenPrices = {} as ApiTokenPrices; const publicKeyTokenPrices = - cachedTokenPrices[networkDetails.network]?.[publicKey]; + cachedTokenPrices[networkDetails.networkPassphrase]?.[publicKey]; const payload = { type: AppDataType.RESOLVED, tokenPrices, diff --git a/extension/src/popup/components/__tests__/AssetDetail.test.tsx b/extension/src/popup/components/__tests__/AssetDetail.test.tsx index c2a85ba60e..0676c0e6ff 100644 --- a/extension/src/popup/components/__tests__/AssetDetail.test.tsx +++ b/extension/src/popup/components/__tests__/AssetDetail.test.tsx @@ -283,7 +283,7 @@ describe("AssetDetail", () => { "test-img-src", }, tokenPrices: { - [TESTNET_NETWORK_DETAILS.network]: { + [TESTNET_NETWORK_DETAILS.networkPassphrase]: { G1: { "BAZ:GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF": { diff --git a/extension/src/popup/components/account/AssetDetail/index.tsx b/extension/src/popup/components/account/AssetDetail/index.tsx index 1a7626a6d4..bae58937e8 100644 --- a/extension/src/popup/components/account/AssetDetail/index.tsx +++ b/extension/src/popup/components/account/AssetDetail/index.tsx @@ -121,7 +121,7 @@ export const AssetDetail = ({ const activeOptionsRef = useRef(null); const isNative = selectedAsset === "native"; const tokenPrices = - cachedTokenPrices[networkDetails.network]?.[publicKey] || null; + cachedTokenPrices[networkDetails.networkPassphrase]?.[publicKey] || null; useEffect(() => { function handleClickOutside(event: MouseEvent) { diff --git a/extension/src/popup/ducks/cache.ts b/extension/src/popup/ducks/cache.ts index 223c533434..fe2ad6cd56 100644 --- a/extension/src/popup/ducks/cache.ts +++ b/extension/src/popup/ducks/cache.ts @@ -69,8 +69,11 @@ interface InitialState { historyData: { [network: string]: Record; }; + // Keyed by networkPassphrase, not network: custom networks all share the + // "STANDALONE" network value, but prices are determined by the passphrase + // (which is what getTokenPrices uses to choose PUBLIC vs TESTNET). tokenPrices: { - [network: string]: Record< + [networkPassphrase: string]: Record< PublicKey, ApiTokenPrices & { updatedAt: number } >; @@ -129,10 +132,10 @@ const cacheSlice = createSlice({ action.payload.publicKey ]; } - if (state.tokenPrices[action.payload.networkDetails.network]) { - delete state.tokenPrices[action.payload.networkDetails.network][ - action.payload.publicKey - ]; + if (state.tokenPrices[action.payload.networkDetails.networkPassphrase]) { + delete state.tokenPrices[ + action.payload.networkDetails.networkPassphrase + ][action.payload.publicKey]; } }, saveIconsForBalances(state, action: { payload: SaveIconsPayload }) { @@ -160,10 +163,11 @@ const cacheSlice = createSlice({ }; }, saveTokenPrices(state, action: { payload: SaveTokenPricesPayload }) { + const { networkPassphrase } = action.payload.networkDetails; state.tokenPrices = { ...state.tokenPrices, - [action.payload.networkDetails.network]: { - ...state.tokenPrices[action.payload.networkDetails.network], + [networkPassphrase]: { + ...state.tokenPrices[networkPassphrase], [action.payload.publicKey]: { ...action.payload.tokenPrices, updatedAt: Date.now(), From 2cc3040d5d2d1b97f41334dc53da977d93a132b9 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Thu, 25 Jun 2026 14:42:03 -0600 Subject: [PATCH 08/11] feat(token-prices): gate v1/v2 endpoint behind Amplitude flag Add the `use_token_prices_v2` Amplitude Experiment flag to toggle the token-prices endpoint between v1 (`INDEXER_URL`) and v2 (`INDEXER_V2_URL`) at runtime, so v2 can be rolled back without a release. - Register `use_token_prices_v2` as a boolean flag in the remoteConfig duck, defaulting to `true` (v2). It survives a missing flag, a null Experiment client, and a rejected fetch. Add `tokenPricesV2Selector`. - Branch `getTokenPrices` on a `useV2` param (default `true`): v2 keeps the passphrase-derived network, unsupported-network/empty-token skips, and `?network=` query param; v1 restores the original endpoint with no network param and no skips. Token filtering and fetch/error handling stay shared. - Read the flag in `useGetTokenPrices` from the store at call time (`store.getState()`) rather than a render-captured selector value, so a freshly resolved flag isn't missed when the fetch runs inside a long-lived async flow that closed over the stale default. - Update the maintenanceMode preloaded state for the new required key; add v1-endpoint and flag-resolution test coverage. --- @shared/api/__tests__/internal.test.ts | 70 +++++++++++++++++++ @shared/api/internal.ts | 47 ++++++++----- .../src/helpers/hooks/useGetTokenPrices.tsx | 12 +++- .../__tests__/maintenanceMode.test.tsx | 1 + .../ducks/__tests__/remoteConfig.test.ts | 31 ++++++++ extension/src/popup/ducks/remoteConfig.ts | 16 ++++- .../__tests__/useGetWalletsData.test.tsx | 1 + 7 files changed, 155 insertions(+), 23 deletions(-) diff --git a/@shared/api/__tests__/internal.test.ts b/@shared/api/__tests__/internal.test.ts index c38f43f6c1..cadc927d34 100644 --- a/@shared/api/__tests__/internal.test.ts +++ b/@shared/api/__tests__/internal.test.ts @@ -235,4 +235,74 @@ describe("internalApi", () => { expect(prices).toEqual({}); }); }); + + describe("getTokenPrices v1 endpoint (useV2 = false)", () => { + const mockFetchOk = () => + jest.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ data: {} }), + } as unknown as Response); + + it("targets the v1 endpoint without a network query param", async () => { + const fetchSpy = mockFetchOk(); + + await internalApi.getTokenPrices( + ["native"], + TESTNET_NETWORK_DETAILS, + false, + ); + + const requestUrl = fetchSpy.mock.calls[0][0] as string; + expect(requestUrl).toContain("/token-prices"); + expect(requestUrl).not.toContain("network="); + }); + + it("still filters LP IDs and contract-ID issuers from the request", async () => { + const fetchSpy = mockFetchOk(); + + await internalApi.getTokenPrices( + [ + "native", + "abc123:lp", + "DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ", + ], + MAINNET_NETWORK_DETAILS, + false, + ); + + const requestInit = fetchSpy.mock.calls[0][1] as RequestInit; + const body = JSON.parse(requestInit.body as string); + expect(body.tokens).toEqual(["native"]); + }); + + it("does NOT skip unsupported networks (unlike v2)", async () => { + const fetchSpy = mockFetchOk(); + + await internalApi.getTokenPrices( + ["native"], + FUTURENET_NETWORK_DETAILS, + false, + ); + + expect(fetchSpy).toHaveBeenCalled(); + }); + + it("does NOT skip when every token is filtered out (unlike v2)", async () => { + const fetchSpy = mockFetchOk(); + + await internalApi.getTokenPrices( + [ + "abc123:lp", + "DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ", + ], + MAINNET_NETWORK_DETAILS, + false, + ); + + expect(fetchSpy).toHaveBeenCalled(); + const requestInit = fetchSpy.mock.calls[0][1] as RequestInit; + const body = JSON.parse(requestInit.body as string); + expect(body.tokens).toEqual([]); + }); + }); }); diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index 8d806aa2ec..a09cad2888 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -602,32 +602,41 @@ export const getAccountIndexerBalances = async ({ export const getTokenPrices = async ( tokens: string[], networkDetails: NetworkDetails, + // Defaults to the v2 endpoint. Callers pass the `use_token_prices_v2` feature + // flag so Amplitude can roll back to the v1 endpoint without a release. + useV2 = true, ): Promise => { - // The v2 token-prices endpoint only supports pubnet and testnet. Derive the - // price network from the passphrase rather than networkDetails.network so - // that custom networks sharing the pubnet/testnet passphrase (stored as - // STANDALONE) still resolve to the correct supported network. Anything else - // (Futurenet, custom passphrases) is skipped to avoid a guaranteed error and - // Sentry noise. - const priceNetwork = { - [Networks.PUBLIC]: NETWORKS.PUBLIC, - [Networks.TESTNET]: NETWORKS.TESTNET, - }[networkDetails.networkPassphrase]; - if (!priceNetwork) { - return {}; - } // NOTE: API does not accept LP IDs or custom tokens const filteredTokens = tokens.filter((tokenId) => { const asset = getAssetFromCanonical(tokenId); return !tokenId.includes(":lp") && !isContractId(asset.issuer); }); - // Nothing priceable left after filtering, so skip the request rather than - // POST an empty tokens array and risk a 4xx that surfaces as an error. - if (!filteredTokens.length) { - return {}; + + let url: URL; + if (useV2) { + // The v2 token-prices endpoint only supports pubnet and testnet. Derive the + // price network from the passphrase rather than networkDetails.network so + // that custom networks sharing the pubnet/testnet passphrase (stored as + // STANDALONE) still resolve to the correct supported network. Anything else + // (Futurenet, custom passphrases) is skipped to avoid a guaranteed error and + // Sentry noise. + const priceNetwork = { + [Networks.PUBLIC]: NETWORKS.PUBLIC, + [Networks.TESTNET]: NETWORKS.TESTNET, + }[networkDetails.networkPassphrase]; + if (!priceNetwork) { + return {}; + } + // Nothing priceable left after filtering, so skip the request rather than + // POST an empty tokens array and risk a 4xx that surfaces as an error. + if (!filteredTokens.length) { + return {}; + } + url = new URL(`${INDEXER_V2_URL}/token-prices`); + url.searchParams.append("network", priceNetwork); + } else { + url = new URL(`${INDEXER_URL}/token-prices`); } - const url = new URL(`${INDEXER_V2_URL}/token-prices`); - url.searchParams.append("network", priceNetwork); const options = { method: "POST", headers: { diff --git a/extension/src/helpers/hooks/useGetTokenPrices.tsx b/extension/src/helpers/hooks/useGetTokenPrices.tsx index d2da6d8d31..3c0fc2002f 100644 --- a/extension/src/helpers/hooks/useGetTokenPrices.tsx +++ b/extension/src/helpers/hooks/useGetTokenPrices.tsx @@ -1,7 +1,8 @@ import { useReducer } from "react"; import { captureException } from "@sentry/browser"; -import { useDispatch, useSelector } from "react-redux"; +import { useDispatch, useSelector, useStore } from "react-redux"; import { tokenPricesSelector } from "popup/ducks/cache"; +import { tokenPricesV2Selector } from "popup/ducks/remoteConfig"; import { getTokenPrices } from "@shared/api/internal"; import { initialState, isCacheValid, reducer } from "helpers/request"; import { AppDataType } from "helpers/hooks/useGetAppData"; @@ -9,7 +10,7 @@ import { getCanonicalFromAsset } from "helpers/stellar"; import { ApiTokenPrices } from "@shared/api/types"; import { NetworkDetails } from "@shared/constants/stellar"; import { saveTokenPrices } from "popup/ducks/cache"; -import { AppDispatch } from "popup/App"; +import { AppDispatch, AppState } from "popup/App"; import { AccountBalances } from "./useGetBalances"; export interface GetTokenPricesData { @@ -19,6 +20,7 @@ export interface GetTokenPricesData { export function useGetTokenPrices() { const reduxDispatch = useDispatch(); + const store = useStore(); const cachedTokenPrices = useSelector(tokenPricesSelector); const [state, dispatch] = useReducer( @@ -65,9 +67,15 @@ export function useGetTokenPrices() { ), ); if (assetIds.length) { + // Read the flag from the store at call time (not a render-captured + // value) so a freshly resolved Amplitude flag isn't missed when this + // fetch runs inside a long-lived async flow that closed over a stale + // default. + const useV2 = tokenPricesV2Selector(store.getState()); const fetchedTokenPrices = await getTokenPrices( assetIds, networkDetails, + useV2, ); reduxDispatch( saveTokenPrices({ diff --git a/extension/src/popup/components/__tests__/maintenanceMode.test.tsx b/extension/src/popup/components/__tests__/maintenanceMode.test.tsx index 8b77101aeb..1fef1e7256 100644 --- a/extension/src/popup/components/__tests__/maintenanceMode.test.tsx +++ b/extension/src/popup/components/__tests__/maintenanceMode.test.tsx @@ -53,6 +53,7 @@ function makeStore(overrides?: { preloadedState: { remoteConfig: { isInitialized: true, + use_token_prices_v2: true, maintenance_screen: overrides?.maintenance_screen ?? { enabled: false, payload: undefined, diff --git a/extension/src/popup/ducks/__tests__/remoteConfig.test.ts b/extension/src/popup/ducks/__tests__/remoteConfig.test.ts index 034b129ae8..fa491ab255 100644 --- a/extension/src/popup/ducks/__tests__/remoteConfig.test.ts +++ b/extension/src/popup/ducks/__tests__/remoteConfig.test.ts @@ -5,6 +5,7 @@ import { maintenanceBannerSelector, maintenanceScreenSelector, isRemoteConfigInitializedSelector, + tokenPricesV2Selector, reducer, } from "../remoteConfig"; import { @@ -72,6 +73,11 @@ describe("remoteConfig duck — initial state", () => { expect(getState(store).isInitialized).toBe(false); }); + it("defaults use_token_prices_v2 to true", () => { + const store = makeStore(); + expect(getState(store).use_token_prices_v2).toBe(true); + }); + it("has both maintenance flags disabled with no payload by default", () => { const store = makeStore(); const state = getState(store); @@ -363,4 +369,29 @@ describe("remoteConfig selectors", () => { const store = makeStore(); expect(isRemoteConfigInitializedSelector(store.getState())).toBe(false); }); + + it("tokenPricesV2Selector defaults to true", () => { + const store = makeStore(); + expect(tokenPricesV2Selector(store.getState())).toBe(true); + }); + + it("tokenPricesV2Selector stays true when Amplitude omits the flag", async () => { + (getExperimentClient as jest.Mock).mockReturnValue(makeClient()); + + const store = makeStore(); + await store.dispatch(fetchFeatureFlags()); + expect(tokenPricesV2Selector(store.getState())).toBe(true); + }); + + it("tokenPricesV2Selector rolls back to false when variant is off", async () => { + (getExperimentClient as jest.Mock).mockReturnValue( + makeClient({ + use_token_prices_v2: { value: "off" }, + }), + ); + + const store = makeStore(); + await store.dispatch(fetchFeatureFlags()); + expect(tokenPricesV2Selector(store.getState())).toBe(false); + }); }); diff --git a/extension/src/popup/ducks/remoteConfig.ts b/extension/src/popup/ducks/remoteConfig.ts index bcd0ba9915..3d805bc81a 100644 --- a/extension/src/popup/ducks/remoteConfig.ts +++ b/extension/src/popup/ducks/remoteConfig.ts @@ -24,9 +24,9 @@ const ON_VARIANT_VALUES = ["on", "true", "enabled", "yes"]; /** * Boolean flags — variant value is checked against ON_VARIANT_VALUES. - * Empty for now; add flag names here as new boolean flags are introduced. + * Add flag names here as new boolean flags are introduced. */ -const BOOLEAN_FLAGS = [] as const; +const BOOLEAN_FLAGS = ["use_token_prices_v2"] as const; /** * Version flags — variant value is parsed from underscore format (1_2_3 → 1.2.3). @@ -82,6 +82,9 @@ interface RemoteConfigState extends FeatureFlags { const initialState: RemoteConfigState = { isInitialized: false, + // Defaults to v2; Amplitude can flip it off to roll back to the v1 + // token-prices endpoint without a release. + use_token_prices_v2: true, maintenance_banner: { enabled: false, payload: undefined }, maintenance_screen: { enabled: false, payload: undefined }, }; @@ -217,6 +220,15 @@ export const maintenanceScreenSelector = createSelector( }, ); +/** + * Returns whether the v2 token-prices endpoint should be used. Defaults to + * true; Amplitude can flip the flag off to roll back to the v1 endpoint. + */ +export const tokenPricesV2Selector = createSelector( + remoteConfigSelector, + (rc) => rc.use_token_prices_v2, +); + /** * Returns true once the Experiment flags have been fetched (or failed). */ diff --git a/extension/src/popup/views/Wallets/hooks/__tests__/useGetWalletsData.test.tsx b/extension/src/popup/views/Wallets/hooks/__tests__/useGetWalletsData.test.tsx index 93226353df..b19c2d41b7 100644 --- a/extension/src/popup/views/Wallets/hooks/__tests__/useGetWalletsData.test.tsx +++ b/extension/src/popup/views/Wallets/hooks/__tests__/useGetWalletsData.test.tsx @@ -228,6 +228,7 @@ describe("useGetWalletsData", () => { "USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", ], MAINNET_NETWORK_DETAILS, + true, ); expect(result.current.state.data).toEqual({ accountValue: { From cbe24c1a429927aeb01498279f41db580c932052 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Thu, 25 Jun 2026 14:54:37 -0600 Subject: [PATCH 09/11] test(token-prices): pass useV2 arg in getTokenPrices call assertions The useGetTokenPrices hook now calls getTokenPrices with a third `useV2` argument, resolved from the remoteConfig store (default `true` via makeDummyStore). Since toHaveBeenCalledWith matches the full argument list, the existing two-argument expectations failed even though the hook behaved correctly. Add the explicit `true` to the affected assertions in useGetTokenPrices.test.tsx (MAINNET, TESTNET, and customTestnet cases). Using the literal value rather than a matcher also documents that the default store resolves to the v2 endpoint. --- .../src/helpers/__tests__/useGetTokenPrices.test.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx b/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx index 57dc9ab2ab..9808b8362a 100644 --- a/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx +++ b/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx @@ -61,6 +61,7 @@ describe("useGetTokenPrices", () => { expect(getTokenPricesSpy).toHaveBeenCalledWith( ["native"], MAINNET_NETWORK_DETAILS, + true, ); expect(result.current.state.state).toBe(RequestState.SUCCESS); expect(result.current.state.data?.tokenPrices).toEqual({ @@ -125,6 +126,7 @@ describe("useGetTokenPrices", () => { expect(getTokenPricesSpy).toHaveBeenCalledWith( ["native"], MAINNET_NETWORK_DETAILS, + true, ); expect(result.current.state.state).toBe(RequestState.SUCCESS); expect(result.current.state.data?.tokenPrices).toEqual({ @@ -254,6 +256,7 @@ describe("useGetTokenPrices", () => { expect(getTokenPricesSpy).toHaveBeenCalledWith( ["native"], TESTNET_NETWORK_DETAILS, + true, ); expect(result.current.state.state).toBe(RequestState.SUCCESS); expect(result.current.state.data?.tokenPrices).toEqual({ @@ -329,7 +332,11 @@ describe("useGetTokenPrices", () => { useCache: true, } as any); }); - expect(getTokenPricesSpy).toHaveBeenCalledWith(["native"], customTestnet); + expect(getTokenPricesSpy).toHaveBeenCalledWith( + ["native"], + customTestnet, + true, + ); expect(result.current.state.data?.tokenPrices).toEqual({ native: { currentPrice: "2", From 41f081179610a760cb2495759722456816aa4017 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 26 Jun 2026 09:30:37 -0600 Subject: [PATCH 10/11] =?UTF-8?q?refactor(token-prices):=20extract=20passp?= =?UTF-8?q?hrase=E2=86=92price-network=20map=20to=20shared=20constants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The passphrase→NETWORKS map that getTokenPrices uses to derive the price-request network was an inline anonymous literal in the API layer. It was the codebase's only passphrase→NETWORKS mapping, so the set of price-supported networks had no discoverable home. Move it to @shared/constants/stellar.ts as PASSPHRASE_TO_PRICE_NETWORK, beside the existing PASSPHRASE_TO_NETWORK_NAME map, giving the supported price networks a single source of truth. Behavior is unchanged. --- @shared/api/internal.ts | 7 +++---- @shared/constants/stellar.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index a09cad2888..17e977d01c 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -76,6 +76,7 @@ import { DEFAULT_NETWORKS, NetworkDetails, NETWORKS, + PASSPHRASE_TO_PRICE_NETWORK, } from "../constants/stellar"; import { SERVICE_TYPES } from "../constants/services"; import { isDev } from "../helpers/dev"; @@ -620,10 +621,8 @@ export const getTokenPrices = async ( // STANDALONE) still resolve to the correct supported network. Anything else // (Futurenet, custom passphrases) is skipped to avoid a guaranteed error and // Sentry noise. - const priceNetwork = { - [Networks.PUBLIC]: NETWORKS.PUBLIC, - [Networks.TESTNET]: NETWORKS.TESTNET, - }[networkDetails.networkPassphrase]; + const priceNetwork = + PASSPHRASE_TO_PRICE_NETWORK[networkDetails.networkPassphrase]; if (!priceNetwork) { return {}; } diff --git a/@shared/constants/stellar.ts b/@shared/constants/stellar.ts index 537e6287d0..6fda68e75c 100644 --- a/@shared/constants/stellar.ts +++ b/@shared/constants/stellar.ts @@ -78,3 +78,11 @@ export const PASSPHRASE_TO_NETWORK_NAME: Record = { [Networks.TESTNET]: NETWORK_NAMES.TESTNET, [FUTURENET_NETWORK_DETAILS.networkPassphrase]: NETWORK_NAMES.FUTURENET, }; + +// The token-prices endpoint only supports pubnet and testnet. This map is the +// single source of truth for which passphrases resolve to a price-supported +// network; anything not listed here is skipped by getTokenPrices. +export const PASSPHRASE_TO_PRICE_NETWORK: Record = { + [Networks.PUBLIC]: NETWORKS.PUBLIC, + [Networks.TESTNET]: NETWORKS.TESTNET, +}; From 6c70dbdd587df9e822e1c2060478f892e809744d Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 26 Jun 2026 09:56:09 -0600 Subject: [PATCH 11/11] test(token-prices): fix stubSendTokenPrices glob to match v2 query URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With v2 enabled, getTokenPrices requests /token-prices?network= instead of a bare /token-prices. Playwright globs must match the entire URL, so the "**/token-prices" route in stubSendTokenPrices no longer intercepted the request — the two Mainnet send-workflow tests could hit the real indexer or time out instead of using deterministic stub prices. Update the glob to "**/token-prices*", matching the v2 query-bearing URL and the existing stubTokenPrices convention in helpers/stubs.ts. --- extension/e2e-tests/sendPayment.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/e2e-tests/sendPayment.test.ts b/extension/e2e-tests/sendPayment.test.ts index 9e9cbc10a3..2adc217343 100644 --- a/extension/e2e-tests/sendPayment.test.ts +++ b/extension/e2e-tests/sendPayment.test.ts @@ -25,7 +25,7 @@ function visibleTokenList(page: Page) { } async function stubSendTokenPrices(context: BrowserContext) { - await context.route("**/token-prices", async (route) => { + await context.route("**/token-prices*", async (route) => { const request = route.request(); let tokenIds = [] as string[];