diff --git a/@shared/api/__tests__/internal.test.ts b/@shared/api/__tests__/internal.test.ts index 4687db43da..cadc927d34 100644 --- a/@shared/api/__tests__/internal.test.ts +++ b/@shared/api/__tests__/internal.test.ts @@ -1,4 +1,9 @@ -import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { Networks } from "stellar-sdk"; +import { + FUTURENET_NETWORK_DETAILS, + MAINNET_NETWORK_DETAILS, + TESTNET_NETWORK_DETAILS, +} from "@shared/constants/stellar"; import * as GetLedgerKeyAccounts from "../helpers/getLedgerKeyAccounts"; import * as internalApi from "../internal"; @@ -146,11 +151,14 @@ describe("internalApi", () => { it("excludes contract-ID issuers from the indexer request", async () => { const fetchSpy = mockFetchOk(); - await internalApi.getTokenPrices([ - "native", - "USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", - "DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ", - ]); + await internalApi.getTokenPrices( + [ + "native", + "USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", + "DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ", + ], + MAINNET_NETWORK_DETAILS, + ); const requestInit = fetchSpy.mock.calls[0][1] as RequestInit; const body = JSON.parse(requestInit.body as string); @@ -163,11 +171,138 @@ describe("internalApi", () => { it("excludes liquidity-pool IDs from the indexer request", async () => { const fetchSpy = mockFetchOk(); - await internalApi.getTokenPrices(["native", "abc123:lp"]); + await internalApi.getTokenPrices( + ["native", "abc123:lp"], + MAINNET_NETWORK_DETAILS, + ); + + const requestInit = fetchSpy.mock.calls[0][1] as RequestInit; + const body = JSON.parse(requestInit.body as string); + expect(body.tokens).toEqual(["native"]); + }); + + it("targets the v2 endpoint with the network query param", async () => { + const fetchSpy = mockFetchOk(); + + await internalApi.getTokenPrices(["native"], TESTNET_NETWORK_DETAILS); + + const requestUrl = fetchSpy.mock.calls[0][0] as string; + expect(requestUrl).toContain("/token-prices"); + expect(requestUrl).toContain("network=TESTNET"); + }); + + it("derives the price network from the passphrase for custom networks", async () => { + const fetchSpy = mockFetchOk(); + + // Custom network stored as STANDALONE but sharing the pubnet passphrase + // must still resolve to PUBLIC and hit the endpoint. + await internalApi.getTokenPrices(["native"], { + ...MAINNET_NETWORK_DETAILS, + network: "STANDALONE", + networkName: "Custom Pubnet", + networkPassphrase: Networks.PUBLIC, + }); + + expect(fetchSpy).toHaveBeenCalled(); + const requestUrl = fetchSpy.mock.calls[0][0] as string; + expect(requestUrl).toContain("network=PUBLIC"); + }); + + it("skips the request on unsupported networks", async () => { + const fetchSpy = mockFetchOk(); + + const prices = await internalApi.getTokenPrices( + ["native"], + FUTURENET_NETWORK_DETAILS, + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(prices).toEqual({}); + }); + + it("skips the request when every token is filtered out", async () => { + const fetchSpy = mockFetchOk(); + + const prices = await internalApi.getTokenPrices( + [ + "abc123:lp", + "DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ", + ], + MAINNET_NETWORK_DETAILS, + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(prices).toEqual({}); + }); + }); + + describe("getTokenPrices v1 endpoint (useV2 = false)", () => { + const mockFetchOk = () => + jest.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ data: {} }), + } as unknown as Response); + + it("targets the v1 endpoint without a network query param", async () => { + const fetchSpy = mockFetchOk(); + + await internalApi.getTokenPrices( + ["native"], + TESTNET_NETWORK_DETAILS, + false, + ); + + const requestUrl = fetchSpy.mock.calls[0][0] as string; + expect(requestUrl).toContain("/token-prices"); + expect(requestUrl).not.toContain("network="); + }); + + it("still filters LP IDs and contract-ID issuers from the request", async () => { + const fetchSpy = mockFetchOk(); + + await internalApi.getTokenPrices( + [ + "native", + "abc123:lp", + "DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ", + ], + MAINNET_NETWORK_DETAILS, + false, + ); const requestInit = fetchSpy.mock.calls[0][1] as RequestInit; const body = JSON.parse(requestInit.body as string); expect(body.tokens).toEqual(["native"]); }); + + it("does NOT skip unsupported networks (unlike v2)", async () => { + const fetchSpy = mockFetchOk(); + + await internalApi.getTokenPrices( + ["native"], + FUTURENET_NETWORK_DETAILS, + false, + ); + + expect(fetchSpy).toHaveBeenCalled(); + }); + + it("does NOT skip when every token is filtered out (unlike v2)", async () => { + const fetchSpy = mockFetchOk(); + + await internalApi.getTokenPrices( + [ + "abc123:lp", + "DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ", + ], + MAINNET_NETWORK_DETAILS, + false, + ); + + expect(fetchSpy).toHaveBeenCalled(); + const requestInit = fetchSpy.mock.calls[0][1] as RequestInit; + const body = JSON.parse(requestInit.body as string); + expect(body.tokens).toEqual([]); + }); }); }); diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index e2180b6a63..17e977d01c 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -76,6 +76,7 @@ import { DEFAULT_NETWORKS, NetworkDetails, NETWORKS, + PASSPHRASE_TO_PRICE_NETWORK, } from "../constants/stellar"; import { SERVICE_TYPES } from "../constants/services"; import { isDev } from "../helpers/dev"; @@ -599,13 +600,42 @@ export const getAccountIndexerBalances = async ({ }; }; -export const getTokenPrices = async (tokens: string[]) => { +export const getTokenPrices = async ( + tokens: string[], + networkDetails: NetworkDetails, + // Defaults to the v2 endpoint. Callers pass the `use_token_prices_v2` feature + // flag so Amplitude can roll back to the v1 endpoint without a release. + useV2 = true, +): Promise => { // NOTE: API does not accept LP IDs or custom tokens const filteredTokens = tokens.filter((tokenId) => { const asset = getAssetFromCanonical(tokenId); return !tokenId.includes(":lp") && !isContractId(asset.issuer); }); - const url = new URL(`${INDEXER_URL}/token-prices`); + + let url: URL; + if (useV2) { + // The v2 token-prices endpoint only supports pubnet and testnet. Derive the + // price network from the passphrase rather than networkDetails.network so + // that custom networks sharing the pubnet/testnet passphrase (stored as + // STANDALONE) still resolve to the correct supported network. Anything else + // (Futurenet, custom passphrases) is skipped to avoid a guaranteed error and + // Sentry noise. + const priceNetwork = + PASSPHRASE_TO_PRICE_NETWORK[networkDetails.networkPassphrase]; + if (!priceNetwork) { + return {}; + } + // Nothing priceable left after filtering, so skip the request rather than + // POST an empty tokens array and risk a 4xx that surfaces as an error. + if (!filteredTokens.length) { + return {}; + } + url = new URL(`${INDEXER_V2_URL}/token-prices`); + url.searchParams.append("network", priceNetwork); + } else { + url = new URL(`${INDEXER_URL}/token-prices`); + } const options = { method: "POST", headers: { diff --git a/@shared/constants/stellar.ts b/@shared/constants/stellar.ts index 537e6287d0..6fda68e75c 100644 --- a/@shared/constants/stellar.ts +++ b/@shared/constants/stellar.ts @@ -78,3 +78,11 @@ export const PASSPHRASE_TO_NETWORK_NAME: Record = { [Networks.TESTNET]: NETWORK_NAMES.TESTNET, [FUTURENET_NETWORK_DETAILS.networkPassphrase]: NETWORK_NAMES.FUTURENET, }; + +// The token-prices endpoint only supports pubnet and testnet. This map is the +// single source of truth for which passphrases resolve to a price-supported +// network; anything not listed here is skipped by getTokenPrices. +export const PASSPHRASE_TO_PRICE_NETWORK: Record = { + [Networks.PUBLIC]: NETWORKS.PUBLIC, + [Networks.TESTNET]: NETWORKS.TESTNET, +}; diff --git a/extension/e2e-tests/helpers/stubs.ts b/extension/e2e-tests/helpers/stubs.ts index 76762a49f1..e8d0b2ec77 100644 --- a/extension/e2e-tests/helpers/stubs.ts +++ b/extension/e2e-tests/helpers/stubs.ts @@ -676,7 +676,7 @@ export const stubTokenDetails = async (page: Page | BrowserContext) => { }; export const stubTokenPrices = async (page: Page | BrowserContext) => { - await page.route("**/token-prices", async (route) => { + await page.route("**/token-prices*", async (route) => { const request = route.request(); let tokenIds = [] as string[]; diff --git a/extension/e2e-tests/sendPayment.test.ts b/extension/e2e-tests/sendPayment.test.ts index 9e9cbc10a3..2adc217343 100644 --- a/extension/e2e-tests/sendPayment.test.ts +++ b/extension/e2e-tests/sendPayment.test.ts @@ -25,7 +25,7 @@ function visibleTokenList(page: Page) { } async function stubSendTokenPrices(context: BrowserContext) { - await context.route("**/token-prices", async (route) => { + await context.route("**/token-prices*", async (route) => { const request = route.request(); let tokenIds = [] as string[]; diff --git a/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx b/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx index ca1da1a991..9808b8362a 100644 --- a/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx +++ b/extension/src/helpers/__tests__/useGetTokenPrices.test.tsx @@ -6,6 +6,10 @@ import { useGetTokenPrices } from "../hooks/useGetTokenPrices"; import { makeDummyStore } from "popup/__testHelpers__"; import { defaultBlockaidScanAssetResult } from "@shared/helpers/stellar"; import { RequestState } from "constants/request"; +import { + MAINNET_NETWORK_DETAILS, + TESTNET_NETWORK_DETAILS, +} from "@shared/constants/stellar"; import * as ApiInternal from "@shared/api/internal"; describe("useGetTokenPrices", () => { @@ -51,9 +55,14 @@ describe("useGetTokenPrices", () => { blockaidData: defaultBlockaidScanAssetResult, }, ], + networkDetails: MAINNET_NETWORK_DETAILS, } as any); }); - expect(getTokenPricesSpy).toHaveBeenCalledWith(["native"]); + expect(getTokenPricesSpy).toHaveBeenCalledWith( + ["native"], + MAINNET_NETWORK_DETAILS, + true, + ); expect(result.current.state.state).toBe(RequestState.SUCCESS); expect(result.current.state.data?.tokenPrices).toEqual({ native: { @@ -76,12 +85,14 @@ describe("useGetTokenPrices", () => { const preloadedState = { cache: { tokenPrices: { - G123: { - native: { - currentPrice: "1", - percentagePriceChange24h: ".5", + [MAINNET_NETWORK_DETAILS.networkPassphrase]: { + G123: { + native: { + currentPrice: "1", + percentagePriceChange24h: ".5", + }, + updatedAt: Date.now() - 60000, }, - updatedAt: Date.now() - 60000, }, }, }, @@ -109,9 +120,14 @@ describe("useGetTokenPrices", () => { blockaidData: defaultBlockaidScanAssetResult, }, ], + networkDetails: MAINNET_NETWORK_DETAILS, } as any); }); - expect(getTokenPricesSpy).toHaveBeenCalledWith(["native"]); + expect(getTokenPricesSpy).toHaveBeenCalledWith( + ["native"], + MAINNET_NETWORK_DETAILS, + true, + ); expect(result.current.state.state).toBe(RequestState.SUCCESS); expect(result.current.state.data?.tokenPrices).toEqual({ native: { @@ -134,12 +150,14 @@ describe("useGetTokenPrices", () => { const preloadedState = { cache: { tokenPrices: { - G123: { - native: { - currentPrice: "1", - percentagePriceChange24h: ".5", + [MAINNET_NETWORK_DETAILS.networkPassphrase]: { + G123: { + native: { + currentPrice: "1", + percentagePriceChange24h: ".5", + }, + updatedAt: Date.now(), }, - updatedAt: Date.now(), }, }, }, @@ -167,6 +185,7 @@ describe("useGetTokenPrices", () => { blockaidData: defaultBlockaidScanAssetResult, }, ], + networkDetails: MAINNET_NETWORK_DETAILS, useCache: true, } as any); }); @@ -179,6 +198,152 @@ describe("useGetTokenPrices", () => { }, }); }); + it("does not reuse another network's cache for the same account", async () => { + // Cache holds a fresh PUBLIC entry for G123. A cached request on TESTNET + // for the same account must ignore it and hit the network. + const getTokenPricesSpy = jest + .spyOn(ApiInternal, "getTokenPrices") + .mockImplementationOnce(() => + Promise.resolve({ + native: { + currentPrice: "2", + percentagePriceChange24h: ".75", + }, + }), + ); + const preloadedState = { + cache: { + tokenPrices: { + [MAINNET_NETWORK_DETAILS.networkPassphrase]: { + G123: { + native: { + currentPrice: "1", + percentagePriceChange24h: ".5", + }, + updatedAt: Date.now(), + }, + }, + }, + }, + }; + + const store = makeDummyStore(preloadedState); + const Wrapper = + (store: ReturnType) => + ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useGetTokenPrices(), { + wrapper: Wrapper(store), + }); + + await act(async () => { + await result.current.fetchData({ + publicKey: "G123", + balances: [ + { + token: { type: "native", code: "XLM" }, + total: new BigNumber("50"), + available: new BigNumber("50"), + blockaidData: defaultBlockaidScanAssetResult, + }, + ], + networkDetails: TESTNET_NETWORK_DETAILS, + useCache: true, + } as any); + }); + expect(getTokenPricesSpy).toHaveBeenCalledWith( + ["native"], + TESTNET_NETWORK_DETAILS, + true, + ); + expect(result.current.state.state).toBe(RequestState.SUCCESS); + expect(result.current.state.data?.tokenPrices).toEqual({ + native: { + currentPrice: "2", + percentagePriceChange24h: ".75", + }, + }); + }); + it("does not reuse one custom network's cache for another", async () => { + // Both custom networks share the STANDALONE network value but differ by + // passphrase. A fresh entry cached for the pubnet-passphrase custom network + // must not be reused for a testnet-passphrase custom network. + const customPubnet = { + ...MAINNET_NETWORK_DETAILS, + network: "STANDALONE", + networkName: "Custom Pubnet", + }; + const customTestnet = { + ...TESTNET_NETWORK_DETAILS, + network: "STANDALONE", + networkName: "Custom Testnet", + }; + const getTokenPricesSpy = jest + .spyOn(ApiInternal, "getTokenPrices") + .mockImplementationOnce(() => + Promise.resolve({ + native: { + currentPrice: "2", + percentagePriceChange24h: ".75", + }, + }), + ); + const preloadedState = { + cache: { + tokenPrices: { + [customPubnet.networkPassphrase]: { + G123: { + native: { + currentPrice: "1", + percentagePriceChange24h: ".5", + }, + updatedAt: Date.now(), + }, + }, + }, + }, + }; + + const store = makeDummyStore(preloadedState); + const Wrapper = + (store: ReturnType) => + ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useGetTokenPrices(), { + wrapper: Wrapper(store), + }); + + await act(async () => { + await result.current.fetchData({ + publicKey: "G123", + balances: [ + { + token: { type: "native", code: "XLM" }, + total: new BigNumber("50"), + available: new BigNumber("50"), + blockaidData: defaultBlockaidScanAssetResult, + }, + ], + networkDetails: customTestnet, + useCache: true, + } as any); + }); + expect(getTokenPricesSpy).toHaveBeenCalledWith( + ["native"], + customTestnet, + true, + ); + expect(result.current.state.data?.tokenPrices).toEqual({ + native: { + currentPrice: "2", + percentagePriceChange24h: ".75", + }, + }); + }); it("should return no token prices for no balances", async () => { const getTokenPricesSpy = jest .spyOn(ApiInternal, "getTokenPrices") @@ -211,6 +376,7 @@ describe("useGetTokenPrices", () => { await result.current.fetchData({ publicKey: "G123", balances: [], + networkDetails: MAINNET_NETWORK_DETAILS, } as any); }); expect(getTokenPricesSpy).not.toHaveBeenCalled(); diff --git a/extension/src/helpers/hooks/useGetTokenPrices.tsx b/extension/src/helpers/hooks/useGetTokenPrices.tsx index ff2d3074a0..3c0fc2002f 100644 --- a/extension/src/helpers/hooks/useGetTokenPrices.tsx +++ b/extension/src/helpers/hooks/useGetTokenPrices.tsx @@ -1,14 +1,16 @@ import { useReducer } from "react"; import { captureException } from "@sentry/browser"; -import { useDispatch, useSelector } from "react-redux"; +import { useDispatch, useSelector, useStore } from "react-redux"; import { tokenPricesSelector } from "popup/ducks/cache"; +import { tokenPricesV2Selector } from "popup/ducks/remoteConfig"; import { getTokenPrices } from "@shared/api/internal"; import { initialState, isCacheValid, reducer } from "helpers/request"; import { AppDataType } from "helpers/hooks/useGetAppData"; import { getCanonicalFromAsset } from "helpers/stellar"; import { ApiTokenPrices } from "@shared/api/types"; +import { NetworkDetails } from "@shared/constants/stellar"; import { saveTokenPrices } from "popup/ducks/cache"; -import { AppDispatch } from "popup/App"; +import { AppDispatch, AppState } from "popup/App"; import { AccountBalances } from "./useGetBalances"; export interface GetTokenPricesData { @@ -18,6 +20,7 @@ export interface GetTokenPricesData { export function useGetTokenPrices() { const reduxDispatch = useDispatch(); + const store = useStore(); const cachedTokenPrices = useSelector(tokenPricesSelector); const [state, dispatch] = useReducer( @@ -27,16 +30,19 @@ export function useGetTokenPrices() { const fetchData = async ({ publicKey, balances, + networkDetails, useCache = false, }: { publicKey: string; balances: AccountBalances["balances"]; + networkDetails: NetworkDetails; useCache: boolean; }): Promise => { dispatch({ type: "FETCH_DATA_START" }); let tokenPrices = {} as ApiTokenPrices; - const publicKeyTokenPrices = cachedTokenPrices[publicKey]; + const publicKeyTokenPrices = + cachedTokenPrices[networkDetails.networkPassphrase]?.[publicKey]; const payload = { type: AppDataType.RESOLVED, tokenPrices, @@ -61,9 +67,22 @@ export function useGetTokenPrices() { ), ); if (assetIds.length) { - const fetchedTokenPrices = await getTokenPrices(assetIds); + // Read the flag from the store at call time (not a render-captured + // value) so a freshly resolved Amplitude flag isn't missed when this + // fetch runs inside a long-lived async flow that closed over a stale + // default. + const useV2 = tokenPricesV2Selector(store.getState()); + const fetchedTokenPrices = await getTokenPrices( + assetIds, + networkDetails, + useV2, + ); reduxDispatch( - saveTokenPrices({ publicKey, tokenPrices: fetchedTokenPrices }), + saveTokenPrices({ + publicKey, + networkDetails, + tokenPrices: fetchedTokenPrices, + }), ); payload.tokenPrices = fetchedTokenPrices; } diff --git a/extension/src/popup/components/__tests__/AssetDetail.test.tsx b/extension/src/popup/components/__tests__/AssetDetail.test.tsx index 5a98f0138c..0676c0e6ff 100644 --- a/extension/src/popup/components/__tests__/AssetDetail.test.tsx +++ b/extension/src/popup/components/__tests__/AssetDetail.test.tsx @@ -283,12 +283,14 @@ describe("AssetDetail", () => { "test-img-src", }, tokenPrices: { - G1: { - "BAZ:GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF": - { - price: 1, - timestamp: 1718236800, - }, + [TESTNET_NETWORK_DETAILS.networkPassphrase]: { + G1: { + "BAZ:GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF": + { + price: 1, + timestamp: 1718236800, + }, + }, }, }, }, diff --git a/extension/src/popup/components/__tests__/maintenanceMode.test.tsx b/extension/src/popup/components/__tests__/maintenanceMode.test.tsx index 8b77101aeb..1fef1e7256 100644 --- a/extension/src/popup/components/__tests__/maintenanceMode.test.tsx +++ b/extension/src/popup/components/__tests__/maintenanceMode.test.tsx @@ -53,6 +53,7 @@ function makeStore(overrides?: { preloadedState: { remoteConfig: { isInitialized: true, + use_token_prices_v2: true, maintenance_screen: overrides?.maintenance_screen ?? { enabled: false, payload: undefined, diff --git a/extension/src/popup/components/account/AssetDetail/index.tsx b/extension/src/popup/components/account/AssetDetail/index.tsx index c299e2cc06..bae58937e8 100644 --- a/extension/src/popup/components/account/AssetDetail/index.tsx +++ b/extension/src/popup/components/account/AssetDetail/index.tsx @@ -120,7 +120,8 @@ export const AssetDetail = ({ const [optionsOpen, setOptionsOpen] = React.useState(false); const activeOptionsRef = useRef(null); const isNative = selectedAsset === "native"; - const tokenPrices = cachedTokenPrices[publicKey] || null; + const tokenPrices = + cachedTokenPrices[networkDetails.networkPassphrase]?.[publicKey] || null; useEffect(() => { function handleClickOutside(event: MouseEvent) { diff --git a/extension/src/popup/components/send/SendAmount/hooks/useSendAmountData.tsx b/extension/src/popup/components/send/SendAmount/hooks/useSendAmountData.tsx index 702f873a2b..27acce8353 100644 --- a/extension/src/popup/components/send/SendAmount/hooks/useSendAmountData.tsx +++ b/extension/src/popup/components/send/SendAmount/hooks/useSendAmountData.tsx @@ -91,6 +91,7 @@ function useGetSendAmountData( const fetchedTokenPrices = await fetchTokenPrices({ publicKey: userDomains.publicKey, balances: userDomains.balances.balances, + networkDetails: userDomains.networkDetails, useCache: true, }); diff --git a/extension/src/popup/components/send/SendDestinationAsset/hooks/useGetDestAssetData.tsx b/extension/src/popup/components/send/SendDestinationAsset/hooks/useGetDestAssetData.tsx index f63a6dc23e..5dc8f5f98f 100644 --- a/extension/src/popup/components/send/SendDestinationAsset/hooks/useGetDestAssetData.tsx +++ b/extension/src/popup/components/send/SendDestinationAsset/hooks/useGetDestAssetData.tsx @@ -74,6 +74,7 @@ export function useGetDestAssetData(getBalancesOptions: { const fetchedTokenPrices = await fetchTokenPrices({ publicKey, balances: balances.balances, + networkDetails, useCache: true, }); diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx b/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx index 1c6f68599d..44b9325a45 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useGetSwapAmountData.tsx @@ -85,6 +85,7 @@ function useGetSwapAmountData( const fetchedTokenPrices = await fetchTokenPrices({ publicKey: userDomains.publicKey, balances: destinationBalances.balances, + networkDetails: userDomains.networkDetails, useCache: true, }); tokenPrices = fetchedTokenPrices.tokenPrices || {}; diff --git a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapFromData.tsx b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapFromData.tsx index b6ab94033b..22d61aa8dc 100644 --- a/extension/src/popup/components/swap/SwapAsset/hooks/useSwapFromData.tsx +++ b/extension/src/popup/components/swap/SwapAsset/hooks/useSwapFromData.tsx @@ -69,6 +69,7 @@ export function useGetSwapFromData(getBalancesOptions: { const fetchedTokenPrices = await fetchTokenPrices({ publicKey, balances: balances.balances, + networkDetails, useCache: true, }); diff --git a/extension/src/popup/ducks/__tests__/remoteConfig.test.ts b/extension/src/popup/ducks/__tests__/remoteConfig.test.ts index 034b129ae8..fa491ab255 100644 --- a/extension/src/popup/ducks/__tests__/remoteConfig.test.ts +++ b/extension/src/popup/ducks/__tests__/remoteConfig.test.ts @@ -5,6 +5,7 @@ import { maintenanceBannerSelector, maintenanceScreenSelector, isRemoteConfigInitializedSelector, + tokenPricesV2Selector, reducer, } from "../remoteConfig"; import { @@ -72,6 +73,11 @@ describe("remoteConfig duck — initial state", () => { expect(getState(store).isInitialized).toBe(false); }); + it("defaults use_token_prices_v2 to true", () => { + const store = makeStore(); + expect(getState(store).use_token_prices_v2).toBe(true); + }); + it("has both maintenance flags disabled with no payload by default", () => { const store = makeStore(); const state = getState(store); @@ -363,4 +369,29 @@ describe("remoteConfig selectors", () => { const store = makeStore(); expect(isRemoteConfigInitializedSelector(store.getState())).toBe(false); }); + + it("tokenPricesV2Selector defaults to true", () => { + const store = makeStore(); + expect(tokenPricesV2Selector(store.getState())).toBe(true); + }); + + it("tokenPricesV2Selector stays true when Amplitude omits the flag", async () => { + (getExperimentClient as jest.Mock).mockReturnValue(makeClient()); + + const store = makeStore(); + await store.dispatch(fetchFeatureFlags()); + expect(tokenPricesV2Selector(store.getState())).toBe(true); + }); + + it("tokenPricesV2Selector rolls back to false when variant is off", async () => { + (getExperimentClient as jest.Mock).mockReturnValue( + makeClient({ + use_token_prices_v2: { value: "off" }, + }), + ); + + const store = makeStore(); + await store.dispatch(fetchFeatureFlags()); + expect(tokenPricesV2Selector(store.getState())).toBe(false); + }); }); diff --git a/extension/src/popup/ducks/cache.ts b/extension/src/popup/ducks/cache.ts index 93ccaeaca0..fe2ad6cd56 100644 --- a/extension/src/popup/ducks/cache.ts +++ b/extension/src/popup/ducks/cache.ts @@ -43,6 +43,7 @@ type SaveTokenDetailsPayload = { contractId: string } & TokenDetailsResponse; type SaveTokenPricesPayload = { publicKey: string; + networkDetails: NetworkDetails; tokenPrices: ApiTokenPrices; }; @@ -68,8 +69,14 @@ interface InitialState { historyData: { [network: string]: Record; }; + // Keyed by networkPassphrase, not network: custom networks all share the + // "STANDALONE" network value, but prices are determined by the passphrase + // (which is what getTokenPrices uses to choose PUBLIC vs TESTNET). tokenPrices: { - [publicKey: string]: ApiTokenPrices & { updatedAt: number }; + [networkPassphrase: string]: Record< + PublicKey, + ApiTokenPrices & { updatedAt: number } + >; }; collections: { [network: string]: Record }; } @@ -125,7 +132,11 @@ const cacheSlice = createSlice({ action.payload.publicKey ]; } - delete state.tokenPrices[action.payload.publicKey]; + if (state.tokenPrices[action.payload.networkDetails.networkPassphrase]) { + delete state.tokenPrices[ + action.payload.networkDetails.networkPassphrase + ][action.payload.publicKey]; + } }, saveIconsForBalances(state, action: { payload: SaveIconsPayload }) { state.icons = { @@ -152,12 +163,16 @@ const cacheSlice = createSlice({ }; }, saveTokenPrices(state, action: { payload: SaveTokenPricesPayload }) { + const { networkPassphrase } = action.payload.networkDetails; state.tokenPrices = { ...state.tokenPrices, - [action.payload.publicKey]: { - ...action.payload.tokenPrices, - updatedAt: Date.now(), - } as ApiTokenPrices & { updatedAt: number }, + [networkPassphrase]: { + ...state.tokenPrices[networkPassphrase], + [action.payload.publicKey]: { + ...action.payload.tokenPrices, + updatedAt: Date.now(), + } as ApiTokenPrices & { updatedAt: number }, + }, }; }, saveCollections(state, action: { payload: SaveCollectionsPayload }) { diff --git a/extension/src/popup/ducks/remoteConfig.ts b/extension/src/popup/ducks/remoteConfig.ts index bcd0ba9915..3d805bc81a 100644 --- a/extension/src/popup/ducks/remoteConfig.ts +++ b/extension/src/popup/ducks/remoteConfig.ts @@ -24,9 +24,9 @@ const ON_VARIANT_VALUES = ["on", "true", "enabled", "yes"]; /** * Boolean flags — variant value is checked against ON_VARIANT_VALUES. - * Empty for now; add flag names here as new boolean flags are introduced. + * Add flag names here as new boolean flags are introduced. */ -const BOOLEAN_FLAGS = [] as const; +const BOOLEAN_FLAGS = ["use_token_prices_v2"] as const; /** * Version flags — variant value is parsed from underscore format (1_2_3 → 1.2.3). @@ -82,6 +82,9 @@ interface RemoteConfigState extends FeatureFlags { const initialState: RemoteConfigState = { isInitialized: false, + // Defaults to v2; Amplitude can flip it off to roll back to the v1 + // token-prices endpoint without a release. + use_token_prices_v2: true, maintenance_banner: { enabled: false, payload: undefined }, maintenance_screen: { enabled: false, payload: undefined }, }; @@ -217,6 +220,15 @@ export const maintenanceScreenSelector = createSelector( }, ); +/** + * Returns whether the v2 token-prices endpoint should be used. Defaults to + * true; Amplitude can flip the flag off to roll back to the v1 endpoint. + */ +export const tokenPricesV2Selector = createSelector( + remoteConfigSelector, + (rc) => rc.use_token_prices_v2, +); + /** * Returns true once the Experiment flags have been fetched (or failed). */ diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 52339ec4bb..0ddd30cdd0 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -77,6 +77,8 @@ "Authorizations": "Authorizations", "Authorize": "Authorize", "Authorized address": "Authorized address", + "Auto-lock timer": "Auto-lock timer", + "Auto-Lock Timer": "Auto-Lock Timer", "available": "available", "Back": "Back", "Balance": "Balance", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index db2c569c1c..3d3a492ff1 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -77,6 +77,8 @@ "Authorizations": "Autorizações", "Authorize": "Autorizar", "Authorized address": "Endereço autorizado", + "Auto-lock timer": "Temporizador de bloqueio automático", + "Auto-Lock Timer": "Temporizador de bloqueio automático", "available": "disponível", "Back": "Voltar", "Balance": "Saldo", diff --git a/extension/src/popup/views/Account/hooks/useGetAccountData.tsx b/extension/src/popup/views/Account/hooks/useGetAccountData.tsx index 59bd869f46..0ab007ca90 100644 --- a/extension/src/popup/views/Account/hooks/useGetAccountData.tsx +++ b/extension/src/popup/views/Account/hooks/useGetAccountData.tsx @@ -120,6 +120,7 @@ function useGetAccountData(options: { const fetchedTokenPrices = await fetchTokenPrices({ publicKey, balances: balancesResult.balances, + networkDetails, useCache: true, }); payload.tokenPrices = fetchedTokenPrices.tokenPrices; @@ -213,6 +214,7 @@ function useGetAccountData(options: { const fetchedTokenPrices = await fetchTokenPrices({ publicKey: resolvedData.publicKey, balances: resolvedData.balances.balances, + networkDetails: resolvedData.networkDetails, useCache: false, }); const payload = { diff --git a/extension/src/popup/views/Wallets/hooks/__tests__/useGetWalletsData.test.tsx b/extension/src/popup/views/Wallets/hooks/__tests__/useGetWalletsData.test.tsx index eab97eb26d..b19c2d41b7 100644 --- a/extension/src/popup/views/Wallets/hooks/__tests__/useGetWalletsData.test.tsx +++ b/extension/src/popup/views/Wallets/hooks/__tests__/useGetWalletsData.test.tsx @@ -221,11 +221,15 @@ describe("useGetWalletsData", () => { await result.current.fetchData(true); }); expect(result.current.state.state).toBe(RequestState.SUCCESS); - expect(getTokenPricesSpy).toHaveBeenCalledWith([ - "native", - "DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ", - "USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", - ]); + expect(getTokenPricesSpy).toHaveBeenCalledWith( + [ + "native", + "DT:CCXVDIGMR6WTXZQX2OEVD6YM6AYCYPXPQ7YYH6OZMRS7U6VD3AVHNGBJ", + "USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", + ], + MAINNET_NETWORK_DETAILS, + true, + ); expect(result.current.state.data).toEqual({ accountValue: { G1: "$250.00", diff --git a/extension/src/popup/views/Wallets/hooks/useGetWalletsData.tsx b/extension/src/popup/views/Wallets/hooks/useGetWalletsData.tsx index ef902e1fff..1f2baa003f 100644 --- a/extension/src/popup/views/Wallets/hooks/useGetWalletsData.tsx +++ b/extension/src/popup/views/Wallets/hooks/useGetWalletsData.tsx @@ -78,6 +78,7 @@ function useGetWalletsData() { const prices = await fetchTokenPrices({ publicKey: account.publicKey, balances: balances.balances, + networkDetails, useCache: true, }); const totalPriceUsd = getTotalUsd(