Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactoring price cache logic #11

Merged
merged 1 commit into from
Dec 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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<string>(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
}, []);
};
106 changes: 106 additions & 0 deletions packages/nextjs/lib/scaffold-stark-2/services/web3/PriceService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import scaffoldConfig from "@scaffold-stark-2/scaffold.config";

export const fetchPriceFromCoingecko = async (symbol: string, retries = 3): Promise<number> => {
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();
Loading
Loading