diff --git a/app/[locale]/enterprise/page.tsx b/app/[locale]/enterprise/page.tsx index 02c54787f28..6895f13fce0 100644 --- a/app/[locale]/enterprise/page.tsx +++ b/app/[locale]/enterprise/page.tsx @@ -62,9 +62,9 @@ import EnterprisePageJsonLD from "./page-jsonld" import type { Case, EcosystemPlayer, Feature } from "./types" import { parseActivity } from "./utils" +import { fetchBeaconchainEpoch } from "@/lib/api/fetchBeaconchainEpoch" import { fetchEthereumStablecoinsMcap } from "@/lib/api/fetchEthereumStablecoinsMcap" import { fetchEthPrice } from "@/lib/api/fetchEthPrice" -import { fetchEthStakedBeaconchain } from "@/lib/api/fetchEthStakedBeaconchain" import { fetchGrowThePie } from "@/lib/api/fetchGrowThePie" import EthGlyph from "@/public/images/assets/svgs/eth-diamond-rainbow.svg" import heroImage from "@/public/images/heroes/enterprise-hero-white.png" @@ -97,7 +97,7 @@ const loadData = dataLoader( ["growThePieData", fetchGrowThePie], ["ethereumStablecoins", fetchEthereumStablecoinsMcap], ["ethPrice", fetchEthPrice], - ["totalEthStaked", fetchEthStakedBeaconchain], + ["beaconchainEpoch", fetchBeaconchainEpoch], ], BASE_TIME_UNIT * 1000 ) @@ -111,7 +111,7 @@ const Page = async ({ params }: { params: { locale: Lang } }) => { { txCount, txCostsMedianUsd }, stablecoinMarketCap, ethPrice, - totalEthStaked, + { totalEthStaked }, ] = await loadData() const metrics = await parseActivity({ diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 450264cd36b..8236fa12234 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -89,8 +89,8 @@ import { getActivity, getUpcomingEvents } from "./utils" import { routing } from "@/i18n/routing" import { getABTestAssignment } from "@/lib/ab-testing/server" import { fetchCommunityEvents } from "@/lib/api/calendarEvents" +import { fetchBeaconchainEpoch } from "@/lib/api/fetchBeaconchainEpoch" import { fetchEthPrice } from "@/lib/api/fetchEthPrice" -import { fetchEthStakedBeaconchain } from "@/lib/api/fetchEthStakedBeaconchain" import { fetchGrowThePie } from "@/lib/api/fetchGrowThePie" import { fetchAttestantPosts } from "@/lib/api/fetchPosts" import { fetchRSS } from "@/lib/api/fetchRSS" @@ -142,7 +142,7 @@ const REVALIDATE_TIME = BASE_TIME_UNIT * 1 const loadData = dataLoader( [ ["ethPrice", fetchEthPrice], - ["totalEthStaked", fetchEthStakedBeaconchain], + ["beaconchainEpoch", fetchBeaconchainEpoch], ["totalValueLocked", fetchTotalValueLocked], ["growThePieData", fetchGrowThePie], ["communityEvents", fetchCommunityEvents], @@ -168,7 +168,7 @@ const Page = async ({ params }: { params: Promise<{ locale: Lang }> }) => { const [ ethPrice, - totalEthStaked, + { totalEthStaked }, totalValueLocked, growThePieData, communityEvents, diff --git a/app/[locale]/staking/page.tsx b/app/[locale]/staking/page.tsx index 83c0d63dc77..d84b832c983 100644 --- a/app/[locale]/staking/page.tsx +++ b/app/[locale]/staking/page.tsx @@ -5,13 +5,7 @@ import { setRequestLocale, } from "next-intl/server" -import { - CommitHistory, - EpochResponse, - EthStoreResponse, - Lang, - StakingStatsData, -} from "@/lib/types" +import { CommitHistory, Lang, StakingStatsData } from "@/lib/types" import I18nProvider from "@/components/I18nProvider" @@ -25,40 +19,17 @@ import { BASE_TIME_UNIT } from "@/lib/constants" import StakingPage from "./_components/staking" import StakingPageJsonLD from "./page-jsonld" -const fetchBeaconchainData = async (): Promise => { - // Fetch Beaconcha.in data - const base = "https://beaconcha.in" - const { href: ethstore } = new URL("api/v1/ethstore/latest", base) - const { href: epoch } = new URL("api/v1/epoch/latest", base) - - // Get current APR from ethstore endpoint - const ethStoreResponse = await fetch(ethstore) - if (!ethStoreResponse.ok) - throw new Error("Network response from Beaconcha.in ETHSTORE was not ok") - const ethStoreResponseJson: EthStoreResponse = await ethStoreResponse.json() - const { - data: { apr }, - } = ethStoreResponseJson - - // Get total eligible ETH staked and total active validators from latest epoch endpoint - const epochResponse = await fetch(epoch) - if (!epochResponse.ok) - throw new Error("Network response from Beaconcha.in EPOCH was not ok") - const epochResponseJson: EpochResponse = await epochResponse.json() - const { - data: { validatorscount, eligibleether: eligibleGwei }, - } = epochResponseJson - - const totalEthStaked = Math.floor(eligibleGwei * 1e-9) - - return { totalEthStaked, validatorscount, apr } -} +import { fetchBeaconchainEpoch } from "@/lib/api/fetchBeaconchainEpoch" +import { fetchBeaconchainEthstore } from "@/lib/api/fetchBeaconchainEthstore" // In seconds const REVALIDATE_TIME = BASE_TIME_UNIT * 1 const loadData = dataLoader( - [["stakingStatsData", fetchBeaconchainData]], + [ + ["beaconchainEpoch", fetchBeaconchainEpoch], + ["beaconchainApr", fetchBeaconchainEthstore], + ], REVALIDATE_TIME * 1000 ) @@ -67,7 +38,13 @@ const Page = async ({ params }: { params: Promise<{ locale: Lang }> }) => { setRequestLocale(locale) - const [data] = await loadData() + const [{ totalEthStaked, validatorscount }, apr] = await loadData() + + const data: StakingStatsData = { + totalEthStaked: "value" in totalEthStaked ? totalEthStaked.value : 0, + validatorscount: "value" in validatorscount ? validatorscount.value : 0, + apr: "value" in apr ? apr.value : 0, + } // Get i18n messages const allMessages = await getMessages({ locale }) diff --git a/netlify.toml b/netlify.toml index ed323ce2e98..66367090c2d 100644 --- a/netlify.toml +++ b/netlify.toml @@ -39,4 +39,4 @@ path = "en/developers/tutorials/creating-a-wagmi-ui-for-your-contract/" [functions] - included_files = ["i18n.config.json", "src/intl/**/*", "src/data/mocks/**/*"] \ No newline at end of file + included_files = ["i18n.config.json", "src/intl/**/*", "src/data/mocks/**/*"] diff --git a/src/components/Staking/StakingStatsBox.tsx b/src/components/Staking/StakingStatsBox.tsx index 91b722bcf9f..2aa803b4e9e 100644 --- a/src/components/Staking/StakingStatsBox.tsx +++ b/src/components/Staking/StakingStatsBox.tsx @@ -47,7 +47,9 @@ const StakingStatsBox = ({ data }: StakingStatsBoxProps) => { // Helper functions const formatInteger = (amount: number): string => - new Intl.NumberFormat(localeForStatsBoxNumbers).format(amount) + amount + ? new Intl.NumberFormat(localeForStatsBoxNumbers).format(amount) + : "—" const formatPercentage = (amount: number): string => new Intl.NumberFormat(localeForStatsBoxNumbers, { diff --git a/src/data/mocks/beaconchainApr.json b/src/data/mocks/beaconchainApr.json new file mode 100644 index 00000000000..1fdfd185287 --- /dev/null +++ b/src/data/mocks/beaconchainApr.json @@ -0,0 +1 @@ +{ "value": 0.025, "timestamp": 1759687080325 } diff --git a/src/data/mocks/beaconchainEpoch.json b/src/data/mocks/beaconchainEpoch.json new file mode 100644 index 00000000000..b5022fcaacc --- /dev/null +++ b/src/data/mocks/beaconchainEpoch.json @@ -0,0 +1,4 @@ +{ + "totalEthStaked": { "value": 35000000, "timestamp": 1759687080325 }, + "validatorscount": { "value": 1000000, "timestamp": 1759687080325 } +} diff --git a/src/data/mocks/ethPrice.json b/src/data/mocks/ethPrice.json index 54588d0cbe4..d8eb75a4f2b 100644 --- a/src/data/mocks/ethPrice.json +++ b/src/data/mocks/ethPrice.json @@ -1 +1 @@ -{"value":2651.16,"timestamp":1727788458138} \ No newline at end of file +{ "value": 4000, "timestamp": 1759687080325 } diff --git a/src/data/mocks/totalEthStaked.json b/src/data/mocks/totalEthStaked.json index f41de111183..6e8e4037e76 100644 --- a/src/data/mocks/totalEthStaked.json +++ b/src/data/mocks/totalEthStaked.json @@ -1 +1 @@ -{"value":34649295.539315075,"timestamp":1727788458553} \ No newline at end of file +{ "value": 35000000, "timestamp": 1759687080325 } diff --git a/src/lib/api/fetchBeaconchainEpoch.ts b/src/lib/api/fetchBeaconchainEpoch.ts new file mode 100644 index 00000000000..fb908d211ed --- /dev/null +++ b/src/lib/api/fetchBeaconchainEpoch.ts @@ -0,0 +1,62 @@ +import type { BeaconchainEpochData, EpochResponse } from "@/lib/types" + +import { MAX_RETRIES } from "../constants" +import { + delayWithJitter, + fetchWithTimeoutAndRevalidation, + shouldStatusRetry, + sleep, +} from "../utils/data/utils" + +export const fetchBeaconchainEpoch = + async (): Promise => { + const base = "https://beaconcha.in" + const endpoint = "api/v1/epoch/latest" + const { href } = new URL(endpoint, base) + + const defaultErrorMessage = `Failed to fetch Beaconcha.in ${endpoint}` + const defaultError: BeaconchainEpochData = { + totalEthStaked: { error: defaultErrorMessage }, + validatorscount: { error: defaultErrorMessage }, + } + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + const response = await fetchWithTimeoutAndRevalidation(href) + if (!response.ok) { + const status = response.status + const shouldRetry = attempt < MAX_RETRIES && shouldStatusRetry(status) + if (shouldRetry) { + await sleep(delayWithJitter()) + continue + } + console.warn("Beaconcha.in fetch non-OK", { status, url: href }) + const error = `Beaconcha.in responded with status ${status}` + return { totalEthStaked: { error }, validatorscount: { 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() + return { + totalEthStaked: { value: totalEthStaked, timestamp }, + validatorscount: { value: validatorscount, timestamp }, + } + } catch (err: unknown) { + const isLastAttempt = attempt >= MAX_RETRIES + if (isLastAttempt) { + console.error("Beaconcha.in fetch failed", { + name: err instanceof Error ? err.name : undefined, + message: err instanceof Error ? err.message : String(err), + url: href, + }) + return defaultError + } + await sleep(delayWithJitter()) + } + } + + return defaultError + } + +export default fetchBeaconchainEpoch diff --git a/src/lib/api/fetchBeaconchainEthstore.ts b/src/lib/api/fetchBeaconchainEthstore.ts new file mode 100644 index 00000000000..3efd90e66ce --- /dev/null +++ b/src/lib/api/fetchBeaconchainEthstore.ts @@ -0,0 +1,50 @@ +import type { EthStoreResponse, MetricReturnData } from "@/lib/types" + +import { MAX_RETRIES } from "../constants" +import { + delayWithJitter, + fetchWithTimeoutAndRevalidation, + shouldStatusRetry, + sleep, +} from "../utils/data/utils" + +export const fetchBeaconchainEthstore = async (): Promise => { + const base = "https://beaconcha.in" + const endpoint = "api/v1/ethstore/latest" + const { href } = new URL(endpoint, base) + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + const response = await fetchWithTimeoutAndRevalidation(href) + if (!response.ok) { + const status = response.status + const shouldRetry = attempt < MAX_RETRIES && shouldStatusRetry(status) + if (shouldRetry) { + await sleep(delayWithJitter()) + continue + } + console.warn("Beaconcha.in fetch non-OK", { status, url: href }) + return { error: `Beaconcha.in responded with status ${status}` } + } + + const json: EthStoreResponse = await response.json() + const apr = json.data.apr + return { value: apr, timestamp: Date.now() } + } catch (err: unknown) { + const isLastAttempt = attempt >= MAX_RETRIES + if (isLastAttempt) { + console.error("Beaconcha.in fetch failed", { + name: err instanceof Error ? err.name : undefined, + message: err instanceof Error ? err.message : String(err), + url: href, + }) + return { error: `Failed to fetch Beaconcha.in ${endpoint}` } + } + await sleep(delayWithJitter()) + } + } + + return { error: "Failed to fetch Beaconcha.in ethstore" } +} + +export default fetchBeaconchainEthstore diff --git a/src/lib/api/fetchEthStakedBeaconchain.ts b/src/lib/api/fetchEthStakedBeaconchain.ts deleted file mode 100644 index 2e97d0d484c..00000000000 --- a/src/lib/api/fetchEthStakedBeaconchain.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { EpochResponse, MetricReturnData } from "@/lib/types" - -export const fetchEthStakedBeaconchain = - async (): Promise => { - // Fetch Beaconcha.in data - const base = "https://beaconcha.in" - const { href } = new URL("api/v1/epoch/latest", base) - - // Get total eligible ETH staked from latest epoch endpoint - const response = await fetch(href) - if (!response.ok) - throw new Error("Network response from Beaconcha.in EPOCH was not ok") - const json: EpochResponse = await response.json() - const { eligibleether: eligibleGwei } = json.data - - const totalEthStaked = Math.floor(eligibleGwei * 1e-9) - - return { value: totalEthStaked, timestamp: Date.now() } - } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 138e83582ad..8fabd31f8b1 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -45,9 +45,14 @@ export const COINGECKO_API_BASE_URL = "https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&category=" export const COINGECKO_API_URL_PARAMS = "&order=market_cap_desc&per_page=250&page=1&sparkline=false" -export const BASE_TIME_UNIT = 3600 // 1 hour export const COLOR_MODE_STORAGE_KEY = "theme" +// API timing +export const BASE_TIME_UNIT = 3600 // (seconds) 1 hour +export const TIMEOUT_MS = 5000 // (milliseconds) +export const MAX_RETRIES = 1 +export const RETRY_DELAY_BASE_MS = 250 // (milliseconds) + // Quiz Hub export const PROGRESS_BAR_GAP = "4px" export const PASSING_QUIZ_SCORE = 65 diff --git a/src/lib/types.ts b/src/lib/types.ts index 1d7d2ee8985..778aa09ff2e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -519,10 +519,14 @@ export type EthStakedResponse = { } } -export type EpochResponse = Data<{ - validatorscount: number - eligibleether: number -}> +export type EpochResponse = Data< + Record<"eligibleether" | "validatorscount", number> +> + +export type BeaconchainEpochData = Record< + "totalEthStaked" | "validatorscount", + MetricReturnData +> export type StakingStatsData = { totalEthStaked: number diff --git a/src/lib/utils/data/utils.ts b/src/lib/utils/data/utils.ts new file mode 100644 index 00000000000..cec5521bf69 --- /dev/null +++ b/src/lib/utils/data/utils.ts @@ -0,0 +1,66 @@ +import { + BASE_TIME_UNIT, + RETRY_DELAY_BASE_MS, + TIMEOUT_MS, +} from "@/lib/constants" + +/** + * Returns a delay time in milliseconds by adding a random jitter to the base delay. + * + * @param ms - The base delay in milliseconds. Defaults to `RETRY_DELAY_BASE_MS`. + * @param jitterMs - The maximum jitter in milliseconds to add to the base delay. Defaults to `RETRY_DELAY_BASE_MS`. + * @returns The total delay in milliseconds, including a random jitter. + */ +export const delayWithJitter = ( + ms: number = RETRY_DELAY_BASE_MS, + jitterMs: number = RETRY_DELAY_BASE_MS +) => ms + Math.floor(Math.random() * jitterMs) + +/** + * Delays execution for a specified number of milliseconds. + * + * @param ms - The number of milliseconds to sleep. + * @returns A promise that resolves after the specified delay. + */ +export const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)) + +/** + * Determines whether a request should be retried based on the HTTP status code. + * + * Retries are recommended for: + * - 429 (Too Many Requests) + * - 5xx server errors (status codes between 500 and 599, inclusive) + * + * @param status - The HTTP status code to evaluate. + * @returns `true` if the request should be retried, otherwise `false`. + */ +export const shouldStatusRetry = (status: number) => + status === 429 || (status >= 500 && status <= 599) + +/** + * Fetches a resource with a specified timeout and optional revalidation. + * + * Initiates a fetch request to the provided URL or Request object, aborting the request if it exceeds the given delay. + * Optionally sets the `next.revalidate` property for caching behavior. + * + * @param href - The resource to fetch. Can be a string URL, a URL object, or a Request object. + * @param delay - The timeout in milliseconds before aborting the request. Defaults to `TIMEOUT_MS`. + * @param revalidate - The revalidation time in seconds or `false` to disable revalidation. Defaults to `BASE_TIME_UNIT`. + * @returns A promise that resolves to the fetch response. + */ +export const fetchWithTimeoutAndRevalidation = async ( + href: string | URL | globalThis.Request, + delay: number = TIMEOUT_MS, + revalidate: number | false = BASE_TIME_UNIT +) => { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), delay) + try { + return await fetch(href, { + signal: controller.signal, + next: { revalidate }, + }) + } finally { + clearTimeout(timeout) + } +}