- {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..61f10ee28 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,31 @@ const networkSchema = z.enum([
"devnet",
]);
+const clientEnvVars = {
+ 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: z.boolean().default(false),
+ NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA: z.string().optional(),
+};
+
+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
@@ -26,34 +44,11 @@ export const env = createEnv({
DATABASE_URL: z.string().url(),
FEEDBACK_WEBHOOK_URL: z.string().optional(),
NODE_ENV: z.enum(["development", "test", "production"]),
- METRICS_ENABLED: booleanSchema.default("false"),
- TRACES_ENABLED: booleanSchema.default("false"),
+ METRICS_ENABLED: z.boolean().default(false),
+ TRACES_ENABLED: z.boolean().default(false),
},
- /**
- * 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(),
+ ...clientEnvVars,
},
/**
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx
index 17347bd03..58e422f87 100644
--- a/apps/web/src/pages/_app.tsx
+++ b/apps/web/src/pages/_app.tsx
@@ -19,26 +19,33 @@ 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 &&
+ env.PUBLIC_POSTHOG_ID !== undefined
+ ) {
+ 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 +92,7 @@ function App({ Component, pageProps }: NextAppProps) {
- {env.NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED && }
+ {env && env.PUBLIC_VERCEL_ANALYTICS_ENABLED && }
);
@@ -95,7 +102,9 @@ function AppWrapper(props: NextAppProps) {
return (
-
+
+
+
);
diff --git a/apps/web/src/pages/api/env/index.ts b/apps/web/src/pages/api/env/index.ts
new file mode 100644
index 000000000..1189625ce
--- /dev/null
+++ b/apps/web/src/pages/api/env/index.ts
@@ -0,0 +1,33 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+
+import { env } from "@blobscan/env";
+
+import { clientEnvVarsSchema } from "../../../env.mjs";
+
+export default async function (req: NextApiRequest, res: NextApiResponse) {
+ const method = req.method;
+
+ switch (method) {
+ case "GET": {
+ const parsedEnv = clientEnvVarsSchema.safeParse(env);
+
+ if (!parsedEnv.success) {
+ return res.status(400).json({
+ error: "Error parsing client side environment variables",
+ details: parsedEnv.error.format(),
+ });
+ }
+
+ const clientEnv = Object.fromEntries(
+ Object.entries(parsedEnv.data)
+ .filter(([key]) => key.startsWith("NEXT_"))
+ .map(([key, value]) => [key.replace(/^NEXT_/, ""), value])
+ );
+
+ 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..1e26ce42a 100644
--- a/apps/web/src/pages/block/[id].tsx
+++ b/apps/web/src/pages/block/[id].tsx
@@ -16,6 +16,7 @@ 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,
@@ -56,6 +57,154 @@ 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]);
+
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 (
<>
;
+
+type ClientEnv = {
+ [Key in keyof ClientEnvVars as Key extends `NEXT_${infer Rest}`
+ ? Rest
+ : never]: ClientEnvVars[Key];
+};
+
+interface EnvContextType {
+ env?: ClientEnv;
+}
+
+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 data = await request.json();
+ setEnv(data);
+ } 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;
+};