From 6f746d19c187a173d4112d046435a5b974e91e9c Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Thu, 25 Jun 2026 16:30:33 -0600 Subject: [PATCH 01/14] feat(prices): migrate token prices to network-scoped v2 endpoint Port stellar/freighter#2870 to mobile. fetchTokenPrices now hits the v2 /token-prices endpoint with a `?network=` query param, gated behind a `use_token_prices_v2` remote-config flag (defaults to v2, rollback to v1 from Amplitude without a release). - remoteConfig: add use_token_prices_v2 boolean flag, default true in both the dev and prod initial-state branches - backend: fetchTokenPrices takes `network` + `useV2`; v2 POSTs to freighterBackendV2 with the network query param, v1 path unchanged. Unsupported networks (Futurenet) and empty token lists short-circuit to a null-filled map with no request, avoiding guaranteed-failing calls and Sentry noise - prices store: both fetch methods read the flag and thread network/useV2 through; fetchPricesForTokenIds gains a required `network` param - thread the active network from useAuthenticationStore through useSwapTokenPrices and useTransactionBalanceListItems - tests: cover v2/v1 selection, testnet mapping, and the Futurenet short-circuit; update prices and SwapAmountScreen assertions --- .../SwapScreen/SwapAmountScreen.test.tsx | 2 + __tests__/ducks/prices.test.ts | 27 +++- __tests__/services/backend.test.ts | 68 ++++++++++ .../2026-06-25-token-prices-v2-design.md | 123 ++++++++++++++++++ .../SwapScreen/hooks/useSwapTokenPrices.ts | 10 +- src/ducks/prices.ts | 17 ++- src/ducks/remoteConfig.ts | 3 + .../useTransactionBalanceListItems.tsx | 4 +- src/services/backend.ts | 64 +++++++-- 9 files changed, 295 insertions(+), 23 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-25-token-prices-v2-design.md diff --git a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx index d8f8a4b7f..62a8af3fe 100644 --- a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx +++ b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx @@ -6,6 +6,7 @@ import BigNumber from "bignumber.js"; import SwapAmountScreen from "components/screens/SwapScreen/screens/SwapAmountScreen"; import Icon from "components/sds/Icon"; import { AnalyticsEvent } from "config/analyticsConfig"; +import { NETWORKS } from "config/constants"; import { SWAP_ROUTES, SwapStackParamList } from "config/routes"; import { useSwapStore } from "ducks/swap"; import { renderWithProviders } from "helpers/testUtils"; @@ -899,6 +900,7 @@ describe("SwapAmountScreen", () => { "yXLM:GARDNV3Q7YGT4AKSDF25LT32YSCCW4EV22Y2TV3I2PU2MMXJTEDL5T55", "FTT:GBDQOFC6SKCNBHPLZ7NXQ6MCKFIYUUFVOWYGNWQCXC2F4AYZ27EUWYWH", ], + network: NETWORKS.PUBLIC, }); }); diff --git a/__tests__/ducks/prices.test.ts b/__tests__/ducks/prices.test.ts index a1c1e3daf..4e6eb2015 100644 --- a/__tests__/ducks/prices.test.ts +++ b/__tests__/ducks/prices.test.ts @@ -20,6 +20,14 @@ jest.mock("services/backend", () => ({ fetchTokenPrices: jest.fn(), })); +// The store reads the use_token_prices_v2 flag to decide v1 vs v2. Default it +// to true (v2) for these tests; the value is forwarded to fetchTokenPrices. +jest.mock("ducks/remoteConfig", () => ({ + useRemoteConfigStore: { + getState: () => ({ use_token_prices_v2: true }), + }, +})); + describe("prices duck", () => { const mockGetTokenIdentifiersFromBalances = balancesHelpers.getTokenIdentifiersFromBalances as jest.MockedFunction< @@ -145,6 +153,8 @@ describe("prices duck", () => { ); expect(mockFetchTokenPrices).toHaveBeenCalledWith({ tokens: mockTokenIdentifiers, + network: NETWORKS.TESTNET, + useV2: true, }); }); @@ -281,12 +291,17 @@ describe("prices duck", () => { }); await act(async () => { - await result.current.fetchPricesForTokenIds({ tokens: trendingIds }); + await result.current.fetchPricesForTokenIds({ + tokens: trendingIds, + network: NETWORKS.PUBLIC, + }); }); // Only the missing token is requested. expect(mockFetchTokenPrices).toHaveBeenCalledWith({ tokens: ["yXLM:GYXLM"], + network: NETWORKS.PUBLIC, + useV2: true, }); }); @@ -309,7 +324,10 @@ describe("prices duck", () => { }); await act(async () => { - await result.current.fetchPricesForTokenIds({ tokens: trendingIds }); + await result.current.fetchPricesForTokenIds({ + tokens: trendingIds, + network: NETWORKS.PUBLIC, + }); }); expect(mockFetchTokenPrices).not.toHaveBeenCalled(); @@ -336,6 +354,7 @@ describe("prices duck", () => { await act(async () => { await result.current.fetchPricesForTokenIds({ tokens: trendingIds, + network: NETWORKS.PUBLIC, forceRefresh: true, }); }); @@ -343,6 +362,8 @@ describe("prices duck", () => { // forceRefresh bypasses the already-loaded skip → both tokens requested. expect(mockFetchTokenPrices).toHaveBeenCalledWith({ tokens: trendingIds, + network: NETWORKS.PUBLIC, + useV2: true, }); }); }); @@ -382,6 +403,8 @@ describe("prices duck", () => { ); expect(mockFetchTokenPrices).toHaveBeenCalledWith({ tokens: mockTokenIdentifiers, + network: NETWORKS.TESTNET, + useV2: true, }); }); }); diff --git a/__tests__/services/backend.test.ts b/__tests__/services/backend.test.ts index da506e6df..07731f5c0 100644 --- a/__tests__/services/backend.test.ts +++ b/__tests__/services/backend.test.ts @@ -3,6 +3,7 @@ import { NETWORK_URLS, NETWORKS } from "config/constants"; import { logger } from "config/logger"; import { fetchCollectibles, + fetchTokenPrices, freighterBackendV1, freighterBackendV2, simulateTransaction, @@ -705,3 +706,70 @@ describe("Backend Service - fetchCollectibles severity split", () => { ); }); }); + +describe("Backend Service - fetchTokenPrices v2 migration", () => { + let mockV1Post: jest.MockedFunction; + let mockV2Post: jest.MockedFunction; + + const tokens = [ + "XLM", + "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + ]; + + beforeEach(() => { + jest.clearAllMocks(); + mockV1Post = freighterBackendV1.post as jest.MockedFunction; + mockV2Post = freighterBackendV2.post as jest.MockedFunction; + const response = { + data: { + data: { XLM: { currentPrice: "0.5", percentagePriceChange24h: 0.02 } }, + }, + }; + mockV1Post.mockResolvedValue(response); + mockV2Post.mockResolvedValue(response); + }); + + it("hits the v2 client with a network query param when useV2 is true", async () => { + await fetchTokenPrices({ tokens, network: NETWORKS.PUBLIC, useV2: true }); + + expect(mockV2Post).toHaveBeenCalledWith( + "/token-prices", + { tokens }, + { params: { network: "PUBLIC" } }, + ); + expect(mockV1Post).not.toHaveBeenCalled(); + }); + + it("maps testnet to the TESTNET network param", async () => { + await fetchTokenPrices({ tokens, network: NETWORKS.TESTNET, useV2: true }); + + expect(mockV2Post).toHaveBeenCalledWith( + "/token-prices", + { tokens }, + { params: { network: "TESTNET" } }, + ); + }); + + it("hits the v1 client with no network param when useV2 is false", async () => { + await fetchTokenPrices({ tokens, network: NETWORKS.PUBLIC, useV2: false }); + + expect(mockV1Post).toHaveBeenCalledWith("/token-prices", { tokens }); + expect(mockV2Post).not.toHaveBeenCalled(); + }); + + it("short-circuits on unsupported networks (Futurenet) without any request", async () => { + const result = await fetchTokenPrices({ + tokens, + network: NETWORKS.FUTURENET, + useV2: true, + }); + + expect(mockV1Post).not.toHaveBeenCalled(); + expect(mockV2Post).not.toHaveBeenCalled(); + // Every requested token is present with null prices. + expect(result.XLM).toEqual({ + currentPrice: null, + percentagePriceChange24h: null, + }); + }); +}); diff --git a/docs/superpowers/specs/2026-06-25-token-prices-v2-design.md b/docs/superpowers/specs/2026-06-25-token-prices-v2-design.md new file mode 100644 index 000000000..45f33d0a1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-token-prices-v2-design.md @@ -0,0 +1,123 @@ +# Token Prices v2 Migration — Design + +**Date:** 2026-06-25 **Branch:** feat/token-prices-v2 **Reference:** +[stellar/freighter#2870](https://github.com/stellar/freighter/pull/2870) + +## Goal + +Migrate `fetchTokenPrices` from the v1 backend endpoint to the network-scoped v2 +endpoint, gated behind a `use_token_prices_v2` remote-config flag so the rollout +can be reverted from Amplitude without shipping a release. This ports the +extension PR above to freighter-mobile. + +## Background + +Today `fetchTokenPrices` (`src/services/backend.ts`) POSTs to +`freighterBackendV1` `/token-prices` with `{ tokens }`. A `freighterBackendV2` +client already exists (`src/services/backend.ts`, configured from +`BackendEnvConfig.FREIGHTER_BACKEND_V2_URL`). + +The v2 `/token-prices` endpoint is **network-scoped**: callers must pass the +active network as a `network` query param (`PUBLIC` or `TESTNET`). Networks the +endpoint does not serve prices for (Futurenet) must be skipped client-side to +avoid guaranteed-failing requests and the resulting Sentry noise. + +Mobile's `NETWORKS` enum values are already the literal strings `"PUBLIC"`, +`"TESTNET"`, `"FUTURENET"` (`src/config/constants.ts`), so no passphrase-to-name +mapping is required — unlike the extension, which maps from `networkPassphrase`. + +Mobile has a remote-config / feature-flag system (`src/ducks/remoteConfig.ts`, +backed by Amplitude Experiment), so the extension's `use_token_prices_v2` flag +is directly portable. + +## Design + +### 1. Feature flag — `src/ducks/remoteConfig.ts` + +- Add `"use_token_prices_v2"` to the `BOOLEAN_FLAGS` tuple. The + `BooleanFeatureFlags` type derives from this array automatically. +- Add `use_token_prices_v2: true` to **both** branches of + `INITIAL_REMOTE_CONFIG_STATE` (the `isDev || __DEV__` branch and the prod + branch). v2 is the default; Amplitude can flip it to `false` to fall back to + v1 without a release. + +### 2. Service — `src/services/backend.ts` `fetchTokenPrices` + +Extend `FetchTokenPricesParams`: + +```ts +export interface FetchTokenPricesParams { + tokens: TokenIdentifier[]; + network: NETWORKS; + useV2: boolean; +} +``` + +Behavior: + +- Keep the existing LP-share / custom-token filtering (`filteredTokens`). +- If `filteredTokens` is empty, short-circuit: return a null-filled map for the + requested `tokens` (no request) — matches the PR's "skip if all tokens filter + out". +- **v2 path** (`useV2 === true`): + - Map network to the price-network query value: `NETWORKS.PUBLIC → "PUBLIC"`, + `NETWORKS.TESTNET → "TESTNET"`. + - Unsupported network (anything else, i.e. Futurenet): **short-circuit** to + the null-filled map, no request. + - Otherwise: + `freighterBackendV2.post("/token-prices", { tokens: filteredTokens }, { params: { network } })`. + Axios serializes `params` into the `?network=` query string. +- **v1 path** (`useV2 === false`): unchanged — `freighterBackendV1.post(...)` + with no network param. +- Response post-processing (null-fill missing tokens, `bigize`) is unchanged and + shared across both paths. + +The null-filled map (used by both short-circuits) reuses the existing contract: +every requested token maps to +`{ currentPrice: null, percentagePriceChange24h: null }`, then `bigize`d for +return-type consistency. + +### 3. Store — `src/ducks/prices.ts` + +The flag is read in the duck layer (not the service), keeping `services/` free +of `ducks/` imports and mirroring the PR's hook-reads-flag structure. + +- Import `useRemoteConfigStore`. Read + `useRemoteConfigStore.getState().use_token_prices_v2` once per fetch call. +- `fetchPricesForBalances`: already receives `network` (currently destructured + away / unused) — forward `network` and `useV2` to `fetchTokenPrices`. +- `fetchPricesForTokenIds`: add a required `network: NETWORKS` param to its + params object; forward `network` and `useV2` to `fetchTokenPrices`. + +### 4. Callers — thread network in + +- `src/components/screens/SwapScreen/hooks/useSwapTokenPrices.ts`: read the + active network from `useAuthenticationStore` and pass it to both + `fetchPricesForTokenIds` calls (the effect fetch and `refreshPrices`). +- `src/hooks/blockaid/useTransactionBalanceListItems.tsx`: read + `useAuthenticationStore.getState().network` inside the memo (it already uses + `usePricesStore.getState()` there) and pass it to `fetchPricesForTokenIds`. +- `src/ducks/balances.ts`: no change — it already passes `network` to + `fetchPricesForBalances`. + +### 5. Tests + +- `__tests__/ducks/prices.test.ts`: mock `useRemoteConfigStore`; update + `fetchTokenPrices` call assertions to include `network` and `useV2`; pass + `network` into `fetchPricesForTokenIds` calls. +- Add coverage for `fetchTokenPrices` itself: + - v2 on + supported network → POSTs to v2 client with the `network` query + param. + - flag off → POSTs to v1 client, no network param. + - v2 on + Futurenet → returns the empty/null-filled map with **no** request. + +## Non-goals + +- **Per-network cache keying.** The extension caches prices keyed by + `[networkPassphrase][publicKey]`. Mobile's prices store is a flat in-memory + `TokenPricesMap` keyed by token identifier, refreshed on balance and network + changes rather than a persistent per-account cache. Network-keyed caching is a + separable enhancement, not required by the endpoint migration. Minor + consequence: after a network switch, a token identifier's previously fetched + price can briefly persist in the merged map until the next fetch overwrites it + — pre-existing behavior, unchanged by this work. diff --git a/src/components/screens/SwapScreen/hooks/useSwapTokenPrices.ts b/src/components/screens/SwapScreen/hooks/useSwapTokenPrices.ts index ca30fc471..aa7deeecf 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapTokenPrices.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapTokenPrices.ts @@ -1,5 +1,6 @@ import { recordTokenId } from "components/screens/SwapScreen/helpers"; import { FormattedSearchTokenRecord, TokenPricesMap } from "config/types"; +import { useAuthenticationStore } from "ducks/auth"; import { usePricesStore } from "ducks/prices"; import { useCallback, useEffect, useMemo } from "react"; @@ -36,6 +37,7 @@ export const useSwapTokenPrices = ({ (state) => state.fetchPricesForTokenIds, ); const prices = usePricesStore((state) => state.prices); + const network = useAuthenticationStore((state) => state.network); // Stabilise the extra-ids array so the effect doesn't fire on every // render when the caller passes a fresh literal. @@ -50,15 +52,15 @@ export const useSwapTokenPrices = ({ const trendingIds = enabled ? tokens.map(recordTokenId) : []; const ids = [...trendingIds, ...stableExtraTokenIds]; if (ids.length === 0) return; - fetchPricesForTokenIds({ tokens: ids }); - }, [enabled, tokens, stableExtraTokenIds, fetchPricesForTokenIds]); + fetchPricesForTokenIds({ tokens: ids, network }); + }, [enabled, tokens, stableExtraTokenIds, fetchPricesForTokenIds, network]); const refreshPrices = useCallback(async () => { const trendingIds = tokens.map(recordTokenId); const ids = [...trendingIds, ...stableExtraTokenIds]; if (ids.length === 0) return; - await fetchPricesForTokenIds({ tokens: ids, forceRefresh: true }); - }, [tokens, stableExtraTokenIds, fetchPricesForTokenIds]); + await fetchPricesForTokenIds({ tokens: ids, network, forceRefresh: true }); + }, [tokens, stableExtraTokenIds, fetchPricesForTokenIds, network]); return { prices, refreshPrices }; }; diff --git a/src/ducks/prices.ts b/src/ducks/prices.ts index effad6c20..6a67f2684 100644 --- a/src/ducks/prices.ts +++ b/src/ducks/prices.ts @@ -1,5 +1,6 @@ import { NETWORKS } from "config/constants"; import { Balance, TokenIdentifier, TokenPricesMap } from "config/types"; +import { useRemoteConfigStore } from "ducks/remoteConfig"; import { getTokenIdentifiersFromBalances } from "helpers/balances"; import { fetchTokenPrices } from "services/backend"; import { create } from "zustand"; @@ -17,6 +18,8 @@ interface PricesState { /** Fetch prices for arbitrary token identifiers (e.g., from Blockaid diffs) */ fetchPricesForTokenIds: (params: { tokens: TokenIdentifier[]; + /** Active network — required by the network-scoped v2 prices endpoint. */ + network: NETWORKS; /** Refetch even tokens already in the map (e.g. pull-to-refresh). */ forceRefresh?: boolean; }) => Promise; @@ -28,7 +31,7 @@ export const usePricesStore = create((set, get) => ({ error: null, lastUpdated: null, /** Fetch prices for tokens present in the user's balances. */ - fetchPricesForBalances: async ({ balances }) => { + fetchPricesForBalances: async ({ balances, network }) => { try { set({ isLoading: true, error: null }); @@ -42,7 +45,8 @@ export const usePricesStore = create((set, get) => ({ return; } - const response = await fetchTokenPrices({ tokens }); + const useV2 = useRemoteConfigStore.getState().use_token_prices_v2; + const response = await fetchTokenPrices({ tokens, network, useV2 }); // Merge instead of replacing — otherwise prices populated by // fetchPricesForTokenIds for non-held tokens get wiped every time @@ -67,7 +71,7 @@ export const usePricesStore = create((set, get) => ({ } }, /** Lightweight fetch for arbitrary tokens */ - fetchPricesForTokenIds: async ({ tokens, forceRefresh = false }) => { + fetchPricesForTokenIds: async ({ tokens, network, forceRefresh = false }) => { try { if (!tokens || tokens.length === 0) return; // Skip tokens already loaded to avoid duplicate requests — unless the @@ -79,7 +83,12 @@ export const usePricesStore = create((set, get) => ({ : tokens.filter((t) => !existing[t]); if (toFetch.length === 0) return; - const response = await fetchTokenPrices({ tokens: toFetch }); + const useV2 = useRemoteConfigStore.getState().use_token_prices_v2; + const response = await fetchTokenPrices({ + tokens: toFetch, + network, + useV2, + }); set({ prices: { ...get().prices, ...response }, lastUpdated: Date.now(), diff --git a/src/ducks/remoteConfig.ts b/src/ducks/remoteConfig.ts index d0505e5d5..cc9fe483f 100644 --- a/src/ducks/remoteConfig.ts +++ b/src/ducks/remoteConfig.ts @@ -17,6 +17,7 @@ const BOOLEAN_FLAGS = [ "swap_enabled", "discover_enabled", "onramp_enabled", + "use_token_prices_v2", ] as const; const VERSION_FLAGS = ["required_app_version", "latest_app_version"] as const; @@ -69,6 +70,7 @@ const INITIAL_REMOTE_CONFIG_STATE = swap_enabled: true, discover_enabled: true, onramp_enabled: true, + use_token_prices_v2: true, required_app_version: currentAppVersion, latest_app_version: currentAppVersion, app_update_banner_text: { @@ -89,6 +91,7 @@ const INITIAL_REMOTE_CONFIG_STATE = swap_enabled: isAndroid, discover_enabled: isAndroid, onramp_enabled: isAndroid, + use_token_prices_v2: true, required_app_version: currentAppVersion, latest_app_version: currentAppVersion, app_update_banner_text: { diff --git a/src/hooks/blockaid/useTransactionBalanceListItems.tsx b/src/hooks/blockaid/useTransactionBalanceListItems.tsx index 21f450b85..0ac0a515f 100644 --- a/src/hooks/blockaid/useTransactionBalanceListItems.tsx +++ b/src/hooks/blockaid/useTransactionBalanceListItems.tsx @@ -10,6 +10,7 @@ import { TokenIdentifier, NonNativeToken, } from "config/types"; +import { useAuthenticationStore } from "ducks/auth"; import { usePricesStore } from "ducks/prices"; import { formatTokenForDisplay, formatFiatAmount } from "helpers/formatAmount"; import useAppTranslation from "hooks/useAppTranslation"; @@ -134,11 +135,12 @@ export const useTransactionBalanceListItems = ( // Fire-and-forget fetch of missing prices (non-blocking render) const { prices, fetchPricesForTokenIds } = usePricesStore.getState(); + const { network } = useAuthenticationStore.getState(); const missing = tokenIds.filter((id) => !prices[id]); if (missing.length > 0) { // Fire and ignore resolution; store handles errors // eslint-disable-next-line @typescript-eslint/no-floating-promises - fetchPricesForTokenIds({ tokens: missing }); + fetchPricesForTokenIds({ tokens: missing, network }); } // Add balance changes to the list diff --git a/src/services/backend.ts b/src/services/backend.ts index 842a67871..05616d859 100644 --- a/src/services/backend.ts +++ b/src/services/backend.ts @@ -321,22 +321,43 @@ interface TokenPricesResponse { export interface FetchTokenPricesParams { /** Array of token identifiers to fetch prices for */ tokens: TokenIdentifier[]; + /** Active network — the v2 endpoint is network-scoped */ + network: NETWORKS; + /** Whether to hit the network-scoped v2 endpoint (remote-config gated) */ + useV2: boolean; } /** - * NOTE: This is a FAKE implementation that returns random data after a 1-second delay - * Simulates fetching the current USD prices and 24h percentage changes for the specified tokens + * The v2 /token-prices endpoint is network-scoped and only serves prices for + * mainnet and testnet. Maps the active network to the `network` query value the + * endpoint expects; networks absent from this map (e.g. Futurenet) are skipped. + */ +const PRICE_NETWORK_PARAMS: Partial> = { + [NETWORKS.PUBLIC]: NETWORKS.PUBLIC, + [NETWORKS.TESTNET]: NETWORKS.TESTNET, +}; + +/** + * Fetches the current USD prices and 24h percentage changes for the given tokens. + * + * When `useV2` is true, hits the network-scoped v2 endpoint, passing the active + * network as a `network` query param; networks the endpoint doesn't serve (e.g. + * Futurenet) are skipped and return null prices. When false, falls back to the + * v1 endpoint. LP shares and custom tokens are always filtered out before the + * request, and any requested token without a returned price is filled with null. * - * @param params Object containing the list of tokens to fetch prices for + * @param params Tokens to price, the active network, and the v2 flag * @returns Promise resolving to a map of token identifiers to their price information * * @example - * // Fetch prices for XLM and USDC + * // Fetch prices for XLM and USDC on mainnet via v2 * const prices = await fetchTokenPrices({ * tokens: [ * "XLM", * "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" - * ] + * ], + * network: NETWORKS.PUBLIC, + * useV2: true, * }); * * // Access individual token prices @@ -345,6 +366,8 @@ export interface FetchTokenPricesParams { */ export const fetchTokenPrices = async ({ tokens, + network, + useV2, }: FetchTokenPricesParams): Promise => { // NOTE: API does not accept LP IDs or custom tokens const filteredTokens = tokens.filter((tokenId) => { @@ -355,12 +378,30 @@ export const fetchTokenPrices = async ({ ); }); - const { data } = await freighterBackendV1.post( - "/token-prices", - { - tokens: filteredTokens, - }, - ); + // Skip the request entirely — returning empty prices that the loop below + // fills with nulls — when there's nothing to ask for, or when v2 is active + // on a network it doesn't serve (e.g. Futurenet). This avoids guaranteed- + // failing calls and the resulting Sentry noise. + const priceNetwork = PRICE_NETWORK_PARAMS[network]; + const shouldSkipRequest = + filteredTokens.length === 0 || (useV2 && !priceNetwork); + + let pricesMap: TokenPricesMap = {}; + + if (!shouldSkipRequest) { + // The v2 endpoint is network-scoped via a `network` query param; v1 is not. + const { data } = useV2 + ? await freighterBackendV2.post( + "/token-prices", + { tokens: filteredTokens }, + { params: { network: priceNetwork } }, + ) + : await freighterBackendV1.post("/token-prices", { + tokens: filteredTokens, + }); + + pricesMap = data.data; + } /* // ======================================================== @@ -401,7 +442,6 @@ export const fetchTokenPrices = async ({ // Make sure it's compliant with the TokenPricesMap type as the backend // returns { "code:issuer" : null } for tokens that are not supported - const pricesMap = data.data; tokens.forEach((token) => { if (!pricesMap[token]) { pricesMap[token] = { From 837a747d46c4c6d036225ee8977030cb86b6d8a8 Mon Sep 17 00:00:00 2001 From: aristides Date: Fri, 26 Jun 2026 10:28:22 -0600 Subject: [PATCH 02/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- __tests__/services/backend.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/__tests__/services/backend.test.ts b/__tests__/services/backend.test.ts index 07731f5c0..b99ae6cfd 100644 --- a/__tests__/services/backend.test.ts +++ b/__tests__/services/backend.test.ts @@ -772,4 +772,20 @@ describe("Backend Service - fetchTokenPrices v2 migration", () => { percentagePriceChange24h: null, }); }); + + it("short-circuits when all tokens filter out (custom tokens) without any request", async () => { + const customTokens = ["USDC:CUSTOM_CONTRACT_ID"]; + const result = await fetchTokenPrices({ + tokens: customTokens, + network: NETWORKS.PUBLIC, + useV2: true, + }); + + expect(mockV1Post).not.toHaveBeenCalled(); + expect(mockV2Post).not.toHaveBeenCalled(); + expect(result["USDC:CUSTOM_CONTRACT_ID"]).toEqual({ + currentPrice: null, + percentagePriceChange24h: null, + }); + }); }); From 6f934c0d1d0d183b48b8043dd9d933947063f7c4 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 26 Jun 2026 10:27:28 -0600 Subject: [PATCH 03/14] fix(prices): recompute blockaid price fetch on network change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useTransactionBalanceListItems read the active network via useAuthenticationStore.getState() inside its useMemo, with network absent from the dependency array. A network switch while the screen was mounted wouldn't recompute the memo, so missing-price fetches could run against the previous network — and v2 prices are network-scoped. Subscribe to network via a selector outside the memo and add it to the dependency array so fetches re-run on network changes, matching the pattern in useSwapTokenPrices. --- src/hooks/blockaid/useTransactionBalanceListItems.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/hooks/blockaid/useTransactionBalanceListItems.tsx b/src/hooks/blockaid/useTransactionBalanceListItems.tsx index 0ac0a515f..6d8b55561 100644 --- a/src/hooks/blockaid/useTransactionBalanceListItems.tsx +++ b/src/hooks/blockaid/useTransactionBalanceListItems.tsx @@ -36,6 +36,9 @@ export const useTransactionBalanceListItems = ( ): ListItemProps[] => { const { themeColors } = useColors(); const { t } = useAppTranslation(); + // Subscribe to the active network so the memo recomputes (and missing-price + // fetches re-run) on network changes — v2 prices are network-scoped. + const network = useAuthenticationStore((state) => state.network); return useMemo(() => { const items: ListItemProps[] = []; @@ -135,7 +138,6 @@ export const useTransactionBalanceListItems = ( // Fire-and-forget fetch of missing prices (non-blocking render) const { prices, fetchPricesForTokenIds } = usePricesStore.getState(); - const { network } = useAuthenticationStore.getState(); const missing = tokenIds.filter((id) => !prices[id]); if (missing.length > 0) { // Fire and ignore resolution; store handles errors @@ -196,6 +198,7 @@ export const useTransactionBalanceListItems = ( return items; }, [ + network, scanResult, signTransactionDetails?.hasTrustlineChanges, signTransactionDetails?.operations, From f01cb345b7dd67a480bbfb7cbe2ffa269690c586 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 26 Jun 2026 10:34:52 -0600 Subject: [PATCH 04/14] fix(prices): invalidate price cache on network change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchPricesForTokenIds deduped purely by token id, and the prices store had no notion of which network the cached prices belonged to. After a network switch the requested ids were already present, so the fetch returned early and the network-scoped v2 endpoint was never re-queried — the UI could show the previous network's prices indefinitely (same for the merged balances map and Futurenet's null-filled entries). Track the network the cached prices were fetched for (pricesNetwork) and drop the cache + reset the dedupe baseline when a fetch arrives for a different network, so every token is refetched for the new network. Same-network merge/dedupe behavior is unchanged. Add tests for refetch-on-network-change in both fetch paths. --- __tests__/ducks/prices.test.ts | 75 +++++++++++++++++++++++++++++++++- src/ducks/prices.ts | 22 ++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/__tests__/ducks/prices.test.ts b/__tests__/ducks/prices.test.ts index 4e6eb2015..95ffe9761 100644 --- a/__tests__/ducks/prices.test.ts +++ b/__tests__/ducks/prices.test.ts @@ -114,6 +114,7 @@ describe("prices duck", () => { act(() => { usePricesStore.setState({ prices: {}, + pricesNetwork: null, isLoading: false, error: null, lastUpdated: null, @@ -176,7 +177,8 @@ describe("prices duck", () => { const { result } = renderHook(() => usePricesStore()); // Pre-seed the store with a price for a non-balance token (e.g., a - // trending token previously loaded via fetchPricesForTokenIds). + // trending token previously loaded via fetchPricesForTokenIds), already + // on the same network the fetch will use so it merges (not cleared). act(() => { usePricesStore.setState({ prices: { @@ -185,6 +187,7 @@ describe("prices duck", () => { percentagePriceChange24h: new BigNumber("1.2"), }, }, + pricesNetwork: NETWORKS.TESTNET, }); }); @@ -198,6 +201,32 @@ describe("prices duck", () => { expect(result.current.prices.XLM).toBeDefined(); }); + it("drops prices from a different network before fetching", async () => { + const { result } = renderHook(() => usePricesStore()); + + // A non-held price cached for PUBLIC; the fetch below is for TESTNET. + act(() => { + usePricesStore.setState({ + prices: { + "AQUA:GBN...": { + currentPrice: new BigNumber("0.003"), + percentagePriceChange24h: new BigNumber("1.2"), + }, + }, + pricesNetwork: NETWORKS.PUBLIC, + }); + }); + + await act(async () => { + await result.current.fetchPricesForBalances(mockParams); // TESTNET + }); + + // Stale PUBLIC price is gone; only the freshly-fetched TESTNET prices remain. + expect(result.current.prices["AQUA:GBN..."]).toBeUndefined(); + expect(result.current.prices.XLM).toBeDefined(); + expect(result.current.pricesNetwork).toBe(NETWORKS.TESTNET); + }); + it("should handle empty token list", async () => { mockGetTokenIdentifiersFromBalances.mockReturnValueOnce([]); @@ -245,11 +274,13 @@ describe("prices duck", () => { }); it("should preserve existing prices when fetch fails", async () => { - // First set some prices + // First set some prices, already on the network the fetch will use so a + // transient failure preserves them (rather than the network-change clear). const mockLastUpdated = Date.now(); act(() => { usePricesStore.setState({ prices: mockPrices, + pricesNetwork: NETWORKS.TESTNET, isLoading: false, error: null, lastUpdated: mockLastUpdated, @@ -287,6 +318,7 @@ describe("prices duck", () => { percentagePriceChange24h: new BigNumber("1.2"), }, }, + pricesNetwork: NETWORKS.PUBLIC, }); }); @@ -320,6 +352,7 @@ describe("prices duck", () => { percentagePriceChange24h: new BigNumber("0.5"), }, }, + pricesNetwork: NETWORKS.PUBLIC, }); }); @@ -348,6 +381,7 @@ describe("prices duck", () => { percentagePriceChange24h: new BigNumber("0.5"), }, }, + pricesNetwork: NETWORKS.PUBLIC, }); }); @@ -366,6 +400,43 @@ describe("prices duck", () => { useV2: true, }); }); + + it("refetches already-loaded tokens when the network changes", async () => { + const { result } = renderHook(() => usePricesStore()); + + // Cache is fully populated, but for a different network than the fetch. + act(() => { + usePricesStore.setState({ + prices: { + "AQUA:GBNAQUA": { + currentPrice: new BigNumber("0.003"), + percentagePriceChange24h: new BigNumber("1.2"), + }, + "yXLM:GYXLM": { + currentPrice: new BigNumber("0.4"), + percentagePriceChange24h: new BigNumber("0.5"), + }, + }, + pricesNetwork: NETWORKS.PUBLIC, + }); + }); + + await act(async () => { + await result.current.fetchPricesForTokenIds({ + tokens: trendingIds, + network: NETWORKS.TESTNET, + }); + }); + + // Stale prices are network-scoped, so the network switch refetches every + // requested token rather than treating them as already-loaded. + expect(mockFetchTokenPrices).toHaveBeenCalledWith({ + tokens: trendingIds, + network: NETWORKS.TESTNET, + useV2: true, + }); + expect(usePricesStore.getState().pricesNetwork).toBe(NETWORKS.TESTNET); + }); }); describe("selector hooks", () => { diff --git a/src/ducks/prices.ts b/src/ducks/prices.ts index 6a67f2684..b900ef1d5 100644 --- a/src/ducks/prices.ts +++ b/src/ducks/prices.ts @@ -7,6 +7,12 @@ import { create } from "zustand"; interface PricesState { prices: TokenPricesMap; + /** + * Network the cached `prices` were fetched for. v2 is network-scoped, so + * prices from a different network are stale and must not be reused — when a + * fetch arrives for a different network the cache is dropped and refetched. + */ + pricesNetwork: NETWORKS | null; isLoading: boolean; error: string | null; lastUpdated: number | null; @@ -27,6 +33,7 @@ interface PricesState { export const usePricesStore = create((set, get) => ({ prices: {}, + pricesNetwork: null, isLoading: false, error: null, lastUpdated: null, @@ -35,6 +42,13 @@ export const usePricesStore = create((set, get) => ({ try { set({ isLoading: true, error: null }); + // Drop prices cached for a different network before doing anything — + // they're stale for the network-scoped v2 endpoint. This also resets the + // dedupe baseline so the subsequent fetch re-queries every token. + if (get().pricesNetwork !== network) { + set({ prices: {}, pricesNetwork: network }); + } + const tokens = getTokenIdentifiersFromBalances(balances); if (tokens.length === 0) { @@ -74,6 +88,14 @@ export const usePricesStore = create((set, get) => ({ fetchPricesForTokenIds: async ({ tokens, network, forceRefresh = false }) => { try { if (!tokens || tokens.length === 0) return; + + // Drop prices cached for a different network — they're stale for the + // network-scoped v2 endpoint. Clearing also empties the dedupe baseline + // below, so every requested token is refetched for the new network. + if (get().pricesNetwork !== network) { + set({ prices: {}, pricesNetwork: network }); + } + // Skip tokens already loaded to avoid duplicate requests — unless the // caller forces a refresh (e.g. pull-to-refresh), since otherwise a // price fetched once would never update for the rest of the session. From 21a58490e010f67ebc5b431667d0a6f6eb5310fe Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 26 Jun 2026 10:54:08 -0600 Subject: [PATCH 05/14] fix(prices): fetch network-scoped prices for all wc balance tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useTransactionBalanceListItems pre-filtered token ids against the flat prices map and only called fetchPricesForTokenIds for the missing ones. When the network changed but every simulated token already had an entry from the previous network, the missing set was empty, the call was skipped, and the store's network-change invalidation never ran — so a TESTNET dApp transaction could keep showing a cached PUBLIC price until some other refresh cleared the store. Pass all token ids and let the store decide what to fetch: it already dedupes already-loaded tokens and clears/refetches on network change. Removes the now-redundant prefilter (and the unused prices read). --- .../blockaid/useTransactionBalanceListItems.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/hooks/blockaid/useTransactionBalanceListItems.tsx b/src/hooks/blockaid/useTransactionBalanceListItems.tsx index 6d8b55561..e46551e9f 100644 --- a/src/hooks/blockaid/useTransactionBalanceListItems.tsx +++ b/src/hooks/blockaid/useTransactionBalanceListItems.tsx @@ -136,13 +136,16 @@ export const useTransactionBalanceListItems = ( c.isNative ? NATIVE_TOKEN_CODE : `${c.assetCode}:${c.assetIssuer ?? ""}`, ); - // Fire-and-forget fetch of missing prices (non-blocking render) - const { prices, fetchPricesForTokenIds } = usePricesStore.getState(); - const missing = tokenIds.filter((id) => !prices[id]); - if (missing.length > 0) { + // Fire-and-forget fetch of prices (non-blocking render). Pass all ids and + // let the store decide what to fetch: it already dedupes already-loaded + // tokens and clears/refetches when the network changes. Pre-filtering by + // the cached map here would skip the network-change invalidation when every + // token already has a (stale, previous-network) entry. + const { fetchPricesForTokenIds } = usePricesStore.getState(); + if (tokenIds.length > 0) { // Fire and ignore resolution; store handles errors // eslint-disable-next-line @typescript-eslint/no-floating-promises - fetchPricesForTokenIds({ tokens: missing, network }); + fetchPricesForTokenIds({ tokens: tokenIds, network }); } // Add balance changes to the list From b4beeece6618f9aa14fd7abfbfeb4301808a2513 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 26 Jun 2026 10:58:22 -0600 Subject: [PATCH 06/14] fix(prices): discard in-flight price responses after a network switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both fetch methods cleared stale prices on entry but merged their response unconditionally once the awaited, network-scoped call resolved. If the user switched networks mid-fetch, a slow PUBLIC response could write PUBLIC prices into the now-TESTNET cache (pricesNetwork already moved on), leaving the new network with stale prices. Capture the requested network and, after the await, discard the response when get().pricesNetwork no longer matches — before merging. Same-network overlapping fetches still merge normally. Add a test per path that flips the network mid-flight and asserts the stale response is not merged. --- __tests__/ducks/prices.test.ts | 68 ++++++++++++++++++++++++++++++++++ src/ducks/prices.ts | 14 +++++++ 2 files changed, 82 insertions(+) diff --git a/__tests__/ducks/prices.test.ts b/__tests__/ducks/prices.test.ts index 95ffe9761..559cd7c9d 100644 --- a/__tests__/ducks/prices.test.ts +++ b/__tests__/ducks/prices.test.ts @@ -5,6 +5,7 @@ import { Balance, NativeBalance, ClassicBalance, + TokenPricesMap, TokenTypeWithCustomToken, } from "config/types"; import { usePricesStore } from "ducks/prices"; @@ -227,6 +228,36 @@ describe("prices duck", () => { expect(result.current.pricesNetwork).toBe(NETWORKS.TESTNET); }); + it("discards an in-flight response when the network changed mid-fetch", async () => { + const { result } = renderHook(() => usePricesStore()); + + // Hold the fetch open so we can switch networks before it resolves. + let resolveFetch: (value: typeof mockPrices) => void = () => {}; + mockFetchTokenPrices.mockReturnValueOnce( + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.fetchPricesForBalances(mockParams); // TESTNET + }); + + // The user switches networks while the TESTNET fetch is still in flight. + act(() => { + usePricesStore.setState({ pricesNetwork: NETWORKS.PUBLIC }); + }); + + await act(async () => { + resolveFetch(mockPrices); + await pending; + }); + + // The stale TESTNET response must not be merged into the PUBLIC cache. + expect(result.current.prices).toEqual({}); + }); + it("should handle empty token list", async () => { mockGetTokenIdentifiersFromBalances.mockReturnValueOnce([]); @@ -437,6 +468,43 @@ describe("prices duck", () => { }); expect(usePricesStore.getState().pricesNetwork).toBe(NETWORKS.TESTNET); }); + + it("discards an in-flight response when the network changed mid-fetch", async () => { + const { result } = renderHook(() => usePricesStore()); + + let resolveFetch: (value: TokenPricesMap) => void = () => {}; + mockFetchTokenPrices.mockReturnValueOnce( + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.fetchPricesForTokenIds({ + tokens: trendingIds, + network: NETWORKS.PUBLIC, + }); + }); + + // Network moves on before the slow PUBLIC fetch resolves. + act(() => { + usePricesStore.setState({ pricesNetwork: NETWORKS.TESTNET }); + }); + + await act(async () => { + resolveFetch({ + "AQUA:GBNAQUA": { + currentPrice: new BigNumber("1"), + percentagePriceChange24h: new BigNumber("0"), + }, + }); + await pending; + }); + + // The stale PUBLIC response must not be merged into the TESTNET cache. + expect(result.current.prices["AQUA:GBNAQUA"]).toBeUndefined(); + }); }); describe("selector hooks", () => { diff --git a/src/ducks/prices.ts b/src/ducks/prices.ts index b900ef1d5..669874d9d 100644 --- a/src/ducks/prices.ts +++ b/src/ducks/prices.ts @@ -62,6 +62,14 @@ export const usePricesStore = create((set, get) => ({ const useV2 = useRemoteConfigStore.getState().use_token_prices_v2; const response = await fetchTokenPrices({ tokens, network, useV2 }); + // The active network may have switched while this request was in flight. + // The response is scoped to `network`; if the network has since moved on, + // discard it rather than merging stale prices into the new network. + if (get().pricesNetwork !== network) { + set({ isLoading: false }); + return; + } + // Merge instead of replacing — otherwise prices populated by // fetchPricesForTokenIds for non-held tokens get wiped every time // balances refresh. @@ -111,6 +119,12 @@ export const usePricesStore = create((set, get) => ({ network, useV2, }); + + // Discard if the active network changed while this request was in flight + // — the response is scoped to the now-stale `network` (see the balances + // fetch above for the full rationale). + if (get().pricesNetwork !== network) return; + set({ prices: { ...get().prices, ...response }, lastUpdated: Date.now(), From 566ca56473e23910c14f13041d721df5a505d1e6 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 26 Jun 2026 11:18:44 -0600 Subject: [PATCH 07/14] fix(prices): apply v1/v2 rollback to cached token-id lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchPricesForTokenIds read use_token_prices_v2 only after the dedupe early-return, so when Amplitude rolled the flag back while a non-held token already had a cached entry, toFetch was empty and the v1 endpoint was never called for it until a force refresh, network change, or app restart — the documented rollback didn't apply to cached lookups. Widen the cache identity from pricesNetwork to (pricesNetwork, pricesUseV2). Both fetch methods now read the flag before the dedupe check, drop the cache and reset the dedupe baseline when the network or endpoint version changes, and discard an in-flight response if either changed mid-fetch. --- __tests__/ducks/prices.test.ts | 53 ++++++++++++++++++++++++++++++-- src/ducks/prices.ts | 56 ++++++++++++++++++++++------------ 2 files changed, 86 insertions(+), 23 deletions(-) diff --git a/__tests__/ducks/prices.test.ts b/__tests__/ducks/prices.test.ts index 559cd7c9d..07baa8304 100644 --- a/__tests__/ducks/prices.test.ts +++ b/__tests__/ducks/prices.test.ts @@ -21,11 +21,13 @@ jest.mock("services/backend", () => ({ fetchTokenPrices: jest.fn(), })); -// The store reads the use_token_prices_v2 flag to decide v1 vs v2. Default it -// to true (v2) for these tests; the value is forwarded to fetchTokenPrices. +// The store reads the use_token_prices_v2 flag to decide v1 vs v2. Use a +// mutable object (must be prefixed `mock` for jest's factory) so tests can flip +// the flag to exercise the rollback path; reset in beforeEach. +const mockRemoteConfig = { use_token_prices_v2: true }; jest.mock("ducks/remoteConfig", () => ({ useRemoteConfigStore: { - getState: () => ({ use_token_prices_v2: true }), + getState: () => mockRemoteConfig, }, })); @@ -112,10 +114,14 @@ describe("prices duck", () => { beforeEach(() => { // Reset the store before each test + // pricesUseV2 starts at the mocked flag default (true) so pre-seeded tests, + // which shallow-merge their own setState, are treated as same-source. + mockRemoteConfig.use_token_prices_v2 = true; act(() => { usePricesStore.setState({ prices: {}, pricesNetwork: null, + pricesUseV2: true, isLoading: false, error: null, lastUpdated: null, @@ -505,6 +511,47 @@ describe("prices duck", () => { // The stale PUBLIC response must not be merged into the TESTNET cache. expect(result.current.prices["AQUA:GBNAQUA"]).toBeUndefined(); }); + + it("refetches cached tokens when the v2 rollback flag flips", async () => { + const { result } = renderHook(() => usePricesStore()); + + // Cache fully populated by v2 on the same network the fetch will use. + act(() => { + usePricesStore.setState({ + prices: { + "AQUA:GBNAQUA": { + currentPrice: new BigNumber("0.003"), + percentagePriceChange24h: new BigNumber("1.2"), + }, + "yXLM:GYXLM": { + currentPrice: new BigNumber("0.4"), + percentagePriceChange24h: new BigNumber("0.5"), + }, + }, + pricesNetwork: NETWORKS.PUBLIC, + pricesUseV2: true, + }); + }); + + // Amplitude rolls the flag back to v1. + mockRemoteConfig.use_token_prices_v2 = false; + + await act(async () => { + await result.current.fetchPricesForTokenIds({ + tokens: trendingIds, + network: NETWORKS.PUBLIC, + }); + }); + + // Despite being cached, the tokens are refetched from v1 — the rollback + // applies to cached token-id lookups, not just new ones. + expect(mockFetchTokenPrices).toHaveBeenCalledWith({ + tokens: trendingIds, + network: NETWORKS.PUBLIC, + useV2: false, + }); + expect(usePricesStore.getState().pricesUseV2).toBe(false); + }); }); describe("selector hooks", () => { diff --git a/src/ducks/prices.ts b/src/ducks/prices.ts index 669874d9d..b82a22720 100644 --- a/src/ducks/prices.ts +++ b/src/ducks/prices.ts @@ -13,6 +13,13 @@ interface PricesState { * fetch arrives for a different network the cache is dropped and refetched. */ pricesNetwork: NETWORKS | null; + /** + * Endpoint version (`use_token_prices_v2`) the cached `prices` came from. + * Together with `pricesNetwork` this identifies the price source — when the + * Amplitude flag rolls v2 back to v1 (or vice versa) the cache is dropped and + * refetched, so the rollback applies even to already-cached token-id lookups. + */ + pricesUseV2: boolean | null; isLoading: boolean; error: string | null; lastUpdated: number | null; @@ -34,6 +41,7 @@ interface PricesState { export const usePricesStore = create((set, get) => ({ prices: {}, pricesNetwork: null, + pricesUseV2: null, isLoading: false, error: null, lastUpdated: null, @@ -42,11 +50,15 @@ export const usePricesStore = create((set, get) => ({ try { set({ isLoading: true, error: null }); - // Drop prices cached for a different network before doing anything — - // they're stale for the network-scoped v2 endpoint. This also resets the - // dedupe baseline so the subsequent fetch re-queries every token. - if (get().pricesNetwork !== network) { - set({ prices: {}, pricesNetwork: network }); + const useV2 = useRemoteConfigStore.getState().use_token_prices_v2; + + // The cache is identified by its source — (network, endpoint version). + // v2 is network-scoped, and a v1/v2 rollback changes which endpoint the + // prices came from. If either differs, drop the cache before doing + // anything, which also resets the dedupe baseline so every token is + // re-queried from the current source. + if (get().pricesNetwork !== network || get().pricesUseV2 !== useV2) { + set({ prices: {}, pricesNetwork: network, pricesUseV2: useV2 }); } const tokens = getTokenIdentifiersFromBalances(balances); @@ -59,13 +71,13 @@ export const usePricesStore = create((set, get) => ({ return; } - const useV2 = useRemoteConfigStore.getState().use_token_prices_v2; const response = await fetchTokenPrices({ tokens, network, useV2 }); - // The active network may have switched while this request was in flight. - // The response is scoped to `network`; if the network has since moved on, - // discard it rather than merging stale prices into the new network. - if (get().pricesNetwork !== network) { + // The source may have changed while this request was in flight (network + // switch or flag flip). The response is scoped to (network, useV2); if the + // active source has since moved on, discard it rather than merging stale + // prices into the new source's cache. + if (get().pricesNetwork !== network || get().pricesUseV2 !== useV2) { set({ isLoading: false }); return; } @@ -97,11 +109,15 @@ export const usePricesStore = create((set, get) => ({ try { if (!tokens || tokens.length === 0) return; - // Drop prices cached for a different network — they're stale for the - // network-scoped v2 endpoint. Clearing also empties the dedupe baseline - // below, so every requested token is refetched for the new network. - if (get().pricesNetwork !== network) { - set({ prices: {}, pricesNetwork: network }); + const useV2 = useRemoteConfigStore.getState().use_token_prices_v2; + + // Drop the cache when its source — (network, endpoint version) — differs + // from this request: v2 is network-scoped, and a v1/v2 rollback changes + // the endpoint. Read the flag *before* the dedupe below so a rollback + // invalidates already-cached token-id lookups too; clearing empties the + // dedupe baseline so every requested token is refetched. + if (get().pricesNetwork !== network || get().pricesUseV2 !== useV2) { + set({ prices: {}, pricesNetwork: network, pricesUseV2: useV2 }); } // Skip tokens already loaded to avoid duplicate requests — unless the @@ -113,17 +129,17 @@ export const usePricesStore = create((set, get) => ({ : tokens.filter((t) => !existing[t]); if (toFetch.length === 0) return; - const useV2 = useRemoteConfigStore.getState().use_token_prices_v2; const response = await fetchTokenPrices({ tokens: toFetch, network, useV2, }); - // Discard if the active network changed while this request was in flight - // — the response is scoped to the now-stale `network` (see the balances - // fetch above for the full rationale). - if (get().pricesNetwork !== network) return; + // Discard if the source changed while this request was in flight (network + // switch or flag flip) — the response is scoped to the now-stale + // (network, useV2) (see the balances fetch above for the full rationale). + if (get().pricesNetwork !== network || get().pricesUseV2 !== useV2) + return; set({ prices: { ...get().prices, ...response }, From 0561a6d4d63930a5425666b2db3602eebe5994eb Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 26 Jun 2026 11:39:18 -0600 Subject: [PATCH 08/14] fix(prices): re-run token-id price fetches on a v1/v2 rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prices store read use_token_prices_v2 internally, so callers could not react to it. While the swap screen stayed mounted with an unchanged token list and network, its effect never re-ran when Amplitude flipped the flag — cached trending/non-held prices kept using the old endpoint until a manual refresh or navigation. Thread useV2 from the callers instead (the reference PR's pattern): - prices store: both fetch methods take useV2 as a param; drop the remoteConfig import and internal reads. The (network, useV2) cache identity uses the passed value, so the store is a pure function of its inputs. - balances duck: read the flag via getState and pass it (non-hook context; balance polling already covers flips). - useSwapTokenPrices / useTransactionBalanceListItems: subscribe to the flag via useRemoteConfigStore and add it to the effect/memo deps so a rollback re-runs the fetch. Drop the dead remoteConfig mock in prices tests, thread useV2 through the calls, and assert it in the swap screen test. --- .../SwapScreen/SwapAmountScreen.test.tsx | 1 + __tests__/ducks/prices.test.ts | 26 +++++++------------ .../SwapScreen/hooks/useSwapTokenPrices.ts | 24 ++++++++++++++--- src/ducks/balances.ts | 3 +++ src/ducks/prices.ts | 25 +++++++++++------- .../useTransactionBalanceListItems.tsx | 10 ++++--- 6 files changed, 56 insertions(+), 33 deletions(-) diff --git a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx index 62a8af3fe..d7b4d307e 100644 --- a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx +++ b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx @@ -901,6 +901,7 @@ describe("SwapAmountScreen", () => { "FTT:GBDQOFC6SKCNBHPLZ7NXQ6MCKFIYUUFVOWYGNWQCXC2F4AYZ27EUWYWH", ], network: NETWORKS.PUBLIC, + useV2: true, }); }); diff --git a/__tests__/ducks/prices.test.ts b/__tests__/ducks/prices.test.ts index 07baa8304..6edb9b327 100644 --- a/__tests__/ducks/prices.test.ts +++ b/__tests__/ducks/prices.test.ts @@ -21,16 +21,6 @@ jest.mock("services/backend", () => ({ fetchTokenPrices: jest.fn(), })); -// The store reads the use_token_prices_v2 flag to decide v1 vs v2. Use a -// mutable object (must be prefixed `mock` for jest's factory) so tests can flip -// the flag to exercise the rollback path; reset in beforeEach. -const mockRemoteConfig = { use_token_prices_v2: true }; -jest.mock("ducks/remoteConfig", () => ({ - useRemoteConfigStore: { - getState: () => mockRemoteConfig, - }, -})); - describe("prices duck", () => { const mockGetTokenIdentifiersFromBalances = balancesHelpers.getTokenIdentifiersFromBalances as jest.MockedFunction< @@ -110,13 +100,13 @@ describe("prices duck", () => { balances: mockBalances, publicKey: "GDNF5WJ2BEPABVBXCF4C7KZKM3XYXP27VUE3SCGPZA3VXWWZ7OFA3VPM", network: NETWORKS.TESTNET, + useV2: true, }; beforeEach(() => { // Reset the store before each test - // pricesUseV2 starts at the mocked flag default (true) so pre-seeded tests, - // which shallow-merge their own setState, are treated as same-source. - mockRemoteConfig.use_token_prices_v2 = true; + // pricesUseV2 starts at true so pre-seeded tests, which shallow-merge their + // own setState and fetch with useV2: true, are treated as same-source. act(() => { usePricesStore.setState({ prices: {}, @@ -363,6 +353,7 @@ describe("prices duck", () => { await result.current.fetchPricesForTokenIds({ tokens: trendingIds, network: NETWORKS.PUBLIC, + useV2: true, }); }); @@ -397,6 +388,7 @@ describe("prices duck", () => { await result.current.fetchPricesForTokenIds({ tokens: trendingIds, network: NETWORKS.PUBLIC, + useV2: true, }); }); @@ -426,6 +418,7 @@ describe("prices duck", () => { await result.current.fetchPricesForTokenIds({ tokens: trendingIds, network: NETWORKS.PUBLIC, + useV2: true, forceRefresh: true, }); }); @@ -462,6 +455,7 @@ describe("prices duck", () => { await result.current.fetchPricesForTokenIds({ tokens: trendingIds, network: NETWORKS.TESTNET, + useV2: true, }); }); @@ -490,6 +484,7 @@ describe("prices duck", () => { pending = result.current.fetchPricesForTokenIds({ tokens: trendingIds, network: NETWORKS.PUBLIC, + useV2: true, }); }); @@ -533,13 +528,12 @@ describe("prices duck", () => { }); }); - // Amplitude rolls the flag back to v1. - mockRemoteConfig.use_token_prices_v2 = false; - + // Amplitude rolled the flag back to v1, so the caller now passes false. await act(async () => { await result.current.fetchPricesForTokenIds({ tokens: trendingIds, network: NETWORKS.PUBLIC, + useV2: false, }); }); diff --git a/src/components/screens/SwapScreen/hooks/useSwapTokenPrices.ts b/src/components/screens/SwapScreen/hooks/useSwapTokenPrices.ts index aa7deeecf..b2a6187a8 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapTokenPrices.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapTokenPrices.ts @@ -2,6 +2,7 @@ import { recordTokenId } from "components/screens/SwapScreen/helpers"; import { FormattedSearchTokenRecord, TokenPricesMap } from "config/types"; import { useAuthenticationStore } from "ducks/auth"; import { usePricesStore } from "ducks/prices"; +import { useRemoteConfigStore } from "ducks/remoteConfig"; import { useCallback, useEffect, useMemo } from "react"; /** @@ -38,6 +39,9 @@ export const useSwapTokenPrices = ({ ); const prices = usePricesStore((state) => state.prices); const network = useAuthenticationStore((state) => state.network); + // Subscribe to the endpoint flag so a v1/v2 rollback re-runs the fetch below + // even while this screen stays mounted with an unchanged token list/network. + const useV2 = useRemoteConfigStore((state) => state.use_token_prices_v2); // Stabilise the extra-ids array so the effect doesn't fire on every // render when the caller passes a fresh literal. @@ -52,15 +56,27 @@ export const useSwapTokenPrices = ({ const trendingIds = enabled ? tokens.map(recordTokenId) : []; const ids = [...trendingIds, ...stableExtraTokenIds]; if (ids.length === 0) return; - fetchPricesForTokenIds({ tokens: ids, network }); - }, [enabled, tokens, stableExtraTokenIds, fetchPricesForTokenIds, network]); + fetchPricesForTokenIds({ tokens: ids, network, useV2 }); + }, [ + enabled, + tokens, + stableExtraTokenIds, + fetchPricesForTokenIds, + network, + useV2, + ]); const refreshPrices = useCallback(async () => { const trendingIds = tokens.map(recordTokenId); const ids = [...trendingIds, ...stableExtraTokenIds]; if (ids.length === 0) return; - await fetchPricesForTokenIds({ tokens: ids, network, forceRefresh: true }); - }, [tokens, stableExtraTokenIds, fetchPricesForTokenIds, network]); + await fetchPricesForTokenIds({ + tokens: ids, + network, + useV2, + forceRefresh: true, + }); + }, [tokens, stableExtraTokenIds, fetchPricesForTokenIds, network, useV2]); return { prices, refreshPrices }; }; diff --git a/src/ducks/balances.ts b/src/ducks/balances.ts index f4d3c8b23..8c42e45ec 100644 --- a/src/ducks/balances.ts +++ b/src/ducks/balances.ts @@ -8,6 +8,7 @@ import { TokenPricesMap, } from "config/types"; import { usePricesStore } from "ducks/prices"; +import { useRemoteConfigStore } from "ducks/remoteConfig"; import { getLPShareCode, isLiquidityPool, @@ -150,12 +151,14 @@ const fetchPricedBalances = async ( ); const { fetchPricesForBalances } = usePricesStore.getState(); + const useV2 = useRemoteConfigStore.getState().use_token_prices_v2; // Fetch updated prices for the balances using the prices store const priceFetchPromise = fetchPricesForBalances({ balances, publicKey: params.publicKey, network: params.network, + useV2, }); // Wait a maximum of 3 seconds for prices to be fetched diff --git a/src/ducks/prices.ts b/src/ducks/prices.ts index b82a22720..37e674fd3 100644 --- a/src/ducks/prices.ts +++ b/src/ducks/prices.ts @@ -1,6 +1,5 @@ import { NETWORKS } from "config/constants"; import { Balance, TokenIdentifier, TokenPricesMap } from "config/types"; -import { useRemoteConfigStore } from "ducks/remoteConfig"; import { getTokenIdentifiersFromBalances } from "helpers/balances"; import { fetchTokenPrices } from "services/backend"; import { create } from "zustand"; @@ -27,12 +26,17 @@ interface PricesState { balances: Record; publicKey: string; network: NETWORKS; + /** Endpoint version, from the `use_token_prices_v2` flag. */ + useV2: boolean; }) => Promise; /** Fetch prices for arbitrary token identifiers (e.g., from Blockaid diffs) */ fetchPricesForTokenIds: (params: { tokens: TokenIdentifier[]; /** Active network — required by the network-scoped v2 prices endpoint. */ network: NETWORKS; + /** Endpoint version, from the `use_token_prices_v2` flag. Callers subscribe + * to the flag so a rollback re-runs the fetch and invalidates the cache. */ + useV2: boolean; /** Refetch even tokens already in the map (e.g. pull-to-refresh). */ forceRefresh?: boolean; }) => Promise; @@ -46,12 +50,10 @@ export const usePricesStore = create((set, get) => ({ error: null, lastUpdated: null, /** Fetch prices for tokens present in the user's balances. */ - fetchPricesForBalances: async ({ balances, network }) => { + fetchPricesForBalances: async ({ balances, network, useV2 }) => { try { set({ isLoading: true, error: null }); - const useV2 = useRemoteConfigStore.getState().use_token_prices_v2; - // The cache is identified by its source — (network, endpoint version). // v2 is network-scoped, and a v1/v2 rollback changes which endpoint the // prices came from. If either differs, drop the cache before doing @@ -105,17 +107,20 @@ export const usePricesStore = create((set, get) => ({ } }, /** Lightweight fetch for arbitrary tokens */ - fetchPricesForTokenIds: async ({ tokens, network, forceRefresh = false }) => { + fetchPricesForTokenIds: async ({ + tokens, + network, + useV2, + forceRefresh = false, + }) => { try { if (!tokens || tokens.length === 0) return; - const useV2 = useRemoteConfigStore.getState().use_token_prices_v2; - // Drop the cache when its source — (network, endpoint version) — differs // from this request: v2 is network-scoped, and a v1/v2 rollback changes - // the endpoint. Read the flag *before* the dedupe below so a rollback - // invalidates already-cached token-id lookups too; clearing empties the - // dedupe baseline so every requested token is refetched. + // the endpoint. Callers pass the current flag so a rollback invalidates + // already-cached token-id lookups too; clearing empties the dedupe + // baseline so every requested token is refetched. if (get().pricesNetwork !== network || get().pricesUseV2 !== useV2) { set({ prices: {}, pricesNetwork: network, pricesUseV2: useV2 }); } diff --git a/src/hooks/blockaid/useTransactionBalanceListItems.tsx b/src/hooks/blockaid/useTransactionBalanceListItems.tsx index e46551e9f..02ab35d94 100644 --- a/src/hooks/blockaid/useTransactionBalanceListItems.tsx +++ b/src/hooks/blockaid/useTransactionBalanceListItems.tsx @@ -12,6 +12,7 @@ import { } from "config/types"; import { useAuthenticationStore } from "ducks/auth"; import { usePricesStore } from "ducks/prices"; +import { useRemoteConfigStore } from "ducks/remoteConfig"; import { formatTokenForDisplay, formatFiatAmount } from "helpers/formatAmount"; import useAppTranslation from "hooks/useAppTranslation"; import useColors from "hooks/useColors"; @@ -36,9 +37,11 @@ export const useTransactionBalanceListItems = ( ): ListItemProps[] => { const { themeColors } = useColors(); const { t } = useAppTranslation(); - // Subscribe to the active network so the memo recomputes (and missing-price - // fetches re-run) on network changes — v2 prices are network-scoped. + // Subscribe to the active network and endpoint flag so the memo recomputes + // (and missing-price fetches re-run) on a network switch or v1/v2 rollback — + // v2 prices are network-scoped and the rollback must re-query the new source. const network = useAuthenticationStore((state) => state.network); + const useV2 = useRemoteConfigStore((state) => state.use_token_prices_v2); return useMemo(() => { const items: ListItemProps[] = []; @@ -145,7 +148,7 @@ export const useTransactionBalanceListItems = ( if (tokenIds.length > 0) { // Fire and ignore resolution; store handles errors // eslint-disable-next-line @typescript-eslint/no-floating-promises - fetchPricesForTokenIds({ tokens: tokenIds, network }); + fetchPricesForTokenIds({ tokens: tokenIds, network, useV2 }); } // Add balance changes to the list @@ -202,6 +205,7 @@ export const useTransactionBalanceListItems = ( return items; }, [ network, + useV2, scanResult, signTransactionDetails?.hasTrustlineChanges, signTransactionDetails?.operations, From 54d987430093e6cb906281fb41b9f1d5af71c3cb Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 26 Jun 2026 11:51:35 -0600 Subject: [PATCH 09/14] fix(prices): fetch dApp tx prices in an effect, not during render useTransactionBalanceListItems fired fetchPricesForTokenIds inside its useMemo. Now that the store action synchronously clears/sets the price store on a source change, calling it during render mutates an external store mid-render. The hook also read prices via getState, so the async price response never recomputed the list. Move the fire-and-forget fetch into a useEffect keyed on the derived token ids, network, and flag (token ids are memoized so the effect doesn't fire every render), and subscribe to prices via the store selector so the list recomputes when the response lands. Network/flag drop out of the render memo's deps since only the effect uses them. Behavior-preserving; no store writes happen during render. --- .../useTransactionBalanceListItems.tsx | 67 ++++++++++++------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/src/hooks/blockaid/useTransactionBalanceListItems.tsx b/src/hooks/blockaid/useTransactionBalanceListItems.tsx index 02ab35d94..f3f05762e 100644 --- a/src/hooks/blockaid/useTransactionBalanceListItems.tsx +++ b/src/hooks/blockaid/useTransactionBalanceListItems.tsx @@ -16,7 +16,7 @@ import { useRemoteConfigStore } from "ducks/remoteConfig"; import { formatTokenForDisplay, formatFiatAmount } from "helpers/formatAmount"; import useAppTranslation from "hooks/useAppTranslation"; import useColors from "hooks/useColors"; -import React, { useMemo } from "react"; +import React, { useEffect, useMemo } from "react"; import { View } from "react-native"; import { getTransactionBalanceChanges } from "services/blockaid/helper"; @@ -37,11 +37,45 @@ export const useTransactionBalanceListItems = ( ): ListItemProps[] => { const { themeColors } = useColors(); const { t } = useAppTranslation(); - // Subscribe to the active network and endpoint flag so the memo recomputes - // (and missing-price fetches re-run) on a network switch or v1/v2 rollback — - // v2 prices are network-scoped and the rollback must re-query the new source. + // Subscribe to the active network and endpoint flag so the price-fetch effect + // re-runs on a network switch or v1/v2 rollback — v2 prices are network-scoped + // and the rollback must re-query the new source. const network = useAuthenticationStore((state) => state.network); const useV2 = useRemoteConfigStore((state) => state.use_token_prices_v2); + // Subscribe to prices so the list recomputes when an async price response + // lands — reading via getState alone would not trigger a re-render. + const prices = usePricesStore((state) => state.prices); + + // Balance changes for this transaction: null = unable to simulate. + const balanceUpdates = useMemo( + () => (scanResult ? getTransactionBalanceChanges(scanResult) : null), + [scanResult], + ); + + // Token ids affected by the transaction. Memoized so its identity is stable + // across renders (changes only with the scan result), which keeps the + // price-fetch effect below from firing on every render. + const tokenIds = useMemo( + () => + (balanceUpdates ?? []).map((change) => + change.isNative + ? NATIVE_TOKEN_CODE + : `${change.assetCode}:${change.assetIssuer ?? ""}`, + ), + [balanceUpdates], + ); + + // Fetch prices as a side effect — never during render, since the store action + // synchronously mutates the price store (clearing it on a source change). + // Pass all ids: the store dedupes already-loaded tokens and clears/refetches + // when the source (network or v1/v2 flag) changes. + useEffect(() => { + if (tokenIds.length === 0) return; + const { fetchPricesForTokenIds } = usePricesStore.getState(); + // Fire and ignore resolution; the store handles errors. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + fetchPricesForTokenIds({ tokens: tokenIds, network, useV2 }); + }, [tokenIds, network, useV2]); return useMemo(() => { const items: ListItemProps[] = []; @@ -94,8 +128,6 @@ export const useTransactionBalanceListItems = ( ]; } - const balanceUpdates = getTransactionBalanceChanges(scanResult); - // Unable to simulate if (balanceUpdates === null) { // If we have change trust operations, show them along with the error @@ -134,23 +166,6 @@ export const useTransactionBalanceListItems = ( ]; } - // Build token IDs and optionally fetch missing prices - const tokenIds: TokenIdentifier[] = balanceUpdates.map((c) => - c.isNative ? NATIVE_TOKEN_CODE : `${c.assetCode}:${c.assetIssuer ?? ""}`, - ); - - // Fire-and-forget fetch of prices (non-blocking render). Pass all ids and - // let the store decide what to fetch: it already dedupes already-loaded - // tokens and clears/refetches when the network changes. Pre-filtering by - // the cached map here would skip the network-change invalidation when every - // token already has a (stale, previous-network) entry. - const { fetchPricesForTokenIds } = usePricesStore.getState(); - if (tokenIds.length > 0) { - // Fire and ignore resolution; store handles errors - // eslint-disable-next-line @typescript-eslint/no-floating-promises - fetchPricesForTokenIds({ tokens: tokenIds, network, useV2 }); - } - // Add balance changes to the list balanceUpdates.forEach((change) => { const { @@ -166,7 +181,7 @@ export const useTransactionBalanceListItems = ( const tokenId: TokenIdentifier = isNative ? NATIVE_TOKEN_CODE : `${tokenCode}:${tokenIssuer ?? ""}`; - const price = usePricesStore.getState().prices[tokenId]?.currentPrice; + const price = prices[tokenId]?.currentPrice; const hasFiat = !!price; const fiatValue = hasFiat ? price.multipliedBy(amount.abs()) : null; @@ -204,8 +219,8 @@ export const useTransactionBalanceListItems = ( return items; }, [ - network, - useV2, + balanceUpdates, + prices, scanResult, signTransactionDetails?.hasTrustlineChanges, signTransactionDetails?.operations, From b3520cc0cefc506b7f270345d78474b3984fc126 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 26 Jun 2026 13:13:40 -0600 Subject: [PATCH 10/14] refactor(prices): key price cache by network; fix concurrency, flicker, observability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flat global prices map guarded by a (network, useV2) value-identity check was the root of a string of regressions: cross-network contamination, a blank-price flicker on every source change, dropped/clobbered prices under concurrent fetches, and silent failures. Replace the model rather than add another guard. Store (src/ducks/prices.ts, rewritten): - Cache keyed per network: pricesByNetwork[network] (+ sourceByNetwork tracking the endpoint that populated each). A network switch reads the other network's submap — no clear, no flicker, and a cross-network read is impossible by construction, so the in-flight cross-network discard guard is gone. - A v1<->v2 rollback drops only that network's cache once and refetches; a narrow post-await endpoint check discards a response whose endpoint was superseded mid-flight. - Export usePricesForNetwork(network) returning a stable empty map. - (Chose this over a monotonic epoch: epoch "latest-wins" would drop tokens when two fetches request disjoint sets; per-network keying removes the need.) Service (src/services/backend.ts): - Defensively coerce data?.data ?? {} so an unexpected v2 response shape can't throw and wipe all prices; warn on shape mismatch. - Wrap the request in logApiError + rethrow so a failing endpoint surfaces in Sentry (warn for connectivity, error for 4xx/5xx/timeout) instead of being swallowed by the store callers. Consumers: read usePricesForNetwork(network) in the swap hooks, the dApp-sign balance list, and the two swap review consumers; balances/auth use the new shape. Tests: rewrite the prices suite around the per-network model (contamination, rollback, in-flight discard, cross-network dedupe) and add backend error-logging coverage; update the auth/balances/screen mocks the refactor touched. --- .../components/screens/HomeScreen.test.tsx | 4 +- .../SwapScreen/SwapAmountScreen.test.tsx | 4 +- __tests__/ducks/auth.test.ts | 6 +- __tests__/ducks/balances.test.ts | 34 +- __tests__/ducks/prices.test.ts | 450 ++++++------------ __tests__/services/backend.test.ts | 40 ++ .../SwapTransactionDetailsBottomSheet.tsx | 4 +- .../SwapScreen/hooks/useReviewTokens.ts | 6 +- .../SwapScreen/hooks/useSwapTokenPrices.ts | 5 +- src/ducks/auth.ts | 3 +- src/ducks/balances.ts | 7 +- src/ducks/prices.ts | 157 +++--- .../useTransactionBalanceListItems.tsx | 9 +- src/services/backend.ts | 51 +- 14 files changed, 381 insertions(+), 399 deletions(-) diff --git a/__tests__/components/screens/HomeScreen.test.tsx b/__tests__/components/screens/HomeScreen.test.tsx index dcf99d3fd..fac188dd0 100644 --- a/__tests__/components/screens/HomeScreen.test.tsx +++ b/__tests__/components/screens/HomeScreen.test.tsx @@ -113,12 +113,14 @@ jest.mock("ducks/balances", () => ({ jest.mock("ducks/prices", () => ({ usePricesStore: jest.fn(() => ({ - prices: {}, + pricesByNetwork: {}, + sourceByNetwork: {}, isLoading: false, error: null, lastUpdated: null, fetchPricesForBalances: jest.fn(), })), + usePricesForNetwork: jest.fn(() => ({})), })); jest.mock("ducks/collectibles", () => ({ diff --git a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx index d7b4d307e..f78b42f99 100644 --- a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx +++ b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx @@ -274,11 +274,13 @@ const mockPrices: Record< jest.mock("ducks/prices", () => ({ usePricesStore: (selector?: (s: unknown) => unknown): unknown => { const state = { - prices: mockPrices, + pricesByNetwork: {}, + sourceByNetwork: {}, fetchPricesForTokenIds: mockFetchPricesForTokenIds, }; return selector ? selector(state) : state; }, + usePricesForNetwork: () => mockPrices, })); // Cache the return value so account / spendableAmount memos stay stable across // re-renders — otherwise the amountError useEffect can re-fire forever when diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index 074afb574..989324a32 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -1845,7 +1845,8 @@ describe("auth duck", () => { // Verify setState was called with correct reset values expect(usePricesStore.setState).toHaveBeenCalledWith({ - prices: {}, + pricesByNetwork: {}, + sourceByNetwork: {}, isLoading: false, error: null, lastUpdated: null, @@ -1927,7 +1928,8 @@ describe("auth duck", () => { const pricesCall = (usePricesStore.setState as jest.Mock).mock .calls[0]?.[0]; - expect(pricesCall?.prices).toEqual({}); + expect(pricesCall?.pricesByNetwork).toEqual({}); + expect(pricesCall?.sourceByNetwork).toEqual({}); expect(pricesCall?.lastUpdated).toBeNull(); }); diff --git a/__tests__/ducks/balances.test.ts b/__tests__/ducks/balances.test.ts index 7595bafd7..44cd1d95a 100644 --- a/__tests__/ducks/balances.test.ts +++ b/__tests__/ducks/balances.test.ts @@ -31,7 +31,8 @@ jest.mock("ducks/prices", () => ({ usePricesStore: { getState: jest.fn().mockReturnValue({ fetchPricesForBalances: jest.fn(), - prices: {}, + pricesByNetwork: {}, + sourceByNetwork: {}, error: null, isLoading: false, lastUpdated: null, @@ -49,7 +50,9 @@ describe("balances duck", () => { >; const mockGetItem = jest.fn(); - // Helper function to create a mock prices store state + // Helper function to create a mock prices store state. The `prices` override + // is exposed under every network so `pricesByNetwork[params.network]` resolves + // regardless of which network a test uses. const createMockPricesStore = ( overrides: Partial<{ fetchPricesForBalances: jest.Mock; @@ -58,14 +61,22 @@ describe("balances duck", () => { isLoading: boolean; lastUpdated: number | null; }> = {}, - ) => ({ - fetchPricesForBalances: jest.fn().mockResolvedValue(undefined), - prices: {}, - error: null, - isLoading: false, - lastUpdated: null, - ...overrides, - }); + ) => { + const { prices = {}, ...rest } = overrides; + return { + fetchPricesForBalances: jest.fn().mockResolvedValue(undefined), + pricesByNetwork: { + [NETWORKS.PUBLIC]: prices, + [NETWORKS.TESTNET]: prices, + [NETWORKS.FUTURENET]: prices, + }, + sourceByNetwork: {}, + error: null, + isLoading: false, + lastUpdated: null, + ...rest, + }; + }; // Mock data const mockNativeBalance: NativeBalance = { @@ -286,7 +297,8 @@ describe("balances duck", () => { mockFetchBalances.mockResolvedValueOnce({ balances: {} }); (usePricesStore.getState as jest.Mock).mockReturnValue({ fetchPricesForBalances: jest.fn().mockResolvedValue(undefined), - prices: {}, + pricesByNetwork: {}, + sourceByNetwork: {}, error: null, isLoading: false, lastUpdated: null, diff --git a/__tests__/ducks/prices.test.ts b/__tests__/ducks/prices.test.ts index 6edb9b327..f093a86b6 100644 --- a/__tests__/ducks/prices.test.ts +++ b/__tests__/ducks/prices.test.ts @@ -8,7 +8,7 @@ import { TokenPricesMap, TokenTypeWithCustomToken, } from "config/types"; -import { usePricesStore } from "ducks/prices"; +import { usePricesStore, usePricesForNetwork } from "ducks/prices"; import * as balancesHelpers from "helpers/balances"; import { fetchTokenPrices } from "services/backend"; @@ -30,13 +30,9 @@ describe("prices duck", () => { typeof fetchTokenPrices >; - // Helper function to create mock balances const createMockBalances = () => { const mockNativeBalance: NativeBalance = { - token: { - code: "XLM", - type: "native" as const, // Fix the type issue - }, + token: { code: "XLM", type: "native" as const }, total: new BigNumber("100.5"), available: new BigNumber("100.5"), minimumBalance: new BigNumber("1"), @@ -59,14 +55,11 @@ describe("prices duck", () => { sellingLiabilities: "0", }; - // Define type to satisfy the linter type MockBalanceRecord = Record & { native: NativeBalance; }; return { - mockNativeBalance, - mockTokenBalance, mockBalances: { native: mockNativeBalance, "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN": @@ -75,8 +68,7 @@ describe("prices duck", () => { }; }; - // Helper function to create mock prices - const createMockPrices = () => ({ + const createMockPrices = (): TokenPricesMap => ({ XLM: { currentPrice: new BigNumber("0.5"), percentagePriceChange24h: new BigNumber("0.02"), @@ -90,7 +82,6 @@ describe("prices duck", () => { const { mockBalances } = createMockBalances(); const mockPrices = createMockPrices(); - // Mock token identifiers const mockTokenIdentifiers = [ "XLM", "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", @@ -103,35 +94,43 @@ describe("prices duck", () => { useV2: true, }; + // Convenience: seed a network's cache as if it were fetched under `useV2`. + const seedNetwork = ( + network: NETWORKS, + prices: TokenPricesMap, + useV2 = true, + ) => + act(() => { + usePricesStore.setState({ + pricesByNetwork: { [network]: prices }, + sourceByNetwork: { [network]: useV2 }, + }); + }); + beforeEach(() => { - // Reset the store before each test - // pricesUseV2 starts at true so pre-seeded tests, which shallow-merge their - // own setState and fetch with useV2: true, are treated as same-source. act(() => { usePricesStore.setState({ - prices: {}, - pricesNetwork: null, - pricesUseV2: true, + pricesByNetwork: {}, + sourceByNetwork: {}, isLoading: false, error: null, lastUpdated: null, }); }); - // Reset the mocks mockGetTokenIdentifiersFromBalances.mockReset(); mockFetchTokenPrices.mockReset(); - // Setup default mock returns mockGetTokenIdentifiersFromBalances.mockReturnValue(mockTokenIdentifiers); mockFetchTokenPrices.mockResolvedValue(mockPrices); }); describe("initial state", () => { - it("should have correct initial state", () => { + it("starts empty", () => { const { result } = renderHook(() => usePricesStore()); - expect(result.current.prices).toEqual({}); + expect(result.current.pricesByNetwork).toEqual({}); + expect(result.current.sourceByNetwork).toEqual({}); expect(result.current.isLoading).toBe(false); expect(result.current.error).toBeNull(); expect(result.current.lastUpdated).toBeNull(); @@ -139,124 +138,66 @@ describe("prices duck", () => { }); describe("fetchPricesForBalances", () => { - it("should update isLoading state when fetching begins", async () => { + it("stores fetched prices under the requested network", async () => { const { result } = renderHook(() => usePricesStore()); await act(async () => { - await result.current.fetchPricesForBalances(mockParams); + await result.current.fetchPricesForBalances(mockParams); // TESTNET }); - expect(mockGetTokenIdentifiersFromBalances).toHaveBeenCalledWith( - mockBalances, - ); expect(mockFetchTokenPrices).toHaveBeenCalledWith({ tokens: mockTokenIdentifiers, network: NETWORKS.TESTNET, useV2: true, }); - }); - - it("should update prices state on successful fetch", async () => { - const { result } = renderHook(() => usePricesStore()); - - await act(async () => { - await result.current.fetchPricesForBalances(mockParams); - }); - - expect(result.current.prices).toEqual(mockPrices); + expect(result.current.pricesByNetwork[NETWORKS.TESTNET]).toEqual( + mockPrices, + ); + expect(result.current.sourceByNetwork[NETWORKS.TESTNET]).toBe(true); expect(result.current.isLoading).toBe(false); expect(result.current.error).toBeNull(); - expect(result.current.lastUpdated).not.toBeNull(); expect(typeof result.current.lastUpdated).toBe("number"); }); - it("should merge with existing prices (preserves non-balance entries)", async () => { + it("merges within a network (preserves non-balance entries)", async () => { const { result } = renderHook(() => usePricesStore()); - // Pre-seed the store with a price for a non-balance token (e.g., a - // trending token previously loaded via fetchPricesForTokenIds), already - // on the same network the fetch will use so it merges (not cleared). - act(() => { - usePricesStore.setState({ - prices: { - "AQUA:GBN...": { - currentPrice: new BigNumber("0.003"), - percentagePriceChange24h: new BigNumber("1.2"), - }, - }, - pricesNetwork: NETWORKS.TESTNET, - }); + seedNetwork(NETWORKS.TESTNET, { + "AQUA:GBN...": { + currentPrice: new BigNumber("0.003"), + percentagePriceChange24h: new BigNumber("1.2"), + }, }); - // Now fetch balance prices — these are different tokens. await act(async () => { - await result.current.fetchPricesForBalances(mockParams); + await result.current.fetchPricesForBalances(mockParams); // TESTNET }); - // Both pre-seeded and freshly-fetched prices should be present. - expect(result.current.prices["AQUA:GBN..."]).toBeDefined(); - expect(result.current.prices.XLM).toBeDefined(); + const testnet = result.current.pricesByNetwork[NETWORKS.TESTNET]; + expect(testnet["AQUA:GBN..."]).toBeDefined(); + expect(testnet.XLM).toBeDefined(); }); - it("drops prices from a different network before fetching", async () => { + it("does not write fetched prices into another network's cache", async () => { const { result } = renderHook(() => usePricesStore()); - // A non-held price cached for PUBLIC; the fetch below is for TESTNET. - act(() => { - usePricesStore.setState({ - prices: { - "AQUA:GBN...": { - currentPrice: new BigNumber("0.003"), - percentagePriceChange24h: new BigNumber("1.2"), - }, - }, - pricesNetwork: NETWORKS.PUBLIC, - }); - }); + // Pre-existing PUBLIC cache must be untouched by a TESTNET fetch. + seedNetwork(NETWORKS.PUBLIC, mockPrices); await act(async () => { await result.current.fetchPricesForBalances(mockParams); // TESTNET }); - // Stale PUBLIC price is gone; only the freshly-fetched TESTNET prices remain. - expect(result.current.prices["AQUA:GBN..."]).toBeUndefined(); - expect(result.current.prices.XLM).toBeDefined(); - expect(result.current.pricesNetwork).toBe(NETWORKS.TESTNET); - }); - - it("discards an in-flight response when the network changed mid-fetch", async () => { - const { result } = renderHook(() => usePricesStore()); - - // Hold the fetch open so we can switch networks before it resolves. - let resolveFetch: (value: typeof mockPrices) => void = () => {}; - mockFetchTokenPrices.mockReturnValueOnce( - new Promise((resolve) => { - resolveFetch = resolve; - }), + expect(result.current.pricesByNetwork[NETWORKS.PUBLIC]).toEqual( + mockPrices, + ); + expect(result.current.pricesByNetwork[NETWORKS.TESTNET]).toEqual( + mockPrices, ); - - let pending: Promise = Promise.resolve(); - act(() => { - pending = result.current.fetchPricesForBalances(mockParams); // TESTNET - }); - - // The user switches networks while the TESTNET fetch is still in flight. - act(() => { - usePricesStore.setState({ pricesNetwork: NETWORKS.PUBLIC }); - }); - - await act(async () => { - resolveFetch(mockPrices); - await pending; - }); - - // The stale TESTNET response must not be merged into the PUBLIC cache. - expect(result.current.prices).toEqual({}); }); - it("should handle empty token list", async () => { + it("handles an empty token list", async () => { mockGetTokenIdentifiersFromBalances.mockReturnValueOnce([]); - const { result } = renderHook(() => usePricesStore()); await act(async () => { @@ -269,64 +210,34 @@ describe("prices duck", () => { }); describe("error handling", () => { - it("should update error state when fetch fails with Error instance", async () => { - const errorMessage = "Network error"; - mockFetchTokenPrices.mockRejectedValueOnce(new Error(errorMessage)); - + it("sets error and keeps loading false on failure", async () => { + mockFetchTokenPrices.mockRejectedValueOnce(new Error("Network error")); const { result } = renderHook(() => usePricesStore()); await act(async () => { await result.current.fetchPricesForBalances(mockParams); }); - expect(result.current.prices).toEqual({}); + expect(result.current.error).toBe("Network error"); expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(errorMessage); - expect(result.current.lastUpdated).toBeNull(); }); - it("should update error state when fetch fails with non-Error", async () => { - mockFetchTokenPrices.mockRejectedValueOnce("Some non-error rejection"); - + it("preserves already-cached prices for the network on failure", async () => { const { result } = renderHook(() => usePricesStore()); - - await act(async () => { - await result.current.fetchPricesForBalances(mockParams); - }); - - expect(result.current.prices).toEqual({}); - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe("Failed to fetch token prices"); - expect(result.current.lastUpdated).toBeNull(); - }); - - it("should preserve existing prices when fetch fails", async () => { - // First set some prices, already on the network the fetch will use so a - // transient failure preserves them (rather than the network-change clear). - const mockLastUpdated = Date.now(); - act(() => { - usePricesStore.setState({ - prices: mockPrices, - pricesNetwork: NETWORKS.TESTNET, - isLoading: false, - error: null, - lastUpdated: mockLastUpdated, - }); - }); - - // Then simulate a failed fetch + // Same network + same endpoint as the failing fetch, so nothing clears. + seedNetwork(NETWORKS.TESTNET, mockPrices, true); + // Only XLM is held now, so the fetch tries to load XLM and fails. + mockGetTokenIdentifiersFromBalances.mockReturnValueOnce(["NEW:GXYZ"]); mockFetchTokenPrices.mockRejectedValueOnce(new Error("Network error")); - const { result } = renderHook(() => usePricesStore()); - await act(async () => { await result.current.fetchPricesForBalances(mockParams); }); - expect(result.current.prices).toEqual(mockPrices); - expect(result.current.isLoading).toBe(false); + expect(result.current.pricesByNetwork[NETWORKS.TESTNET]).toEqual( + mockPrices, + ); expect(result.current.error).toBe("Network error"); - expect(result.current.lastUpdated).toBe(mockLastUpdated); }); }); }); @@ -334,19 +245,13 @@ describe("prices duck", () => { describe("fetchPricesForTokenIds", () => { const trendingIds = ["AQUA:GBNAQUA", "yXLM:GYXLM"]; - it("only fetches tokens not already in the map", async () => { + it("only fetches tokens not already in the network cache", async () => { const { result } = renderHook(() => usePricesStore()); - - act(() => { - usePricesStore.setState({ - prices: { - "AQUA:GBNAQUA": { - currentPrice: new BigNumber("0.003"), - percentagePriceChange24h: new BigNumber("1.2"), - }, - }, - pricesNetwork: NETWORKS.PUBLIC, - }); + seedNetwork(NETWORKS.PUBLIC, { + "AQUA:GBNAQUA": { + currentPrice: new BigNumber("0.003"), + percentagePriceChange24h: new BigNumber("1.2"), + }, }); await act(async () => { @@ -357,7 +262,6 @@ describe("prices duck", () => { }); }); - // Only the missing token is requested. expect(mockFetchTokenPrices).toHaveBeenCalledWith({ tokens: ["yXLM:GYXLM"], network: NETWORKS.PUBLIC, @@ -365,23 +269,17 @@ describe("prices duck", () => { }); }); - it("does not fetch when all tokens are already loaded", async () => { + it("does not fetch when all tokens are already loaded for that network", async () => { const { result } = renderHook(() => usePricesStore()); - - act(() => { - usePricesStore.setState({ - prices: { - "AQUA:GBNAQUA": { - currentPrice: new BigNumber("0.003"), - percentagePriceChange24h: new BigNumber("1.2"), - }, - "yXLM:GYXLM": { - currentPrice: new BigNumber("0.4"), - percentagePriceChange24h: new BigNumber("0.5"), - }, - }, - pricesNetwork: NETWORKS.PUBLIC, - }); + seedNetwork(NETWORKS.PUBLIC, { + "AQUA:GBNAQUA": { + currentPrice: new BigNumber("0.003"), + percentagePriceChange24h: new BigNumber("1.2"), + }, + "yXLM:GYXLM": { + currentPrice: new BigNumber("0.4"), + percentagePriceChange24h: new BigNumber("0.5"), + }, }); await act(async () => { @@ -395,25 +293,49 @@ describe("prices duck", () => { expect(mockFetchTokenPrices).not.toHaveBeenCalled(); }); - it("refetches already-loaded tokens when forceRefresh is true", async () => { + it("treats a different network as uncached (no cross-network dedupe)", async () => { const { result } = renderHook(() => usePricesStore()); + // All ids cached for PUBLIC... + seedNetwork(NETWORKS.PUBLIC, { + "AQUA:GBNAQUA": { + currentPrice: new BigNumber("0.003"), + percentagePriceChange24h: new BigNumber("1.2"), + }, + "yXLM:GYXLM": { + currentPrice: new BigNumber("0.4"), + percentagePriceChange24h: new BigNumber("0.5"), + }, + }); - act(() => { - usePricesStore.setState({ - prices: { - "AQUA:GBNAQUA": { - currentPrice: new BigNumber("0.003"), - percentagePriceChange24h: new BigNumber("1.2"), - }, - "yXLM:GYXLM": { - currentPrice: new BigNumber("0.4"), - percentagePriceChange24h: new BigNumber("0.5"), - }, - }, - pricesNetwork: NETWORKS.PUBLIC, + // ...but a TESTNET request must still fetch them (prices are per-network). + await act(async () => { + await result.current.fetchPricesForTokenIds({ + tokens: trendingIds, + network: NETWORKS.TESTNET, + useV2: true, }); }); + expect(mockFetchTokenPrices).toHaveBeenCalledWith({ + tokens: trendingIds, + network: NETWORKS.TESTNET, + useV2: true, + }); + }); + + it("refetches already-loaded tokens when forceRefresh is true", async () => { + const { result } = renderHook(() => usePricesStore()); + seedNetwork(NETWORKS.PUBLIC, { + "AQUA:GBNAQUA": { + currentPrice: new BigNumber("0.003"), + percentagePriceChange24h: new BigNumber("1.2"), + }, + "yXLM:GYXLM": { + currentPrice: new BigNumber("0.4"), + percentagePriceChange24h: new BigNumber("0.5"), + }, + }); + await act(async () => { await result.current.fetchPricesForTokenIds({ tokens: trendingIds, @@ -423,7 +345,6 @@ describe("prices duck", () => { }); }); - // forceRefresh bypasses the already-loaded skip → both tokens requested. expect(mockFetchTokenPrices).toHaveBeenCalledWith({ tokens: trendingIds, network: NETWORKS.PUBLIC, @@ -431,46 +352,45 @@ describe("prices duck", () => { }); }); - it("refetches already-loaded tokens when the network changes", async () => { + it("refetches cached tokens from v1 when the rollback flag flips", async () => { const { result } = renderHook(() => usePricesStore()); - - // Cache is fully populated, but for a different network than the fetch. - act(() => { - usePricesStore.setState({ - prices: { - "AQUA:GBNAQUA": { - currentPrice: new BigNumber("0.003"), - percentagePriceChange24h: new BigNumber("1.2"), - }, - "yXLM:GYXLM": { - currentPrice: new BigNumber("0.4"), - percentagePriceChange24h: new BigNumber("0.5"), - }, + // Cache populated by v2 on PUBLIC. + seedNetwork( + NETWORKS.PUBLIC, + { + "AQUA:GBNAQUA": { + currentPrice: new BigNumber("0.003"), + percentagePriceChange24h: new BigNumber("1.2"), }, - pricesNetwork: NETWORKS.PUBLIC, - }); - }); + "yXLM:GYXLM": { + currentPrice: new BigNumber("0.4"), + percentagePriceChange24h: new BigNumber("0.5"), + }, + }, + true, + ); + // Rolled back to v1 → caller passes useV2: false. await act(async () => { await result.current.fetchPricesForTokenIds({ tokens: trendingIds, - network: NETWORKS.TESTNET, - useV2: true, + network: NETWORKS.PUBLIC, + useV2: false, }); }); - // Stale prices are network-scoped, so the network switch refetches every - // requested token rather than treating them as already-loaded. + // The endpoint changed, so all ids are refetched (cache was dropped). expect(mockFetchTokenPrices).toHaveBeenCalledWith({ tokens: trendingIds, - network: NETWORKS.TESTNET, - useV2: true, + network: NETWORKS.PUBLIC, + useV2: false, }); - expect(usePricesStore.getState().pricesNetwork).toBe(NETWORKS.TESTNET); + expect(result.current.sourceByNetwork[NETWORKS.PUBLIC]).toBe(false); }); - it("discards an in-flight response when the network changed mid-fetch", async () => { + it("discards an in-flight response when the endpoint flips mid-fetch", async () => { const { result } = renderHook(() => usePricesStore()); + seedNetwork(NETWORKS.PUBLIC, {}, true); let resolveFetch: (value: TokenPricesMap) => void = () => {}; mockFetchTokenPrices.mockReturnValueOnce( @@ -488,9 +408,11 @@ describe("prices duck", () => { }); }); - // Network moves on before the slow PUBLIC fetch resolves. + // A rollback to v1 lands before the v2 fetch resolves. act(() => { - usePricesStore.setState({ pricesNetwork: NETWORKS.TESTNET }); + usePricesStore.setState({ + sourceByNetwork: { [NETWORKS.PUBLIC]: false }, + }); }); await act(async () => { @@ -503,89 +425,29 @@ describe("prices duck", () => { await pending; }); - // The stale PUBLIC response must not be merged into the TESTNET cache. - expect(result.current.prices["AQUA:GBNAQUA"]).toBeUndefined(); - }); - - it("refetches cached tokens when the v2 rollback flag flips", async () => { - const { result } = renderHook(() => usePricesStore()); - - // Cache fully populated by v2 on the same network the fetch will use. - act(() => { - usePricesStore.setState({ - prices: { - "AQUA:GBNAQUA": { - currentPrice: new BigNumber("0.003"), - percentagePriceChange24h: new BigNumber("1.2"), - }, - "yXLM:GYXLM": { - currentPrice: new BigNumber("0.4"), - percentagePriceChange24h: new BigNumber("0.5"), - }, - }, - pricesNetwork: NETWORKS.PUBLIC, - pricesUseV2: true, - }); - }); - - // Amplitude rolled the flag back to v1, so the caller now passes false. - await act(async () => { - await result.current.fetchPricesForTokenIds({ - tokens: trendingIds, - network: NETWORKS.PUBLIC, - useV2: false, - }); - }); - - // Despite being cached, the tokens are refetched from v1 — the rollback - // applies to cached token-id lookups, not just new ones. - expect(mockFetchTokenPrices).toHaveBeenCalledWith({ - tokens: trendingIds, - network: NETWORKS.PUBLIC, - useV2: false, - }); - expect(usePricesStore.getState().pricesUseV2).toBe(false); + // The stale v2 response must not be merged into the v1-flipped cache. + expect( + result.current.pricesByNetwork[NETWORKS.PUBLIC]["AQUA:GBNAQUA"], + ).toBeUndefined(); }); }); - describe("selector hooks", () => { - it("should have correct state values", () => { - const mockLastUpdated = Date.now(); - - act(() => { - usePricesStore.setState({ - prices: mockPrices, - isLoading: true, - error: "Test error", - lastUpdated: mockLastUpdated, - }); - }); - - const { result } = renderHook(() => usePricesStore()); - - expect(result.current.prices).toEqual(mockPrices); - expect(result.current.isLoading).toBe(true); - expect(result.current.error).toBe("Test error"); - expect(result.current.lastUpdated).toBe(mockLastUpdated); + describe("usePricesForNetwork", () => { + it("returns the requested network's prices", () => { + seedNetwork(NETWORKS.PUBLIC, mockPrices); + const { result } = renderHook(() => usePricesForNetwork(NETWORKS.PUBLIC)); + expect(result.current).toEqual(mockPrices); }); - it("should have fetchPricesForBalances function", async () => { - const { result } = renderHook(() => usePricesStore()); - - expect(typeof result.current.fetchPricesForBalances).toBe("function"); - - await act(async () => { - await result.current.fetchPricesForBalances(mockParams); - }); - - expect(mockGetTokenIdentifiersFromBalances).toHaveBeenCalledWith( - mockBalances, + it("returns a stable empty map for an uncached network", () => { + const { result, rerender } = renderHook(() => + usePricesForNetwork(NETWORKS.FUTURENET), ); - expect(mockFetchTokenPrices).toHaveBeenCalledWith({ - tokens: mockTokenIdentifiers, - network: NETWORKS.TESTNET, - useV2: true, - }); + const first = result.current; + expect(first).toEqual({}); + rerender(); + // Same reference across renders → no spurious re-renders. + expect(result.current).toBe(first); }); }); }); diff --git a/__tests__/services/backend.test.ts b/__tests__/services/backend.test.ts index b99ae6cfd..682f589a7 100644 --- a/__tests__/services/backend.test.ts +++ b/__tests__/services/backend.test.ts @@ -788,4 +788,44 @@ describe("Backend Service - fetchTokenPrices v2 migration", () => { percentagePriceChange24h: null, }); }); + + it("logs an error to Sentry and rethrows on a backend failure", async () => { + const backendError = { + message: "Internal Server Error", + status: 500, + isNetworkError: false, + }; + mockV2Post.mockRejectedValueOnce(backendError); + + await expect( + fetchTokenPrices({ tokens, network: NETWORKS.PUBLIC, useV2: true }), + ).rejects.toEqual(backendError); + + expect(logger.error).toHaveBeenCalledWith( + "backendApi.fetchTokenPrices", + "Error fetching token prices", + backendError, + ); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("demotes a connectivity failure to a warn breadcrumb (no Sentry error)", async () => { + const networkError = { + message: "Network Error", + status: 0, + isNetworkError: true, + }; + mockV2Post.mockRejectedValueOnce(networkError); + + await expect( + fetchTokenPrices({ tokens, network: NETWORKS.PUBLIC, useV2: true }), + ).rejects.toEqual(networkError); + + expect(logger.warn).toHaveBeenCalledWith( + "backendApi.fetchTokenPrices", + "Network unreachable while fetching token prices", + networkError, + ); + expect(logger.error).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/screens/SwapScreen/components/SwapTransactionDetailsBottomSheet.tsx b/src/components/screens/SwapScreen/components/SwapTransactionDetailsBottomSheet.tsx index 66f74dca0..1df19387c 100644 --- a/src/components/screens/SwapScreen/components/SwapTransactionDetailsBottomSheet.tsx +++ b/src/components/screens/SwapScreen/components/SwapTransactionDetailsBottomSheet.tsx @@ -14,7 +14,7 @@ import { logger } from "config/logger"; import { THEME } from "config/theme"; import { NonNativeToken, NativeToken } from "config/types"; import { useAuthenticationStore } from "ducks/auth"; -import { usePricesStore } from "ducks/prices"; +import { usePricesForNetwork } from "ducks/prices"; import { useSwapSettingsStore } from "ducks/swapSettings"; import { useTransactionBuilderStore } from "ducks/transactionBuilder"; import { calculateSwapRate } from "helpers/balances"; @@ -147,7 +147,7 @@ const SwapTransactionDetailsBottomSheet: React.FC< // Thread the live prices map so non-held destinations resolve their // fiat via the token-id lookup (strategy 3) — without it, the // destination row renders "--" after every swap to a new token. - const prices = usePricesStore((state) => state.prices); + const prices = usePricesForNetwork(network); const sourceTokenFiatAmountValue = calculateTokenFiatAmount({ token: sourceToken, diff --git a/src/components/screens/SwapScreen/hooks/useReviewTokens.ts b/src/components/screens/SwapScreen/hooks/useReviewTokens.ts index 4e040bf98..fa6915b6c 100644 --- a/src/components/screens/SwapScreen/hooks/useReviewTokens.ts +++ b/src/components/screens/SwapScreen/hooks/useReviewTokens.ts @@ -10,7 +10,8 @@ import { PricedBalance, TokenTypeWithCustomToken, } from "config/types"; -import { usePricesStore } from "ducks/prices"; +import { useAuthenticationStore } from "ducks/auth"; +import { usePricesForNetwork } from "ducks/prices"; import { SwapPathResult } from "ducks/swap"; import { formatFiatAmount } from "helpers/formatAmount"; import { useMemo } from "react"; @@ -97,7 +98,8 @@ export const useReviewTokens = ({ // their fiat via the token-id lookup (strategy 3) — without it the // review sheet renders "--" instead of the dollar amount for any // token the user doesn't already hold. - const prices = usePricesStore((state) => state.prices); + const network = useAuthenticationStore((state) => state.network); + const prices = usePricesForNetwork(network); const sourceTokenFiatAmountValue = calculateTokenFiatAmount({ token: sourceToken, diff --git a/src/components/screens/SwapScreen/hooks/useSwapTokenPrices.ts b/src/components/screens/SwapScreen/hooks/useSwapTokenPrices.ts index b2a6187a8..9fa574b18 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapTokenPrices.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapTokenPrices.ts @@ -1,7 +1,7 @@ import { recordTokenId } from "components/screens/SwapScreen/helpers"; import { FormattedSearchTokenRecord, TokenPricesMap } from "config/types"; import { useAuthenticationStore } from "ducks/auth"; -import { usePricesStore } from "ducks/prices"; +import { usePricesForNetwork, usePricesStore } from "ducks/prices"; import { useRemoteConfigStore } from "ducks/remoteConfig"; import { useCallback, useEffect, useMemo } from "react"; @@ -37,11 +37,12 @@ export const useSwapTokenPrices = ({ const fetchPricesForTokenIds = usePricesStore( (state) => state.fetchPricesForTokenIds, ); - const prices = usePricesStore((state) => state.prices); const network = useAuthenticationStore((state) => state.network); // Subscribe to the endpoint flag so a v1/v2 rollback re-runs the fetch below // even while this screen stays mounted with an unchanged token list/network. const useV2 = useRemoteConfigStore((state) => state.use_token_prices_v2); + // Read the active network's prices only — never another network's. + const prices = usePricesForNetwork(network); // Stabilise the extra-ids array so the effect doesn't fire on every // render when the caller passes a fresh literal. diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 61546263d..e4ebeaa5c 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -1298,7 +1298,8 @@ export function clearAccountData(): void { // Clear prices data usePricesStore.setState({ - prices: {}, + pricesByNetwork: {}, + sourceByNetwork: {}, isLoading: false, error: null, lastUpdated: null, diff --git a/src/ducks/balances.ts b/src/ducks/balances.ts index 8c42e45ec..550fc699b 100644 --- a/src/ducks/balances.ts +++ b/src/ducks/balances.ts @@ -178,10 +178,11 @@ const fetchPricedBalances = async ( // Make sure to wait until the prices finishes fetching await priceFetchPromise; - // Get the updated prices from the store - const { prices, error: pricesError } = usePricesStore.getState(); + // Get the updated prices for this network from the store + const { pricesByNetwork, error: pricesError } = usePricesStore.getState(); + const prices = pricesByNetwork[params.network] ?? {}; - if (pricesError || !prices || Object.keys(prices).length === 0) { + if (pricesError || Object.keys(prices).length === 0) { // Return existing data in case of price fetch error return existingPricedBalances; } diff --git a/src/ducks/prices.ts b/src/ducks/prices.ts index 37e674fd3..590fb7681 100644 --- a/src/ducks/prices.ts +++ b/src/ducks/prices.ts @@ -4,21 +4,29 @@ import { getTokenIdentifiersFromBalances } from "helpers/balances"; import { fetchTokenPrices } from "services/backend"; import { create } from "zustand"; +/** + * Stable empty map so selectors return a referentially-stable value when a + * network has no cached prices yet (avoids a fresh `{}` re-render each call). + */ +const EMPTY_PRICES: TokenPricesMap = Object.freeze({}); + interface PricesState { - prices: TokenPricesMap; /** - * Network the cached `prices` were fetched for. v2 is network-scoped, so - * prices from a different network are stale and must not be reused — when a - * fetch arrives for a different network the cache is dropped and refetched. + * Per-network price caches, keyed by {@link NETWORKS}. v2 prices are + * network-scoped, so keying the cache by network makes a cross-network read + * impossible by construction — a network *switch* never needs to clear the + * cache (which previously caused a blank-price flicker), and a price response + * that resolves after the user switched networks still lands in its own + * network's submap rather than polluting the active one. */ - pricesNetwork: NETWORKS | null; + pricesByNetwork: Record; /** - * Endpoint version (`use_token_prices_v2`) the cached `prices` came from. - * Together with `pricesNetwork` this identifies the price source — when the - * Amplitude flag rolls v2 back to v1 (or vice versa) the cache is dropped and - * refetched, so the rollback applies even to already-cached token-id lookups. + * Endpoint version (`use_token_prices_v2`) that populated each network's + * cache. A v1<->v2 rollback flips this; the next fetch for that network drops + * its stale-endpoint cache and refetches, so the rollback applies even to + * already-cached tokens. */ - pricesUseV2: boolean | null; + sourceByNetwork: Record; isLoading: boolean; error: string | null; lastUpdated: number | null; @@ -34,18 +42,35 @@ interface PricesState { tokens: TokenIdentifier[]; /** Active network — required by the network-scoped v2 prices endpoint. */ network: NETWORKS; - /** Endpoint version, from the `use_token_prices_v2` flag. Callers subscribe - * to the flag so a rollback re-runs the fetch and invalidates the cache. */ + /** Endpoint version, from the `use_token_prices_v2` flag. */ useV2: boolean; /** Refetch even tokens already in the map (e.g. pull-to-refresh). */ forceRefresh?: boolean; }) => Promise; } +/** + * Drop a network's cache when the endpoint (v1/v2) that populated it differs + * from the requested one, and stamp the new endpoint. A network *switch* is + * handled purely by the per-network key, so this only fires on an actual + * v1<->v2 rollback (rare). Returns whether a clear happened. + */ +const reconcileSource = ( + get: () => PricesState, + set: (partial: Partial) => void, + network: NETWORKS, + useV2: boolean, +): void => { + if (get().sourceByNetwork[network] === useV2) return; + set({ + pricesByNetwork: { ...get().pricesByNetwork, [network]: {} }, + sourceByNetwork: { ...get().sourceByNetwork, [network]: useV2 }, + }); +}; + export const usePricesStore = create((set, get) => ({ - prices: {}, - pricesNetwork: null, - pricesUseV2: null, + pricesByNetwork: {}, + sourceByNetwork: {}, isLoading: false, error: null, lastUpdated: null, @@ -54,50 +79,52 @@ export const usePricesStore = create((set, get) => ({ try { set({ isLoading: true, error: null }); - // The cache is identified by its source — (network, endpoint version). - // v2 is network-scoped, and a v1/v2 rollback changes which endpoint the - // prices came from. If either differs, drop the cache before doing - // anything, which also resets the dedupe baseline so every token is - // re-queried from the current source. - if (get().pricesNetwork !== network || get().pricesUseV2 !== useV2) { - set({ prices: {}, pricesNetwork: network, pricesUseV2: useV2 }); + const tokens = getTokenIdentifiersFromBalances(balances); + if (tokens.length === 0) { + set({ isLoading: false, lastUpdated: Date.now() }); + return; } - const tokens = getTokenIdentifiersFromBalances(balances); + reconcileSource(get, set, network, useV2); - if (tokens.length === 0) { - set({ - isLoading: false, - lastUpdated: Date.now(), - }); + // Dedupe against the network's cache (emptied above if the endpoint just + // changed, so a rollback refetches everything). + const cached = get().pricesByNetwork[network] ?? EMPTY_PRICES; + const toFetch = tokens.filter((t) => !cached[t]); + if (toFetch.length === 0) { + set({ isLoading: false, lastUpdated: Date.now() }); return; } - const response = await fetchTokenPrices({ tokens, network, useV2 }); + const response = await fetchTokenPrices({ + tokens: toFetch, + network, + useV2, + }); - // The source may have changed while this request was in flight (network - // switch or flag flip). The response is scoped to (network, useV2); if the - // active source has since moved on, discard it rather than merging stale - // prices into the new source's cache. - if (get().pricesNetwork !== network || get().pricesUseV2 !== useV2) { + // A v1<->v2 rollback may have landed while this request was in flight; + // its response is for the now-superseded endpoint, so drop it rather than + // polluting the freshly-stamped cache. (No network guard needed — a late + // response only ever writes its own network's submap.) + if (get().sourceByNetwork[network] !== useV2) { set({ isLoading: false }); return; } - // Merge instead of replacing — otherwise prices populated by - // fetchPricesForTokenIds for non-held tokens get wiped every time - // balances refresh. set({ - prices: { ...get().prices, ...response }, + pricesByNetwork: { + ...get().pricesByNetwork, + [network]: { + ...(get().pricesByNetwork[network] ?? EMPTY_PRICES), + ...response, + }, + }, isLoading: false, + error: null, lastUpdated: Date.now(), }); } catch (error) { - // Preserve existing prices data in case of error - const currentPrices = get().prices; - set({ - prices: currentPrices, error: error instanceof Error ? error.message @@ -116,22 +143,13 @@ export const usePricesStore = create((set, get) => ({ try { if (!tokens || tokens.length === 0) return; - // Drop the cache when its source — (network, endpoint version) — differs - // from this request: v2 is network-scoped, and a v1/v2 rollback changes - // the endpoint. Callers pass the current flag so a rollback invalidates - // already-cached token-id lookups too; clearing empties the dedupe - // baseline so every requested token is refetched. - if (get().pricesNetwork !== network || get().pricesUseV2 !== useV2) { - set({ prices: {}, pricesNetwork: network, pricesUseV2: useV2 }); - } + reconcileSource(get, set, network, useV2); // Skip tokens already loaded to avoid duplicate requests — unless the - // caller forces a refresh (e.g. pull-to-refresh), since otherwise a - // price fetched once would never update for the rest of the session. - const existing = get().prices; - const toFetch = forceRefresh - ? tokens - : tokens.filter((t) => !existing[t]); + // caller forces a refresh (e.g. pull-to-refresh), since otherwise a price + // fetched once would never update for the rest of the session. + const cached = get().pricesByNetwork[network] ?? EMPTY_PRICES; + const toFetch = forceRefresh ? tokens : tokens.filter((t) => !cached[t]); if (toFetch.length === 0) return; const response = await fetchTokenPrices({ @@ -140,19 +158,30 @@ export const usePricesStore = create((set, get) => ({ useV2, }); - // Discard if the source changed while this request was in flight (network - // switch or flag flip) — the response is scoped to the now-stale - // (network, useV2) (see the balances fetch above for the full rationale). - if (get().pricesNetwork !== network || get().pricesUseV2 !== useV2) - return; + // Drop the response if a v1<->v2 rollback superseded its endpoint while + // it was in flight (see fetchPricesForBalances for the rationale). + if (get().sourceByNetwork[network] !== useV2) return; set({ - prices: { ...get().prices, ...response }, + pricesByNetwork: { + ...get().pricesByNetwork, + [network]: { + ...(get().pricesByNetwork[network] ?? EMPTY_PRICES), + ...response, + }, + }, lastUpdated: Date.now(), }); } catch (error) { - // Silently keep existing prices on error - set({ lastUpdated: Date.now() }); + // Silently keep existing prices on error. } }, })); + +/** + * Live price map for the given network. Returns a stable empty map when the + * network has no cached prices yet. Use this instead of reading the raw store + * so consumers never see another network's (or the wrong endpoint's) prices. + */ +export const usePricesForNetwork = (network: NETWORKS): TokenPricesMap => + usePricesStore((state) => state.pricesByNetwork[network] ?? EMPTY_PRICES); diff --git a/src/hooks/blockaid/useTransactionBalanceListItems.tsx b/src/hooks/blockaid/useTransactionBalanceListItems.tsx index f3f05762e..d76c28799 100644 --- a/src/hooks/blockaid/useTransactionBalanceListItems.tsx +++ b/src/hooks/blockaid/useTransactionBalanceListItems.tsx @@ -11,7 +11,7 @@ import { NonNativeToken, } from "config/types"; import { useAuthenticationStore } from "ducks/auth"; -import { usePricesStore } from "ducks/prices"; +import { usePricesForNetwork, usePricesStore } from "ducks/prices"; import { useRemoteConfigStore } from "ducks/remoteConfig"; import { formatTokenForDisplay, formatFiatAmount } from "helpers/formatAmount"; import useAppTranslation from "hooks/useAppTranslation"; @@ -42,9 +42,10 @@ export const useTransactionBalanceListItems = ( // and the rollback must re-query the new source. const network = useAuthenticationStore((state) => state.network); const useV2 = useRemoteConfigStore((state) => state.use_token_prices_v2); - // Subscribe to prices so the list recomputes when an async price response - // lands — reading via getState alone would not trigger a re-render. - const prices = usePricesStore((state) => state.prices); + // Subscribe to the active network's prices so the list recomputes when an + // async price response lands (reading via getState alone would not trigger a + // re-render) and never shows another network's prices. + const prices = usePricesForNetwork(network); // Balance changes for this transaction: null = unable to simulate. const balanceUpdates = useMemo( diff --git a/src/services/backend.ts b/src/services/backend.ts index 05616d859..e2221d6a3 100644 --- a/src/services/backend.ts +++ b/src/services/backend.ts @@ -389,18 +389,45 @@ export const fetchTokenPrices = async ({ let pricesMap: TokenPricesMap = {}; if (!shouldSkipRequest) { - // The v2 endpoint is network-scoped via a `network` query param; v1 is not. - const { data } = useV2 - ? await freighterBackendV2.post( - "/token-prices", - { tokens: filteredTokens }, - { params: { network: priceNetwork } }, - ) - : await freighterBackendV1.post("/token-prices", { - tokens: filteredTokens, - }); - - pricesMap = data.data; + try { + // The v2 endpoint is network-scoped via a `network` query param; v1 is not. + const { data } = useV2 + ? await freighterBackendV2.post( + "/token-prices", + { tokens: filteredTokens }, + { params: { network: priceNetwork } }, + ) + : await freighterBackendV1.post("/token-prices", { + tokens: filteredTokens, + }); + + // Defensive: if the endpoint returns an unexpected shape (e.g. v2 + // diverges from `{ data: TokenPricesMap }`), fall back to an empty map so + // the null-fill loop below still produces a valid TokenPricesMap instead + // of throwing and silently wiping out all prices. + if (!data?.data) { + logger.warn( + "backendApi.fetchTokenPrices", + "Unexpected token-prices response shape", + { useV2, topLevelKeys: data ? Object.keys(data) : [] }, + ); + } + pricesMap = data?.data ?? {}; + } catch (error) { + // Without this, the store callers swallow price-fetch failures (keeping + // stale prices / a local error string) with no Sentry signal — so a + // broadly-failing endpoint (e.g. a bad v2 rollout) would be invisible. + // Connectivity failures (offline/DNS/TLS) demote to a warn breadcrumb; + // backend 4xx/5xx and timeouts surface as logger.error. Rethrow so the + // callers still manage UI state. + logApiError( + "backendApi.fetchTokenPrices", + "Network unreachable while fetching token prices", + "Error fetching token prices", + error, + ); + throw error; + } } /* From f1516a0b36a446c3eeeabbb2ac3fafd347356ba3 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 26 Jun 2026 13:33:44 -0600 Subject: [PATCH 11/14] fix(prices): send native asset as "native" on the v2 endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit freighter-backend-v2 keys the native asset as "native" (as the ported browser extension sends), but mobile uses NATIVE_TOKEN_CODE ("XLM") everywhere and was POSTing "XLM" to v2. If v2 doesn't accept "XLM" for native, the XLM price — the most important in the wallet — silently comes back null and renders "--", with no test catching it. Translate only at the v2 wire boundary inside fetchTokenPrices: map "XLM" -> "native" in the request body and "native" -> "XLM" in the response, so the rest of the app keeps using "XLM". The v1 fallback is left untouched (it's known to accept "XLM"). Also drop the stale "only returns prices for Mainnet" comment (v2 is network-scoped). Tests: v2 sends "native" + network param, v2 response is remapped to "XLM", v1 still sends "XLM". --- __tests__/services/backend.test.ts | 34 ++++++++++++++++--- src/services/backend.ts | 52 ++++++++++++++++++++++-------- 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/__tests__/services/backend.test.ts b/__tests__/services/backend.test.ts index 682f589a7..34bc623a6 100644 --- a/__tests__/services/backend.test.ts +++ b/__tests__/services/backend.test.ts @@ -715,6 +715,11 @@ describe("Backend Service - fetchTokenPrices v2 migration", () => { "XLM", "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", ]; + // What the v2 request body should contain: native "XLM" mapped to "native". + const v2Tokens = [ + "native", + "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + ]; beforeEach(() => { jest.clearAllMocks(); @@ -729,12 +734,13 @@ describe("Backend Service - fetchTokenPrices v2 migration", () => { mockV2Post.mockResolvedValue(response); }); - it("hits the v2 client with a network query param when useV2 is true", async () => { + it("hits the v2 client with a network query param and native id when useV2 is true", async () => { await fetchTokenPrices({ tokens, network: NETWORKS.PUBLIC, useV2: true }); + // Native "XLM" is sent to v2 as "native". expect(mockV2Post).toHaveBeenCalledWith( "/token-prices", - { tokens }, + { tokens: v2Tokens }, { params: { network: "PUBLIC" } }, ); expect(mockV1Post).not.toHaveBeenCalled(); @@ -745,14 +751,34 @@ describe("Backend Service - fetchTokenPrices v2 migration", () => { expect(mockV2Post).toHaveBeenCalledWith( "/token-prices", - { tokens }, + { tokens: v2Tokens }, { params: { network: "TESTNET" } }, ); }); - it("hits the v1 client with no network param when useV2 is false", async () => { + it("remaps the v2 'native' price back to the app's 'XLM' key", async () => { + mockV2Post.mockResolvedValueOnce({ + data: { + data: { + native: { currentPrice: "0.5", percentagePriceChange24h: 0.02 }, + }, + }, + }); + + const result = await fetchTokenPrices({ + tokens, + network: NETWORKS.PUBLIC, + useV2: true, + }); + + expect(result.XLM?.currentPrice?.toString()).toBe("0.5"); + expect(result.native).toBeUndefined(); + }); + + it("hits the v1 client with the 'XLM' native id and no network param when useV2 is false", async () => { await fetchTokenPrices({ tokens, network: NETWORKS.PUBLIC, useV2: false }); + // v1 is not native-translated — it still receives "XLM". expect(mockV1Post).toHaveBeenCalledWith("/token-prices", { tokens }); expect(mockV2Post).not.toHaveBeenCalled(); }); diff --git a/src/services/backend.ts b/src/services/backend.ts index e2221d6a3..9bd4c74e1 100644 --- a/src/services/backend.ts +++ b/src/services/backend.ts @@ -16,7 +16,7 @@ /* eslint-disable arrow-body-style */ import { Horizon, TransactionBuilder } from "@stellar/stellar-sdk"; import { AxiosError } from "axios"; -import { NetworkDetails, NETWORKS } from "config/constants"; +import { NATIVE_TOKEN_CODE, NetworkDetails, NETWORKS } from "config/constants"; import { BackendEnvConfig } from "config/envConfig"; import { logger, normalizeError } from "config/logger"; import { @@ -337,6 +337,14 @@ const PRICE_NETWORK_PARAMS: Partial> = { [NETWORKS.TESTNET]: NETWORKS.TESTNET, }; +/** + * Wire identifier the v2 /token-prices endpoint expects for the native asset. + * The app uses NATIVE_TOKEN_CODE ("XLM") everywhere, but v2 keys the native + * asset as "native" (matching the browser extension). We translate only at the + * v2 request/response boundary so the rest of the app keeps using "XLM". + */ +const V2_NATIVE_PRICE_ID = "native"; + /** * Fetches the current USD prices and 24h percentage changes for the given tokens. * @@ -390,16 +398,25 @@ export const fetchTokenPrices = async ({ if (!shouldSkipRequest) { try { - // The v2 endpoint is network-scoped via a `network` query param; v1 is not. - const { data } = useV2 - ? await freighterBackendV2.post( - "/token-prices", - { tokens: filteredTokens }, - { params: { network: priceNetwork } }, - ) - : await freighterBackendV1.post("/token-prices", { - tokens: filteredTokens, - }); + let data: TokenPricesResponse; + if (useV2) { + // The v2 endpoint is network-scoped via a `network` query param and keys + // the native asset as "native", so translate "XLM" -> "native" on the + // way out (v1, below, is neither network-scoped nor native-translated). + const v2Tokens = filteredTokens.map((tokenId) => + tokenId === NATIVE_TOKEN_CODE ? V2_NATIVE_PRICE_ID : tokenId, + ); + ({ data } = await freighterBackendV2.post( + "/token-prices", + { tokens: v2Tokens }, + { params: { network: priceNetwork } }, + )); + } else { + ({ data } = await freighterBackendV1.post( + "/token-prices", + { tokens: filteredTokens }, + )); + } // Defensive: if the endpoint returns an unexpected shape (e.g. v2 // diverges from `{ data: TokenPricesMap }`), fall back to an empty map so @@ -413,6 +430,14 @@ export const fetchTokenPrices = async ({ ); } pricesMap = data?.data ?? {}; + + // Translate the native price back to the app's "XLM" convention. The + // null-fill loop below keys off the original (un-translated) `tokens`, so + // remapping here ensures the XLM entry is present and not null-filled. + if (useV2 && pricesMap[V2_NATIVE_PRICE_ID]) { + pricesMap[NATIVE_TOKEN_CODE] = pricesMap[V2_NATIVE_PRICE_ID]; + delete pricesMap[V2_NATIVE_PRICE_ID]; + } } catch (error) { // Without this, the store callers swallow price-fetch failures (keeping // stale prices / a local error string) with no Sentry signal — so a @@ -432,9 +457,8 @@ export const fetchTokenPrices = async ({ /* // ======================================================== - // Uncomment this to simulate token-prices response - // This may be useful for testing the UI with token prices on Testnet - // as the /token-prices endpoint only returns prices for Mainnet + // Uncomment this to simulate a token-prices response — useful for exercising + // the price UI locally without depending on live backend data. // Simulate network delay // eslint-disable-next-line no-promise-executor-return From b9e08cb3300bfcbe06c83c258e9aaf92e79f3024 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 26 Jun 2026 14:06:14 -0600 Subject: [PATCH 12/14] fix(prices): refetch held-asset prices on every balance poll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-network refactor unified the per-token dedupe into fetchPricesForBalances too, which regressed the balance path: the original fetched all held tokens on every call, so the 30s balance poll and Home pull-to-refresh kept currentPrice/fiatTotal current. With the dedupe, once a held token's price was cached — including a null-filled entry for unpriceable tokens — it was never refetched, freezing prices for the rest of the session (pull-to-refresh included) until a network switch, v1/v2 flag flip, or restart. Drop the dedupe from fetchPricesForBalances; refetch all held tokens each call (the poll is the refresh mechanism). reconcileSource, the in-flight endpoint guard, and the per-network merge are unchanged. fetchPricesForTokenIds keeps its dedupe + forceRefresh (eager swap path). --- __tests__/ducks/prices.test.ts | 19 +++++++++++++++++++ src/ducks/prices.ts | 19 +++++-------------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/__tests__/ducks/prices.test.ts b/__tests__/ducks/prices.test.ts index f093a86b6..71bcc54b2 100644 --- a/__tests__/ducks/prices.test.ts +++ b/__tests__/ducks/prices.test.ts @@ -159,6 +159,25 @@ describe("prices duck", () => { expect(typeof result.current.lastUpdated).toBe("number"); }); + it("refetches held tokens even when already cached (poll keeps prices fresh)", async () => { + const { result } = renderHook(() => usePricesStore()); + + // All held tokens are already cached for this network/endpoint... + seedNetwork(NETWORKS.TESTNET, mockPrices, true); + + await act(async () => { + await result.current.fetchPricesForBalances(mockParams); // TESTNET + }); + + // ...but the balance path must still refetch them (no per-token dedupe), + // otherwise held-asset prices freeze for the rest of the session. + expect(mockFetchTokenPrices).toHaveBeenCalledWith({ + tokens: mockTokenIdentifiers, + network: NETWORKS.TESTNET, + useV2: true, + }); + }); + it("merges within a network (preserves non-balance entries)", async () => { const { result } = renderHook(() => usePricesStore()); diff --git a/src/ducks/prices.ts b/src/ducks/prices.ts index 590fb7681..c61d4888c 100644 --- a/src/ducks/prices.ts +++ b/src/ducks/prices.ts @@ -87,20 +87,11 @@ export const usePricesStore = create((set, get) => ({ reconcileSource(get, set, network, useV2); - // Dedupe against the network's cache (emptied above if the endpoint just - // changed, so a rollback refetches everything). - const cached = get().pricesByNetwork[network] ?? EMPTY_PRICES; - const toFetch = tokens.filter((t) => !cached[t]); - if (toFetch.length === 0) { - set({ isLoading: false, lastUpdated: Date.now() }); - return; - } - - const response = await fetchTokenPrices({ - tokens: toFetch, - network, - useV2, - }); + // Refetch every held token on each call — no per-token dedupe here. The + // balance poll (and pull-to-refresh) is the price-refresh mechanism for + // held assets, so deduping would freeze currentPrice/fiatTotal (and any + // null-filled entries) for the rest of the session. + const response = await fetchTokenPrices({ tokens, network, useV2 }); // A v1<->v2 rollback may have landed while this request was in flight; // its response is for the now-superseded endpoint, so drop it rather than From b8f0d5596905c3234acc1f54e30faa1625a9a59e Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 26 Jun 2026 14:09:51 -0600 Subject: [PATCH 13/14] chore(prices): drop internal design doc from the PR --- .../2026-06-25-token-prices-v2-design.md | 123 ------------------ 1 file changed, 123 deletions(-) delete mode 100644 docs/superpowers/specs/2026-06-25-token-prices-v2-design.md diff --git a/docs/superpowers/specs/2026-06-25-token-prices-v2-design.md b/docs/superpowers/specs/2026-06-25-token-prices-v2-design.md deleted file mode 100644 index 45f33d0a1..000000000 --- a/docs/superpowers/specs/2026-06-25-token-prices-v2-design.md +++ /dev/null @@ -1,123 +0,0 @@ -# Token Prices v2 Migration — Design - -**Date:** 2026-06-25 **Branch:** feat/token-prices-v2 **Reference:** -[stellar/freighter#2870](https://github.com/stellar/freighter/pull/2870) - -## Goal - -Migrate `fetchTokenPrices` from the v1 backend endpoint to the network-scoped v2 -endpoint, gated behind a `use_token_prices_v2` remote-config flag so the rollout -can be reverted from Amplitude without shipping a release. This ports the -extension PR above to freighter-mobile. - -## Background - -Today `fetchTokenPrices` (`src/services/backend.ts`) POSTs to -`freighterBackendV1` `/token-prices` with `{ tokens }`. A `freighterBackendV2` -client already exists (`src/services/backend.ts`, configured from -`BackendEnvConfig.FREIGHTER_BACKEND_V2_URL`). - -The v2 `/token-prices` endpoint is **network-scoped**: callers must pass the -active network as a `network` query param (`PUBLIC` or `TESTNET`). Networks the -endpoint does not serve prices for (Futurenet) must be skipped client-side to -avoid guaranteed-failing requests and the resulting Sentry noise. - -Mobile's `NETWORKS` enum values are already the literal strings `"PUBLIC"`, -`"TESTNET"`, `"FUTURENET"` (`src/config/constants.ts`), so no passphrase-to-name -mapping is required — unlike the extension, which maps from `networkPassphrase`. - -Mobile has a remote-config / feature-flag system (`src/ducks/remoteConfig.ts`, -backed by Amplitude Experiment), so the extension's `use_token_prices_v2` flag -is directly portable. - -## Design - -### 1. Feature flag — `src/ducks/remoteConfig.ts` - -- Add `"use_token_prices_v2"` to the `BOOLEAN_FLAGS` tuple. The - `BooleanFeatureFlags` type derives from this array automatically. -- Add `use_token_prices_v2: true` to **both** branches of - `INITIAL_REMOTE_CONFIG_STATE` (the `isDev || __DEV__` branch and the prod - branch). v2 is the default; Amplitude can flip it to `false` to fall back to - v1 without a release. - -### 2. Service — `src/services/backend.ts` `fetchTokenPrices` - -Extend `FetchTokenPricesParams`: - -```ts -export interface FetchTokenPricesParams { - tokens: TokenIdentifier[]; - network: NETWORKS; - useV2: boolean; -} -``` - -Behavior: - -- Keep the existing LP-share / custom-token filtering (`filteredTokens`). -- If `filteredTokens` is empty, short-circuit: return a null-filled map for the - requested `tokens` (no request) — matches the PR's "skip if all tokens filter - out". -- **v2 path** (`useV2 === true`): - - Map network to the price-network query value: `NETWORKS.PUBLIC → "PUBLIC"`, - `NETWORKS.TESTNET → "TESTNET"`. - - Unsupported network (anything else, i.e. Futurenet): **short-circuit** to - the null-filled map, no request. - - Otherwise: - `freighterBackendV2.post("/token-prices", { tokens: filteredTokens }, { params: { network } })`. - Axios serializes `params` into the `?network=` query string. -- **v1 path** (`useV2 === false`): unchanged — `freighterBackendV1.post(...)` - with no network param. -- Response post-processing (null-fill missing tokens, `bigize`) is unchanged and - shared across both paths. - -The null-filled map (used by both short-circuits) reuses the existing contract: -every requested token maps to -`{ currentPrice: null, percentagePriceChange24h: null }`, then `bigize`d for -return-type consistency. - -### 3. Store — `src/ducks/prices.ts` - -The flag is read in the duck layer (not the service), keeping `services/` free -of `ducks/` imports and mirroring the PR's hook-reads-flag structure. - -- Import `useRemoteConfigStore`. Read - `useRemoteConfigStore.getState().use_token_prices_v2` once per fetch call. -- `fetchPricesForBalances`: already receives `network` (currently destructured - away / unused) — forward `network` and `useV2` to `fetchTokenPrices`. -- `fetchPricesForTokenIds`: add a required `network: NETWORKS` param to its - params object; forward `network` and `useV2` to `fetchTokenPrices`. - -### 4. Callers — thread network in - -- `src/components/screens/SwapScreen/hooks/useSwapTokenPrices.ts`: read the - active network from `useAuthenticationStore` and pass it to both - `fetchPricesForTokenIds` calls (the effect fetch and `refreshPrices`). -- `src/hooks/blockaid/useTransactionBalanceListItems.tsx`: read - `useAuthenticationStore.getState().network` inside the memo (it already uses - `usePricesStore.getState()` there) and pass it to `fetchPricesForTokenIds`. -- `src/ducks/balances.ts`: no change — it already passes `network` to - `fetchPricesForBalances`. - -### 5. Tests - -- `__tests__/ducks/prices.test.ts`: mock `useRemoteConfigStore`; update - `fetchTokenPrices` call assertions to include `network` and `useV2`; pass - `network` into `fetchPricesForTokenIds` calls. -- Add coverage for `fetchTokenPrices` itself: - - v2 on + supported network → POSTs to v2 client with the `network` query - param. - - flag off → POSTs to v1 client, no network param. - - v2 on + Futurenet → returns the empty/null-filled map with **no** request. - -## Non-goals - -- **Per-network cache keying.** The extension caches prices keyed by - `[networkPassphrase][publicKey]`. Mobile's prices store is a flat in-memory - `TokenPricesMap` keyed by token identifier, refreshed on balance and network - changes rather than a persistent per-account cache. Network-keyed caching is a - separable enhancement, not required by the endpoint migration. Minor - consequence: after a network switch, a token identifier's previously fetched - price can briefly persist in the merged map until the next fetch overwrites it - — pre-existing behavior, unchanged by this work. From 6ca774560104361d0ff96e59e945453918f3d46d Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 26 Jun 2026 14:49:29 -0600 Subject: [PATCH 14/14] fix(home): hide total on testnet and gate Send/Swap on holdings Two issues from gating fiat off on testnet: - The total balance summed missing prices to "$0.00", implying the user holds nothing. useTotalBalance now exposes hasFiatTotal (true only when a held asset is actually priced); HomeScreen renders the total Display only when it's true, so testnet (and the brief pre-price-load window on mainnet) shows no total element instead of $0.00. - Send/Swap were gated by hasZeroBalance = fiat total <= 0, so with no testnet fiat they disabled for funded accounts. Gate on actual holdings instead (any token balance > 0), which is fiat-independent; rawBalance is no longer needed in HomeScreen. --- .../components/screens/HomeScreen.test.tsx | 1 + __tests__/services/backend.test.ts | 34 +++++++++++++++---- .../screens/HomeScreen/HomeScreen.tsx | 20 +++++++---- src/hooks/useTotalBalance.ts | 14 ++++++++ src/services/backend.ts | 19 ++++++----- 5 files changed, 66 insertions(+), 22 deletions(-) diff --git a/__tests__/components/screens/HomeScreen.test.tsx b/__tests__/components/screens/HomeScreen.test.tsx index fac188dd0..99a86a2fe 100644 --- a/__tests__/components/screens/HomeScreen.test.tsx +++ b/__tests__/components/screens/HomeScreen.test.tsx @@ -232,6 +232,7 @@ jest.mock("hooks/useTotalBalance", () => ({ useTotalBalance: jest.fn(() => ({ formattedBalance: "$350.75", totalBalance: "350.75", + hasFiatTotal: true, })), })); diff --git a/__tests__/services/backend.test.ts b/__tests__/services/backend.test.ts index 34bc623a6..435c661af 100644 --- a/__tests__/services/backend.test.ts +++ b/__tests__/services/backend.test.ts @@ -746,14 +746,34 @@ describe("Backend Service - fetchTokenPrices v2 migration", () => { expect(mockV1Post).not.toHaveBeenCalled(); }); - it("maps testnet to the TESTNET network param", async () => { - await fetchTokenPrices({ tokens, network: NETWORKS.TESTNET, useV2: true }); + it("gates testnet entirely — no request, null prices (fiat is mainnet-only)", async () => { + const result = await fetchTokenPrices({ + tokens, + network: NETWORKS.TESTNET, + useV2: true, + }); - expect(mockV2Post).toHaveBeenCalledWith( - "/token-prices", - { tokens: v2Tokens }, - { params: { network: "TESTNET" } }, - ); + expect(mockV2Post).not.toHaveBeenCalled(); + expect(mockV1Post).not.toHaveBeenCalled(); + expect(result.XLM).toEqual({ + currentPrice: null, + percentagePriceChange24h: null, + }); + }); + + it("gates testnet on the v1 fallback too (rollback can't reintroduce testnet fiat)", async () => { + const result = await fetchTokenPrices({ + tokens, + network: NETWORKS.TESTNET, + useV2: false, + }); + + expect(mockV1Post).not.toHaveBeenCalled(); + expect(mockV2Post).not.toHaveBeenCalled(); + expect(result.XLM).toEqual({ + currentPrice: null, + percentagePriceChange24h: null, + }); }); it("remaps the v2 'native' price back to the app's 'XLM' key", async () => { diff --git a/src/components/screens/HomeScreen/HomeScreen.tsx b/src/components/screens/HomeScreen/HomeScreen.tsx index 3f0e62b0a..1aab860a0 100644 --- a/src/components/screens/HomeScreen/HomeScreen.tsx +++ b/src/components/screens/HomeScreen/HomeScreen.tsx @@ -80,7 +80,7 @@ export const HomeScreen: React.FC = React.memo( const { t } = useAppTranslation(); const { copyToClipboard } = useClipboard(); - const { formattedBalance, rawBalance } = useTotalBalance(); + const { formattedBalance, hasFiatTotal } = useTotalBalance(); const { balances, isFunded, @@ -95,9 +95,15 @@ export const HomeScreen: React.FC = React.memo( () => Object.keys(balances).length > 0, [balances], ); + // Send/Swap require something to spend. Gate on actual holdings (any + // non-zero token balance), not fiat value — fiat is unavailable on testnet + // by design, so a fiat-based gate would wrongly disable a funded account. const hasZeroBalance = useMemo( - () => rawBalance?.isLessThanOrEqualTo(0) ?? true, - [rawBalance], + () => + !Object.values(balances).some((balance) => + balance.total?.isGreaterThan(0), + ), + [balances], ); // Set up navigation headers (hook handles navigation.setOptions internally) @@ -297,9 +303,11 @@ export const HomeScreen: React.FC = React.memo( /> - - {formattedBalance} - + {hasFiatTotal && ( + + {formattedBalance} + + )} diff --git a/src/hooks/useTotalBalance.ts b/src/hooks/useTotalBalance.ts index 373b62205..94e07b9af 100644 --- a/src/hooks/useTotalBalance.ts +++ b/src/hooks/useTotalBalance.ts @@ -6,6 +6,12 @@ import { useMemo } from "react"; interface TotalBalance { formattedBalance: string; rawBalance: BigNumber; + /** + * Whether any held asset is actually priced. When false (e.g. testnet, where + * fiat is gated off, or before prices load) consumers should hide the total + * rather than render a misleading "$0.00". + */ + hasFiatTotal: boolean; } /** @@ -27,9 +33,17 @@ export const useTotalBalance = (): TotalBalance => { new BigNumber(0), ); + // A total is only meaningful when at least one held asset is priced. + // Otherwise (e.g. testnet, where fiat is gated off, or before prices load) + // the sum is a misleading "$0.00" — consumers hide the total instead. + const hasFiatTotal = Object.values(pricedBalances).some( + (balance) => balance.fiatTotal != null, + ); + return { formattedBalance: formatFiatAmount(rawBalance), rawBalance, + hasFiatTotal, }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [fiatTotalsKey]); diff --git a/src/services/backend.ts b/src/services/backend.ts index 9bd4c74e1..64faa17fb 100644 --- a/src/services/backend.ts +++ b/src/services/backend.ts @@ -328,13 +328,14 @@ export interface FetchTokenPricesParams { } /** - * The v2 /token-prices endpoint is network-scoped and only serves prices for - * mainnet and testnet. Maps the active network to the `network` query value the - * endpoint expects; networks absent from this map (e.g. Futurenet) are skipped. + * Networks we fetch fiat prices for, mapped to the `network` query value the v2 + * endpoint expects. Only mainnet has a real price source — testnet assets have + * no market and v2 returns $0, so (like the browser extension) we gate testnet + * out and show no fiat there. Networks absent from this map (testnet, futurenet) + * are skipped on both v1 and v2, so a rollback can't reintroduce testnet fiat. */ const PRICE_NETWORK_PARAMS: Partial> = { [NETWORKS.PUBLIC]: NETWORKS.PUBLIC, - [NETWORKS.TESTNET]: NETWORKS.TESTNET, }; /** @@ -387,12 +388,12 @@ export const fetchTokenPrices = async ({ }); // Skip the request entirely — returning empty prices that the loop below - // fills with nulls — when there's nothing to ask for, or when v2 is active - // on a network it doesn't serve (e.g. Futurenet). This avoids guaranteed- - // failing calls and the resulting Sentry noise. + // fills with nulls (→ "--" in the UI) — when there's nothing to ask for, or + // when the active network isn't priceable (testnet/futurenet). Fiat is + // mainnet-only; gating here applies to both v1 and v2 so a rollback can't + // reintroduce testnet fiat, and avoids guaranteed-failing/zero-value calls. const priceNetwork = PRICE_NETWORK_PARAMS[network]; - const shouldSkipRequest = - filteredTokens.length === 0 || (useV2 && !priceNetwork); + const shouldSkipRequest = filteredTokens.length === 0 || !priceNetwork; let pricesMap: TokenPricesMap = {};