diff --git a/packages/nextjs/lib/scaffold-stark-2/hooks/scaffold-stark/__tests__/useNativeCurrencyPrice.test.ts b/packages/nextjs/lib/scaffold-stark-2/hooks/scaffold-stark/__tests__/useNativeCurrencyPrice.test.ts new file mode 100644 index 0000000..c026877 --- /dev/null +++ b/packages/nextjs/lib/scaffold-stark-2/hooks/scaffold-stark/__tests__/useNativeCurrencyPrice.test.ts @@ -0,0 +1,122 @@ +import { useNativeCurrencyPrice } from "../useNativeCurrencyPrice"; +import { useGlobalState } from "@scaffold-stark-2/services/store/store"; +import { priceService } from "@scaffold-stark-2/services/web3/PriceService"; +import { renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the store +vi.mock("@scaffold-stark-2/services/store/store", () => ({ + useGlobalState: vi.fn(), +})); + +// Mock the price service +vi.mock("@scaffold-stark-2/services/web3/PriceServiceq", () => ({ + priceService: { + getNextId: vi.fn(), + startPolling: vi.fn(), + stopPolling: vi.fn(), + }, +})); + +describe("useNativeCurrencyPrice", () => { + const mockSetNativeCurrencyPrice = vi.fn(); + const mockSetStrkCurrencyPrice = vi.fn(); + const mockIds = { + first: 123, + second: 124, + }; + + beforeEach(() => { + // Setup mocks + vi.mocked(useGlobalState).mockImplementation(selector => { + if (selector.toString().includes("setNativeCurrencyPrice")) { + return mockSetNativeCurrencyPrice; + } + return mockSetStrkCurrencyPrice; + }); + + vi.mocked(priceService.getNextId).mockReturnValue(mockIds.first); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should start polling on mount", () => { + renderHook(() => useNativeCurrencyPrice()); + + expect(priceService.getNextId).toHaveBeenCalled(); + expect(priceService.startPolling).toHaveBeenCalledWith( + mockIds.first.toString(), + mockSetNativeCurrencyPrice, + mockSetStrkCurrencyPrice, + ); + }); + + it("should stop polling on unmount", () => { + const { unmount } = renderHook(() => useNativeCurrencyPrice()); + + unmount(); + + expect(priceService.stopPolling).toHaveBeenCalledWith(mockIds.first.toString()); + }); + + it("should maintain the same polling instance across rerenders", () => { + const { rerender } = renderHook(() => useNativeCurrencyPrice()); + const firstCallArgs = vi.mocked(priceService.startPolling).mock.calls[0]; + + vi.clearAllMocks(); + + rerender(); + + expect(priceService.startPolling).not.toHaveBeenCalled(); + expect(priceService.getNextId).toReturnWith(Number(firstCallArgs[0])); + }); + + it("should use store setters from global state", () => { + renderHook(() => useNativeCurrencyPrice()); + + expect(useGlobalState).toHaveBeenCalledWith(expect.any(Function)); + expect(priceService.startPolling).toHaveBeenCalledWith( + mockIds.first.toString(), + mockSetNativeCurrencyPrice, + mockSetStrkCurrencyPrice, + ); + }); + + it("should handle multiple instances correctly", () => { + vi.mocked(priceService.getNextId).mockReturnValueOnce(mockIds.first).mockReturnValueOnce(mockIds.second); + + const { unmount: unmount1 } = renderHook(() => useNativeCurrencyPrice()); + const { unmount: unmount2 } = renderHook(() => useNativeCurrencyPrice()); + + expect(priceService.startPolling).toHaveBeenNthCalledWith( + 1, + mockIds.first.toString(), + mockSetNativeCurrencyPrice, + mockSetStrkCurrencyPrice, + ); + expect(priceService.startPolling).toHaveBeenNthCalledWith( + 2, + mockIds.second.toString(), + mockSetNativeCurrencyPrice, + mockSetStrkCurrencyPrice, + ); + + unmount1(); + expect(priceService.stopPolling).toHaveBeenCalledWith(mockIds.first.toString()); + + unmount2(); + expect(priceService.stopPolling).toHaveBeenCalledWith(mockIds.second.toString()); + }); + + it("should handle errors in global state selectors gracefully", () => { + vi.mocked(useGlobalState).mockImplementation(() => { + return () => {}; + }); + + expect(() => { + renderHook(() => useNativeCurrencyPrice()); + }).not.toThrow(); + }); +}); diff --git a/packages/nextjs/lib/scaffold-stark-2/hooks/scaffold-stark/useNativeCurrencyPrice.ts b/packages/nextjs/lib/scaffold-stark-2/hooks/scaffold-stark/useNativeCurrencyPrice.ts index c5ef995..6f28db7 100644 --- a/packages/nextjs/lib/scaffold-stark-2/hooks/scaffold-stark/useNativeCurrencyPrice.ts +++ b/packages/nextjs/lib/scaffold-stark-2/hooks/scaffold-stark/useNativeCurrencyPrice.ts @@ -1,40 +1,21 @@ -import { useEffect } from "react"; -import { useTargetNetwork } from "./useTargetNetwork"; -import scaffoldConfig from "@scaffold-stark-2/scaffold.config"; import { useGlobalState } from "@scaffold-stark-2/services/store/store"; -import { fetchPriceFromCoingecko } from "@scaffold-stark-2/utils/scaffold-stark"; -import { useInterval } from "usehooks-ts"; +import { priceService } from "@scaffold-stark-2/services/web3/PriceService"; +import { useEffect, useRef } from "react"; -/** - * Get the price of Native Currency based on Native Token/DAI trading pair from Uniswap SDK - */ export const useNativeCurrencyPrice = () => { - const { targetNetwork } = useTargetNetwork(); - const nativeCurrencyPrice = useGlobalState(state => state.nativeCurrencyPrice); - const strkCurrencyPrice = useGlobalState(state => state.strkCurrencyPrice); - const setNativeCurrencyPrice = useGlobalState(state => state.setNativeCurrencyPrice); - const setStrkCurrencyPrice = useGlobalState(state => state.setStrkCurrencyPrice); - // Get the price of ETH & STRK from Coingecko on mount + const setNativeCurrencyPrice = useGlobalState( + (state) => state.setNativeCurrencyPrice, + ); + const setStrkCurrencyPrice = useGlobalState( + (state) => state.setStrkCurrencyPrice, + ); + const ref = useRef(priceService.getNextId().toString()); useEffect(() => { - (async () => { - if (nativeCurrencyPrice == 0) { - const price = await fetchPriceFromCoingecko("ETH"); - setNativeCurrencyPrice(price); - } - if (strkCurrencyPrice == 0) { - const strkPrice = await fetchPriceFromCoingecko("STRK"); - setStrkCurrencyPrice(strkPrice); - } - })(); - }, [targetNetwork]); - - // Get the price of ETH & STRK from Coingecko at a given interval - useInterval(async () => { - const price = await fetchPriceFromCoingecko("ETH"); - setNativeCurrencyPrice(price); - const strkPrice = await fetchPriceFromCoingecko("STRK"); - setStrkCurrencyPrice(strkPrice); - }, scaffoldConfig.pollingInterval); - - //return nativeCurrencyPrice; + const id = ref.current; + priceService.startPolling(id, setNativeCurrencyPrice, setStrkCurrencyPrice); + return () => { + priceService.stopPolling(id); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); }; diff --git a/packages/nextjs/lib/scaffold-stark-2/services/web3/PriceService.ts b/packages/nextjs/lib/scaffold-stark-2/services/web3/PriceService.ts new file mode 100644 index 0000000..65f8be8 --- /dev/null +++ b/packages/nextjs/lib/scaffold-stark-2/services/web3/PriceService.ts @@ -0,0 +1,106 @@ +import scaffoldConfig from "@scaffold-stark-2/scaffold.config"; + +export const fetchPriceFromCoingecko = async (symbol: string, retries = 3): Promise => { + let attempt = 0; + while (attempt < retries) { + try { + const response = await fetch(`/api/price/${symbol}`); + const data = await response.json(); + return symbol === "ETH" ? data.ethereum.usd : data.starknet.usd; + } catch (error) { + console.error(`Attempt ${attempt + 1} - Error fetching ${symbol} price from Coingecko: `, error); + attempt++; + if (attempt === retries) { + console.error(`Failed to fetch price after ${retries} attempts.`); + return 0; + } + } + } + return 0; +}; + +class PriceService { + private static instance: PriceService; + private intervalId: NodeJS.Timeout | null = null; + private listeners: Map< + any, + { + setNativeCurrencyPrice: (price: number) => void; + setStrkCurrencyPrice: (price: number) => void; + } + > = new Map(); + private currentNativeCurrencyPrice: number = 0; + private currentStrkCurrencyPrice: number = 0; + private idCounter: number = 0; + + private constructor() { } + + static getInstance(): PriceService { + if (!PriceService.instance) { + PriceService.instance = new PriceService(); + } + return PriceService.instance; + } + + public getNextId(): number { + return ++this.idCounter; + } + + public startPolling( + ref: any, + setNativeCurrencyPrice: (price: number) => void, + setStrkCurrencyPrice: (price: number) => void, + ) { + if (this.listeners.has(ref)) return; + this.listeners.set(ref, { setNativeCurrencyPrice, setStrkCurrencyPrice }); + + if (this.intervalId) { + setNativeCurrencyPrice(this.currentNativeCurrencyPrice); + setStrkCurrencyPrice(this.currentStrkCurrencyPrice); + return; + } + + this.fetchPrices(); + this.intervalId = setInterval(() => { + this.fetchPrices(); + }, scaffoldConfig.pollingInterval); + } + + public stopPolling(ref: any) { + if (!this.intervalId) return; + if (!this.listeners.has(ref)) return; + + this.listeners.delete(ref); + if (this.listeners.size === 0) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + public getCurrentNativeCurrencyPrice() { + return this.currentNativeCurrencyPrice; + } + + public getCurrentStrkCurrencyPrice() { + return this.currentStrkCurrencyPrice; + } + + private async fetchPrices() { + try { + const ethPrice = await fetchPriceFromCoingecko("ETH"); + const strkPrice = await fetchPriceFromCoingecko("STRK"); + if (ethPrice && strkPrice) { + this.currentNativeCurrencyPrice = ethPrice; + this.currentStrkCurrencyPrice = strkPrice; + } + this.listeners.forEach(listener => { + listener.setNativeCurrencyPrice(ethPrice || this.currentNativeCurrencyPrice); + listener.setStrkCurrencyPrice(strkPrice || this.currentStrkCurrencyPrice); + }); + } catch (error) { + console.error("Error fetching prices:", error); + } + } +} + +export const priceService = PriceService.getInstance(); diff --git a/packages/nextjs/lib/scaffold-stark-2/services/web3/__test__/PriceService.test.ts b/packages/nextjs/lib/scaffold-stark-2/services/web3/__test__/PriceService.test.ts new file mode 100644 index 0000000..a9dd395 --- /dev/null +++ b/packages/nextjs/lib/scaffold-stark-2/services/web3/__test__/PriceService.test.ts @@ -0,0 +1,289 @@ +import { priceService } from "../PriceService"; +import scaffoldConfig from "@scaffold-stark-2/scaffold.config"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); +mockFetch.mockImplementation((url: string) => { + if (url.includes("ETH")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ ethereum: { usd: 2000 } }), + }); + } + if (url.includes("STRK")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ starknet: { usd: 100 } }), + }); + } + return Promise.reject(new Error("Unknown URL")); +}); + +describe("PriceService", () => { + let mockSetNativeCurrencyPrice: ReturnType; + let mockSetStrkCurrencyPrice: ReturnType; + + beforeAll(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + // vi.spyOn(console, 'log').mockImplementation(() => { }); + }); + + beforeEach(() => { + // Reset mocks and timers before each test + vi.useFakeTimers(); + mockSetNativeCurrencyPrice = vi.fn(); + mockSetStrkCurrencyPrice = vi.fn(); + vi.clearAllMocks(); + }); + + afterEach(() => { + // Clean up after each test + vi.useRealTimers(); + // Stop all polling to clean up + priceService["listeners"].forEach((_, ref) => { + priceService.stopPolling(ref); + }); + }); + + describe("Singleton Pattern", () => { + it("should always return the same instance", () => { + const instance1 = priceService; + const instance2 = priceService; + expect(instance1).toBe(instance2); + }); + }); + + describe("ID Generation", () => { + it("should generate incremental IDs", () => { + const id1 = priceService.getNextId(); + const id2 = priceService.getNextId(); + expect(id2).toBe(id1 + 1); + }); + }); + + describe("Polling Management", () => { + it("should start polling when first listener is added", () => { + const ref = priceService.getNextId(); + priceService.startPolling(ref, mockSetNativeCurrencyPrice, mockSetStrkCurrencyPrice); + + expect(priceService["intervalId"]).not.toBeNull(); + expect(priceService["listeners"].size).toBe(1); + }); + + it("should not create multiple intervals for multiple listeners", () => { + const ref1 = priceService.getNextId(); + const ref2 = priceService.getNextId(); + + priceService.startPolling(ref1, mockSetNativeCurrencyPrice, mockSetStrkCurrencyPrice); + const firstIntervalId = priceService["intervalId"]; + + priceService.startPolling(ref2, mockSetNativeCurrencyPrice, mockSetStrkCurrencyPrice); + + expect(priceService["intervalId"]).toBe(firstIntervalId); + expect(priceService["listeners"].size).toBe(2); + }); + + it("should stop polling when last listener is removed", () => { + const ref = priceService.getNextId(); + priceService.startPolling(ref, mockSetNativeCurrencyPrice, mockSetStrkCurrencyPrice); + priceService.stopPolling(ref); + + expect(priceService["intervalId"]).toBeNull(); + expect(priceService["listeners"].size).toBe(0); + }); + + it("should not stop polling if other listeners exist", () => { + const ref1 = priceService.getNextId(); + const ref2 = priceService.getNextId(); + + priceService.startPolling(ref1, mockSetNativeCurrencyPrice, mockSetStrkCurrencyPrice); + priceService.startPolling(ref2, mockSetNativeCurrencyPrice, mockSetStrkCurrencyPrice); + + priceService.stopPolling(ref1); + + expect(priceService["intervalId"]).not.toBeNull(); + expect(priceService["listeners"].size).toBe(1); + }); + + it("should notify all listeners of price updates", async () => { + const mockListener1 = { native: vi.fn(), strk: vi.fn() }; + const mockListener2 = { native: vi.fn(), strk: vi.fn() }; + + const ref1 = priceService.getNextId(); + const ref2 = priceService.getNextId(); + + priceService.startPolling(ref1, mockListener1.native, mockListener1.strk); + priceService.startPolling(ref2, mockListener2.native, mockListener2.strk); + + await vi.advanceTimersByTimeAsync(0); + + expect(mockListener1.native).toHaveBeenCalled(); + expect(mockListener1.strk).toHaveBeenCalled(); + expect(mockListener2.native).toHaveBeenCalled(); + expect(mockListener2.strk).toHaveBeenCalled(); + }); + }); + + describe("Price Updates", () => { + it("should update prices and notify listeners immediately after starting", async () => { + const mockEthPrice = 2000; + const mockStrkPrice = 100; + + const ref = priceService.getNextId(); + priceService.startPolling(ref, mockSetNativeCurrencyPrice, mockSetStrkCurrencyPrice); + + await vi.advanceTimersByTimeAsync(0); + + expect(mockSetNativeCurrencyPrice).toHaveBeenCalledWith(mockEthPrice); + expect(mockSetStrkCurrencyPrice).toHaveBeenCalledWith(mockStrkPrice); + }); + + it("should update prices on polling interval", async () => { + const mockEthPrice = 2000; + const mockStrkPrice = 100; + + const ref = priceService.getNextId(); + priceService.startPolling(ref, mockSetNativeCurrencyPrice, mockSetStrkCurrencyPrice); + + await vi.advanceTimersByTimeAsync(0); + vi.clearAllMocks(); + + await vi.advanceTimersByTimeAsync(scaffoldConfig.pollingInterval); + + expect(mockSetNativeCurrencyPrice).toHaveBeenCalledWith(mockEthPrice); + expect(mockSetStrkCurrencyPrice).toHaveBeenCalledWith(mockStrkPrice); + }); + + it("should use cached prices when subsequent fetch fails", async () => { + const mockEthPrice = 2000; + const mockStrkPrice = 100; + + mockFetch.mockImplementationOnce((url: string) => { + if (url.includes("ETH")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ ethereum: { usd: mockEthPrice } }), + }); + } + if (url.includes("STRK")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ starknet: { usd: mockStrkPrice } }), + }); + } + return Promise.reject(new Error("Unknown URL")); + }); + + const ref = priceService.getNextId(); + priceService.startPolling(ref, mockSetNativeCurrencyPrice, mockSetStrkCurrencyPrice); + + await vi.advanceTimersByTimeAsync(0); + + vi.clearAllMocks(); + mockFetch.mockImplementation(() => Promise.reject(new Error("Network error"))); + + await vi.advanceTimersByTimeAsync(scaffoldConfig.pollingInterval); + + expect(mockSetNativeCurrencyPrice).toHaveBeenCalledWith(mockEthPrice); + expect(mockSetStrkCurrencyPrice).toHaveBeenCalledWith(mockStrkPrice); + }); + }); + + describe("Error Handling", () => { + it("should handle fetch errors gracefully", async () => { + // Mock the entire function to avoid using fetch + mockFetch.mockImplementation(() => Promise.reject(new Error("Network error"))); + + const ref = priceService.getNextId(); + priceService.startPolling(ref, mockSetNativeCurrencyPrice, mockSetStrkCurrencyPrice); + + // Should not throw error + await expect(priceService["fetchPrices"]()).resolves.not.toThrow(); + }); + + it("should handle malformed API responses", async () => { + mockFetch.mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ invalid: "format" }), + }), + ); + + await expect(priceService["fetchPrices"]()).resolves.not.toThrow(); + }); + + it("should retry failed requests up to maximum attempts", async () => { + const mockError = new Error("Network error"); + mockFetch + // First attempt (ETH + STRK) + .mockRejectedValueOnce(mockError) + .mockRejectedValueOnce(mockError) + // Second attempt (ETH + STRK) + .mockRejectedValueOnce(mockError) + .mockRejectedValueOnce(mockError) + // Third attempt (ETH + STRK) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ ethereum: { usd: 2000 } }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ starknet: { usd: 100 } }), + }); + + await priceService["fetchPrices"](); + expect(mockFetch).toHaveBeenCalledTimes(6); // 3次尝试 × 2个币种 = 6次调用 + }); + + it("should handle non-200 API responses", async () => { + mockFetch.mockImplementation(() => + Promise.resolve({ + ok: false, + status: 429, + statusText: "Too Many Requests", + }), + ); + + await expect(priceService["fetchPrices"]()).resolves.not.toThrow(); + }); + }); + + describe("Price Getters", () => { + it("should return current prices", () => { + const mockEthPrice = 2000; + const mockStrkPrice = 100; + + priceService["currentNativeCurrencyPrice"] = mockEthPrice; + priceService["currentStrkCurrencyPrice"] = mockStrkPrice; + + expect(priceService.getCurrentNativeCurrencyPrice()).toBe(mockEthPrice); + expect(priceService.getCurrentStrkCurrencyPrice()).toBe(mockStrkPrice); + }); + }); + + describe("Resource Cleanup", () => { + it("should properly clean up resources when stopping polling", () => { + const ref = priceService.getNextId(); + priceService.startPolling(ref, vi.fn(), vi.fn()); + + const intervalId = priceService["intervalId"]; + priceService.stopPolling(ref); + + expect(priceService["intervalId"]).toBeNull(); + expect(priceService["listeners"].size).toBe(0); + }); + }); + + describe("Invalid Usage", () => { + it("should handle stopping non-existent polling reference", () => { + expect(() => priceService.stopPolling(999)).not.toThrow(); + }); + + it("should handle duplicate polling starts with same reference", () => { + const ref = priceService.getNextId(); + priceService.startPolling(ref, vi.fn(), vi.fn()); + expect(() => priceService.startPolling(ref, vi.fn(), vi.fn())).not.toThrow(); + }); + }); +}); diff --git a/packages/nextjs/lib/scaffold-stark-2/utils/scaffold-stark/fetchPriceFromCoingecko.ts b/packages/nextjs/lib/scaffold-stark-2/utils/scaffold-stark/fetchPriceFromCoingecko.ts deleted file mode 100644 index 94a3f5e..0000000 --- a/packages/nextjs/lib/scaffold-stark-2/utils/scaffold-stark/fetchPriceFromCoingecko.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ChainWithAttributes } from "@scaffold-stark-2/utils/scaffold-stark"; - -// Cache object to store the last fetched prices based on currency symbols -const priceCache: Record = {}; - -export const fetchPriceFromCoingecko = async (symbol: string, retryCount = 3): Promise => { - if (symbol !== "ETH" && symbol !== "SEP" && symbol !== "STRK") { - return 0; - } - - // Check cache first - if (priceCache[symbol] !== undefined) { - return priceCache[symbol]; - } - - return updatePriceCache(symbol, retryCount); -}; - -const updatePriceCache = async (symbol: string, retries = 3): Promise => { - let attempt = 0; - while (attempt < retries) { - try { - const response = await fetch(`/api/price/${symbol}`); - const data = await response.json(); - const price = symbol === "ETH" ? data.ethereum.usd : data.starknet.usd; - priceCache[symbol] = price; - return price; - } catch (error) { - console.error(`Attempt ${attempt + 1} - Error fetching ${symbol} price from Coingecko: `, error); - attempt++; - if (attempt === retries) { - console.error(`Failed to fetch price after ${retries} attempts.`); - return priceCache[symbol] || 0; - } - } - } - return priceCache[symbol] || 0; -}; - -setInterval(() => { - Object.keys(priceCache).forEach(symbol => { - console.log(`Updating price for ${symbol}`); - updatePriceCache(symbol); - }); -}, 300000); diff --git a/packages/nextjs/lib/scaffold-stark-2/utils/scaffold-stark/index.ts b/packages/nextjs/lib/scaffold-stark-2/utils/scaffold-stark/index.ts index 30dd5a8..20baa36 100644 --- a/packages/nextjs/lib/scaffold-stark-2/utils/scaffold-stark/index.ts +++ b/packages/nextjs/lib/scaffold-stark-2/utils/scaffold-stark/index.ts @@ -1,4 +1,3 @@ export * from "./networks"; export * from "./notification"; -export * from "./fetchPriceFromCoingecko"; export * from "./types";