diff --git a/src/data-layer/fetchers/fetchEthPrice.ts b/src/data-layer/fetchers/fetchEthPrice.ts deleted file mode 100644 index 624a7c22002..00000000000 --- a/src/data-layer/fetchers/fetchEthPrice.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { EthPriceData } from "@/lib/types" - -import { fetchRetry } from "./fetchRetry" - -export const FETCH_ETH_PRICE_TASK_ID = "fetch-eth-price" - -/** - * Fetch Ethereum price data from CoinGecko API. - * Returns the latest USD price and 24hr percent change. - */ -export async function fetchEthPrice(): Promise { - const apiKey = process.env.COINGECKO_API_KEY - const url = `https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd&include_24hr_change=true&x_cg_demo_api_key=${apiKey}` - - console.log("Starting Ethereum price data fetch") - - const response = await fetchRetry(url) - - if (!response.ok) { - const status = response.status - console.warn("CoinGecko fetch non-OK", { status, url }) - const error = `CoinGecko responded with status ${status}` - throw new Error(error) - } - - const data: { ethereum: { usd: number; usd_24h_change?: number } } = - await response.json() - const { - ethereum: { usd, usd_24h_change }, - } = data - - if (!usd) { - throw new Error("Unable to fetch ETH price from CoinGecko") - } - - const timestamp = Date.now() - const percentChange24h = - typeof usd_24h_change === "number" ? usd_24h_change / 100 : undefined - - console.log("Successfully fetched Ethereum price data", { - price: usd, - percentChange24h, - timestamp, - }) - - return { value: usd, timestamp, percentChange24h } -} diff --git a/src/data-layer/fetchers/fetchEthereumMarketcap.ts b/src/data-layer/fetchers/fetchEthereumMarketcap.ts deleted file mode 100644 index 77d4ecd80c3..00000000000 --- a/src/data-layer/fetchers/fetchEthereumMarketcap.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { MetricReturnData } from "@/lib/types" - -import { fetchRetry } from "./fetchRetry" - -export const FETCH_ETHEREUM_MARKETCAP_TASK_ID = "fetch-ethereum-marketcap" - -/** - * Fetch Ethereum market cap data from CoinGecko API. - * Returns the latest USD market cap data. - */ -export async function fetchEthereumMarketcap(): Promise { - const apiKey = process.env.COINGECKO_API_KEY - const url = `https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd&include_market_cap=true&x_cg_demo_api_key=${apiKey}` - - console.log("Starting Ethereum market cap data fetch") - - const response = await fetchRetry(url) - - if (!response.ok) { - const status = response.status - console.warn("CoinGecko fetch non-OK", { status, url }) - const error = `CoinGecko responded with status ${status}` - throw new Error(error) - } - - const data: { ethereum: { usd_market_cap: number } } = await response.json() - const { - ethereum: { usd_market_cap }, - } = data - - if (!usd_market_cap) { - throw new Error("Unable to fetch ETH market cap from CoinGecko") - } - - const timestamp = Date.now() - - console.log("Successfully fetched Ethereum market cap data", { - marketCap: usd_market_cap, - timestamp, - }) - - return { value: usd_market_cap, timestamp } -} diff --git a/src/data-layer/fetchers/fetchEthereumMetrics.ts b/src/data-layer/fetchers/fetchEthereumMetrics.ts new file mode 100644 index 00000000000..9bfaccd2690 --- /dev/null +++ b/src/data-layer/fetchers/fetchEthereumMetrics.ts @@ -0,0 +1,89 @@ +import type { EthPriceData, MetricReturnData } from "@/lib/types" + +import { fetchRetry } from "./fetchRetry" + +type SuccessfulEthPriceData = Extract +type SuccessfulMetricReturnData = Extract< + MetricReturnData, + { value: number } +> + +interface CoinGeckoEthereumResponse { + ethereum?: { + usd?: number + usd_24h_change?: number | null + usd_market_cap?: number + } +} + +export interface EthereumMetricsData { + ethPrice: SuccessfulEthPriceData + ethereumMarketcap: SuccessfulMetricReturnData +} + +export function parseEthereumMetrics( + data: CoinGeckoEthereumResponse, + timestamp = Date.now() +): EthereumMetricsData { + const { usd, usd_24h_change, usd_market_cap } = data.ethereum ?? {} + + if (typeof usd !== "number" || !Number.isFinite(usd)) { + throw new Error("Unable to fetch ETH price from CoinGecko") + } + + if ( + typeof usd_market_cap !== "number" || + !Number.isFinite(usd_market_cap) + ) { + throw new Error("Unable to fetch ETH market cap from CoinGecko") + } + + const percentChange24h = + typeof usd_24h_change === "number" && Number.isFinite(usd_24h_change) + ? usd_24h_change / 100 + : undefined + + return { + ethPrice: { value: usd, timestamp, percentChange24h }, + ethereumMarketcap: { value: usd_market_cap, timestamp }, + } +} + +/** + * Fetch Ethereum price, 24-hour change, and market cap in one CoinGecko call. + */ +export async function fetchEthereumMetrics(): Promise { + const apiKey = process.env.COINGECKO_API_KEY + const params = new URLSearchParams({ + ids: "ethereum", + vs_currencies: "usd", + include_24hr_change: "true", + include_market_cap: "true", + }) + const url = `https://api.coingecko.com/api/v3/simple/price?${params}` + + console.log("Starting Ethereum metrics fetch") + + const response = await fetchRetry(url, { + headers: apiKey ? { "x-cg-demo-api-key": apiKey } : undefined, + }) + + if (!response.ok) { + const { status } = response + console.warn("CoinGecko fetch non-OK", { status }) + throw new Error(`CoinGecko responded with status ${status}`) + } + + const metrics = parseEthereumMetrics( + (await response.json()) as CoinGeckoEthereumResponse + ) + + console.log("Successfully fetched Ethereum metrics", { + price: metrics.ethPrice.value, + percentChange24h: metrics.ethPrice.percentChange24h, + marketCap: metrics.ethereumMarketcap.value, + timestamp: metrics.ethPrice.timestamp, + }) + + return metrics +} diff --git a/src/data-layer/tasks.ts b/src/data-layer/tasks.ts index 20cf936842a..7ef3611c982 100644 --- a/src/data-layer/tasks.ts +++ b/src/data-layer/tasks.ts @@ -15,9 +15,8 @@ import { fetchApps } from "./fetchers/fetchApps" import { fetchBlobStats } from "./fetchers/fetchBlobStats" import { fetchCalendarEvents } from "./fetchers/fetchCalendarEvents" import { fetchCommunityPicks } from "./fetchers/fetchCommunityPicks" -import { fetchEthereumMarketcap } from "./fetchers/fetchEthereumMarketcap" import { fetchEthereumStablecoinsMcap } from "./fetchers/fetchEthereumStablecoinsMcap" -import { fetchEthPrice } from "./fetchers/fetchEthPrice" +import { fetchEthereumMetrics } from "./fetchers/fetchEthereumMetrics" import { fetchEvents } from "./fetchers/fetchEvents" import { fetchGasPrice } from "./fetchers/fetchGasPrice" import { fetchGFIs } from "./fetchers/fetchGFIs" @@ -96,9 +95,7 @@ const DAILY: TaskDef[] = [ ] const HOURLY: TaskDef[] = [ - [KEYS.ETHEREUM_MARKETCAP, fetchEthereumMarketcap], [KEYS.ETHEREUM_STABLECOINS_MCAP, fetchEthereumStablecoinsMcap], - [KEYS.ETH_PRICE, fetchEthPrice], [KEYS.GAS_PRICE, fetchGasPrice], [KEYS.TOTAL_ETH_STAKED, fetchTotalEthStaked], [KEYS.TOTAL_VALUE_LOCKED, fetchTotalValueLocked], @@ -124,9 +121,30 @@ function createDataTask([key, fetchFn]: TaskDef) { }) } +const ethereumMetricsFetchTask = task({ + id: KEYS.ETH_PRICE, + retry: { + maxAttempts: 2, + }, + catchError: async ({ error }) => { + logger.error(`[${KEYS.ETH_PRICE}] failed`, { error }) + }, + run: async () => { + const { ethPrice, ethereumMarketcap } = await fetchEthereumMetrics() + + await Promise.all([ + set(KEYS.ETH_PRICE, ethPrice), + set(KEYS.ETHEREUM_MARKETCAP, ethereumMarketcap), + ]) + + logger.info(`Stored ${KEYS.ETH_PRICE} + ${KEYS.ETHEREUM_MARKETCAP}`) + return { keys: [KEYS.ETH_PRICE, KEYS.ETHEREUM_MARKETCAP] } + }, +}) + const weeklyFetchTasks = WEEKLY.map(createDataTask) const dailyFetchTasks = DAILY.map(createDataTask) -const hourlyFetchTasks = HOURLY.map(createDataTask) +const hourlyFetchTasks = [ethereumMetricsFetchTask, ...HOURLY.map(createDataTask)] // Must export for trigger.dev to discover export const allFetchTasks = [ diff --git a/tests/unit/data-layer/fetchEthereumMetrics.spec.ts b/tests/unit/data-layer/fetchEthereumMetrics.spec.ts new file mode 100644 index 00000000000..3801ca16316 --- /dev/null +++ b/tests/unit/data-layer/fetchEthereumMetrics.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from "@playwright/test" + +import { parseEthereumMetrics } from "@/data-layer/fetchers/fetchEthereumMetrics" + +test.describe("parseEthereumMetrics", () => { + test("maps one CoinGecko response to both stored metric formats", () => { + const timestamp = 1_700_000_000_000 + const result = parseEthereumMetrics( + { + ethereum: { + usd: 3_500, + usd_24h_change: 2.5, + usd_market_cap: 420_000_000_000, + }, + }, + timestamp + ) + + expect(result.ethPrice).toEqual({ + value: 3_500, + timestamp, + percentChange24h: 0.025, + }) + expect(result.ethereumMarketcap).toEqual({ + value: 420_000_000_000, + timestamp, + }) + }) + + test("allows CoinGecko to omit the 24-hour change", () => { + const result = parseEthereumMetrics({ + ethereum: { usd: 3_500, usd_market_cap: 420_000_000_000 }, + }) + + expect(result.ethPrice.percentChange24h).toBeUndefined() + }) + + for (const [name, response] of [ + ["price", { ethereum: { usd_market_cap: 420_000_000_000 } }], + ["market cap", { ethereum: { usd: 3_500 } }], + ] as const) { + test(`rejects a response without a valid ${name}`, () => { + expect(() => parseEthereumMetrics(response)).toThrow() + }) + } +})