diff --git a/.claude/skills/data-layer/SKILL.md b/.claude/skills/data-layer/SKILL.md index ba672d601e6..4d23f377c0e 100644 --- a/.claude/skills/data-layer/SKILL.md +++ b/.claude/skills/data-layer/SKILL.md @@ -39,7 +39,7 @@ const DAILY: Task[] = [ const HOURLY: Task[] = [ [KEYS.ETH_PRICE, fetchEthPrice], - [KEYS.BEACONCHAIN_EPOCH, fetchBeaconChainEpoch], + [KEYS.BEACONCHAIN, fetchBeaconChain], ] ``` diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index d6faa9cd25d..06ef0efb30b 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -82,7 +82,7 @@ import { routing } from "@/i18n/routing" import { getAppsData, getAttestantPosts, - getBeaconchainEpochData, + getBeaconchainData, getEthPrice, getEventsData, getGrowThePieData, @@ -139,7 +139,7 @@ const Page = async ({ params }: { params: PageParams }) => { // Fetch data using the new data-layer functions (already cached) const [ ethPrice, - beaconchainEpochData, + beaconchainData, totalValueLocked, growThePieData, attestantPosts, @@ -148,7 +148,7 @@ const Page = async ({ params }: { params: PageParams }) => { eventsData, ] = await Promise.all([ getEthPrice(), - getBeaconchainEpochData(), + getBeaconchainData(), getTotalValueLockedData(), getGrowThePieData(), getAttestantPosts(), @@ -161,8 +161,8 @@ const Page = async ({ params }: { params: PageParams }) => { if (!ethPrice) { throw new Error("Failed to fetch ETH price data") } - if (!beaconchainEpochData) { - throw new Error("Failed to fetch Beaconchain epoch data") + if (!beaconchainData) { + throw new Error("Failed to fetch Beaconchain data") } if (!totalValueLocked) { throw new Error("Failed to fetch total value locked data") @@ -186,8 +186,8 @@ const Page = async ({ params }: { params: PageParams }) => { ) } - // Extract totalEthStaked from beaconchainEpochData - const { totalEthStaked } = beaconchainEpochData + // Extract totalEthStaked from beaconchainData + const { totalEthStaked } = beaconchainData // Events - use empty array as fallback const upcomingEvents = (eventsData ?? []).slice(0, 3) diff --git a/app/[locale]/staking/page.tsx b/app/[locale]/staking/page.tsx index 69241d873a2..ea1ecf93108 100644 --- a/app/[locale]/staking/page.tsx +++ b/app/[locale]/staking/page.tsx @@ -16,7 +16,7 @@ import { getRequiredNamespacesForPage } from "@/lib/utils/translations" import StakingPage from "./_components/staking" import StakingPageJsonLD from "./page-jsonld" -import { getBeaconchainEpochData, getBeaconchainEthstoreData } from "@/lib/data" +import { getBeaconchainData } from "@/lib/data" const Page = async ({ params }: { params: PageParams }) => { const { locale } = params @@ -24,21 +24,15 @@ const Page = async ({ params }: { params: PageParams }) => { setRequestLocale(locale) // Fetch data using the new data-layer functions (already cached) - const [beaconchainEpochData, apr] = await Promise.all([ - getBeaconchainEpochData(), - getBeaconchainEthstoreData(), - ]) + const beaconchainData = await getBeaconchainData() // Handle null cases - throw error if required data is missing - if (!beaconchainEpochData) { - throw new Error("Failed to fetch Beaconchain epoch data") - } - if (!apr) { - throw new Error("Failed to fetch Beaconchain APR data") + if (!beaconchainData) { + throw new Error("Failed to fetch Beaconchain data") } // Extract values from data structures - const { totalEthStaked, validatorscount } = beaconchainEpochData + const { totalEthStaked, validatorscount, apr } = beaconchainData const data: StakingStatsData = { totalEthStaked: "value" in totalEthStaked ? totalEthStaked.value : 0, diff --git a/src/data-layer/fetchers/fetchBeaconChain.ts b/src/data-layer/fetchers/fetchBeaconChain.ts new file mode 100644 index 00000000000..6cfd0ab215e --- /dev/null +++ b/src/data-layer/fetchers/fetchBeaconChain.ts @@ -0,0 +1,63 @@ +import type { + BeaconchainEpochData, + EpochResponse, + EthStoreResponse, + MetricReturnData, +} from "@/lib/types" + +export type BeaconChainData = BeaconchainEpochData & { apr: MetricReturnData } + +/** + * Fetch beaconchain data from Beaconcha.in API. + * Combines epoch and ethstore endpoints (sequential to respect 1 req/sec limit). + */ +export async function fetchBeaconChain(): Promise { + const base = "https://beaconcha.in" + + console.log("Starting beaconchain data fetch") + + // Fetch epoch data + const epochUrl = new URL("api/v1/epoch/latest", base).href + const epochResponse = await fetch(epochUrl) + if (!epochResponse.ok) { + const status = epochResponse.status + console.warn("Beaconcha.in epoch fetch non-OK", { status, url: epochUrl }) + const error = `Beaconcha.in epoch responded with status ${status}` + throw new Error(error) + } + const epochJson: EpochResponse = await epochResponse.json() + const { validatorscount, eligibleether } = epochJson.data + const totalEthStaked = Math.floor(eligibleether * 1e-9) + + // Wait 1s to respect rate limit + await new Promise((r) => setTimeout(r, 1000)) + + // Fetch ethstore data + const ethstoreUrl = new URL("api/v1/ethstore/latest", base).href + const ethstoreResponse = await fetch(ethstoreUrl) + if (!ethstoreResponse.ok) { + const status = ethstoreResponse.status + console.warn("Beaconcha.in ethstore fetch non-OK", { + status, + url: ethstoreUrl, + }) + const error = `Beaconcha.in ethstore responded with status ${status}` + throw new Error(error) + } + const ethstoreJson: EthStoreResponse = await ethstoreResponse.json() + const apr = ethstoreJson.data.apr + + const timestamp = Date.now() + + console.log("Successfully fetched beaconchain data", { + totalEthStaked, + validatorscount, + apr, + }) + + return { + totalEthStaked: { value: totalEthStaked, timestamp }, + validatorscount: { value: validatorscount, timestamp }, + apr: { value: apr, timestamp }, + } +} diff --git a/src/data-layer/fetchers/fetchBeaconChainEpoch.ts b/src/data-layer/fetchers/fetchBeaconChainEpoch.ts deleted file mode 100644 index 99aaa371f88..00000000000 --- a/src/data-layer/fetchers/fetchBeaconChainEpoch.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { BeaconchainEpochData, EpochResponse } from "@/lib/types" - -export const FETCH_BEACONCHAIN_EPOCH_TASK_ID = "fetch-beaconchain-epoch" - -/** - * Fetch beaconchain epoch data from Beaconcha.in API. - * Returns the latest epoch data including total ETH staked and validator count. - */ -export async function fetchBeaconChainEpoch(): Promise { - const base = "https://beaconcha.in" - const endpoint = "api/v1/epoch/latest" - const { href } = new URL(endpoint, base) - - console.log("Starting beaconchain epoch data fetch") - - const response = await fetch(href) - - if (!response.ok) { - const status = response.status - console.warn("Beaconcha.in fetch non-OK", { status, url: href }) - const error = `Beaconcha.in responded with status ${status}` - throw new Error(error) - } - - const json: EpochResponse = await response.json() - const { validatorscount, eligibleether } = json.data - const totalEthStaked = Math.floor(eligibleether * 1e-9) // `eligibleether` value returned in `gwei` - const timestamp = Date.now() - - console.log("Successfully fetched beaconchain epoch data", { - totalEthStaked, - validatorscount, - timestamp, - }) - - return { - totalEthStaked: { value: totalEthStaked, timestamp }, - validatorscount: { value: validatorscount, timestamp }, - } -} diff --git a/src/data-layer/fetchers/fetchBeaconChainEthstore.ts b/src/data-layer/fetchers/fetchBeaconChainEthstore.ts deleted file mode 100644 index b0c0eb5168a..00000000000 --- a/src/data-layer/fetchers/fetchBeaconChainEthstore.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { EthStoreResponse, MetricReturnData } from "@/lib/types" - -export const FETCH_BEACONCHAIN_ETHSTORE_TASK_ID = "fetch-beaconchain-ethstore" - -/** - * Fetch beaconchain ethstore data from Beaconcha.in API. - * Returns the latest APR data. - */ -export async function fetchBeaconChainEthstore(): Promise { - const base = "https://beaconcha.in" - const endpoint = "api/v1/ethstore/latest" - const { href } = new URL(endpoint, base) - - console.log("Starting beaconchain ethstore data fetch") - - const response = await fetch(href) - - if (!response.ok) { - const status = response.status - console.warn("Beaconcha.in fetch non-OK", { status, url: href }) - const error = `Beaconcha.in responded with status ${status}` - throw new Error(error) - } - - const json: EthStoreResponse = await response.json() - const apr = json.data.apr - const timestamp = Date.now() - - console.log("Successfully fetched beaconchain ethstore data", { - apr, - timestamp, - }) - - return { value: apr, timestamp } -} diff --git a/src/data-layer/index.ts b/src/data-layer/index.ts index 97125507141..c5d56fb6ca8 100644 --- a/src/data-layer/index.ts +++ b/src/data-layer/index.ts @@ -1,6 +1,5 @@ import type { AppData, - BeaconchainEpochData, BlobscanOverallStats, BlockspaceData, Commit, @@ -16,6 +15,7 @@ import type { } from "@/lib/types" import type { CommunityEventsReturnType } from "@/lib/interfaces" +import type { BeaconChainData } from "./fetchers/fetchBeaconChain" import type { CoinGeckoCoinMarketResponse } from "./fetchers/fetchStablecoinsData" import { get } from "./storage" import { KEYS } from "./tasks" @@ -32,8 +32,7 @@ export const getCommunityPicks = () => get(KEYS.COMMUNITY_PICKS export const getCalendarEvents = () => get(KEYS.CALENDAR_EVENTS) export const getRSSData = () => get(KEYS.RSS) export const getAttestantPosts = () => get(KEYS.POSTS) -export const getBeaconchainEpochData = () => get(KEYS.BEACONCHAIN_EPOCH) -export const getBeaconchainEthstoreData = () => get(KEYS.BEACONCHAIN_ETHSTORE) +export const getBeaconchainData = () => get(KEYS.BEACONCHAIN) export const getBlobscanStats = () => get(KEYS.BLOBSCAN_STATS) export const getEthereumMarketcapData = () => get(KEYS.ETHEREUM_MARKETCAP) export const getEthereumStablecoinsMcapData = () => get(KEYS.ETHEREUM_STABLECOINS_MCAP) diff --git a/src/data-layer/mocks/fetch-beaconchain-ethstore.json b/src/data-layer/mocks/fetch-beaconchain-ethstore.json deleted file mode 100644 index 2ce8816b7fc..00000000000 --- a/src/data-layer/mocks/fetch-beaconchain-ethstore.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "value": 0.0281997826662808, - "timestamp": 1765840902754 -} \ No newline at end of file diff --git a/src/data-layer/mocks/fetch-beaconchain-epoch.json b/src/data-layer/mocks/fetch-beaconchain.json similarity index 66% rename from src/data-layer/mocks/fetch-beaconchain-epoch.json rename to src/data-layer/mocks/fetch-beaconchain.json index d20ab6d7fd0..e92fb70689f 100644 --- a/src/data-layer/mocks/fetch-beaconchain-epoch.json +++ b/src/data-layer/mocks/fetch-beaconchain.json @@ -6,5 +6,9 @@ "validatorscount": { "value": 993342, "timestamp": 1765912003680 + }, + "apr": { + "value": 0.0281997826662808, + "timestamp": 1765912003680 } -} \ No newline at end of file +} diff --git a/src/data-layer/mocks/index.ts b/src/data-layer/mocks/index.ts index 11ba75286ef..f7e9e784f50 100644 --- a/src/data-layer/mocks/index.ts +++ b/src/data-layer/mocks/index.ts @@ -5,14 +5,13 @@ * for local development without needing to connect to Netlify Blobs. * * Generated: 2025-12-16T18:32:05.983Z - * Total files: 21 + * Total files: 20 */ export const mockTaskIds = [ "fetch-apps", - "fetch-beaconchain-epoch", + "fetch-beaconchain", "fetch-events", - "fetch-beaconchain-ethstore", "fetch-blobscan-stats", "fetch-calendar-events", "fetch-community-picks", diff --git a/src/data-layer/tasks.ts b/src/data-layer/tasks.ts index 3acb86f292f..19863382013 100644 --- a/src/data-layer/tasks.ts +++ b/src/data-layer/tasks.ts @@ -8,8 +8,7 @@ import { schedules } from "@trigger.dev/sdk/v3" import { fetchApps } from "./fetchers/fetchApps" -import { fetchBeaconChainEpoch } from "./fetchers/fetchBeaconChainEpoch" -import { fetchBeaconChainEthstore } from "./fetchers/fetchBeaconChainEthstore" +import { fetchBeaconChain } from "./fetchers/fetchBeaconChain" import { fetchBlobscanStats } from "./fetchers/fetchBlobscanStats" import { fetchCalendarEvents } from "./fetchers/fetchCalendarEvents" import { fetchCommunityPicks } from "./fetchers/fetchCommunityPicks" @@ -45,8 +44,7 @@ export const KEYS = { RSS: "fetch-rss", GITHUB_REPO_DATA: "fetch-github-repo-data", EVENTS: "fetch-events", - BEACONCHAIN_EPOCH: "fetch-beaconchain-epoch", - BEACONCHAIN_ETHSTORE: "fetch-beaconchain-ethstore", + BEACONCHAIN: "fetch-beaconchain", BLOBSCAN_STATS: "fetch-blobscan-stats", ETHEREUM_MARKETCAP: "fetch-ethereum-marketcap", ETHEREUM_STABLECOINS_MCAP: "fetch-ethereum-stablecoins-mcap", @@ -76,8 +74,7 @@ const DAILY: Task[] = [ ] const HOURLY: Task[] = [ - [KEYS.BEACONCHAIN_EPOCH, fetchBeaconChainEpoch], - [KEYS.BEACONCHAIN_ETHSTORE, fetchBeaconChainEthstore], + [KEYS.BEACONCHAIN, fetchBeaconChain], [KEYS.BLOBSCAN_STATS, fetchBlobscanStats], [KEYS.ETHEREUM_MARKETCAP, fetchEthereumMarketcap], [KEYS.ETHEREUM_STABLECOINS_MCAP, fetchEthereumStablecoinsMcap], diff --git a/src/lib/data/index.ts b/src/lib/data/index.ts index ac77febeec2..477367507d3 100644 --- a/src/lib/data/index.ts +++ b/src/lib/data/index.ts @@ -77,15 +77,9 @@ export const getAttestantPosts = createCachedGetter( CACHE_REVALIDATE_HOUR ) -export const getBeaconchainEpochData = createCachedGetter( - dataLayer.getBeaconchainEpochData, - ["beaconchain-epoch-data"], - CACHE_REVALIDATE_HOUR -) - -export const getBeaconchainEthstoreData = createCachedGetter( - dataLayer.getBeaconchainEthstoreData, - ["beaconchain-ethstore-data"], +export const getBeaconchainData = createCachedGetter( + dataLayer.getBeaconchainData, + ["beaconchain-data"], CACHE_REVALIDATE_HOUR ) diff --git a/tests/unit/data-layer/getters.spec.ts b/tests/unit/data-layer/getters.spec.ts index 56b463a8363..09b4cc4be48 100644 --- a/tests/unit/data-layer/getters.spec.ts +++ b/tests/unit/data-layer/getters.spec.ts @@ -221,30 +221,25 @@ test.describe("Data-Layer Getters", () => { }) test.describe("Staking & Beaconchain", () => { - test("getBeaconchainEpochData returns epoch data or null", async () => { - const result = await dataLayer.getBeaconchainEpochData() + test("getBeaconchainData returns combined data or null", async () => { + const result = await dataLayer.getBeaconchainData() if (result !== null) { expect(result).toHaveProperty("totalEthStaked") expect(result).toHaveProperty("validatorscount") - // Check for value property before accessing (MetricReturnData is ValueOrError) - const totalEthStaked = result.totalEthStaked - if ("value" in totalEthStaked) { - expect(typeof totalEthStaked.value).toBe("number") - expect(totalEthStaked.value).toBeGreaterThan(0) + expect(result).toHaveProperty("apr") + // Check values (MetricReturnData is a union type) + if ("value" in result.totalEthStaked) { + expect(typeof result.totalEthStaked.value).toBe("number") + expect(result.totalEthStaked.value).toBeGreaterThan(0) } - const validatorscount = result.validatorscount - if ("value" in validatorscount) { - expect(typeof validatorscount.value).toBe("number") - expect(validatorscount.value).toBeGreaterThan(0) + if ("value" in result.validatorscount) { + expect(typeof result.validatorscount.value).toBe("number") + expect(result.validatorscount.value).toBeGreaterThan(0) + } + if ("value" in result.apr) { + expect(typeof result.apr.value).toBe("number") + expect(result.apr.value).toBeGreaterThan(0) } - } - }) - - test("getBeaconchainEthstoreData returns MetricReturnData or null", async () => { - const result = await dataLayer.getBeaconchainEthstoreData() - if (result !== null && "value" in result) { - expect(typeof result.value).toBe("number") - expect(result.value).toBeGreaterThan(0) } })