diff --git a/__tests__/components/screens/HomeScreen.test.tsx b/__tests__/components/screens/HomeScreen.test.tsx index dcf99d3fd..99a86a2fe 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", () => ({ @@ -230,6 +232,7 @@ jest.mock("hooks/useTotalBalance", () => ({ useTotalBalance: jest.fn(() => ({ formattedBalance: "$350.75", totalBalance: "350.75", + hasFiatTotal: true, })), })); diff --git a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx index d8f8a4b7f..f78b42f99 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"; @@ -273,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 @@ -899,6 +902,8 @@ describe("SwapAmountScreen", () => { "yXLM:GARDNV3Q7YGT4AKSDF25LT32YSCCW4EV22Y2TV3I2PU2MMXJTEDL5T55", "FTT:GBDQOFC6SKCNBHPLZ7NXQ6MCKFIYUUFVOWYGNWQCXC2F4AYZ27EUWYWH", ], + network: NETWORKS.PUBLIC, + useV2: true, }); }); 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 a1c1e3daf..71bcc54b2 100644 --- a/__tests__/ducks/prices.test.ts +++ b/__tests__/ducks/prices.test.ts @@ -5,9 +5,10 @@ import { Balance, NativeBalance, ClassicBalance, + 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"; @@ -29,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"), @@ -58,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": @@ -74,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"), @@ -89,7 +82,6 @@ describe("prices duck", () => { const { mockBalances } = createMockBalances(); const mockPrices = createMockPrices(); - // Mock token identifiers const mockTokenIdentifiers = [ "XLM", "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", @@ -99,33 +91,46 @@ describe("prices duck", () => { balances: mockBalances, publicKey: "GDNF5WJ2BEPABVBXCF4C7KZKM3XYXP27VUE3SCGPZA3VXWWZ7OFA3VPM", network: NETWORKS.TESTNET, + 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 act(() => { usePricesStore.setState({ - prices: {}, + 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(); @@ -133,64 +138,85 @@ 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, }); + 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(typeof result.current.lastUpdated).toBe("number"); }); - it("should update prices state on successful fetch", async () => { + 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); + await result.current.fetchPricesForBalances(mockParams); // TESTNET }); - expect(result.current.prices).toEqual(mockPrices); - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBeNull(); - expect(result.current.lastUpdated).not.toBeNull(); - expect(typeof result.current.lastUpdated).toBe("number"); + // ...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("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). - act(() => { - usePricesStore.setState({ - prices: { - "AQUA:GBN...": { - currentPrice: new BigNumber("0.003"), - percentagePriceChange24h: new BigNumber("1.2"), - }, - }, - }); + 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("should handle empty token list", async () => { - mockGetTokenIdentifiersFromBalances.mockReturnValueOnce([]); + it("does not write fetched prices into another network's cache", async () => { + const { result } = renderHook(() => usePricesStore()); + + // Pre-existing PUBLIC cache must be untouched by a TESTNET fetch. + seedNetwork(NETWORKS.PUBLIC, mockPrices); + + await act(async () => { + await result.current.fetchPricesForBalances(mockParams); // TESTNET + }); + expect(result.current.pricesByNetwork[NETWORKS.PUBLIC]).toEqual( + mockPrices, + ); + expect(result.current.pricesByNetwork[NETWORKS.TESTNET]).toEqual( + mockPrices, + ); + }); + + it("handles an empty token list", async () => { + mockGetTokenIdentifiersFromBalances.mockReturnValueOnce([]); const { result } = renderHook(() => usePricesStore()); await act(async () => { @@ -203,62 +229,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 - const mockLastUpdated = Date.now(); - act(() => { - usePricesStore.setState({ - prices: mockPrices, - 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); }); }); }); @@ -266,123 +264,209 @@ 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"), - }, - }, - }); + seedNetwork(NETWORKS.PUBLIC, { + "AQUA:GBNAQUA": { + currentPrice: new BigNumber("0.003"), + percentagePriceChange24h: new BigNumber("1.2"), + }, }); await act(async () => { - await result.current.fetchPricesForTokenIds({ tokens: trendingIds }); + await result.current.fetchPricesForTokenIds({ + tokens: trendingIds, + network: NETWORKS.PUBLIC, + useV2: true, + }); }); - // Only the missing token is requested. expect(mockFetchTokenPrices).toHaveBeenCalledWith({ tokens: ["yXLM:GYXLM"], + network: NETWORKS.PUBLIC, + useV2: true, }); }); - 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"), - }, - }, - }); + 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 }); + await result.current.fetchPricesForTokenIds({ + tokens: trendingIds, + network: NETWORKS.PUBLIC, + useV2: true, + }); }); 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"), - }, - }, + // ...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, + network: NETWORKS.PUBLIC, + useV2: true, forceRefresh: true, }); }); - // forceRefresh bypasses the already-loaded skip → both tokens requested. expect(mockFetchTokenPrices).toHaveBeenCalledWith({ tokens: trendingIds, + network: NETWORKS.PUBLIC, + useV2: true, }); }); - }); - describe("selector hooks", () => { - it("should have correct state values", () => { - const mockLastUpdated = Date.now(); + it("refetches cached tokens from v1 when the rollback flag flips", async () => { + const { result } = renderHook(() => usePricesStore()); + // Cache populated by v2 on 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"), + }, + }, + true, + ); - act(() => { - usePricesStore.setState({ - prices: mockPrices, - isLoading: true, - error: "Test error", - lastUpdated: mockLastUpdated, + // Rolled back to v1 → caller passes useV2: false. + await act(async () => { + await result.current.fetchPricesForTokenIds({ + tokens: trendingIds, + network: NETWORKS.PUBLIC, + useV2: false, }); }); - 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); + // The endpoint changed, so all ids are refetched (cache was dropped). + expect(mockFetchTokenPrices).toHaveBeenCalledWith({ + tokens: trendingIds, + network: NETWORKS.PUBLIC, + useV2: false, + }); + expect(result.current.sourceByNetwork[NETWORKS.PUBLIC]).toBe(false); }); - it("should have fetchPricesForBalances function", 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( + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); - expect(typeof result.current.fetchPricesForBalances).toBe("function"); + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.fetchPricesForTokenIds({ + tokens: trendingIds, + network: NETWORKS.PUBLIC, + useV2: true, + }); + }); + + // A rollback to v1 lands before the v2 fetch resolves. + act(() => { + usePricesStore.setState({ + sourceByNetwork: { [NETWORKS.PUBLIC]: false }, + }); + }); await act(async () => { - await result.current.fetchPricesForBalances(mockParams); + resolveFetch({ + "AQUA:GBNAQUA": { + currentPrice: new BigNumber("1"), + percentagePriceChange24h: new BigNumber("0"), + }, + }); + await pending; }); - expect(mockGetTokenIdentifiersFromBalances).toHaveBeenCalledWith( - mockBalances, + // The stale v2 response must not be merged into the v1-flipped cache. + expect( + result.current.pricesByNetwork[NETWORKS.PUBLIC]["AQUA:GBNAQUA"], + ).toBeUndefined(); + }); + }); + + 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("returns a stable empty map for an uncached network", () => { + const { result, rerender } = renderHook(() => + usePricesForNetwork(NETWORKS.FUTURENET), ); - expect(mockFetchTokenPrices).toHaveBeenCalledWith({ - tokens: mockTokenIdentifiers, - }); + 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 da506e6df..435c661af 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,172 @@ 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", + ]; + // What the v2 request body should contain: native "XLM" mapped to "native". + const v2Tokens = [ + "native", + "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 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: v2Tokens }, + { params: { network: "PUBLIC" } }, + ); + expect(mockV1Post).not.toHaveBeenCalled(); + }); + + 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).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 () => { + 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(); + }); + + 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, + }); + }); + + 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, + }); + }); + + 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/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/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 ca30fc471..9fa574b18 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapTokenPrices.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapTokenPrices.ts @@ -1,6 +1,8 @@ import { recordTokenId } from "components/screens/SwapScreen/helpers"; import { FormattedSearchTokenRecord, TokenPricesMap } from "config/types"; -import { usePricesStore } from "ducks/prices"; +import { useAuthenticationStore } from "ducks/auth"; +import { usePricesForNetwork, usePricesStore } from "ducks/prices"; +import { useRemoteConfigStore } from "ducks/remoteConfig"; import { useCallback, useEffect, useMemo } from "react"; /** @@ -35,7 +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. @@ -50,15 +57,27 @@ 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, 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, forceRefresh: true }); - }, [tokens, stableExtraTokenIds, fetchPricesForTokenIds]); + await fetchPricesForTokenIds({ + tokens: ids, + network, + useV2, + forceRefresh: true, + }); + }, [tokens, stableExtraTokenIds, fetchPricesForTokenIds, network, useV2]); return { prices, refreshPrices }; }; 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 f4d3c8b23..550fc699b 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 @@ -175,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 effad6c20..c61d4888c 100644 --- a/src/ducks/prices.ts +++ b/src/ducks/prices.ts @@ -4,8 +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; + /** + * 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. + */ + pricesByNetwork: Record; + /** + * 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. + */ + sourceByNetwork: Record; isLoading: boolean; error: string | null; lastUpdated: number | null; @@ -13,51 +34,88 @@ 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. */ + 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: {}, + pricesByNetwork: {}, + sourceByNetwork: {}, isLoading: false, error: null, lastUpdated: null, /** Fetch prices for tokens present in the user's balances. */ - fetchPricesForBalances: async ({ balances }) => { + fetchPricesForBalances: async ({ balances, network, useV2 }) => { try { set({ isLoading: true, error: null }); const tokens = getTokenIdentifiersFromBalances(balances); - if (tokens.length === 0) { - set({ - isLoading: false, - lastUpdated: Date.now(), - }); + set({ isLoading: false, lastUpdated: Date.now() }); return; } - const response = await fetchTokenPrices({ tokens }); + reconcileSource(get, set, 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 + // 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 @@ -67,26 +125,54 @@ export const usePricesStore = create((set, get) => ({ } }, /** Lightweight fetch for arbitrary tokens */ - fetchPricesForTokenIds: async ({ tokens, forceRefresh = false }) => { + fetchPricesForTokenIds: async ({ + tokens, + network, + useV2, + forceRefresh = false, + }) => { try { if (!tokens || tokens.length === 0) return; + + 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({ tokens: toFetch }); + const response = await fetchTokenPrices({ + tokens: toFetch, + network, + useV2, + }); + + // 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/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..d76c28799 100644 --- a/src/hooks/blockaid/useTransactionBalanceListItems.tsx +++ b/src/hooks/blockaid/useTransactionBalanceListItems.tsx @@ -10,11 +10,13 @@ import { TokenIdentifier, NonNativeToken, } from "config/types"; -import { usePricesStore } from "ducks/prices"; +import { useAuthenticationStore } from "ducks/auth"; +import { usePricesForNetwork, 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"; -import React, { useMemo } from "react"; +import React, { useEffect, useMemo } from "react"; import { View } from "react-native"; import { getTransactionBalanceChanges } from "services/blockaid/helper"; @@ -35,6 +37,46 @@ export const useTransactionBalanceListItems = ( ): ListItemProps[] => { const { themeColors } = useColors(); const { t } = useAppTranslation(); + // 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 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( + () => (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[] = []; @@ -87,8 +129,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 @@ -127,20 +167,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 missing prices (non-blocking render) - const { prices, fetchPricesForTokenIds } = usePricesStore.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 }); - } - // Add balance changes to the list balanceUpdates.forEach((change) => { const { @@ -156,7 +182,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; @@ -194,6 +220,8 @@ export const useTransactionBalanceListItems = ( return items; }, [ + balanceUpdates, + prices, scanResult, signTransactionDetails?.hasTrustlineChanges, signTransactionDetails?.operations, 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 842a67871..64faa17fb 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 { @@ -321,22 +321,52 @@ 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 + * 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, +}; + +/** + * 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. + * + * 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 +375,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,18 +387,79 @@ 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 (→ "--" 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 || !priceNetwork; + + let pricesMap: TokenPricesMap = {}; + + if (!shouldSkipRequest) { + try { + 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 + // 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 ?? {}; + + // 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 + // 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; + } + } /* // ======================================================== - // 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 @@ -401,7 +494,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] = {