diff --git a/.changeset/sweet-beans-speak.md b/.changeset/sweet-beans-speak.md new file mode 100644 index 000000000..ca095446e --- /dev/null +++ b/.changeset/sweet-beans-speak.md @@ -0,0 +1,5 @@ +--- +"@blobscan/web": minor +--- + +Get environment variables from API endpoint. diff --git a/.env.example b/.env.example index 2553fd6a7..877df11bf 100644 --- a/.env.example +++ b/.env.example @@ -42,10 +42,10 @@ NEXTAUTH_URL=http://localhost:3000 # You can generate the secret via 'openssl rand -base64 32' on Unix # @see https://next-auth.js.org/configuration/options#secret SECRET_KEY=supersecret -NEXT_PUBLIC_NETWORK_NAME=mainnet +PUBLIC_NETWORK_NAME=mainnet NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED=false -NEXT_PUBLIC_BEACON_BASE_URL=https://dora.ethpandaops.io/ -NEXT_PUBLIC_EXPLORER_BASE_URL=https://etherscan.io/ +PUBLIC_BEACON_BASE_URL=https://dora.ethpandaops.io/ +PUBLIC_EXPLORER_BASE_URL=https://etherscan.io/ #### rest api server diff --git a/apps/docs/src/app/docs/environment/page.md b/apps/docs/src/app/docs/environment/page.md index 5d1dec3a4..6123d8d4d 100644 --- a/apps/docs/src/app/docs/environment/page.md +++ b/apps/docs/src/app/docs/environment/page.md @@ -12,20 +12,20 @@ nextjs: | -------------------------------------- | ------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `DATABASE_URL` | PostgreSQL database URI | Yes | (empty) | | `FEEDBACK_WEBHOOK_URL` | Discord webhook URL for feedback | No | (empty) | -| `NEXT_PUBLIC_NETWORK_NAME` | Network name | No | mainnet | -| `NEXT_PUBLIC_EXPLORER_BASE_URL` | Block explorer URL | No | `https://etherscan.io` | -| `NEXT_PUBLIC_BEACON_BASE_URL` | Beacon explorer URL | No | `https://beaconcha.in/` | +| `PUBLIC_NETWORK_NAME` | Network name | No | mainnet | +| `PUBLIC_EXPLORER_BASE_URL` | Block explorer URL | No | `https://etherscan.io` | +| `PUBLIC_BEACON_BASE_URL` | Beacon explorer URL | No | `https://beaconcha.in/` | | `NEXT_PUBLIC_BLOBSCAN_RELEASE` | Blobscan version | No | (empty) | -| `NEXT_PUBLIC_SUPPORTED_NETWORKS` | Link to other pages from the Network menu | No | `[{"label":"Ethereum Mainnet","href":"https://blobscan.com/"},{"label":"Gnosis","href":"https://gnosis.blobscan.com/"},{"label":"Holesky Testnet","href":"https://holesky.blobscan.com/"},{"label":"Sepolia Testnet","href":"https://sepolia.blobscan.com/"}]` | +| `PUBLIC_SUPPORTED_NETWORKS` | Link to other pages from the Network menu | No | `[{"label":"Ethereum Mainnet","href":"https://blobscan.com/"},{"label":"Gnosis","href":"https://gnosis.blobscan.com/"},{"label":"Holesky Testnet","href":"https://holesky.blobscan.com/"},{"label":"Sepolia Testnet","href":"https://sepolia.blobscan.com/"}]` | | `NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED` | Enable Vercel analytics | No | `false` | -| `NEXT_PUBLIC_SENTRY_DSN_WEB` | Sentry DSN | No | (empty) | +| `PUBLIC_SENTRY_DSN_WEB` | Sentry DSN | No | (empty) | | `NODE_ENV` | Used in Node.js applications to specify the environment in which the application is running | No | (empty) | | `SENTRY_PROJECT` | Sentry project name | No | (empty) | | `SENTRY_ORG` | Sentry organization | No | (empty) | | `METRICS_ENABLED` | Expose the /metrics endpoint | No | `false` | | `TRACES_ENABLED` | Enable instrumentation of functions and sending traces to a collector | No | `false` | -| `NEXT_PUBLIC_POSTHOG_ID` | PostHog project API key used for tracking events and analytics | No | (empty) | -| `NEXT_PUBLIC_POSTHOG_HOST` | Host URL for the PostHog instance used for analytics tracking | No | `https://us.i.posthog.com` | +| `PUBLIC_POSTHOG_ID` | PostHog project API key used for tracking events and analytics | No | (empty) | +| `PUBLIC_POSTHOG_HOST` | Host URL for the PostHog instance used for analytics tracking | No | `https://us.i.posthog.com` | ## Blobscan API diff --git a/apps/web/banner.mjs b/apps/web/banner.mjs index 2f5f6bb01..487467724 100644 --- a/apps/web/banner.mjs +++ b/apps/web/banner.mjs @@ -11,14 +11,13 @@ export function printBanner() { console.log("Blobscan Web App (EIP-4844 blob explorer) - blobscan.com"); console.log("=======================================================\n"); console.log( - `Configuration: network=${process.env.NEXT_PUBLIC_NETWORK_NAME} explorer=${ - process.env.NEXT_PUBLIC_EXPLORER_BASE_URL + `Configuration: network=${process.env.PUBLIC_NETWORK_NAME} explorer=${ + process.env.PUBLIC_EXPLORER_BASE_URL } beaconExplorer=${ - process.env.NEXT_PUBLIC_BEACON_BASE_URL + process.env.PUBLIC_BEACON_BASE_URL } feedbackEnabled=${!!process.env .FEEDBACK_WEBHOOK_URL} tracesEnabled=${!!process.env - .TRACES_ENABLED} sentryEnabled=${!!process.env - .NEXT_PUBLIC_SENTRY_DSN_WEB}` + .TRACES_ENABLED} sentryEnabled=${!!process.env.PUBLIC_SENTRY_DSN_WEB}` ); console.log( `Blob storage manager configuration: chainId=${process.env.CHAIN_ID}, file_system=${process.env.FILE_SYSTEM_STORAGE_ENABLED} postgres=${process.env.POSTGRES_STORAGE_ENABLED}, gcs=${process.env.GOOGLE_STORAGE_ENABLED}, swarm=${process.env.SWARM_STORAGE_ENABLED}` @@ -35,9 +34,7 @@ export function printBanner() { } if (process.env.SWARM_STORAGE_ENABLED) { - console.log( - `Swarm configuration: beeEndpoint=${process.env.BEE_ENDPOINT}` - ); + console.log(`Swarm configuration: beeEndpoint=${process.env.BEE_ENDPOINT}`); } if (process.env.FILE_SYSTEM_STORAGE_ENABLED) { diff --git a/apps/web/package.json b/apps/web/package.json index 4ed1a3836..a46ed43e9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,7 +20,6 @@ "@blobscan/dates": "workspace:*", "@blobscan/dayjs": "workspace:^0.1.0", "@blobscan/db": "workspace:^0.13.0", - "@blobscan/env": "workspace:^0.1.0", "@blobscan/eth-format": "workspace:^0.1.0", "@blobscan/open-telemetry": "workspace:^0.0.9", "@blobscan/rollups": "workspace:^0.2.2", diff --git a/apps/web/sentry.client.config.ts b/apps/web/sentry.client.config.ts index 0627d1db4..c67311727 100644 --- a/apps/web/sentry.client.config.ts +++ b/apps/web/sentry.client.config.ts @@ -3,12 +3,29 @@ // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from "@sentry/nextjs"; +import type { z } from "zod"; -import { env } from "./src/env.mjs"; +import type { clientEnvVarsSchema } from "~/env.mjs"; -Sentry.init({ - dsn: env.NEXT_PUBLIC_SENTRY_DSN_WEB, - environment: env.NEXT_PUBLIC_NETWORK_NAME, - tracesSampleRate: 1, - debug: false, -}); +type ClientEnvVars = z.output; + +const initSentry = async () => { + try { + const request = await fetch("/api/env"); + const env = (await request.json()) as ClientEnvVars; + + const dns = env.PUBLIC_SENTRY_DSN_WEB; + const environment = env.PUBLIC_NETWORK_NAME; + + Sentry.init({ + dsn: dns, + environment, + tracesSampleRate: 1, + debug: false, + }); + } catch (error) { + console.error("Error during Sentry initialization", error); + } +}; + +initSentry(); diff --git a/apps/web/sentry.edge.config.ts b/apps/web/sentry.edge.config.ts index c552e322b..eb4d0e0cf 100644 --- a/apps/web/sentry.edge.config.ts +++ b/apps/web/sentry.edge.config.ts @@ -8,8 +8,8 @@ import * as Sentry from "@sentry/nextjs"; import { env } from "./src/env.mjs"; Sentry.init({ - dsn: env.NEXT_PUBLIC_SENTRY_DSN_WEB, - environment: env.NEXT_PUBLIC_NETWORK_NAME, + dsn: env.PUBLIC_SENTRY_DSN_WEB, + environment: env.PUBLIC_NETWORK_NAME, tracesSampleRate: 1, debug: false, }); diff --git a/apps/web/sentry.server.config.ts b/apps/web/sentry.server.config.ts index 48eaca44b..b1b96cd8b 100644 --- a/apps/web/sentry.server.config.ts +++ b/apps/web/sentry.server.config.ts @@ -7,8 +7,8 @@ import * as Sentry from "@sentry/nextjs"; import { env } from "./src/env.mjs"; Sentry.init({ - dsn: env.NEXT_PUBLIC_SENTRY_DSN_WEB, - environment: env.NEXT_PUBLIC_NETWORK_NAME, + dsn: env.PUBLIC_SENTRY_DSN_WEB, + environment: env.PUBLIC_NETWORK_NAME, tracesSampleRate: 1, debug: false, }); diff --git a/apps/web/src/components/BlobscanVersionInfo.tsx b/apps/web/src/components/BlobscanVersionInfo.tsx index ab9a63efd..5b6325dea 100644 --- a/apps/web/src/components/BlobscanVersionInfo.tsx +++ b/apps/web/src/components/BlobscanVersionInfo.tsx @@ -1,30 +1,20 @@ import { env } from "~/env.mjs"; import { Link } from "./Link"; -function getVersionData(): { url: string; label: string } { +export const BlobscanVersionInfo: React.FC = () => { + let url = "https://github.com/Blobscan/blobscan/"; + let label = "Development"; + if (env.NEXT_PUBLIC_BLOBSCAN_RELEASE) { - return { - url: `https://github.com/Blobscan/blobscan/releases/tag/${env.NEXT_PUBLIC_BLOBSCAN_RELEASE}`, - label: env.NEXT_PUBLIC_BLOBSCAN_RELEASE, - }; + url = `https://github.com/Blobscan/blobscan/releases/tag/${env.NEXT_PUBLIC_BLOBSCAN_RELEASE}`; + label = env.NEXT_PUBLIC_BLOBSCAN_RELEASE; } if (env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA) { - return { - url: `https://github.com/Blobscan/blobscan/commit/${env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA}`, - label: env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA.slice(0, 7), - }; + url = `https://github.com/Blobscan/blobscan/commit/${env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA}`; + label = env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA.slice(0, 7); } - return { - url: "https://github.com/Blobscan/blobscan/", - label: "Development", - }; -} - -export const BlobscanVersionInfo: React.FC = () => { - const { url, label } = getVersionData(); - return (
diff --git a/apps/web/src/components/ExplorerDetails.tsx b/apps/web/src/components/ExplorerDetails.tsx index 3c0cd051c..0cb2dde12 100644 --- a/apps/web/src/components/ExplorerDetails.tsx +++ b/apps/web/src/components/ExplorerDetails.tsx @@ -7,8 +7,8 @@ import Skeleton from "react-loading-skeleton"; import { formatTtl } from "@blobscan/dates"; import { api } from "~/api-client"; -import { env } from "~/env.mjs"; import Gas from "~/icons/gas.svg"; +import { useEnv } from "~/providers/Env"; import { capitalize, formatNumber } from "~/utils"; import { EtherUnitDisplay } from "./Displays/EtherUnitDisplay"; @@ -45,11 +45,20 @@ export function ExplorerDetails({ placement }: ExplorerDetailsProps) { const { data: blobStoragesState } = api.blobStoragesState.getState.useQuery(); const { data: latestBlock } = api.block.getLatestBlock.useQuery(); + const { env } = useEnv(); + const explorerDetailsItems: ExplorerDetailsItemProps[] = []; if (placement === "top") { explorerDetailsItems.push( - { name: "Network", value: capitalize(env.NEXT_PUBLIC_NETWORK_NAME) }, + { + name: "Network", + value: env ? ( + capitalize(env.PUBLIC_NETWORK_NAME) + ) : ( + + ), + }, { name: "Blob gas price", icon: , diff --git a/apps/web/src/components/Filters/RollupFilter.tsx b/apps/web/src/components/Filters/RollupFilter.tsx index f22657a13..38e67c30c 100644 --- a/apps/web/src/components/Filters/RollupFilter.tsx +++ b/apps/web/src/components/Filters/RollupFilter.tsx @@ -1,44 +1,22 @@ import { useRef } from "react"; import type { FC } from "react"; -import { getChainRollups } from "@blobscan/rollups"; - import { Dropdown } from "~/components/Dropdown"; import type { DropdownProps, Option } from "~/components/Dropdown"; -import { RollupIcon } from "~/components/RollupIcon"; -import { env } from "~/env.mjs"; -import type { Rollup } from "~/types"; -import { capitalize, getChainIdByName } from "~/utils"; -import { RollupBadge } from "../Badges/RollupBadge"; -type RollupFilterProps = Pick & { +type RollupFilterProps = Pick< + DropdownProps, + "selected" | "disabled" | "options" +> & { onChange(newRollups: Option[]): void; selected: Option[] | null; }; -const chainId = getChainIdByName(env.NEXT_PUBLIC_NETWORK_NAME); -const rollups = chainId ? getChainRollups(chainId) : []; - -export const ROLLUP_OPTIONS = rollups.map( - ([name, addresses]) => - ({ - value: addresses, - selectedLabel: ( - - ), - label: ( -
- -
{capitalize(name)}
-
- ), - } satisfies Option) -) satisfies Option[]; - export const RollupFilter: FC = function ({ onChange, selected, disabled, + options, }) { const noneIsSelected = useRef(false); @@ -66,7 +44,7 @@ export const RollupFilter: FC = function ({ return ( { + const chainId = env && getChainIdByName(env.PUBLIC_NETWORK_NAME); + const rollups = chainId ? getChainRollups(chainId) : []; + + return rollups.map( + ([name, addresses]) => + ({ + value: addresses, + selectedLabel: ( + + ), + label: ( +
+ +
{capitalize(name)}
+
+ ), + } satisfies Option) + ); + }, [env]); + useEffect(() => { const { sort } = queryParams.paginationParams; const { @@ -177,7 +205,7 @@ export const Filters: FC = function () { const newFilters: Partial = {}; if (from) { - const rollupOptions = ROLLUP_OPTIONS.filter((opt) => { + const rollupOptions_ = rollupOptions.filter((opt) => { const fromAddresses = from?.split(FROM_ADDRESSES_FORMAT_SEPARATOR); const rollupOptionAddresses = Array.isArray(opt.value) ? opt.value @@ -185,13 +213,13 @@ export const Filters: FC = function () { return ( rollupOptionAddresses.filter((rollupAddress) => - fromAddresses?.includes(rollupAddress) + fromAddresses?.includes(rollupAddress as string) ).length > 0 ); }); - if (rollupOptions) { - newFilters.rollups = rollupOptions; + if (rollupOptions_) { + newFilters.rollups = rollupOptions_; } } @@ -225,7 +253,7 @@ export const Filters: FC = function () { } dispatch({ type: "UPDATE", payload: newFilters }); - }, [queryParams]); + }, [queryParams, rollupOptions]); return ( @@ -265,6 +293,7 @@ export const Filters: FC = function () {
{ + const { env } = useEnv(); + + const navigationItems = useMemo(() => { + const networkName = env ? env.PUBLIC_NETWORK_NAME : undefined; + const publicSupportedNetworks = env + ? env.PUBLIC_SUPPORTED_NETWORKS + : undefined; + + return networkName && publicSupportedNetworks + ? getNavigationItems(networkName, publicSupportedNetworks) + : undefined; + }, [env]); + + if (!navigationItems) { + return ; + } + return (
- {NAVIGATION_ITEMS.map((item) => + {navigationItems.map((item) => isExpandibleNavigationItem(item) ? ( ) : ( diff --git a/apps/web/src/components/SidebarNavigationMenu.tsx b/apps/web/src/components/SidebarNavigationMenu.tsx index cdf24e0d3..8b38574f1 100644 --- a/apps/web/src/components/SidebarNavigationMenu.tsx +++ b/apps/web/src/components/SidebarNavigationMenu.tsx @@ -1,10 +1,12 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { ChevronDownIcon } from "@heroicons/react/24/outline"; import { Bars3Icon } from "@heroicons/react/24/solid"; import cn from "classnames"; +import Skeleton from "react-loading-skeleton"; +import { useEnv } from "~/providers/Env"; import { BlobscanLogo } from "./BlobscanLogo"; import { Collapsable } from "./Collapsable"; import { IconButton } from "./IconButton"; @@ -12,7 +14,7 @@ import { Rotable } from "./Rotable"; import { SidePanel, useSidePanel } from "./SidePanel"; import { ThemeModeButton } from "./ThemeModeButton"; import type { ExpandibleNavigationItem, NavigationItem } from "./content"; -import { isExpandibleNavigationItem, NAVIGATION_ITEMS } from "./content"; +import { isExpandibleNavigationItem, getNavigationItems } from "./content"; export function SidebarNavigationMenu({ className }: { className?: string }) { const [open, setOpen] = useState(false); @@ -21,6 +23,18 @@ export function SidebarNavigationMenu({ className }: { className?: string }) { const closeSidebar = useCallback(() => setOpen(false), []); + const { env } = useEnv(); + + const navigationItems = useMemo(() => { + const networkName = env ? env.PUBLIC_NETWORK_NAME : undefined; + const publicSupportedNetworks = env + ? env.PUBLIC_SUPPORTED_NETWORKS + : undefined; + return networkName && publicSupportedNetworks + ? getNavigationItems(networkName, publicSupportedNetworks) + : undefined; + }, [env]); + return (
@@ -30,20 +44,24 @@ export function SidebarNavigationMenu({ className }: { className?: string }) {
- {NAVIGATION_ITEMS.map((item, i) => - isExpandibleNavigationItem(item) ? ( - - ) : ( - + {navigationItems ? ( + navigationItems.map((item, i) => + isExpandibleNavigationItem(item) ? ( + + ) : ( + + ) ) + ) : ( + )}
diff --git a/apps/web/src/components/content.tsx b/apps/web/src/components/content.tsx index e7de44735..10333e27e 100644 --- a/apps/web/src/components/content.tsx +++ b/apps/web/src/components/content.tsx @@ -6,7 +6,6 @@ import { Squares2X2Icon, } from "@heroicons/react/24/solid"; -import { env } from "~/env.mjs"; import EthereumIcon from "~/icons/ethereum.svg"; import { buildBlocksRoute, @@ -15,17 +14,15 @@ import { buildAllStatsRoute, } from "~/utils"; -function resolveApiUrl(): string { - if (env.NEXT_PUBLIC_NETWORK_NAME === "mainnet") { +function resolveApiUrl(networkName: string): string { + if (networkName === "mainnet") { return "https://api.blobscan.com"; } - return `https://api.${env.NEXT_PUBLIC_NETWORK_NAME}.blobscan.com`; + return `https://api.${networkName}.blobscan.com`; } -type Network = typeof env.NEXT_PUBLIC_NETWORK_NAME; - -const NETWORKS_FIRST_BLOB_NUMBER: Record = { +const NETWORKS_FIRST_BLOB_NUMBER: Record = { mainnet: 19426589, holesky: 894735, sepolia: 5187052, @@ -34,8 +31,8 @@ const NETWORKS_FIRST_BLOB_NUMBER: Record = { devnet: 0, }; -export function getFirstBlobNumber(): number { - return NETWORKS_FIRST_BLOB_NUMBER[env.NEXT_PUBLIC_NETWORK_NAME]; +export function getFirstBlobNumber(networkName: string): number | undefined { + return NETWORKS_FIRST_BLOB_NUMBER[networkName]; } export type NavigationItem = { @@ -61,45 +58,48 @@ export function isExpandibleNavigationItem( return typeof item === "object" && item !== null && "items" in item; } -export const NAVIGATION_ITEMS: Array< - NavigationItem | ExpandibleNavigationItem -> = [ - { - label: "Blockchain", - icon: , - items: [ - { - label: "Blobs", - href: buildBlobsRoute(), - }, - { - label: "Blocks", - href: buildBlocksRoute(), - }, - { - label: "Transactions", - href: buildTransactionsRoute(), - }, - ], - }, - { - label: "Networks", - icon: , - items: JSON.parse(env.NEXT_PUBLIC_SUPPORTED_NETWORKS || "[]"), - }, - { - label: "Stats", - icon: , - href: buildAllStatsRoute(), - }, - { - label: "API", - icon: , - href: resolveApiUrl(), - }, - { - label: "Docs", - icon: , - href: "https://docs.blobscan.com", - }, -]; +export const getNavigationItems = ( + networkName: string, + publicSupportedNetworks: string +): Array => { + return [ + { + label: "Blockchain", + icon: , + items: [ + { + label: "Blobs", + href: buildBlobsRoute(), + }, + { + label: "Blocks", + href: buildBlocksRoute(), + }, + { + label: "Transactions", + href: buildTransactionsRoute(), + }, + ], + }, + { + label: "Networks", + icon: , + items: JSON.parse(publicSupportedNetworks || "[]"), + }, + { + label: "Stats", + icon: , + href: buildAllStatsRoute(), + }, + { + label: "API", + icon: , + href: resolveApiUrl(networkName), + }, + { + label: "Docs", + icon: , + href: "https://docs.blobscan.com", + }, + ]; +}; diff --git a/apps/web/src/env.mjs b/apps/web/src/env.mjs index 0e6d34f9a..2d99e65e1 100644 --- a/apps/web/src/env.mjs +++ b/apps/web/src/env.mjs @@ -1,13 +1,6 @@ import { createEnv } from "@t3-oss/env-nextjs"; import { z } from "zod"; -// See booleanSchema from packages/zod/src/schemas.ts -// We need to redefine it because we can't import ts files from here -const booleanSchema = z - .string() - .refine((s) => s === "true" || s === "false") - .transform((s) => s === "true"); - const networkSchema = z.enum([ "mainnet", "holesky", @@ -17,6 +10,30 @@ const networkSchema = z.enum([ "devnet", ]); +// See booleanSchema from packages/zod/src/schemas.ts +// We need to redefine it because we can't import ts files from here +const booleanSchema = z + .string() + .refine((s) => s === "true" || s === "false") + .transform((s) => s === "true"); + +const clientEnvVars = { + PUBLIC_BEACON_BASE_URL: z.string().url().default("https://beaconcha.in"), + PUBLIC_EXPLORER_BASE_URL: z.string().url().default("https://etherscan.io"), + PUBLIC_NETWORK_NAME: networkSchema.default("mainnet"), + PUBLIC_SENTRY_DSN_WEB: z.string().url().optional(), + PUBLIC_POSTHOG_ID: z.string().optional(), + PUBLIC_POSTHOG_HOST: z.string().default("https://us.i.posthog.com"), + PUBLIC_SUPPORTED_NETWORKS: z + .string() + .default( + '[{"label":"Ethereum Mainnet","href":"https://blobscan.com/"},{"label":"Gnosis","href":"https://gnosis.blobscan.com/"},{"label":"Holesky Testnet","href":"https://holesky.blobscan.com/"},{"label":"Sepolia Testnet","href":"https://sepolia.blobscan.com/"}]' + ), + PUBLIC_VERCEL_ANALYTICS_ENABLED: booleanSchema.default("false"), +}; + +export const clientEnvVarsSchema = z.object(clientEnvVars); + export const env = createEnv({ /** * Specify your server-side environment variables schema here. This way you can ensure the app isn't @@ -28,32 +45,11 @@ export const env = createEnv({ NODE_ENV: z.enum(["development", "test", "production"]), METRICS_ENABLED: booleanSchema.default("false"), TRACES_ENABLED: booleanSchema.default("false"), + ...clientEnvVars, }, - /** - * Specify your client-side environment variables schema here. - * For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`. - */ client: { - NEXT_PUBLIC_BEACON_BASE_URL: z - .string() - .url() - .default("https://beaconcha.in/"), - NEXT_PUBLIC_BLOBSCAN_RELEASE: z.string().optional(), - NEXT_PUBLIC_EXPLORER_BASE_URL: z - .string() - .url() - .default("https://etherscan.io/"), - NEXT_PUBLIC_NETWORK_NAME: networkSchema.default("mainnet"), - NEXT_PUBLIC_SENTRY_DSN_WEB: z.string().url().optional(), - NEXT_PUBLIC_POSTHOG_ID: z.string().optional(), - NEXT_PUBLIC_POSTHOG_HOST: z.string().default("https://us.i.posthog.com"), - NEXT_PUBLIC_SUPPORTED_NETWORKS: z - .string() - .default( - '[{"label":"Ethereum Mainnet","href":"https://blobscan.com/"},{"label":"Gnosis","href":"https://gnosis.blobscan.com/"},{"label":"Holesky Testnet","href":"https://holesky.blobscan.com/"},{"label":"Sepolia Testnet","href":"https://sepolia.blobscan.com/"}]' - ), - NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED: booleanSchema.default("false"), NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA: z.string().optional(), + NEXT_PUBLIC_BLOBSCAN_RELEASE: z.string().optional(), }, /** * Destructure all variables from `process.env` to make sure they aren't tree-shaken away. @@ -65,16 +61,17 @@ export const env = createEnv({ NODE_ENV: process.env.NODE_ENV, TRACES_ENABLED: process.env.TRACES_ENABLED, - NEXT_PUBLIC_BLOBSCAN_RELEASE: process.env.NEXT_PUBLIC_BLOBSCAN_RELEASE, - NEXT_PUBLIC_BEACON_BASE_URL: process.env.NEXT_PUBLIC_BEACON_BASE_URL, - NEXT_PUBLIC_EXPLORER_BASE_URL: process.env.NEXT_PUBLIC_EXPLORER_BASE_URL, - NEXT_PUBLIC_NETWORK_NAME: process.env.NEXT_PUBLIC_NETWORK_NAME, - NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, - NEXT_PUBLIC_POSTHOG_ID: process.env.NEXT_PUBLIC_POSTHOG_ID, - NEXT_PUBLIC_SENTRY_DSN_WEB: process.env.NEXT_PUBLIC_SENTRY_DSN_WEB, - NEXT_PUBLIC_SUPPORTED_NETWORKS: process.env.NEXT_PUBLIC_SUPPORTED_NETWORKS, - NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED: + PUBLIC_BEACON_BASE_URL: process.env.PUBLIC_BEACON_BASE_URL, + PUBLIC_EXPLORER_BASE_URL: process.env.PUBLIC_EXPLORER_BASE_URL, + PUBLIC_NETWORK_NAME: process.env.PUBLIC_NETWORK_NAME, + PUBLIC_POSTHOG_HOST: process.env.PUBLIC_POSTHOG_HOST, + PUBLIC_POSTHOG_ID: process.env.PUBLIC_POSTHOG_ID, + PUBLIC_SENTRY_DSN_WEB: process.env.PUBLIC_SENTRY_DSN_WEB, + PUBLIC_SUPPORTED_NETWORKS: process.env.PUBLIC_SUPPORTED_NETWORKS, + PUBLIC_VERCEL_ANALYTICS_ENABLED: process.env.NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED, + + NEXT_PUBLIC_BLOBSCAN_RELEASE: process.env.NEXT_PUBLIC_BLOBSCAN_RELEASE, NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA, }, diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx index 17347bd03..bbbf47e29 100644 --- a/apps/web/src/pages/_app.tsx +++ b/apps/web/src/pages/_app.tsx @@ -19,26 +19,29 @@ import { SkeletonTheme } from "react-loading-skeleton"; import AppLayout from "~/components/AppLayout/AppLayout"; import { FeedbackWidget } from "~/components/FeedbackWidget/FeedbackWidget"; import { api } from "~/api-client"; -import { env } from "~/env.mjs"; import { useIsMounted } from "~/hooks/useIsMounted"; import { BlobDecoderWorkerProvider } from "~/providers/BlobDecoderWorker"; - -if (typeof window !== "undefined" && env.NEXT_PUBLIC_POSTHOG_ID) { - posthog.init(env.NEXT_PUBLIC_POSTHOG_ID, { - api_host: env.NEXT_PUBLIC_POSTHOG_HOST, - person_profiles: "identified_only", - loaded: (posthog) => { - if (window.location.hostname.includes("localhost")) { - posthog.debug(); - } - }, - }); -} +import { EnvProvider, useEnv } from "~/providers/Env"; function App({ Component, pageProps }: NextAppProps) { const { resolvedTheme } = useTheme(); const isMounted = useIsMounted(); const router = useRouter(); + const { env } = useEnv(); + + useEffect(() => { + if (typeof window !== "undefined" && !!env?.PUBLIC_POSTHOG_ID) { + posthog.init(env.PUBLIC_POSTHOG_ID, { + api_host: env.PUBLIC_POSTHOG_HOST, + person_profiles: "identified_only", + loaded: (posthog) => { + if (window.location.hostname.includes("localhost")) { + posthog.debug(); + } + }, + }); + } + }, [env]); useEffect(() => { const handleRouteChange = () => { @@ -85,7 +88,7 @@ function App({ Component, pageProps }: NextAppProps) { - {env.NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED && } + {env && env.PUBLIC_VERCEL_ANALYTICS_ENABLED && } ); @@ -95,7 +98,9 @@ function AppWrapper(props: NextAppProps) { return ( - + + + ); diff --git a/apps/web/src/pages/address/[address].tsx b/apps/web/src/pages/address/[address].tsx index b051bd26d..c5cf4db06 100644 --- a/apps/web/src/pages/address/[address].tsx +++ b/apps/web/src/pages/address/[address].tsx @@ -9,10 +9,12 @@ import { PaginatedListLayout } from "~/components/Layouts/PaginatedListLayout"; import { api } from "~/api-client"; import { useQueryParams } from "~/hooks/useQueryParams"; import NextError from "~/pages/_error"; +import { useEnv } from "~/providers/Env"; import type { TransactionWithExpandedBlockAndBlob } from "~/types"; -import { buildAddressExternalUrl, deserializeFullTransaction } from "~/utils"; +import { deserializeFullTransaction } from "~/utils"; const Address: NextPage = () => { + const { env } = useEnv(); const router = useRouter(); const { paginationParams } = useQueryParams(); const address = (router.query.address as string | undefined) ?? ""; @@ -50,7 +52,7 @@ const Address: NextPage = () => { <> key.startsWith("PUBLIC")) + ); + + return res.status(200).json(clientEnv); + } + + default: + return res.status(405).json({ error: "Method not allowed" }); + } +} diff --git a/apps/web/src/pages/block/[id].tsx b/apps/web/src/pages/block/[id].tsx index b963760d6..3cab548cd 100644 --- a/apps/web/src/pages/block/[id].tsx +++ b/apps/web/src/pages/block/[id].tsx @@ -16,11 +16,10 @@ import { BlockStatus } from "~/components/Status"; import { getFirstBlobNumber } from "~/components/content"; import { api } from "~/api-client"; import NextError from "~/pages/_error"; +import { useEnv } from "~/providers/Env"; import type { BlockWithExpandedBlobsAndTransactions } from "~/types"; import { BLOB_GAS_LIMIT_PER_BLOCK, - buildBlockExternalUrl, - buildSlotExternalUrl, deserializeFullBlock, formatBytes, formatNumber, @@ -56,6 +55,157 @@ const Block: NextPage = function () { const { data: latestBlock } = api.block.getLatestBlock.useQuery(); const blockNumber = blockData ? blockData.number : undefined; + const { env } = useEnv(); + const networkName = env ? env.PUBLIC_NETWORK_NAME : undefined; + + const detailsFields: DetailsLayoutProps["fields"] | undefined = + useMemo(() => { + if (blockData) { + const totalBlockBlobSize = blockData?.transactions.reduce( + (acc, { blobs }) => { + const totalBlobsSize = blobs.reduce( + (blobAcc, { size }) => blobAcc + size, + 0 + ); + + return acc + totalBlobsSize; + }, + 0 + ); + + const firstBlobNumber = networkName + ? getFirstBlobNumber(networkName) + : undefined; + + const previousBlockHref = + firstBlobNumber && blockNumber && firstBlobNumber < blockNumber + ? `/block_neighbor?blockNumber=${blockNumber}&direction=prev` + : undefined; + + return [ + { + name: "Block Height", + helpText: + "Also referred to as the Block Number, the block height represents the length of the blockchain and increases with each newly added block.", + value: ( +
+ {blockData.number} + {blockNumber !== undefined && previousBlockHref && ( + + )} +
+ ), + }, + { + name: "Status", + helpText: "The finality status of the block.", + value: , + }, + { + name: "Hash", + helpText: "The hash of the block header.", + value: , + }, + { + name: "Timestamp", + helpText: "The time at which the block was created.", + value: ( +
+ {formatTimestamp(blockData.timestamp)} +
+ ), + }, + { + name: "Slot", + helpText: "The slot number of the block.", + value: ( + + {blockData.slot} + + ), + }, + { + name: "Blob size", + helpText: "Total amount of space used for blobs in this block.", + value: ( +
+ {formatBytes(totalBlockBlobSize)} + + ({formatNumber(totalBlockBlobSize / GAS_PER_BLOB)}{" "} + {pluralize("blob", totalBlockBlobSize / GAS_PER_BLOB)}) + +
+ ), + }, + { + name: "Blob Gas Price", + helpText: + "The cost per unit of blob gas used by the blobs in this block.", + value: , + }, + { + name: "Blob Gas Used", + helpText: `The total blob gas used by the blobs in this block, along with its percentage relative to both the total blob gas limit and the blob gas target (${( + TARGET_BLOB_GAS_PER_BLOCK / 1024 + ).toFixed(0)} KB).`, + value: , + }, + { + name: "Blob Gas Limit", + helpText: "The maximum blob gas limit for this block.", + value: ( +
+ {formatNumber(BLOB_GAS_LIMIT_PER_BLOCK)} + + ({formatNumber(MAX_BLOBS_PER_BLOCK)}{" "} + {pluralize("blob", MAX_BLOBS_PER_BLOCK)} per block) + +
+ ), + }, + { + name: "Blob As Calldata Gas", + helpText: + "The total gas that would have been used in this block if the blobs were sent as calldata.", + value: ( +
+ {formatNumber(blockData.blobAsCalldataGasUsed)} + + ( + + {formatNumber( + performDiv( + blockData.blobAsCalldataGasUsed, + blockData.blobGasUsed + ), + "standard", + { maximumFractionDigits: 2 } + )} + {" "} + times more expensive) + +
+ ), + }, + ]; + } + }, [blockData, networkName, latestBlock, blockNumber, env]); + if (error) { return ( Block not found
; } - let detailsFields: DetailsLayoutProps["fields"] | undefined; - - if (blockData) { - const totalBlockBlobSize = blockData?.transactions.reduce( - (acc, { blobs }) => { - const totalBlobsSize = blobs.reduce( - (blobAcc, { size }) => blobAcc + size, - 0 - ); - - return acc + totalBlobsSize; - }, - 0 - ); - - detailsFields = [ - { - name: "Block Height", - helpText: - "Also referred to as the Block Number, the block height represents the length of the blockchain and increases with each newly added block.", - value: ( -
- {blockData.number} - {blockNumber !== undefined && ( - - )} -
- ), - }, - { - name: "Status", - helpText: "The finality status of the block.", - value: , - }, - { - name: "Hash", - helpText: "The hash of the block header.", - value: , - }, - { - name: "Timestamp", - helpText: "The time at which the block was created.", - value: ( -
- {formatTimestamp(blockData.timestamp)} -
- ), - }, - { - name: "Slot", - helpText: "The slot number of the block.", - value: ( - - {blockData.slot} - - ), - }, - { - name: "Blob size", - helpText: "Total amount of space used for blobs in this block.", - value: ( -
- {formatBytes(totalBlockBlobSize)} - - ({formatNumber(totalBlockBlobSize / GAS_PER_BLOB)}{" "} - {pluralize("blob", totalBlockBlobSize / GAS_PER_BLOB)}) - -
- ), - }, - { - name: "Blob Gas Price", - helpText: - "The cost per unit of blob gas used by the blobs in this block.", - value: , - }, - { - name: "Blob Gas Used", - helpText: `The total blob gas used by the blobs in this block, along with its percentage relative to both the total blob gas limit and the blob gas target (${( - TARGET_BLOB_GAS_PER_BLOCK / 1024 - ).toFixed(0)} KB).`, - value: , - }, - { - name: "Blob Gas Limit", - helpText: "The maximum blob gas limit for this block.", - value: ( -
- {formatNumber(BLOB_GAS_LIMIT_PER_BLOCK)} - - ({formatNumber(MAX_BLOBS_PER_BLOCK)}{" "} - {pluralize("blob", MAX_BLOBS_PER_BLOCK)} per block) - -
- ), - }, - { - name: "Blob As Calldata Gas", - helpText: - "The total gas that would have been used in this block if the blobs were sent as calldata.", - value: ( -
- {formatNumber(blockData.blobAsCalldataGasUsed)} - - ( - - {formatNumber( - performDiv( - blockData.blobAsCalldataGasUsed, - blockData.blobGasUsed - ), - "standard", - { maximumFractionDigits: 2 } - )} - {" "} - times more expensive) - -
- ), - }, - ]; - } - return ( <> diff --git a/apps/web/src/pages/blocks.tsx b/apps/web/src/pages/blocks.tsx index 3efbd5837..c560bcd8e 100644 --- a/apps/web/src/pages/blocks.tsx +++ b/apps/web/src/pages/blocks.tsx @@ -16,17 +16,18 @@ import { TimestampToggle } from "~/components/TimestampToggle"; import { api } from "~/api-client"; import { useQueryParams } from "~/hooks/useQueryParams"; import NextError from "~/pages/_error"; +import { useEnv } from "~/providers/Env"; import type { DeserializedBlock } from "~/utils"; import { buildBlobRoute, buildBlockRoute, - buildSlotExternalUrl, buildTransactionRoute, deserializeBlock, formatNumber, } from "~/utils"; const Blocks: NextPage = function () { + const { env } = useEnv(); const { filterParams, paginationParams } = useQueryParams(); const { data: serializedBlocksData, @@ -231,7 +232,10 @@ const Blocks: NextPage = function () { }, { item: ( - + {slot} ), @@ -261,7 +265,7 @@ const Blocks: NextPage = function () { }; }) : undefined; - }, [blocks, timeFormat]); + }, [blocks, timeFormat, env]); if (error) { return ( diff --git a/apps/web/src/pages/tx/[hash].tsx b/apps/web/src/pages/tx/[hash].tsx index 2be335e22..6078d3d06 100644 --- a/apps/web/src/pages/tx/[hash].tsx +++ b/apps/web/src/pages/tx/[hash].tsx @@ -17,11 +17,11 @@ import { NavArrows } from "~/components/NavArrows"; import { BlockStatus } from "~/components/Status"; import { api } from "~/api-client"; import NextError from "~/pages/_error"; +import { useEnv } from "~/providers/Env"; import type { TransactionWithExpandedBlockAndBlob } from "~/types"; import { buildAddressRoute, buildBlockRoute, - buildTransactionExternalUrl, formatTimestamp, formatBytes, formatNumber, @@ -30,6 +30,7 @@ import { } from "~/utils"; const Tx: NextPage = () => { + const { env } = useEnv(); const router = useRouter(); const hash = (router.query.hash as string | undefined) ?? ""; @@ -251,7 +252,7 @@ const Tx: NextPage = () => { />
} - externalLink={tx ? buildTransactionExternalUrl(tx.hash) : undefined} + externalLink={tx ? `${env}` : undefined} fields={detailsFields} /> diff --git a/apps/web/src/providers/Env.tsx b/apps/web/src/providers/Env.tsx new file mode 100644 index 000000000..f522be9e5 --- /dev/null +++ b/apps/web/src/providers/Env.tsx @@ -0,0 +1,47 @@ +import React, { createContext, useContext, useState, useEffect } from "react"; +import type { z } from "zod"; + +import type { clientEnvVarsSchema } from "~/env.mjs"; + +export type ClientEnvVars = z.output; +interface EnvContextType { + env?: ClientEnvVars; +} + +const EnvContext = createContext({ env: undefined }); + +export const EnvProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [env, setEnv] = useState(); + + useEffect(() => { + const fetchEnv = async () => { + try { + const request = await fetch("/api/env"); + const env = (await request.json()) as ClientEnvVars; + + setEnv(env); + } catch (error) { + console.error( + "Error fetching environment variables from server side:", + error + ); + } + }; + + fetchEnv(); + }, []); + + return ( + {children} + ); +}; + +export const useEnv = () => { + const context = useContext(EnvContext); + if (!context) { + throw new Error("useEnv must be used within an EnvProvider"); + } + return context; +}; diff --git a/apps/web/src/utils/explorers.ts b/apps/web/src/utils/explorers.ts deleted file mode 100644 index 6d67aa11c..000000000 --- a/apps/web/src/utils/explorers.ts +++ /dev/null @@ -1,20 +0,0 @@ -const BASE_URL = - process.env.NEXT_PUBLIC_EXPLORER_BASE_URL ?? "https://etherscan.io/"; -const BEACON_BASE_URL = - process.env.NEXT_PUBLIC_BEACON_BASE_URL ?? "https://beaconscan.com/"; - -export function buildBlockExternalUrl(id: number): string { - return `${BASE_URL}block/${id}`; -} - -export function buildTransactionExternalUrl(id: string): string { - return `${BASE_URL}tx/${id}`; -} - -export function buildSlotExternalUrl(slot: number) { - return `${BEACON_BASE_URL}slot/${slot}`; -} - -export function buildAddressExternalUrl(address: string) { - return `${BASE_URL}address/${address}`; -} diff --git a/apps/web/src/utils/index.ts b/apps/web/src/utils/index.ts index 28c6a2fd8..f43ba4fae 100644 --- a/apps/web/src/utils/index.ts +++ b/apps/web/src/utils/index.ts @@ -4,7 +4,6 @@ export * from "./blob-decoders"; export * from "./deserializers"; export * from "./ethereum"; export * from "./date"; -export * from "./explorers"; export * from "./routes"; export * from "./search"; export * from "./number"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4762e6893..796f0d4a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -244,9 +244,6 @@ importers: '@blobscan/db': specifier: workspace:^0.13.0 version: link:../../packages/db - '@blobscan/env': - specifier: workspace:^0.1.0 - version: link:../../packages/env '@blobscan/eth-format': specifier: workspace:^0.1.0 version: link:../../packages/eth-format diff --git a/scripts/ci/deploy_vercel_env.sh b/scripts/ci/deploy_vercel_env.sh index d2ccb769c..15be8e295 100755 --- a/scripts/ci/deploy_vercel_env.sh +++ b/scripts/ci/deploy_vercel_env.sh @@ -1,6 +1,6 @@ #!/bin/bash environment="production" -variables="CHAIN_ID NEXT_PUBLIC_EXPLORER_BASE_URL NEXT_PUBLIC_NETWORK_NAME NEXT_PUBLIC_SUPPORTED_NETWORKS POSTGRES_STORAGE_ENABLED SWARM_STORAGE_ENABLED GOOGLE_STORAGE_ENABLED DATABASE_URL SECRET_KEY" +variables="CHAIN_ID PUBLIC_EXPLORER_BASE_URL PUBLIC_NETWORK_NAME PUBLIC_SUPPORTED_NETWORKS POSTGRES_STORAGE_ENABLED SWARM_STORAGE_ENABLED GOOGLE_STORAGE_ENABLED DATABASE_URL SECRET_KEY" # other not so recently updated variables # o_vars="METRICS_ENABLED BEE_ENDPOINT GOOGLE_SERVICE_KEY GOOGLE_STORAGE_BUCKET_NAME GOOGLE_STORAGE_PROJECT_ID" diff --git a/turbo.json b/turbo.json index f66143c3b..3f1afa4e4 100644 --- a/turbo.json +++ b/turbo.json @@ -49,8 +49,8 @@ }, "globalEnv": [ "NEXT_PUBLIC_BLOBSCAN_RELEASE", - "NEXT_PUBLIC_POSTHOG_ID", - "NEXT_PUBLIC_POSTHOG_HOST", + "PUBLIC_POSTHOG_ID", + "PUBLIC_POSTHOG_HOST", "AUTH0_CLIENT_ID", "AUTH0_CLIENT_SECRET", "AUTH0_ISSUER", @@ -73,12 +73,12 @@ "PRISMA_BATCH_OPERATIONS_MAX_SIZE", "NEXTAUTH_URL", "NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED", - "NEXT_PUBLIC_NETWORK_NAME", + "PUBLIC_NETWORK_NAME", "NETWORK_NAME", - "NEXT_PUBLIC_SUPPORTED_NETWORKS", - "NEXT_PUBLIC_BEACON_BASE_URL", - "NEXT_PUBLIC_EXPLORER_BASE_URL", - "NEXT_PUBLIC_SENTRY_DSN_WEB", + "PUBLIC_SUPPORTED_NETWORKS", + "PUBLIC_BEACON_BASE_URL", + "PUBLIC_EXPLORER_BASE_URL", + "PUBLIC_SENTRY_DSN_WEB", "NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA", "NEXT_RUNTIME", "NODE_ENV",