瀏覽代碼

feat(entropy-explorer): add initial build of entropy explorer

This is an initial build of the new Entropy Explorer app, to replace the old
Entropy Debugger app.  It's mostly ready, with the following exceptions:

1. The data is currently mock data, and will need to be replaced with live APIs.
2. We should add some error payload parsing to the details drawer.
3. Not all chains are currently added (Viem doesn't have chain metadata for all
   the chains we support and I want to get this merged and the UI figured out
   before I worry about ensuring the chain metadata is correct)
Connor Prussin 6 月之前
父節點
當前提交
ce86bced14
共有 93 個文件被更改,包括 2158 次插入233 次删除
  1. 1 0
      apps/entropy-explorer/.gitignore
  2. 7 0
      apps/entropy-explorer/.prettierignore
  3. 1 0
      apps/entropy-explorer/eslint.config.js
  4. 1 0
      apps/entropy-explorer/jest.config.js
  5. 5 0
      apps/entropy-explorer/next-env.d.ts
  6. 54 0
      apps/entropy-explorer/next.config.js
  7. 57 0
      apps/entropy-explorer/package.json
  8. 1 0
      apps/entropy-explorer/prettier.config.js
  9. 二進制
      apps/entropy-explorer/public/android-chrome-192x192.png
  10. 二進制
      apps/entropy-explorer/public/android-chrome-512x512.png
  11. 二進制
      apps/entropy-explorer/public/apple-touch-icon.png
  12. 二進制
      apps/entropy-explorer/public/favicon-16x16.png
  13. 二進制
      apps/entropy-explorer/public/favicon-32x32.png
  14. 二進制
      apps/entropy-explorer/public/favicon-light.ico
  15. 二進制
      apps/entropy-explorer/public/favicon.ico
  16. 3 0
      apps/entropy-explorer/src/app/error.ts
  17. 16 0
      apps/entropy-explorer/src/app/global-error.tsx
  18. 2 0
      apps/entropy-explorer/src/app/layout.ts
  19. 25 0
      apps/entropy-explorer/src/app/manifest.ts
  20. 1 0
      apps/entropy-explorer/src/app/not-found.ts
  21. 1 0
      apps/entropy-explorer/src/app/page.ts
  22. 11 0
      apps/entropy-explorer/src/app/robots.ts
  23. 15 0
      apps/entropy-explorer/src/components/Home/chain-select.module.scss
  24. 107 0
      apps/entropy-explorer/src/components/Home/chain-select.tsx
  25. 30 0
      apps/entropy-explorer/src/components/Home/index.module.scss
  26. 27 0
      apps/entropy-explorer/src/components/Home/index.tsx
  27. 75 0
      apps/entropy-explorer/src/components/Home/results.module.scss
  28. 420 0
      apps/entropy-explorer/src/components/Home/results.tsx
  29. 37 0
      apps/entropy-explorer/src/components/Home/search-bar.tsx
  30. 48 0
      apps/entropy-explorer/src/components/Home/use-query.ts
  31. 21 0
      apps/entropy-explorer/src/components/Root/evm-provider.tsx
  32. 30 0
      apps/entropy-explorer/src/components/Root/index.tsx
  33. 13 0
      apps/entropy-explorer/src/config/isomorphic.ts
  34. 30 0
      apps/entropy-explorer/src/config/server.ts
  35. 471 0
      apps/entropy-explorer/src/entropy-deployments.ts
  36. 71 0
      apps/entropy-explorer/src/get-requests-for-chain.ts
  37. 52 0
      apps/entropy-explorer/src/metadata.ts
  38. 4 0
      apps/entropy-explorer/src/type-utils.ts
  39. 21 0
      apps/entropy-explorer/stylelint.config.js
  40. 6 0
      apps/entropy-explorer/svg.d.ts
  41. 5 0
      apps/entropy-explorer/tsconfig.json
  42. 41 0
      apps/entropy-explorer/turbo.json
  43. 5 0
      apps/entropy-explorer/vercel.json
  44. 1 1
      apps/insights/src/app/error.ts
  45. 3 4
      apps/insights/src/app/global-error.tsx
  46. 1 1
      apps/insights/src/app/not-found.ts
  47. 1 1
      apps/insights/src/app/price-feeds/[slug]/error.ts
  48. 1 1
      apps/insights/src/app/publishers/[cluster]/[key]/error.ts
  49. 1 1
      apps/insights/src/components/FeedKey/index.tsx
  50. 1 1
      apps/insights/src/components/PriceComponentDrawer/index.module.scss
  51. 2 2
      apps/insights/src/components/PriceComponentDrawer/index.tsx
  52. 8 8
      apps/insights/src/components/PriceComponentsCard/index.tsx
  53. 1 1
      apps/insights/src/components/PriceFeedChangePercent/index.tsx
  54. 7 4
      apps/insights/src/components/PriceFeeds/coming-soon-list.tsx
  55. 9 6
      apps/insights/src/components/PriceFeeds/price-feeds-card.tsx
  56. 1 1
      apps/insights/src/components/Publisher/layout.tsx
  57. 3 3
      apps/insights/src/components/Publisher/performance.tsx
  58. 1 1
      apps/insights/src/components/Publisher/top-feeds-table.tsx
  59. 1 2
      apps/insights/src/components/PublisherKey/index.tsx
  60. 3 3
      apps/insights/src/components/Publishers/publishers-card.tsx
  61. 2 2
      apps/insights/src/components/Root/search-button.module.scss
  62. 1 1
      apps/insights/src/components/Root/search-button.tsx
  63. 3 1
      packages/component-library/package.json
  64. 4 0
      packages/component-library/src/AppShell/index.module.scss
  65. 1 1
      packages/component-library/src/AppShell/index.tsx
  66. 5 4
      packages/component-library/src/Badge/index.module.scss
  67. 5 9
      packages/component-library/src/Button/index.module.scss
  68. 5 7
      packages/component-library/src/Button/index.tsx
  69. 5 5
      packages/component-library/src/CopyButton/index.module.scss
  70. 2 2
      packages/component-library/src/CopyButton/index.tsx
  71. 2 0
      packages/component-library/src/EntityList/index.module.scss
  72. 2 2
      packages/component-library/src/EntityList/index.tsx
  73. 1 1
      packages/component-library/src/ErrorPage/index.module.scss
  74. 2 2
      packages/component-library/src/ErrorPage/index.tsx
  75. 3 2
      packages/component-library/src/Footer/index.tsx
  76. 1 1
      packages/component-library/src/Header/index.module.scss
  77. 2 2
      packages/component-library/src/Header/theme-switch.tsx
  78. 11 0
      packages/component-library/src/Meter/index.module.scss
  79. 13 4
      packages/component-library/src/Meter/index.tsx
  80. 1 1
      packages/component-library/src/MobileNavTabs/index.tsx
  81. 0 0
      packages/component-library/src/NoResults/index.module.scss
  82. 7 5
      packages/component-library/src/NoResults/index.tsx
  83. 1 1
      packages/component-library/src/NotFoundPage/index.module.scss
  84. 2 2
      packages/component-library/src/NotFoundPage/index.tsx
  85. 2 2
      packages/component-library/src/Paginator/index.tsx
  86. 29 3
      packages/component-library/src/Select/index.module.scss
  87. 10 7
      packages/component-library/src/Select/index.stories.tsx
  88. 106 32
      packages/component-library/src/Select/index.tsx
  89. 10 17
      packages/component-library/src/Status/index.module.scss
  90. 6 4
      packages/component-library/src/useData/index.ts
  91. 1 1
      packages/component-library/src/useDrawer/index.module.scss
  92. 53 71
      packages/component-library/src/useDrawer/index.tsx
  93. 100 0
      pnpm-lock.yaml

+ 1 - 0
apps/entropy-explorer/.gitignore

@@ -0,0 +1 @@
+.env*.local

+ 7 - 0
apps/entropy-explorer/.prettierignore

@@ -0,0 +1,7 @@
+.next/
+coverage/
+node_modules/
+*.tsbuildinfo
+.env*.local
+.env
+.DS_Store

+ 1 - 0
apps/entropy-explorer/eslint.config.js

@@ -0,0 +1 @@
+export { nextjs as default } from "@cprussin/eslint-config";

+ 1 - 0
apps/entropy-explorer/jest.config.js

@@ -0,0 +1 @@
+export { nextjs as default } from "@cprussin/jest-config/next";

+ 5 - 0
apps/entropy-explorer/next-env.d.ts

@@ -0,0 +1,5 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 54 - 0
apps/entropy-explorer/next.config.js

@@ -0,0 +1,54 @@
+const config = {
+  reactStrictMode: true,
+
+  pageExtensions: ["ts", "tsx", "mdx"],
+
+  logging: {
+    fetches: {
+      fullUrl: true,
+    },
+  },
+
+  webpack(config) {
+    config.module.rules.push({
+      test: /\.svg$/i,
+      use: ["@svgr/webpack"],
+    });
+
+    config.resolve.extensionAlias = {
+      ".js": [".js", ".ts", ".tsx"],
+    };
+
+    return config;
+  },
+
+  headers: async () => [
+    {
+      source: "/:path*",
+      headers: [
+        {
+          key: "X-XSS-Protection",
+          value: "1; mode=block",
+        },
+        {
+          key: "Referrer-Policy",
+          value: "strict-origin-when-cross-origin",
+        },
+        {
+          key: "Strict-Transport-Security",
+          value: "max-age=2592000",
+        },
+        {
+          key: "X-Content-Type-Options",
+          value: "nosniff",
+        },
+        {
+          key: "Permissions-Policy",
+          value:
+            "vibrate=(), geolocation=(), midi=(), notifications=(), push=(), sync-xhr=(), microphone=(), camera=(), magnetometer=(), gyroscope=(), speaker=(), vibrate=(), fullscreen=self",
+        },
+      ],
+    },
+  ],
+};
+export default config;

+ 57 - 0
apps/entropy-explorer/package.json

@@ -0,0 +1,57 @@
+{
+  "name": "@pythnetwork/entropy-explorer",
+  "version": "0.0.0",
+  "private": true,
+  "type": "module",
+  "engines": {
+    "node": "22"
+  },
+  "scripts": {
+    "build:vercel": "next build",
+    "fix:format": "prettier --write .",
+    "fix:lint:eslint": "eslint --fix .",
+    "fix:lint:stylelint": "stylelint --fix 'src/**/*.scss'",
+    "pull:env": "[ $CI ] || VERCEL_ORG_ID=team_BKQrg3JJFLxZyTqpuYtIY0rj VERCEL_PROJECT_ID=prj_TBkf9EyQjQF37gs4Vk0sQKJj97kE vercel env pull",
+    "start:dev": "next dev --port 3006",
+    "start:prod": "next start --port 3006",
+    "test:format": "prettier --check .",
+    "test:lint:eslint": "eslint . --max-warnings 0",
+    "test:lint:stylelint": "stylelint 'src/**/*.scss' --max-warnings 0",
+    "test:types": "tsc"
+  },
+  "dependencies": {
+    "@phosphor-icons/react": "catalog:",
+    "@pythnetwork/component-library": "workspace:*",
+    "clsx": "catalog:",
+    "connectkit": "catalog:",
+    "next": "catalog:",
+    "nuqs": "catalog:",
+    "react": "catalog:",
+    "react-aria": "catalog:",
+    "react-dom": "catalog:",
+    "viem": "catalog:",
+    "wagmi": "catalog:",
+    "zod": "catalog:"
+  },
+  "devDependencies": {
+    "@cprussin/eslint-config": "catalog:",
+    "@cprussin/jest-config": "catalog:",
+    "@cprussin/prettier-config": "catalog:",
+    "@cprussin/tsconfig": "catalog:",
+    "@svgr/webpack": "catalog:",
+    "@types/jest": "catalog:",
+    "@types/node": "catalog:",
+    "@types/react": "catalog:",
+    "@types/react-dom": "catalog:",
+    "autoprefixer": "catalog:",
+    "eslint": "catalog:",
+    "jest": "catalog:",
+    "postcss": "catalog:",
+    "prettier": "catalog:",
+    "sass": "catalog:",
+    "stylelint": "catalog:",
+    "stylelint-config-standard-scss": "catalog:",
+    "typescript": "catalog:",
+    "vercel": "catalog:"
+  }
+}

+ 1 - 0
apps/entropy-explorer/prettier.config.js

@@ -0,0 +1 @@
+export { base as default } from "@cprussin/prettier-config";

二進制
apps/entropy-explorer/public/android-chrome-192x192.png


二進制
apps/entropy-explorer/public/android-chrome-512x512.png


二進制
apps/entropy-explorer/public/apple-touch-icon.png


二進制
apps/entropy-explorer/public/favicon-16x16.png


二進制
apps/entropy-explorer/public/favicon-32x32.png


二進制
apps/entropy-explorer/public/favicon-light.ico


二進制
apps/entropy-explorer/public/favicon.ico


+ 3 - 0
apps/entropy-explorer/src/app/error.ts

@@ -0,0 +1,3 @@
+"use client";
+
+export { ErrorPage as default } from "@pythnetwork/component-library/ErrorPage";

+ 16 - 0
apps/entropy-explorer/src/app/global-error.tsx

@@ -0,0 +1,16 @@
+"use client";
+
+import { ErrorPage } from "@pythnetwork/component-library/ErrorPage";
+import { LoggerProvider } from "@pythnetwork/component-library/useLogger";
+import type { ComponentProps } from "react";
+
+const GlobalError = (props: ComponentProps<typeof ErrorPage>) => (
+  <LoggerProvider>
+    <html lang="en" dir="ltr">
+      <body>
+        <ErrorPage {...props} />
+      </body>
+    </html>
+  </LoggerProvider>
+);
+export default GlobalError;

+ 2 - 0
apps/entropy-explorer/src/app/layout.ts

@@ -0,0 +1,2 @@
+export { Root as default } from "../components/Root";
+export { metadata, viewport } from "../metadata";

+ 25 - 0
apps/entropy-explorer/src/app/manifest.ts

@@ -0,0 +1,25 @@
+import type { MetadataRoute } from "next";
+
+import { metadata, viewport } from "../metadata";
+
+const manifest = (): MetadataRoute.Manifest => ({
+  name: metadata.applicationName,
+  short_name: metadata.applicationName,
+  description: metadata.description,
+  theme_color: viewport.themeColor,
+  background_color: viewport.themeColor,
+  icons: [
+    {
+      src: "/android-chrome-192x192.png",
+      sizes: "192x192",
+      type: "image/png",
+    },
+    {
+      src: "/android-chrome-512x512.png",
+      sizes: "512x512",
+      type: "image/png",
+    },
+  ],
+  display: "standalone",
+});
+export default manifest;

+ 1 - 0
apps/entropy-explorer/src/app/not-found.ts

@@ -0,0 +1 @@
+export { NotFoundPage as default } from "@pythnetwork/component-library/NotFoundPage";

+ 1 - 0
apps/entropy-explorer/src/app/page.ts

@@ -0,0 +1 @@
+export { Home as default } from "../components/Home";

+ 11 - 0
apps/entropy-explorer/src/app/robots.ts

@@ -0,0 +1,11 @@
+import type { MetadataRoute } from "next";
+
+import { IS_PRODUCTION_SERVER } from "../config/server";
+
+const robots = (): MetadataRoute.Robots => ({
+  rules: {
+    userAgent: "*",
+    ...(IS_PRODUCTION_SERVER ? { allow: "/" } : { disallow: "/" }),
+  },
+});
+export default robots;

+ 15 - 0
apps/entropy-explorer/src/components/Home/chain-select.module.scss

@@ -0,0 +1,15 @@
+@use "@pythnetwork/component-library/theme";
+
+.searchBar {
+  display: grid;
+  grid-template-columns: max-content 1fr;
+  gap: theme.spacing(2);
+  width: 100%;
+}
+
+.chainSelectItem {
+  display: grid;
+  grid-template-columns: max-content 1fr;
+  gap: theme.spacing(2);
+  align-items: center;
+}

+ 107 - 0
apps/entropy-explorer/src/components/Home/chain-select.tsx

@@ -0,0 +1,107 @@
+"use client";
+
+import type { Props as SelectProps } from "@pythnetwork/component-library/Select";
+import { Select } from "@pythnetwork/component-library/Select";
+import { ChainIcon } from "connectkit";
+import type { ComponentProps } from "react";
+import { Suspense, useCallback, useMemo } from "react";
+import { useCollator } from "react-aria";
+import * as viemChains from "viem/chains";
+
+import styles from "./chain-select.module.scss";
+import { useQuery } from "./use-query";
+import { EntropyDeployments } from "../../entropy-deployments";
+import type { ConstrainedOmit } from "../../type-utils";
+
+export const ChainSelect = (
+  props: ComponentProps<typeof ResolvedChainSelect>,
+) => (
+  <Suspense
+    fallback={
+      <Select
+        {...defaultProps}
+        {...props}
+        isPending
+        options={[]}
+        defaultSelectedKey={undefined}
+      />
+    }
+  >
+    <ResolvedChainSelect {...props} />
+  </Suspense>
+);
+
+type Deployment = ReturnType<typeof entropyDeploymentsByNetwork>[number];
+
+const ResolvedChainSelect = (
+  props: ConstrainedOmit<
+    SelectProps<Deployment>,
+    keyof typeof defaultProps | keyof ReturnType<typeof useResolvedProps>
+  >,
+) => {
+  const resolvedProps = useResolvedProps();
+
+  return <Select {...defaultProps} {...resolvedProps} {...props} />;
+};
+
+const useResolvedProps = () => {
+  const collator = useCollator();
+  const { chain, setChain } = useQuery();
+  const chains = useMemo(
+    () => [
+      {
+        name: "MAINNET",
+        options: entropyDeploymentsByNetwork("mainnet", collator),
+      },
+      {
+        name: "TESTNET",
+        options: entropyDeploymentsByNetwork("testnet", collator),
+      },
+    ],
+    [collator],
+  );
+
+  const showChain = useCallback(
+    (chain: Deployment) => (
+      <div className={styles.chainSelectItem}>
+        <ChainIcon id={chain.chainId} />
+        {chain.name}
+      </div>
+    ),
+    [],
+  );
+
+  const chainTextValue = useCallback((chain: Deployment) => chain.name, []);
+
+  return {
+    selectedKey: chain ?? undefined,
+    onSelectionChange: setChain,
+    optionGroups: chains,
+    show: showChain,
+    textValue: chainTextValue,
+  };
+};
+
+const defaultProps = {
+  label: "Chain",
+  hideLabel: true,
+  defaultButtonLabel: "Select Chain",
+} as const;
+
+const entropyDeploymentsByNetwork = (
+  network: "mainnet" | "testnet",
+  collator: ReturnType<typeof useCollator>,
+) =>
+  Object.entries(EntropyDeployments)
+    .map(([slug, chain]) => {
+      // eslint-disable-next-line import/namespace
+      const viemChain = viemChains[slug as keyof typeof EntropyDeployments];
+      return {
+        ...chain,
+        name: viemChain.name,
+        chainId: viemChain.id,
+        id: slug as keyof typeof EntropyDeployments,
+      };
+    })
+    .filter((chain) => chain.network === network)
+    .toSorted((a, b) => collator.compare(a.name, b.name));

+ 30 - 0
apps/entropy-explorer/src/components/Home/index.module.scss

@@ -0,0 +1,30 @@
+@use "@pythnetwork/component-library/theme";
+
+.home {
+  .header {
+    @include theme.h3;
+    @include theme.max-width;
+
+    // stylelint-disable-next-line no-duplicate-selectors
+    & {
+      color: theme.color("heading");
+      margin-bottom: theme.spacing(4);
+    }
+
+    @include theme.breakpoint("sm") {
+      margin-bottom: theme.spacing(6);
+    }
+  }
+
+  .body {
+    @include theme.max-width;
+
+    .searchBar {
+      width: 100%;
+
+      @include theme.breakpoint("lg") {
+        width: theme.spacing(100);
+      }
+    }
+  }
+}

+ 27 - 0
apps/entropy-explorer/src/components/Home/index.tsx

@@ -0,0 +1,27 @@
+import { ListDashes } from "@phosphor-icons/react/dist/ssr/ListDashes";
+import { Card } from "@pythnetwork/component-library/Card";
+
+import { ChainSelect } from "./chain-select";
+import styles from "./index.module.scss";
+import { Results } from "./results";
+import { SearchBar } from "./search-bar";
+
+export const Home = () => (
+  <div className={styles.home}>
+    <h1 className={styles.header}>Requests</h1>
+    <div className={styles.body}>
+      <Card
+        title="Request Log"
+        icon={<ListDashes />}
+        toolbar={
+          <>
+            <ChainSelect variant="outline" size="sm" placement="bottom right" />
+            <SearchBar className={styles.searchBar ?? ""} />
+          </>
+        }
+      >
+        <Results />
+      </Card>
+    </div>
+  </div>
+);

+ 75 - 0
apps/entropy-explorer/src/components/Home/results.module.scss

@@ -0,0 +1,75 @@
+@use "@pythnetwork/component-library/theme";
+
+.table {
+  display: none;
+
+  @include theme.breakpoint("xl") {
+    display: block;
+  }
+}
+
+.entityList {
+  background: white;
+  border-radius: theme.border-radius("xl");
+
+  @include theme.breakpoint("xl") {
+    display: none;
+  }
+}
+
+.timestamp {
+  @include theme.text("sm", "medium");
+
+  @include theme.breakpoint("xl") {
+    @include theme.text("base", "medium");
+  }
+}
+
+.address {
+  display: flex;
+  flex-flow: row nowrap;
+  gap: theme.spacing(2);
+  font-size: theme.font-size("sm");
+
+  .full {
+    display: none;
+  }
+
+  &:not([data-always-truncate]) {
+    @include theme.breakpoint("xl") {
+      .truncated {
+        display: none;
+      }
+
+      .full {
+        display: unset;
+      }
+    }
+  }
+}
+
+.requestDrawer {
+  .cards {
+    display: grid;
+    gap: theme.spacing(4);
+    margin-bottom: theme.spacing(10);
+    grid-template-columns: repeat(2, 1fr);
+    padding-left: theme.spacing(4);
+    padding-right: theme.spacing(4);
+  }
+
+  .details {
+    width: 100%;
+    overflow: auto;
+
+    .field {
+      @include theme.text("sm", "normal");
+
+      color: theme.color("muted");
+    }
+
+    .gasMeterLabel {
+      @include theme.text("xs", "medium");
+    }
+  }
+}

+ 420 - 0
apps/entropy-explorer/src/components/Home/results.tsx

@@ -0,0 +1,420 @@
+"use client";
+
+import { Sparkle } from "@phosphor-icons/react/dist/ssr/Sparkle";
+import { Warning } from "@phosphor-icons/react/dist/ssr/Warning";
+import { Badge } from "@pythnetwork/component-library/Badge";
+import { CopyButton } from "@pythnetwork/component-library/CopyButton";
+import { EntityList } from "@pythnetwork/component-library/EntityList";
+import { Link } from "@pythnetwork/component-library/Link";
+import { Meter } from "@pythnetwork/component-library/Meter";
+import { NoResults } from "@pythnetwork/component-library/NoResults";
+import { StatCard } from "@pythnetwork/component-library/StatCard";
+import { Status as StatusImpl } from "@pythnetwork/component-library/Status";
+import type { RowConfig } from "@pythnetwork/component-library/Table";
+import { Table } from "@pythnetwork/component-library/Table";
+import { StateType, useData } from "@pythnetwork/component-library/useData";
+import { useDrawer } from "@pythnetwork/component-library/useDrawer";
+import type { ComponentProps } from "react";
+import { Suspense, useMemo, useCallback } from "react";
+import { useDateFormatter, useFilter, useNumberFormatter } from "react-aria";
+
+import { ChainSelect } from "./chain-select";
+import styles from "./results.module.scss";
+import { useQuery } from "./use-query";
+import { EntropyDeployments } from "../../entropy-deployments";
+import { getRequestsForChain } from "../../get-requests-for-chain";
+
+export const Results = () => (
+  <Suspense fallback={<ResultsImpl isLoading />}>
+    <MountedResults />
+  </Suspense>
+);
+
+const MountedResults = () => {
+  const { chain } = useQuery();
+
+  return chain ? (
+    <ResultsForChain chain={chain} />
+  ) : (
+    <Empty
+      icon={<Sparkle />}
+      header={<ChainSelect variant="primary" size="sm" placement="bottom" />}
+      body="Select a chain to list and search for Entropy requests"
+      variant="info"
+    />
+  );
+};
+
+const ResultsForChain = ({
+  chain,
+}: {
+  chain: keyof typeof EntropyDeployments;
+}) => {
+  const getTxData = useCallback(() => getRequestsForChain(chain), [chain]);
+  const results = useData(["requests", chain], getTxData, {
+    refreshInterval: 0,
+    revalidateIfStale: false,
+    revalidateOnFocus: false,
+    revalidateOnReconnect: false,
+  });
+  switch (results.type) {
+    case StateType.Error: {
+      return (
+        <Empty
+          icon={<Warning />}
+          header="Uh oh, we hit an error"
+          body={results.error.message}
+          variant="error"
+        />
+      );
+    }
+    case StateType.NotLoaded:
+    case StateType.Loading: {
+      return <ResultsImpl isLoading />;
+    }
+    case StateType.Loaded: {
+      return (
+        <ResolvedResults
+          chain={chain}
+          data={results.data}
+          isUpdating={results.isValidating}
+        />
+      );
+    }
+  }
+};
+
+type ResolvedResultsProps = {
+  chain: keyof typeof EntropyDeployments;
+  data: Awaited<ReturnType<typeof getRequestsForChain>>;
+  isUpdating?: boolean | undefined;
+};
+
+const ResolvedResults = ({ chain, data, isUpdating }: ResolvedResultsProps) => {
+  const drawer = useDrawer();
+  const { search } = useQuery();
+  const gasFormatter = useNumberFormatter({ maximumFractionDigits: 3 });
+  const dateFormatter = useDateFormatter({
+    dateStyle: "long",
+    timeStyle: "long",
+  });
+  const filter = useFilter({ sensitivity: "base", usage: "search" });
+  const rows = useMemo(
+    () =>
+      data
+        .filter(
+          (request) =>
+            filter.contains(request.txHash, search) ||
+            filter.contains(request.provider, search) ||
+            filter.contains(request.caller, search) ||
+            filter.contains(request.sequenceNumber.toString(), search),
+        )
+        .map((request) => ({
+          id: request.sequenceNumber.toString(),
+          textValue: request.txHash,
+          onAction: () => {
+            drawer.open({
+              title: `Request ${truncate(request.txHash)}`,
+              headingExtra: <Status request={request} />,
+              className: styles.requestDrawer ?? "",
+              fill: true,
+              contents: (
+                <>
+                  <div className={styles.cards}>
+                    <StatCard
+                      nonInteractive
+                      header="Result"
+                      small
+                      variant="primary"
+                      stat={
+                        request.hasCallbackCompleted ? (
+                          <code>{request.callbackResult.randomNumber}</code>
+                        ) : (
+                          <Status request={request} />
+                        )
+                      }
+                    />
+                    <StatCard
+                      nonInteractive
+                      header="Sequence Number"
+                      small
+                      stat={request.sequenceNumber}
+                    />
+                  </div>
+                  <Table
+                    label="Details"
+                    fill
+                    className={styles.details ?? ""}
+                    stickyHeader
+                    columns={[
+                      {
+                        id: "field",
+                        name: "Field",
+                        alignment: "left",
+                        isRowHeader: true,
+                        sticky: true,
+                      },
+                      {
+                        id: "value",
+                        name: "Value",
+                        fill: true,
+                        alignment: "left",
+                      },
+                    ]}
+                    rows={[
+                      {
+                        field: "Request Timestamp",
+                        value: dateFormatter.format(request.timestamp),
+                      },
+                      ...(request.hasCallbackCompleted
+                        ? [
+                            {
+                              field: "Result Timestamp",
+                              value: dateFormatter.format(
+                                request.callbackResult.timestamp,
+                              ),
+                            },
+                          ]
+                        : []),
+                      {
+                        field: "Transaction Hash",
+                        value: <Address chain={chain} value={request.txHash} />,
+                      },
+                      {
+                        field: "Caller",
+                        value: <Address chain={chain} value={request.caller} />,
+                      },
+                      {
+                        field: "Provider",
+                        value: (
+                          <Address chain={chain} value={request.provider} />
+                        ),
+                      },
+                      {
+                        field: "Gas",
+                        value: request.hasCallbackCompleted ? (
+                          <Meter
+                            label="Gas"
+                            value={request.callbackResult.gasUsed}
+                            maxValue={request.gasLimit}
+                            startLabel={
+                              <>
+                                {gasFormatter.format(
+                                  request.callbackResult.gasUsed,
+                                )}{" "}
+                                used
+                              </>
+                            }
+                            endLabel={
+                              <>{gasFormatter.format(request.gasLimit)} max</>
+                            }
+                            labelClassName={styles.gasMeterLabel ?? ""}
+                            variant={
+                              request.callbackResult.gasUsed > request.gasLimit
+                                ? "error"
+                                : "default"
+                            }
+                          />
+                        ) : (
+                          <>{gasFormatter.format(request.gasLimit)} max</>
+                        ),
+                      },
+                    ].map((data) => ({
+                      id: data.field,
+                      data: {
+                        field: (
+                          <span className={styles.field}>{data.field}</span>
+                        ),
+                        value: data.value,
+                      },
+                    }))}
+                  />
+                </>
+              ),
+            });
+          },
+          data: {
+            timestamp: (
+              <div className={styles.timestamp}>
+                {dateFormatter.format(request.timestamp)}
+              </div>
+            ),
+            sequenceNumber: (
+              <Badge size="md" variant="info" style="outline">
+                {request.sequenceNumber}
+              </Badge>
+            ),
+            caller: (
+              <Address alwaysTruncate chain={chain} value={request.caller} />
+            ),
+            provider: (
+              <Address alwaysTruncate chain={chain} value={request.provider} />
+            ),
+            txHash: (
+              <Address alwaysTruncate chain={chain} value={request.txHash} />
+            ),
+            status: <Status request={request} />,
+          },
+        })),
+    [data, search, chain, dateFormatter, drawer, filter, gasFormatter],
+  );
+
+  return <ResultsImpl rows={rows} isUpdating={isUpdating} search={search} />;
+};
+
+type ResultsImplProps =
+  | {
+      isLoading: true;
+    }
+  | {
+      isLoading?: false | undefined;
+      rows: (RowConfig<(typeof defaultProps)["columns"][number]["id"]> & {
+        textValue: string;
+      })[];
+      isUpdating?: boolean | undefined;
+      search: string;
+    };
+
+const ResultsImpl = (props: ResultsImplProps) => (
+  <>
+    <EntityList
+      label={defaultProps.label}
+      className={styles.entityList ?? ""}
+      fields={[
+        { id: "sequenceNumber", name: "Sequence Number" },
+        { id: "timestamp", name: "Timestamp" },
+        { id: "txHash", name: "Transaction Hash" },
+        { id: "provider", name: "Provider" },
+        { id: "caller", name: "Caller" },
+        { id: "status", name: "Status" },
+      ]}
+      {...(props.isLoading ? { isLoading: true } : { rows: props.rows })}
+    />
+    <Table
+      className={styles.table ?? ""}
+      {...defaultProps}
+      {...(props.isLoading
+        ? { isLoading: true }
+        : {
+            rows: props.rows,
+            isUpdating: props.isUpdating,
+            emptyState: <NoResults query={props.search} />,
+            className: styles.table ?? "",
+          })}
+    />
+  </>
+);
+
+const Empty = (props: ComponentProps<typeof NoResults>) => (
+  <>
+    <NoResults className={styles.entityList} {...props} />
+    <Table
+      className={styles.table ?? ""}
+      rows={[]}
+      emptyState={<NoResults {...props} />}
+      {...defaultProps}
+    />
+  </>
+);
+
+const Address = ({
+  value,
+  chain,
+  alwaysTruncate,
+}: {
+  value: string;
+  chain: keyof typeof EntropyDeployments;
+  alwaysTruncate?: boolean | undefined;
+}) => {
+  const { explorer } = EntropyDeployments[chain];
+  const truncatedValue = useMemo(() => truncate(value), [value]);
+  return (
+    <div
+      data-always-truncate={alwaysTruncate ? "" : undefined}
+      className={styles.address}
+    >
+      <Link
+        href={explorer.replace("$ADDRESS", value)}
+        target="_blank"
+        rel="noreferrer"
+      >
+        <code className={styles.truncated}>{truncatedValue}</code>
+        <code className={styles.full}>{value}</code>
+      </Link>
+      <CopyButton text={value} />
+    </div>
+  );
+};
+
+const Status = ({
+  request,
+}: {
+  request: Awaited<ReturnType<typeof getRequestsForChain>>[number];
+}) => {
+  switch (getStatus(request)) {
+    case "error": {
+      return <StatusImpl variant="error">FAILED</StatusImpl>;
+    }
+    case "success": {
+      return <StatusImpl variant="success">SUCCESS</StatusImpl>;
+    }
+    case "pending": {
+      return (
+        <StatusImpl variant="disabled" style="outline">
+          PENDING
+        </StatusImpl>
+      );
+    }
+  }
+};
+
+const defaultProps = {
+  label: "Requests",
+  rounded: true,
+  fill: true,
+  columns: [
+    {
+      id: "sequenceNumber" as const,
+      name: "SEQUENCE NUMBER",
+      alignment: "center",
+      width: 20,
+    },
+    {
+      id: "timestamp" as const,
+      name: "TIMESTAMP",
+    },
+    {
+      id: "txHash" as const,
+      name: "TRANSACTION HASH",
+      width: 30,
+    },
+    {
+      id: "provider" as const,
+      name: "PROVIDER",
+      width: 30,
+    },
+    {
+      id: "caller" as const,
+      name: "CALLER",
+      width: 30,
+    },
+    {
+      id: "status" as const,
+      name: "STATUS",
+      alignment: "center",
+      width: 25,
+    },
+  ],
+} satisfies Partial<ComponentProps<typeof Table<string>>>;
+
+const truncate = (value: string) => `${value.slice(0, 6)}...${value.slice(-4)}`;
+
+const getStatus = (
+  request: Awaited<ReturnType<typeof getRequestsForChain>>[number],
+) => {
+  if (request.hasCallbackCompleted) {
+    return request.callbackResult.failed ? "error" : "success";
+  } else {
+    return "pending";
+  }
+};

+ 37 - 0
apps/entropy-explorer/src/components/Home/search-bar.tsx

@@ -0,0 +1,37 @@
+"use client";
+
+import { SearchInput } from "@pythnetwork/component-library/SearchInput";
+import type { ComponentProps } from "react";
+import { Suspense } from "react";
+
+import { useQuery } from "./use-query";
+import type { ConstrainedOmit } from "../../type-utils";
+
+export const SearchBar = (props: ComponentProps<typeof ResolvedSearchBar>) => (
+  <Suspense fallback={<SearchInput isPending {...defaultProps} {...props} />}>
+    <ResolvedSearchBar {...props} />
+  </Suspense>
+);
+
+const ResolvedSearchBar = (
+  props: ConstrainedOmit<
+    ComponentProps<typeof SearchInput>,
+    keyof typeof defaultProps | "value" | "onChange"
+  >,
+) => {
+  const { search, setSearch } = useQuery();
+
+  return (
+    <SearchInput
+      {...defaultProps}
+      {...props}
+      value={search}
+      onChange={setSearch}
+    />
+  );
+};
+
+const defaultProps = {
+  size: "sm",
+  placeholder: "Sequence number, provider, caller or tx hash",
+} as const;

+ 48 - 0
apps/entropy-explorer/src/components/Home/use-query.ts

@@ -0,0 +1,48 @@
+import { useLogger } from "@pythnetwork/component-library/useLogger";
+import { useQueryStates, parseAsString, parseAsStringEnum } from "nuqs";
+import { useCallback } from "react";
+
+import { EntropyDeployments } from "../../entropy-deployments";
+
+const queryParams = {
+  search: parseAsString.withDefault(""),
+  chain: parseAsStringEnum<keyof typeof EntropyDeployments>(
+    Object.keys(EntropyDeployments) as (keyof typeof EntropyDeployments)[],
+  ),
+};
+
+export const useQuery = () => {
+  const logger = useLogger();
+  const [{ search, chain }, setQuery] = useQueryStates(queryParams);
+
+  const updateQuery = useCallback(
+    (newQuery: Parameters<typeof setQuery>[0]) => {
+      setQuery(newQuery).catch((error: unknown) => {
+        logger.error("Failed to update query", error);
+      });
+    },
+    [setQuery, logger],
+  );
+
+  const setSearch = useCallback(
+    (newSearch: string) => {
+      updateQuery({ search: newSearch });
+    },
+    [updateQuery],
+  );
+
+  const setChain = useCallback(
+    (newChain: keyof typeof EntropyDeployments | undefined) => {
+      // eslint-disable-next-line unicorn/no-null
+      updateQuery({ chain: newChain ?? null });
+    },
+    [updateQuery],
+  );
+
+  return {
+    search,
+    chain,
+    setSearch,
+    setChain,
+  };
+};

+ 21 - 0
apps/entropy-explorer/src/components/Root/evm-provider.tsx

@@ -0,0 +1,21 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { mainnet } from "viem/chains";
+import { WagmiProvider, createConfig, http } from "wagmi";
+
+// We only use wagmi because we use connectkit to get chain icons, and
+// connectkit blows up if there isn't a wagmi context initialized.  However, the
+// wagmi config isn't actually used when fetching chain icons.  But wagmi
+// requires at least one chain to create a config, so we'll just inject mainnet
+// here to make everyone happy.
+export const EvmProvider = ({ children }: { children: ReactNode }) => (
+  <WagmiProvider
+    config={createConfig({
+      chains: [mainnet],
+      transports: { [mainnet.id]: http() },
+    })}
+  >
+    {children}
+  </WagmiProvider>
+);

+ 30 - 0
apps/entropy-explorer/src/components/Root/index.tsx

@@ -0,0 +1,30 @@
+import { AppShell } from "@pythnetwork/component-library/AppShell";
+import { NuqsAdapter } from "nuqs/adapters/next/app";
+import type { ReactNode } from "react";
+
+import { EvmProvider } from "./evm-provider";
+import {
+  ENABLE_ACCESSIBILITY_REPORTING,
+  GOOGLE_ANALYTICS_ID,
+  AMPLITUDE_API_KEY,
+} from "../../config/server";
+
+type Props = {
+  children: ReactNode;
+};
+
+export const Root = ({ children }: Props) => (
+  <AppShell
+    appName="Entropy Explorer"
+    amplitudeApiKey={AMPLITUDE_API_KEY}
+    googleAnalyticsId={GOOGLE_ANALYTICS_ID}
+    enableAccessibilityReporting={ENABLE_ACCESSIBILITY_REPORTING}
+    mainCta={{
+      label: "Entropy Docs",
+      href: "https://docs.pyth.network/entropy",
+    }}
+    providers={[EvmProvider, NuqsAdapter]}
+  >
+    {children}
+  </AppShell>
+);

+ 13 - 0
apps/entropy-explorer/src/config/isomorphic.ts

@@ -0,0 +1,13 @@
+/* eslint-disable n/no-process-env */
+
+/**
+ * Indicates this is a production-optimized build.  Note this does NOT
+ * necessarily indicate that we're running on a cloud machine or the live build
+ * -- use `RUNNING_IN_CLOUD` or `IS_PRODUCTION_SERVER` out of `config/server.ts`
+ * for that (if you need that on the client you'll need to write a client
+ * component that receives that value as a prop).
+ *
+ * Basically this indicates if we're minified, excluding source maps, running
+ * with the optimized React build, etc.
+ */
+export const IS_PRODUCTION_BUILD = process.env.NODE_ENV === "production";

+ 30 - 0
apps/entropy-explorer/src/config/server.ts

@@ -0,0 +1,30 @@
+// Disable the following rule because this file is the intended place to declare
+// and load all env variables.
+/* eslint-disable n/no-process-env */
+
+import "server-only";
+
+const getEnvOrDefault = (key: string, defaultValue: string) =>
+  process.env[key] ?? defaultValue;
+
+/**
+ * Indicates that this server is the live customer-facing production server.
+ */
+export const IS_PRODUCTION_SERVER = process.env.VERCEL_ENV === "production";
+
+const defaultInProduction = IS_PRODUCTION_SERVER
+  ? getEnvOrDefault
+  : (key: string) => process.env[key];
+
+export const GOOGLE_ANALYTICS_ID = defaultInProduction(
+  "GOOGLE_ANALYTICS_ID",
+  "G-E1QSY256EQ",
+);
+
+export const AMPLITUDE_API_KEY = defaultInProduction(
+  "AMPLITUDE_API_KEY",
+  "6faa78c51eff33087eb19f0f3dc76f33",
+);
+
+export const ENABLE_ACCESSIBILITY_REPORTING =
+  !IS_PRODUCTION_SERVER && !process.env.DISABLE_ACCESSIBILITY_REPORTING;

+ 471 - 0
apps/entropy-explorer/src/entropy-deployments.ts

@@ -0,0 +1,471 @@
+import type * as viemChains from "viem/chains";
+
+export type EntropyDeployment = {
+  address: string;
+  network: "mainnet" | "testnet";
+  explorer: string;
+  delay: string;
+  gasLimit: string;
+  rpc?: string;
+  nativeCurrency: string;
+};
+
+export const EntropyDeployments = {
+  berachain: {
+    address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+    network: "mainnet",
+    explorer: "https://berascan.com/address/$ADDRESS",
+    delay: "1 block",
+    gasLimit: "2.5M",
+    rpc: "https://rpc.berachain.com",
+    nativeCurrency: "BERA",
+  },
+  blast: {
+    address: "0x5744Cbf430D99456a0A8771208b674F27f8EF0Fb",
+    network: "mainnet",
+    explorer: "https://blastscan.io/address/$ADDRESS",
+    delay: "1 block",
+    gasLimit: "500K",
+    rpc: "https://rpc.blast.io",
+    nativeCurrency: "ETH",
+  },
+  lightlinkPhoenix: {
+    address: "0x98046Bd286715D3B0BC227Dd7a956b83D8978603",
+    network: "mainnet",
+    explorer: "https://phoenix.lightlink.io/address/$ADDRESS",
+    delay: "1 block",
+    gasLimit: "500K",
+    rpc: "https://replicator.phoenix.lightlink.io/rpc/v1",
+    nativeCurrency: "ETH",
+  },
+  chiliz: {
+    address: "0x0708325268dF9F66270F1401206434524814508b",
+    network: "mainnet",
+    explorer: "https://scan.chiliz.com/address/$ADDRESS",
+    delay: "12 blocks",
+    gasLimit: "500K",
+    rpc: "https://rpc.ankr.com/chiliz",
+    nativeCurrency: "CHZ",
+  },
+  arbitrum: {
+    address: "0x7698E925FfC29655576D0b361D75Af579e20AdAc",
+    network: "mainnet",
+    explorer: "https://arbiscan.io/address/$ADDRESS",
+    delay: "6 blocks",
+    gasLimit: "2.5M",
+    rpc: "https://arb1.arbitrum.io/rpc",
+    nativeCurrency: "ETH",
+  },
+  optimism: {
+    address: "0xdF21D137Aadc95588205586636710ca2890538d5",
+    network: "mainnet",
+    explorer: "https://optimistic.etherscan.io/address/$ADDRESS",
+    delay: "2 blocks",
+    gasLimit: "500K",
+    rpc: "https://optimism.llamarpc.com",
+    nativeCurrency: "ETH",
+  },
+  mode: {
+    address: "0x8D254a21b3C86D32F7179855531CE99164721933",
+    network: "mainnet",
+    explorer: "https://explorer.mode.network/address/$ADDRESS",
+    delay: "2 blocks",
+    gasLimit: "500K",
+    rpc: "https://mainnet.mode.network/",
+    nativeCurrency: "ETH",
+  },
+  zetachain: {
+    address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+    network: "mainnet",
+    explorer: "https://zetachain.blockscout.com/address/$ADDRESS",
+    delay: "0 block",
+    gasLimit: "500K",
+    rpc: "https://zetachain-evm.blockpi.network/v1/rpc/public",
+    nativeCurrency: "ZETA",
+  },
+  base: {
+    address: "0x6E7D74FA7d5c90FEF9F0512987605a6d546181Bb",
+    network: "mainnet",
+    explorer: "https://basescan.org/address/$ADDRESS",
+    delay: "1 block",
+    gasLimit: "500K",
+    rpc: "https://developer-access-mainnet.base.org/",
+    nativeCurrency: "ETH",
+  },
+  lightlinkPegasus: {
+    rpc: "https://replicator.pegasus.lightlink.io/rpc/v1",
+    network: "testnet",
+    delay: "",
+    address: "0x8250f4aF4B972684F7b336503E2D6dFeDeB1487a",
+    explorer: "https://pegasus.lightlink.io/address/$ADDRESS",
+    gasLimit: "500K",
+    nativeCurrency: "ETH",
+  },
+  spicy: {
+    rpc: "https://spicy-rpc.chiliz.com",
+    network: "testnet",
+    delay: "",
+    address: "0xD458261E832415CFd3BAE5E416FdF3230ce6F134",
+    explorer: "https://spicy-explorer.chiliz.com/address/$ADDRESS",
+    gasLimit: "500K",
+    nativeCurrency: "CHZ",
+  },
+  confluxESpaceTestnet: {
+    rpc: "https://evmtestnet.confluxrpc.com",
+    network: "testnet",
+    delay: "",
+    address: "0xdF21D137Aadc95588205586636710ca2890538d5",
+    explorer: "https://evmtestnet.confluxscan.org/address/$ADDRESS",
+    gasLimit: "500K",
+    nativeCurrency: "CFX",
+  },
+  modeTestnet: {
+    rpc: "https://sepolia.mode.network/",
+    network: "testnet",
+    delay: "",
+    address: "0x98046Bd286715D3B0BC227Dd7a956b83D8978603",
+    explorer: "https://sepolia.explorer.mode.network/address/$ADDRESS",
+    gasLimit: "500K",
+    nativeCurrency: "ETH",
+  },
+  seiTestnet: {
+    rpc: "https://evm-rpc-testnet.sei-apis.com",
+    network: "testnet",
+    delay: "",
+    address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+    explorer: "https://seitrace.com/address/$ADDRESS?chain=atlantic-2",
+    gasLimit: "500K",
+    nativeCurrency: "SEI",
+  },
+  arbitrumSepolia: {
+    rpc: "https://sepolia-rollup.arbitrum.io/rpc",
+    network: "testnet",
+    delay: "",
+    address: "0x549Ebba8036Ab746611B4fFA1423eb0A4Df61440",
+    explorer: "https://sepolia.arbiscan.io/address/$ADDRESS",
+    gasLimit: "2.5M",
+    nativeCurrency: "ETH",
+  },
+  blastSepolia: {
+    rpc: "https://sepolia.blast.io",
+    network: "testnet",
+    delay: "",
+    address: "0x98046Bd286715D3B0BC227Dd7a956b83D8978603",
+    explorer: "https://testnet.blastscan.io/address/$ADDRESS",
+    gasLimit: "500K",
+    nativeCurrency: "ETH",
+  },
+  optimismSepolia: {
+    rpc: "https://api.zan.top/opt-sepolia",
+    network: "testnet",
+    delay: "",
+    address: "0x4821932D0CDd71225A6d914706A621e0389D7061",
+    explorer: "https://optimism-sepolia.blockscout.com/address/$ADDRESS",
+    gasLimit: "500K",
+    nativeCurrency: "ETH",
+  },
+  baseSepolia: {
+    rpc: "https://sepolia.base.org",
+    network: "testnet",
+    delay: "",
+    address: "0x41c9e39574F40Ad34c79f1C99B66A45eFB830d4c",
+    explorer: "https://base-sepolia.blockscout.com/address/$ADDRESS",
+    gasLimit: "500K",
+    nativeCurrency: "ETH",
+  },
+  berachainTestnetbArtio: {
+    rpc: "https://evm-rpc-bera.rhino-apis.com/",
+    network: "testnet",
+    delay: "",
+    address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+    explorer: "https://bartio.beratrail.io/address/$ADDRESS",
+    gasLimit: "2.5M",
+    nativeCurrency: "BERA",
+  },
+  // coreTestnet1: {
+  //   rpc: "https://rpc.test.btcs.network",
+  //   network: "testnet",
+  //   delay: "",
+  //   address: "0xf0a1b566B55e0A0CB5BeF52Eb2a57142617Bee67",
+  //   explorer: "https://scan.test.btcs.network/address/$ADDRESS",
+  //   gasLimit: "500K",
+  //   nativeCurrency: "tCORE",
+  // },
+  zetachainAthensTestnet: {
+    rpc: "https://zetachain-athens-evm.blockpi.network/v1/rpc/public",
+    network: "testnet",
+    delay: "",
+    address: "0x4374e5a8b9C22271E9EB878A2AA31DE97DF15DAF",
+    explorer: "https://explorer.zetachain.com/address/$ADDRESS",
+    gasLimit: "500K",
+    nativeCurrency: "ZETA",
+  },
+  taikoHekla: {
+    rpc: "https://rpc.hekla.taiko.xyz/",
+    network: "testnet",
+    delay: "",
+    address: "0x98046Bd286715D3B0BC227Dd7a956b83D8978603",
+    explorer: "https://hekla.taikoscan.network/address/$ADDRESS",
+    gasLimit: "500K",
+    nativeCurrency: "ETH",
+  },
+  // orangeTestnet: {
+  //   address: "0x98046Bd286715D3B0BC227Dd7a956b83D8978603",
+  //   explorer: "https://subnets-test.avax.network/orangetest/address/$ADDRESS",
+  //   delay: "",
+  //   gasLimit: "500K",
+  //   network: "testnet",
+  //   rpc: "https://subnets.avax.network/orangetest/testnet/rpc",
+  //   nativeCurrency: "JUICE",
+  // },
+  sei: {
+    address: "0x98046Bd286715D3B0BC227Dd7a956b83D8978603",
+    explorer: "https://seitrace.com/address/$ADDRESS?chain=pacific-1",
+    delay: "1 block",
+    gasLimit: "500K",
+    network: "mainnet",
+    rpc: "https://evm-rpc.sei-apis.com",
+    nativeCurrency: "SEI",
+  },
+  merlin: {
+    address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+    explorer: "https://scan.merlinchain.io/address/$ADDRESS",
+    delay: "1 block",
+    gasLimit: "500K",
+    network: "mainnet",
+    rpc: "https://rpc.merlinchain.io",
+    nativeCurrency: "BTC",
+  },
+  // merlinTestnet: {
+  //   address: "0x5744Cbf430D99456a0A8771208b674F27f8EF0Fb",
+  //   explorer: "https://testnet-scan.merlinchain.io/address/$ADDRESS",
+  //   delay: "",
+  //   gasLimit: "500K",
+  //   network: "testnet",
+  //   rpc: "https://testnet-rpc.merlinchain.io/",
+  //   nativeCurrency: "BTC",
+  // },
+  taiko: {
+    address: "0x26DD80569a8B23768A1d80869Ed7339e07595E85",
+    explorer: "https://taikoscan.io/address/$ADDRESS",
+    delay: "1 block",
+    gasLimit: "500K",
+    network: "mainnet",
+    rpc: "https://rpc.mainnet.taiko.xyz",
+    nativeCurrency: "ETH",
+  },
+  etherlinkTestnet: {
+    address: "0x23f0e8FAeE7bbb405E7A7C3d60138FCfd43d7509",
+    explorer: "https://testnet.explorer.etherlink.com/address/$ADDRESS",
+    delay: "",
+    gasLimit: "15M",
+    network: "testnet",
+    rpc: "https://node.ghostnet.etherlink.com",
+    nativeCurrency: "XTZ",
+  },
+  etherlink: {
+    address: "0x23f0e8FAeE7bbb405E7A7C3d60138FCfd43d7509",
+    explorer: "https://explorer.etherlink.com/address/$ADDRESS",
+    delay: "1 block",
+    gasLimit: "15M",
+    network: "mainnet",
+    rpc: "https://node.mainnet.etherlink.com/",
+    nativeCurrency: "XTZ",
+  },
+  kaia: {
+    address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+    explorer: "https://kaiascan.io/address/$ADDRESS",
+    delay: "1 block",
+    gasLimit: "500K",
+    network: "mainnet",
+    rpc: "https://rpc.ankr.com/klaytn",
+    nativeCurrency: "KLAY",
+  },
+  kairos: {
+    address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+    explorer: "https://kairos.kaiascan.io/address/$ADDRESS",
+    delay: "",
+    gasLimit: "500K",
+    network: "testnet",
+    rpc: "https://rpc.ankr.com/klaytn_testnet",
+    nativeCurrency: "KLAY",
+  },
+  // tabiTestnet: {
+  //   address: "0xEbe57e8045F2F230872523bbff7374986E45C486",
+  //   explorer: "https://testnetv2.tabiscan.com/address/$ADDRESS",
+  //   delay: "",
+  //   gasLimit: "500K",
+  //   network: "testnet",
+  //   rpc: "https://rpc.testnetv2.tabichain.com",
+  //   nativeCurrency: "TABI",
+  // },
+  // b3Testnet: {
+  //   address: "0x5744Cbf430D99456a0A8771208b674F27f8EF0Fb",
+  //   explorer: "https://sepolia.explorer.b3.fun/address/$ADDRESS",
+  //   delay: "",
+  //   gasLimit: "500K",
+  //   network: "testnet",
+  //   rpc: "https://sepolia.b3.fun/http/",
+  //   nativeCurrency: "ETH",
+  // },
+  // b3: {
+  //   address: "0x5744Cbf430D99456a0A8771208b674F27f8EF0Fb",
+  //   explorer: "https://explorer.b3.fun/address/$ADDRESS",
+  //   delay: "1 block",
+  //   gasLimit: "500K",
+  //   network: "mainnet",
+  //   rpc: "https://mainnet-rpc.b3.fun/http",
+  //   nativeCurrency: "ETH",
+  // },
+  // apechainTestnet: {
+  //   address: "0x23f0e8FAeE7bbb405E7A7C3d60138FCfd43d7509",
+  //   explorer: "https://curtis.explorer.caldera.xyz/address/$ADDRESS",
+  //   delay: "",
+  //   gasLimit: "500K",
+  //   network: "testnet",
+  //   rpc: "https://curtis.rpc.caldera.xyz/http",
+  //   nativeCurrency: "APE",
+  // },
+  // soneiumMinatoTestnet: {
+  //   address: "0x23f0e8FAeE7bbb405E7A7C3d60138FCfd43d7509",
+  //   explorer: "https://explorer-testnet.soneium.org/address/$ADDRESS",
+  //   delay: "",
+  //   gasLimit: "500K",
+  //   network: "testnet",
+  //   rpc: "https://rpc.minato.soneium.org/",
+  //   nativeCurrency: "ETH",
+  // },
+  // sanko: {
+  //   address: "0x5744Cbf430D99456a0A8771208b674F27f8EF0Fb",
+  //   explorer: "https://explorer.sanko.xyz/address/$ADDRESS",
+  //   delay: "1 block",
+  //   gasLimit: "500K",
+  //   network: "mainnet",
+  //   rpc: "https://mainnet.sanko.xyz",
+  //   nativeCurrency: "DMT",
+  // },
+  // sankoTestnet: {
+  //   address: "0x5744Cbf430D99456a0A8771208b674F27f8EF0Fb",
+  //   explorer: "https://sanko-arb-sepolia.explorer.caldera.xyz/address/$ADDRESS",
+  //   delay: "",
+  //   gasLimit: "500K",
+  //   network: "testnet",
+  //   rpc: "https://sanko-arb-sepolia.rpc.caldera.xyz/http",
+  //   nativeCurrency: "DMT",
+  // },
+  // apechain: {
+  //   address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+  //   explorer: "https://apechain.calderaexplorer.xyz/address/$ADDRESS",
+  //   delay: "1 block",
+  //   gasLimit: "500K",
+  //   network: "mainnet",
+  //   rpc: "https://apechain.calderachain.xyz/http",
+  //   nativeCurrency: "APE",
+  // },
+  // abstractTestnet: {
+  //   address: "0x858687fD592112f7046E394A3Bf10D0C11fF9e63",
+  //   explorer: "https://explorer.testnet.abs.xyz/address/$ADDRESS",
+  //   delay: "",
+  //   gasLimit: "500K",
+  //   network: "testnet",
+  //   rpc: "https://api.testnet.abs.xyz",
+  //   nativeCurrency: "ETH",
+  // },
+  // sonicBlazeTestnet: {
+  //   address: "0xebe57e8045f2f230872523bbff7374986e45c486",
+  //   explorer: "https://blaze.soniclabs.com/address/$ADDRESS",
+  //   delay: "",
+  //   gasLimit: "500K",
+  //   network: "testnet",
+  //   rpc: "https://rpc.blaze.soniclabs.com",
+  //   nativeCurrency: "S",
+  // },
+  // unichainSepolia: {
+  //   address: "0x8D254a21b3C86D32F7179855531CE99164721933",
+  //   explorer: "https://unichain-sepolia.blockscout.com/address/$ADDRESS",
+  //   delay: "",
+  //   gasLimit: "500K",
+  //   network: "testnet",
+  //   rpc: "https://sepolia.unichain.org",
+  //   nativeCurrency: "ETH",
+  // },
+  // sonic: {
+  //   address: "0x36825bf3fbdf5a29e2d5148bfe7dcf7b5639e320",
+  //   explorer: "https://sonicscan.org/address/$ADDRESS",
+  //   delay: "1 block",
+  //   gasLimit: "500K",
+  //   network: "mainnet",
+  //   rpc: "https://rpc.soniclabs.com",
+  //   nativeCurrency: "S",
+  // },
+  // storyTestnet: {
+  //   address: "0x5744Cbf430D99456a0A8771208b674F27f8EF0Fb",
+  //   explorer: "https://aeneid.storyscan.xyz/address/$ADDRESS",
+  //   delay: "",
+  //   gasLimit: "500K",
+  //   network: "testnet",
+  //   rpc: "https://aeneid.storyrpc.io",
+  //   nativeCurrency: "IP",
+  // },
+  // monadTestnet: {
+  //   address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+  //   explorer: "https://testnet.monadexplorer.com/address/$ADDRESS",
+  //   delay: "",
+  //   gasLimit: "500K",
+  //   network: "testnet",
+  //   rpc: "https://testnet-rpc.monad.xyz",
+  //   nativeCurrency: "MON",
+  // },
+  // abstract: {
+  //   address: "0x5a4a369F4db5df2054994AF031b7b23949b98c0e",
+  //   explorer: "https://abscan.org/address/$ADDRESS",
+  //   delay: "1 block",
+  //   gasLimit: "500K",
+  //   network: "mainnet",
+  //   rpc: "https://api.mainnet.abs.xyz",
+  //   nativeCurrency: "ETH",
+  // },
+  // story: {
+  //   address: "0xdF21D137Aadc95588205586636710ca2890538d5",
+  //   explorer: "https://storyscan.xyz/address/$ADDRESS",
+  //   delay: "",
+  //   gasLimit: "500K",
+  //   network: "mainnet",
+  //   rpc: "https://mainnet.storyrpc.io",
+  //   nativeCurrency: "IP",
+  // },
+  // berachainBepolia: {
+  //   address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+  //   explorer: "https://bepolia.beratrail.io/address/$ADDRESS",
+  //   delay: "",
+  //   gasLimit: "500K",
+  //   network: "testnet",
+  //   rpc: "https://bepolia.rpc.berachain.com",
+  //   nativeCurrency: "BERA",
+  // },
+  // hyperevm: {
+  //   address: "0xfA25E653b44586dBbe27eE9d252192F0e4956683",
+  //   explorer: "https://hyperliquid.cloud.blockscout.com/address/$ADDRESS",
+  //   delay: "",
+  //   gasLimit: "500K",
+  //   network: "mainnet",
+  //   rpc: "https://rpc.hyperliquid.xyz/evm",
+  //   nativeCurrency: "HYPE",
+  // },
+  soneium: {
+    address: "0x0708325268dF9F66270F1401206434524814508b",
+    explorer: "https://soneium.blockscout.com/address/$ADDRESS",
+    delay: "1 block",
+    gasLimit: "500K",
+    network: "mainnet",
+    rpc: "https://soneium.drpc.org",
+    nativeCurrency: "ETH",
+  },
+} as const satisfies Partial<
+  Record<keyof typeof viemChains, EntropyDeployment>
+>;
+
+export const isValidDeployment = (
+  name: string,
+): name is keyof typeof EntropyDeployments =>
+  Object.prototype.hasOwnProperty.call(EntropyDeployments, name);

+ 71 - 0
apps/entropy-explorer/src/get-requests-for-chain.ts

@@ -0,0 +1,71 @@
+import { z } from "zod";
+
+import type { EntropyDeployments } from "./entropy-deployments";
+
+export const getRequestsForChain = async (
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  _chain: keyof typeof EntropyDeployments,
+) => {
+  await new Promise((resolve) => setTimeout(resolve, 1000));
+
+  return resultSchema.parse(
+    range(20).map((i) => {
+      const completed = randomBoolean();
+      return {
+        sequenceNumber: i,
+        provider: `0x${randomHex(42)}`,
+        caller: `0x${randomHex(42)}`,
+        txHash: `0x${randomHex(42)}`,
+        gasLimit: randomBetween(10_000, 1_000_000),
+        timestamp: new Date().toLocaleString(),
+        hasCallbackCompleted: completed,
+        ...(completed && {
+          callbackResult: {
+            failed: randomBoolean(),
+            randomNumber: `0x${randomHex(10)}`,
+            returnValue: `0x${randomHex(10)}`, // "0xabcd1234", // will need to decode this in frontend. If failed == true, this contains the error code + additional debugging data. If it's "" and gasUsed is >= gasLimit, then it's an out of gas error.
+            gasUsed: randomBetween(1000, 1_000_000),
+            timestamp: new Date().toLocaleString(), // datetime in some reasonable format
+          },
+        }),
+      };
+    }),
+  );
+};
+
+const schemaBase = z.strictObject({
+  sequenceNumber: z.number(),
+  provider: z.string(),
+  caller: z.string(),
+  txHash: z.string(),
+  gasLimit: z.number(),
+  timestamp: z.string().transform((value) => new Date(value)),
+});
+const inProgressRequestScehma = schemaBase.extend({
+  hasCallbackCompleted: z.literal(false),
+});
+const completedRequestSchema = schemaBase.extend({
+  hasCallbackCompleted: z.literal(true),
+  callbackResult: z.strictObject({
+    failed: z.boolean(),
+    randomNumber: z.string(),
+    returnValue: z.string(),
+    gasUsed: z.number(),
+    timestamp: z.string().transform((value) => new Date(value)),
+  }),
+});
+const resultSchema = z.array(
+  z.union([inProgressRequestScehma, completedRequestSchema]),
+);
+
+const range = (i: number) => [...Array.from({ length: i }).keys()];
+
+const randomBetween = (min: number, max: number) =>
+  Math.random() * (max - min) + min;
+
+const randomBoolean = (): boolean => Math.random() < 0.5;
+
+const randomHex = (length: number) =>
+  Array.from({ length })
+    .map(() => Math.floor(Math.random() * 16).toString(16))
+    .join("");

+ 52 - 0
apps/entropy-explorer/src/metadata.ts

@@ -0,0 +1,52 @@
+import type { Metadata, Viewport } from "next";
+
+export const metadata = {
+  metadataBase: new URL("https://entropy-explorer.pyth.network"),
+  title: {
+    default: "Pyth Network Entropy Explorer",
+    template: "%s | Pyth Network Entropy Explorer",
+  },
+  applicationName: "Pyth Network Entropy Explorer",
+  description:
+    "Explore interactions with the Pyth Network Entropy on-chain random number generator.",
+  referrer: "strict-origin-when-cross-origin",
+  openGraph: {
+    type: "website",
+  },
+  twitter: {
+    creator: "@PythNetwork",
+    card: "summary_large_image",
+  },
+  icons: {
+    icon: [
+      {
+        media: "(prefers-color-scheme: light)",
+        type: "image/x-icon",
+        url: "/favicon.ico",
+      },
+      {
+        media: "(prefers-color-scheme: dark)",
+        type: "image/x-icon",
+        url: "/favicon-light.ico",
+      },
+      {
+        type: "image/png",
+        sizes: "32x32",
+        url: "/favicon-32x32.png",
+      },
+      {
+        type: "image/png",
+        sizes: "16x16",
+        url: "/favicon-16x16.png",
+      },
+    ],
+    apple: {
+      url: "/apple-touch-icon.png",
+      sizes: "180x180",
+    },
+  },
+} satisfies Metadata;
+
+export const viewport = {
+  themeColor: "#242235",
+} satisfies Viewport;

+ 4 - 0
apps/entropy-explorer/src/type-utils.ts

@@ -0,0 +1,4 @@
+export type ConstrainedOmit<T, K> = {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  [P in keyof T as Exclude<P, K & keyof any>]: T[P];
+};

+ 21 - 0
apps/entropy-explorer/stylelint.config.js

@@ -0,0 +1,21 @@
+import standardScss from "stylelint-config-standard-scss";
+
+const config = {
+  extends: standardScss,
+  rules: {
+    "selector-class-pattern": [
+      "^[a-z][a-zA-Z0-9]+$",
+      {
+        message: (selector) =>
+          `Expected class selector "${selector}" to be camel-case`,
+      },
+    ],
+    "selector-pseudo-class-no-unknown": [
+      true,
+      {
+        ignorePseudoClasses: ["global", "export"],
+      },
+    ],
+  },
+};
+export default config;

+ 6 - 0
apps/entropy-explorer/svg.d.ts

@@ -0,0 +1,6 @@
+declare module "*.svg" {
+  import type { ReactElement, SVGProps } from "react";
+
+  const content: (props: SVGProps<SVGElement>) => ReactElement;
+  export default content;
+}

+ 5 - 0
apps/entropy-explorer/tsconfig.json

@@ -0,0 +1,5 @@
+{
+  "extends": "@cprussin/tsconfig/nextjs.json",
+  "include": ["svg.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+  "exclude": ["node_modules"]
+}

+ 41 - 0
apps/entropy-explorer/turbo.json

@@ -0,0 +1,41 @@
+{
+  "$schema": "https://turbo.build/schema.json",
+  "extends": ["//"],
+  "tasks": {
+    "build:vercel": {
+      "env": [
+        "VERCEL_ENV",
+        "GOOGLE_ANALYTICS_ID",
+        "AMPLITUDE_API_KEY",
+        "DISABLE_ACCESSIBILITY_REPORTING"
+      ]
+    },
+    "fix:lint": {
+      "dependsOn": [
+        "//#install:modules",
+        "fix:lint:eslint",
+        "fix:lint:stylelint"
+      ]
+    },
+    "fix:lint:eslint": {
+      "dependsOn": ["//#install:modules", "^build"],
+      "cache": false
+    },
+    "fix:lint:stylelint": {
+      "dependsOn": ["//#install:modules"],
+      "cache": false
+    },
+    "start:prod": {
+      "dependsOn": ["//#install:modules", "build:vercel"]
+    },
+    "test:lint": {
+      "dependsOn": ["test:lint:eslint", "test:lint:stylelint"]
+    },
+    "test:lint:eslint": {
+      "dependsOn": ["//#install:modules", "^build"]
+    },
+    "test:lint:stylelint": {
+      "dependsOn": ["//#install:modules"]
+    }
+  }
+}

+ 5 - 0
apps/entropy-explorer/vercel.json

@@ -0,0 +1,5 @@
+{
+  "$schema": "https://openapi.vercel.sh/vercel.json",
+  "ignoreCommand": "../../vercel-ignore.sh",
+  "buildCommand": "turbo run build:vercel --filter @pythnetwork/entropy-explorer"
+}

+ 1 - 1
apps/insights/src/app/error.ts

@@ -1,3 +1,3 @@
 "use client";
 
-export { Error as default } from "../components/Error";
+export { ErrorPage as default } from "@pythnetwork/component-library/ErrorPage";

+ 3 - 4
apps/insights/src/app/global-error.tsx

@@ -1,15 +1,14 @@
 "use client";
 
+import { ErrorPage } from "@pythnetwork/component-library/ErrorPage";
 import { LoggerProvider } from "@pythnetwork/component-library/useLogger";
 import type { ComponentProps } from "react";
 
-import { Error } from "../components/Error";
-
-const GlobalError = (props: ComponentProps<typeof Error>) => (
+const GlobalError = (props: ComponentProps<typeof ErrorPage>) => (
   <LoggerProvider>
     <html lang="en" dir="ltr">
       <body>
-        <Error {...props} />
+        <ErrorPage {...props} />
       </body>
     </html>
   </LoggerProvider>

+ 1 - 1
apps/insights/src/app/not-found.ts

@@ -1 +1 @@
-export { NotFound as default } from "../components/NotFound";
+export { NotFoundPage as default } from "@pythnetwork/component-library/NotFoundPage";

+ 1 - 1
apps/insights/src/app/price-feeds/[slug]/error.ts

@@ -1,3 +1,3 @@
 "use client";
 
-export { Error as default } from "../../../components/Error";
+export { ErrorPage as default } from "@pythnetwork/component-library/ErrorPage";

+ 1 - 1
apps/insights/src/app/publishers/[cluster]/[key]/error.ts

@@ -1,3 +1,3 @@
 "use client";
 
-export { Error as default } from "../../../../components/Error";
+export { ErrorPage as default } from "@pythnetwork/component-library/ErrorPage";

+ 1 - 1
apps/insights/src/components/FeedKey/index.tsx

@@ -1,8 +1,8 @@
+import { CopyButton } from "@pythnetwork/component-library/CopyButton";
 import type { ComponentProps } from "react";
 import { useMemo } from "react";
 
 import { toHex, truncateHex } from "../../hex";
-import { CopyButton } from "../CopyButton";
 
 type OwnProps = {
   feedKey: string;

+ 1 - 1
apps/insights/src/components/PriceComponentDrawer/index.module.scss

@@ -33,7 +33,7 @@
     display: none;
 
     @include theme.breakpoint("md") {
-      display: inline flow-root;
+      display: inline-flex;
     }
   }
 

+ 2 - 2
apps/insights/src/components/PriceComponentDrawer/index.tsx

@@ -9,6 +9,7 @@ import { Spinner } from "@pythnetwork/component-library/Spinner";
 import { StatCard } from "@pythnetwork/component-library/StatCard";
 import { Table } from "@pythnetwork/component-library/Table";
 import type { Button as UnstyledButton } from "@pythnetwork/component-library/unstyled/Button";
+import { StateType, useData } from "@pythnetwork/component-library/useData";
 import { useDrawer } from "@pythnetwork/component-library/useDrawer";
 import { useLogger } from "@pythnetwork/component-library/useLogger";
 import { useMountEffect } from "@react-hookz/web";
@@ -27,7 +28,6 @@ import type { CategoricalChartState } from "recharts/types/chart/types";
 import { z } from "zod";
 
 import styles from "./index.module.scss";
-import { StateType, useData } from "../../hooks/use-data";
 import { Cluster, ClusterToName } from "../../services/pyth";
 import type { Status } from "../../status";
 import { LiveConfidence, LivePrice, LiveComponentValue } from "../LivePrices";
@@ -353,7 +353,7 @@ const ScoreBreakdown = ({
               setSelectedPeriod(evaluationPeriod);
             }
           }}
-          options={evaluationPeriods.map(({ label }) => label)}
+          options={evaluationPeriods.map(({ label }) => ({ id: label }))}
           placement="bottom end"
         />
       }

+ 8 - 8
apps/insights/src/components/PriceComponentsCard/index.tsx

@@ -3,6 +3,8 @@
 import { Badge } from "@pythnetwork/component-library/Badge";
 import { Button } from "@pythnetwork/component-library/Button";
 import { Card } from "@pythnetwork/component-library/Card";
+import { EntityList } from "@pythnetwork/component-library/EntityList";
+import { NoResults } from "@pythnetwork/component-library/NoResults";
 import { Paginator } from "@pythnetwork/component-library/Paginator";
 import { SearchInput } from "@pythnetwork/component-library/SearchInput";
 import { Select } from "@pythnetwork/component-library/Select";
@@ -29,12 +31,10 @@ import {
   Status as StatusType,
   statusNameToStatus,
 } from "../../status";
-import { EntityList } from "../EntityList";
 import { Explain } from "../Explain";
 import { EvaluationTime } from "../Explanations";
 import { FormattedNumber } from "../FormattedNumber";
 import { LivePrice, LiveConfidence, LiveComponentValue } from "../LivePrices";
-import { NoResults } from "../NoResults";
 import { usePriceComponentDrawer } from "../PriceComponentDrawer";
 import { PriceName } from "../PriceName";
 import { Score } from "../Score";
@@ -385,21 +385,21 @@ export const PriceComponentsCardContents = <
             </div>
           )}
           <div data-section="search" className={styles.toolbarSection}>
-            <Select<StatusName | "">
+            <Select<{ id: StatusName | "" }>
               label="Status"
               size="sm"
               variant="outline"
               hideLabel
               options={[
-                "",
-                ...Object.values(STATUS_NAMES).toSorted((a, b) =>
-                  collator.compare(a, b),
-                ),
+                { id: "" },
+                ...Object.values(STATUS_NAMES)
+                  .toSorted((a, b) => collator.compare(a, b))
+                  .map((id) => ({ id })),
               ]}
               {...(props.isLoading
                 ? { isPending: true, buttonLabel: "Status" }
                 : {
-                    show: (value) => (value === "" ? "All" : value),
+                    show: ({ id }) => (id === "" ? "All" : id),
                     placement: "bottom end",
                     buttonLabel: props.status === "" ? "Status" : props.status,
                     selectedKey: props.status,

+ 1 - 1
apps/insights/src/components/PriceFeedChangePercent/index.tsx

@@ -1,10 +1,10 @@
 "use client";
 
+import { StateType, useData } from "@pythnetwork/component-library/useData";
 import type { ComponentProps } from "react";
 import { createContext, use } from "react";
 import { z } from "zod";
 
-import { StateType, useData } from "../../hooks/use-data";
 import { useLivePriceData } from "../../hooks/use-live-price-data";
 import { Cluster } from "../../services/pyth";
 import { ChangePercent } from "../ChangePercent";

+ 7 - 4
apps/insights/src/components/PriceFeeds/coming-soon-list.tsx

@@ -1,5 +1,6 @@
 "use client";
 
+import { NoResults } from "@pythnetwork/component-library/NoResults";
 import { SearchInput } from "@pythnetwork/component-library/SearchInput";
 import { Select } from "@pythnetwork/component-library/Select";
 import { Table } from "@pythnetwork/component-library/Table";
@@ -9,7 +10,6 @@ import { useCollator, useFilter } from "react-aria";
 
 import styles from "./coming-soon-list.module.scss";
 import { AssetClassBadge } from "../AssetClassBadge";
-import { NoResults } from "../NoResults";
 import { PriceFeedTag } from "../PriceFeedTag";
 
 type Props = {
@@ -90,11 +90,14 @@ export const ComingSoonList = ({ comingSoonFeeds }: Props) => {
         />
         <Select
           optionGroups={[
-            { name: "All", options: [""] },
-            { name: "Asset classes", options: assetClasses },
+            { name: "All", options: [{ id: "" }] },
+            {
+              name: "Asset classes",
+              options: assetClasses.map((id) => ({ id })),
+            },
           ]}
           hideGroupLabel
-          show={(value) => (value === "" ? "All" : value)}
+          show={({ id }) => (id === "" ? "All" : id)}
           placement="bottom end"
           selectedKey={assetClass}
           onSelectionChange={setAssetClass}

+ 9 - 6
apps/insights/src/components/PriceFeeds/price-feeds-card.tsx

@@ -3,6 +3,8 @@
 import { ChartLine } from "@phosphor-icons/react/dist/ssr/ChartLine";
 import { Badge } from "@pythnetwork/component-library/Badge";
 import { Card } from "@pythnetwork/component-library/Card";
+import { EntityList } from "@pythnetwork/component-library/EntityList";
+import { NoResults } from "@pythnetwork/component-library/NoResults";
 import { Paginator } from "@pythnetwork/component-library/Paginator";
 import { SearchInput } from "@pythnetwork/component-library/SearchInput";
 import { Select } from "@pythnetwork/component-library/Select";
@@ -21,7 +23,6 @@ import styles from "./price-feeds-card.module.scss";
 import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination";
 import { Cluster } from "../../services/pyth";
 import { AssetClassBadge } from "../AssetClassBadge";
-import { EntityList } from "../EntityList";
 import { FeedKey } from "../FeedKey";
 import {
   SKELETON_WIDTH,
@@ -29,7 +30,6 @@ import {
   LiveConfidence,
   LiveValue,
 } from "../LivePrices";
-import { NoResults } from "../NoResults";
 import { PriceFeedTag } from "../PriceFeedTag";
 import { PriceName } from "../PriceName";
 
@@ -249,7 +249,7 @@ const PriceFeedsCardContents = ({ id, ...props }: PriceFeedsCardContents) => (
                 onChange: props.onSearchChange,
               })}
         />
-        <Select<string>
+        <Select
           label="Asset Class"
           size="sm"
           variant="outline"
@@ -258,11 +258,14 @@ const PriceFeedsCardContents = ({ id, ...props }: PriceFeedsCardContents) => (
             ? { isPending: true, options: [], buttonLabel: "Asset Class" }
             : {
                 optionGroups: [
-                  { name: "All", options: [""] },
-                  { name: "Asset classes", options: props.assetClasses },
+                  { name: "All", options: [{ id: "" }] },
+                  {
+                    name: "Asset classes",
+                    options: props.assetClasses.map((id) => ({ id })),
+                  },
                 ],
                 hideGroupLabel: true,
-                show: (value) => (value === "" ? "All" : value),
+                show: ({ id }) => (id === "" ? "All" : id),
                 placement: "bottom end",
                 buttonLabel:
                   props.assetClass === "" ? "Asset Class" : props.assetClass,

+ 1 - 1
apps/insights/src/components/Publisher/layout.tsx

@@ -7,6 +7,7 @@ import { Breadcrumbs } from "@pythnetwork/component-library/Breadcrumbs";
 import { Button } from "@pythnetwork/component-library/Button";
 import { InfoBox } from "@pythnetwork/component-library/InfoBox";
 import { Link } from "@pythnetwork/component-library/Link";
+import { Meter } from "@pythnetwork/component-library/Meter";
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
 import { StatCard } from "@pythnetwork/component-library/StatCard";
 import { lookup } from "@pythnetwork/known-publishers";
@@ -40,7 +41,6 @@ import { getPriceFeeds } from "./get-price-feeds";
 import styles from "./layout.module.scss";
 import { FormattedDate } from "../FormattedDate";
 import { FormattedTokens } from "../FormattedTokens";
-import { Meter } from "../Meter";
 import { SemicircleMeter } from "../SemicircleMeter";
 import { TabPanel, TabRoot, Tabs } from "../Tabs";
 import { TokenIcon } from "../TokenIcon";

+ 3 - 3
apps/insights/src/components/Publisher/performance.tsx

@@ -3,7 +3,10 @@ import { Confetti } from "@phosphor-icons/react/dist/ssr/Confetti";
 import { Network } from "@phosphor-icons/react/dist/ssr/Network";
 import { SmileySad } from "@phosphor-icons/react/dist/ssr/SmileySad";
 import { Card } from "@pythnetwork/component-library/Card";
+import { EntityList } from "@pythnetwork/component-library/EntityList";
 import { Link } from "@pythnetwork/component-library/Link";
+import type { Variant as NoResultsVariant } from "@pythnetwork/component-library/NoResults";
+import { NoResults } from "@pythnetwork/component-library/NoResults";
 import { Table } from "@pythnetwork/component-library/Table";
 import { lookup } from "@pythnetwork/known-publishers";
 import { notFound } from "next/navigation";
@@ -16,14 +19,11 @@ import { getPublishers } from "../../services/clickhouse";
 import type { Cluster } from "../../services/pyth";
 import { ClusterToName, parseCluster } from "../../services/pyth";
 import { Status } from "../../status";
-import { EntityList } from "../EntityList";
 import {
   ExplainActive,
   ExplainInactive,
   ExplainAverage,
 } from "../Explanations";
-import type { Variant as NoResultsVariant } from "../NoResults";
-import { NoResults } from "../NoResults";
 import { PriceFeedIcon } from "../PriceFeedIcon";
 import { PriceFeedTag } from "../PriceFeedTag";
 import { PublisherIcon } from "../PublisherIcon";

+ 1 - 1
apps/insights/src/components/Publisher/top-feeds-table.tsx

@@ -1,5 +1,6 @@
 "use client";
 
+import { EntityList } from "@pythnetwork/component-library/EntityList";
 import type { RowConfig } from "@pythnetwork/component-library/Table";
 import { Table } from "@pythnetwork/component-library/Table";
 import type { ReactNode } from "react";
@@ -9,7 +10,6 @@ import styles from "./top-feeds-table.module.scss";
 import type { Cluster } from "../../services/pyth";
 import type { Status } from "../../status";
 import { AssetClassBadge } from "../AssetClassBadge";
-import { EntityList } from "../EntityList";
 import { usePriceComponentDrawer } from "../PriceComponentDrawer";
 import { PriceFeedTag } from "../PriceFeedTag";
 import { Score } from "../Score";

+ 1 - 2
apps/insights/src/components/PublisherKey/index.tsx

@@ -1,7 +1,6 @@
+import { CopyButton } from "@pythnetwork/component-library/CopyButton";
 import type { ComponentProps } from "react";
 
-import { CopyButton } from "../CopyButton";
-
 type KeyProps = Omit<
   ComponentProps<typeof CopyButton>,
   "variant" | "text" | "children"

+ 3 - 3
apps/insights/src/components/Publishers/publishers-card.tsx

@@ -4,7 +4,9 @@ import { Broadcast } from "@phosphor-icons/react/dist/ssr/Broadcast";
 import { Database } from "@phosphor-icons/react/dist/ssr/Database";
 import { Badge } from "@pythnetwork/component-library/Badge";
 import { Card } from "@pythnetwork/component-library/Card";
+import { EntityList } from "@pythnetwork/component-library/EntityList";
 import { Link } from "@pythnetwork/component-library/Link";
+import { NoResults } from "@pythnetwork/component-library/NoResults";
 import { Paginator } from "@pythnetwork/component-library/Paginator";
 import { SearchInput } from "@pythnetwork/component-library/SearchInput";
 import { Select } from "@pythnetwork/component-library/Select";
@@ -23,13 +25,11 @@ import { useFilter, useCollator } from "react-aria";
 import styles from "./publishers-card.module.scss";
 import { useQueryParamFilterPagination } from "../../hooks/use-query-param-filter-pagination";
 import { CLUSTER_NAMES } from "../../services/pyth";
-import { EntityList } from "../EntityList";
 import {
   ExplainPermissioned,
   ExplainActive,
   ExplainRanking,
 } from "../Explanations";
-import { NoResults } from "../NoResults";
 import { PublisherTag } from "../PublisherTag";
 import { Ranking } from "../Ranking";
 import { Score } from "../Score";
@@ -269,7 +269,7 @@ const PublishersCardContents = ({
           size="sm"
           variant="outline"
           hideLabel
-          options={CLUSTER_NAMES}
+          options={CLUSTER_NAMES.map((id) => ({ id }))}
           icon={Database}
           {...(props.isLoading
             ? { isPending: true, buttonLabel: "Cluster" }

+ 2 - 2
apps/insights/src/components/Root/search-button.module.scss

@@ -5,7 +5,7 @@
     display: none;
 
     @include theme.breakpoint("md") {
-      display: unset;
+      display: inline-flex;
     }
   }
 
@@ -83,7 +83,7 @@
     display: none;
 
     @include theme.breakpoint("sm") {
-      display: inline flow-root;
+      display: inline-flex;
     }
   }
 

+ 1 - 1
apps/insights/src/components/Root/search-button.tsx

@@ -5,6 +5,7 @@ import { XCircle } from "@phosphor-icons/react/dist/ssr/XCircle";
 import { Badge } from "@pythnetwork/component-library/Badge";
 import type { Props as ButtonProps } from "@pythnetwork/component-library/Button";
 import { Button } from "@pythnetwork/component-library/Button";
+import { NoResults } from "@pythnetwork/component-library/NoResults";
 import { SearchInput } from "@pythnetwork/component-library/SearchInput";
 import { SingleToggleGroup } from "@pythnetwork/component-library/SingleToggleGroup";
 import { Skeleton } from "@pythnetwork/component-library/Skeleton";
@@ -26,7 +27,6 @@ import { useIsSSR, useCollator, useFilter } from "react-aria";
 import styles from "./search-button.module.scss";
 import { Cluster, ClusterToName } from "../../services/pyth";
 import { AssetClassBadge } from "../AssetClassBadge";
-import { NoResults } from "../NoResults";
 import { PriceFeedTag } from "../PriceFeedTag";
 import { PublisherTag } from "../PublisherTag";
 import { Score } from "../Score";

+ 3 - 1
packages/component-library/package.json

@@ -8,6 +8,7 @@
   },
   "exports": {
     "./*": "./src/*/index.tsx",
+    "./useData": "./src/useData/index.ts",
     "./theme": "./src/theme.scss"
   },
   "scripts": {
@@ -39,7 +40,8 @@
     "pino": "catalog:",
     "react-aria": "catalog:",
     "react-aria-components": "catalog:",
-    "react-dom": "catalog:"
+    "react-dom": "catalog:",
+    "swr": "catalog:"
   },
   "devDependencies": {
     "@cprussin/eslint-config": "catalog:",

+ 4 - 0
packages/component-library/src/AppShell/index.module.scss

@@ -61,6 +61,10 @@
           display: none;
         }
       }
+
+      .footer {
+        margin-top: theme.spacing(20);
+      }
     }
   }
 }

+ 1 - 1
packages/component-library/src/AppShell/index.tsx

@@ -81,7 +81,7 @@ export const AppBody = ({ tabs, children, ...props }: AppBodyProps) => (
       <main className={styles.main}>
         <TabPanel>{children}</TabPanel>
       </main>
-      <Footer />
+      <Footer className={styles.footer} />
       {tabs && <MobileNavTabs tabs={tabs} className={styles.mobileNavTabs} />}
     </TabRoot>
   </BodyProviders>

+ 5 - 4
packages/component-library/src/Badge/index.module.scss

@@ -1,7 +1,10 @@
 @use "../theme";
 
 .badge {
-  display: inline flow-root;
+  display: inline-flex;
+  flex-flow: row nowrap;
+  align-items: center;
+  justify-content: center;
   border-radius: theme.border-radius("3xl");
   transition-property: color, background-color, border-color;
   transition-duration: 100ms;
@@ -9,9 +12,9 @@
   white-space: nowrap;
   border-width: 1px;
   border-style: solid;
+  line-height: normal;
 
   &[data-size="xs"] {
-    line-height: theme.spacing(4);
     height: theme.spacing(4);
     padding: 0 theme.spacing(2);
     font-size: theme.font-size("xxs");
@@ -19,7 +22,6 @@
   }
 
   &[data-size="md"] {
-    line-height: theme.spacing(6);
     height: theme.spacing(6);
     padding: 0 theme.spacing(3);
     font-size: theme.font-size("xs");
@@ -27,7 +29,6 @@
   }
 
   &[data-size="lg"] {
-    line-height: theme.spacing(9);
     height: theme.spacing(9);
     padding: 0 theme.spacing(5);
     font-size: theme.font-size("sm");

+ 5 - 9
packages/component-library/src/Button/index.module.scss

@@ -1,7 +1,10 @@
 @use "../theme";
 
 .button {
-  display: inline flow-root;
+  display: inline-flex;
+  flex-flow: row nowrap;
+  align-items: center;
+  justify-content: center;
   cursor: pointer;
   white-space: nowrap;
   font-weight: theme.font-weight("medium");
@@ -13,15 +16,9 @@
   outline-offset: 0;
   outline: theme.spacing(1) solid transparent;
   text-align: center;
+  line-height: normal;
   -webkit-tap-highlight-color: transparent;
 
-  .iconWrapper {
-    display: inline-grid;
-    height: 100%;
-    place-content: center;
-    vertical-align: top;
-  }
-
   @each $size, $values in theme.$button-sizes {
     &[data-size="#{$size}"] {
       height: theme.map-get-strict($values, "height");
@@ -36,7 +33,6 @@
 
       .text {
         padding: 0 theme.map-get-strict($values, "gap");
-        line-height: theme.map-get-strict($values, "height");
       }
     }
   }

+ 5 - 7
packages/component-library/src/Button/index.tsx

@@ -52,8 +52,8 @@ const buttonProps = ({
   rounded = false,
   className,
   children,
-  beforeIcon,
-  afterIcon,
+  beforeIcon: BeforeIcon,
+  afterIcon: AfterIcon,
   hideText = false,
   ...otherProps
 }: OwnProps & { className?: Parameters<typeof clsx>[0] }) => ({
@@ -65,17 +65,15 @@ const buttonProps = ({
   className: clsx(styles.button, className),
   children: (
     <>
-      {beforeIcon !== undefined && <Icon icon={beforeIcon} />}
+      {BeforeIcon !== undefined && <BeforeIcon className={styles.icon} />}
       <span className={styles.text}>{children}</span>
-      {afterIcon !== undefined && <Icon icon={afterIcon} />}
+      {AfterIcon !== undefined && <AfterIcon className={styles.icon} />}
     </>
   ),
 });
 
 const Icon = ({ icon: IconComponent }: { icon: Icon }) => (
-  <span className={styles.iconWrapper}>
-    <IconComponent className={styles.icon} />
-  </span>
+  <IconComponent className={styles.icon} />
 );
 
 type Icon = ComponentType<{ className?: string | undefined }>;

+ 5 - 5
apps/insights/src/components/CopyButton/index.module.scss → packages/component-library/src/CopyButton/index.module.scss

@@ -1,4 +1,4 @@
-@use "@pythnetwork/component-library/theme";
+@use "../theme";
 
 .copyButton {
   display: inline-flex;
@@ -17,10 +17,10 @@
   border-radius: theme.border-radius("base");
   cursor: pointer;
   line-height: 150%;
-  padding-left: 0.5em;
-  padding-right: 0.5em;
-  margin-left: -0.5em;
-  margin-right: -0.5em;
+  padding-left: 0.25em;
+  padding-right: 0.25em;
+  margin-left: -0.25em;
+  margin-right: -0.25em;
 
   .iconContainer {
     position: relative;

+ 2 - 2
apps/insights/src/components/CopyButton/index.tsx → packages/component-library/src/CopyButton/index.tsx

@@ -2,13 +2,13 @@
 
 import { Check } from "@phosphor-icons/react/dist/ssr/Check";
 import { Copy } from "@phosphor-icons/react/dist/ssr/Copy";
-import { Button } from "@pythnetwork/component-library/unstyled/Button";
-import { useLogger } from "@pythnetwork/component-library/useLogger";
 import clsx from "clsx";
 import type { ComponentProps } from "react";
 import { useCallback, useEffect, useState } from "react";
 
 import styles from "./index.module.scss";
+import { Button } from "../unstyled/Button/index.js";
+import { useLogger } from "../useLogger/index.js";
 
 const COPY_INDICATOR_TIME = 1000;
 

+ 2 - 0
apps/insights/src/components/EntityList/index.module.scss → packages/component-library/src/EntityList/index.module.scss

@@ -74,10 +74,12 @@
           @include theme.text("sm", "normal");
 
           color: theme.color("muted");
+          margin-right: theme.spacing(4);
         }
 
         dd {
           margin: 0;
+          text-align: right;
         }
       }
     }

+ 2 - 2
apps/insights/src/components/EntityList/index.tsx → packages/component-library/src/EntityList/index.tsx

@@ -34,7 +34,7 @@ type Props<T extends string> = ComponentProps<typeof GridList<RowConfig<T>>> & {
 type RowConfig<T extends string> = {
   id: string | number;
   data: Record<T, ReactNode>;
-  header: ReactNode;
+  header?: ReactNode | undefined;
   href?: string;
   textValue: string;
 };
@@ -81,7 +81,7 @@ export const EntityList = <T extends string>({
     ) : (
       ({ data, header, ...props }) => (
         <GridListItem className={styles.entityItem ?? ""} {...props}>
-          <div className={styles.itemHeader}>{header}</div>
+          {header && <div className={styles.itemHeader}>{header}</div>}
           <dl className={styles.itemDetails}>
             {fields.map((field) => (
               <div key={field.id} className={styles.itemDetailsItem}>

+ 1 - 1
apps/insights/src/components/Error/index.module.scss → packages/component-library/src/ErrorPage/index.module.scss

@@ -1,6 +1,6 @@
 @use "@pythnetwork/component-library/theme";
 
-.error {
+.errorPage {
   display: flex;
   flex-flow: column nowrap;
   gap: theme.spacing(12);

+ 2 - 2
apps/insights/src/components/Error/index.tsx → packages/component-library/src/ErrorPage/index.tsx

@@ -10,7 +10,7 @@ type Props = {
   reset?: () => void;
 };
 
-export const Error = ({ error, reset }: Props) => {
+export const ErrorPage = ({ error, reset }: Props) => {
   const logger = useLogger();
 
   useEffect(() => {
@@ -18,7 +18,7 @@ export const Error = ({ error, reset }: Props) => {
   }, [error, logger]);
 
   return (
-    <div className={styles.error}>
+    <div className={styles.errorPage}>
       <Warning className={styles.errorIcon} />
       <div className={styles.text}>
         <h1 className={styles.header}>Uh oh!</h1>

+ 3 - 2
packages/component-library/src/Footer/index.tsx

@@ -1,3 +1,4 @@
+import clsx from "clsx";
 import type { ComponentProps, ElementType } from "react";
 
 import styles from "./index.module.scss";
@@ -8,8 +9,8 @@ import { SupportDrawer } from "../Header/index.js";
 import { Link } from "../Link/index.js";
 import { socialLinks } from "../social-links.js";
 
-export const Footer = () => (
-  <footer className={styles.footer}>
+export const Footer = ({ className, ...props }: ComponentProps<"footer">) => (
+  <footer className={clsx(styles.footer, className)} {...props}>
     <div className={styles.topContent}>
       <div className={styles.main}>
         <Link href="https://www.pyth.network" className={styles.logoLink ?? ""}>

+ 1 - 1
packages/component-library/src/Header/index.module.scss

@@ -85,7 +85,7 @@
         display: none;
 
         @include theme.breakpoint("lg") {
-          display: unset;
+          display: inline-flex;
         }
       }
 

+ 2 - 2
packages/component-library/src/Header/theme-switch.tsx

@@ -4,8 +4,6 @@ import type { IconProps } from "@phosphor-icons/react";
 import { Desktop } from "@phosphor-icons/react/dist/ssr/Desktop";
 import { Moon } from "@phosphor-icons/react/dist/ssr/Moon";
 import { Sun } from "@phosphor-icons/react/dist/ssr/Sun";
-import type { Props as ButtonProps } from "@pythnetwork/component-library/Button";
-import { Button } from "@pythnetwork/component-library/Button";
 import clsx from "clsx";
 import { motion } from "motion/react";
 import { useTheme } from "next-themes";
@@ -14,6 +12,8 @@ import { useCallback, useRef, useMemo } from "react";
 import { useIsSSR } from "react-aria";
 
 import styles from "./theme-switch.module.scss";
+import type { Props as ButtonProps } from "../Button/index.js";
+import { Button } from "../Button/index.js";
 
 type Props<T extends ElementType> = Omit<
   ButtonProps<T>,

+ 11 - 0
apps/insights/src/components/Meter/index.module.scss → packages/component-library/src/Meter/index.module.scss

@@ -28,7 +28,18 @@
       bottom: 0;
       left: 0;
       border-radius: theme.border-radius("full");
+    }
+  }
+
+  &[data-variant="default"] {
+    .score .fill {
       background: theme.color("chart", "series", "primary");
     }
   }
+
+  &[data-variant="error"] {
+    .score .fill {
+      background: theme.color("states", "error", "color");
+    }
+  }
 }

+ 13 - 4
apps/insights/src/components/Meter/index.tsx → packages/component-library/src/Meter/index.tsx

@@ -9,18 +9,27 @@ type OwnProps = {
   label: string;
   startLabel?: ReactNode | undefined;
   endLabel?: ReactNode | undefined;
+  labelClassName?: string | undefined;
+  variant?: "default" | "error";
 };
 type Props = Omit<ComponentProps<typeof MeterComponent>, keyof OwnProps> &
   OwnProps;
 
-export const Meter = ({ label, startLabel, endLabel, ...props }: Props) => (
+export const Meter = ({
+  label,
+  startLabel,
+  endLabel,
+  labelClassName,
+  variant = "default",
+  ...props
+}: Props) => (
   <MeterComponent aria-label={label} {...props}>
     {({ percentage }) => (
-      <div className={styles.meter}>
+      <div data-variant={variant} className={styles.meter}>
         {(startLabel !== undefined || endLabel !== undefined) && (
           <div className={styles.labels}>
-            {startLabel ?? <div />}
-            {endLabel ?? <div />}
+            <div className={labelClassName}>{startLabel}</div>
+            <div className={labelClassName}>{endLabel}</div>
           </div>
         )}
         <div className={styles.score}>

+ 1 - 1
packages/component-library/src/MobileNavTabs/index.tsx

@@ -1,6 +1,5 @@
 "use client";
 
-import { Link } from "@pythnetwork/component-library/unstyled/Link";
 import clsx from "clsx";
 import { motion } from "motion/react";
 import { usePathname } from "next/navigation";
@@ -8,6 +7,7 @@ import type { ReactNode } from "react";
 import { useId, useMemo } from "react";
 
 import styles from "./index.module.scss";
+import { Link } from "../unstyled/Link/index.js";
 
 type Props = {
   className?: string | undefined;

+ 0 - 0
apps/insights/src/components/NoResults/index.module.scss → packages/component-library/src/NoResults/index.module.scss


+ 7 - 5
apps/insights/src/components/NoResults/index.tsx → packages/component-library/src/NoResults/index.tsx

@@ -1,27 +1,29 @@
 import { MagnifyingGlass } from "@phosphor-icons/react/dist/ssr/MagnifyingGlass";
-import { Button } from "@pythnetwork/component-library/Button";
+import clsx from "clsx";
 import type { ReactNode } from "react";
 
 import styles from "./index.module.scss";
+import { Button } from "../Button/index.js";
 
 type Props = {
+  className?: string | undefined;
   onClearSearch?: (() => void) | undefined;
 } & (
   | { query: string }
   | {
       icon: ReactNode;
-      header: string;
-      body: string;
+      header: ReactNode;
+      body: ReactNode;
       variant?: Variant | undefined;
     }
 );
 
 export type Variant = "success" | "error" | "warning" | "info" | "data";
 
-export const NoResults = ({ onClearSearch, ...props }: Props) => (
+export const NoResults = ({ className, onClearSearch, ...props }: Props) => (
   <div
     data-variant={"variant" in props ? (props.variant ?? "info") : "info"}
-    className={styles.noResults}
+    className={clsx(styles.noResults, className)}
   >
     <div className={styles.icon}>
       {"icon" in props ? props.icon : <MagnifyingGlass />}

+ 1 - 1
apps/insights/src/components/NotFound/index.module.scss → packages/component-library/src/NotFoundPage/index.module.scss

@@ -1,6 +1,6 @@
 @use "@pythnetwork/component-library/theme";
 
-.notFound {
+.notFoundPage {
   display: flex;
   flex-flow: column nowrap;
   gap: theme.spacing(12);

+ 2 - 2
apps/insights/src/components/NotFound/index.tsx → packages/component-library/src/NotFoundPage/index.tsx

@@ -3,8 +3,8 @@ import { Button } from "@pythnetwork/component-library/Button";
 
 import styles from "./index.module.scss";
 
-export const NotFound = () => (
-  <div className={styles.notFound}>
+export const NotFoundPage = () => (
+  <div className={styles.notFoundPage}>
     <div className={styles.searchIcon}>
       <MagnifyingGlass />
     </div>

+ 2 - 2
packages/component-library/src/Paginator/index.tsx

@@ -65,10 +65,10 @@ const PageSizeSelect = ({
     className={styles.pageSizeSelect ?? ""}
     label="Page size"
     hideLabel
-    options={pageSizeOptions}
+    options={pageSizeOptions.map((option) => ({ id: option }))}
     selectedKey={pageSize}
     onSelectionChange={onPageSizeChange}
-    show={(value) => `${value.toString()} per page`}
+    show={(value) => `${value.id.toString()} per page`}
     variant="ghost"
     size="sm"
   />

+ 29 - 3
packages/component-library/src/Select/index.module.scss

@@ -29,7 +29,8 @@
     0 4px 6px -4px rgb(from black r g b / 10%),
     0 10px 15px -3px rgb(from black r g b / 10%);
 
-  .title {
+  .title,
+  .groupLabel {
     padding: theme.spacing(3);
     padding-bottom: theme.spacing(1);
     color: theme.color("muted");
@@ -38,10 +39,21 @@
     font-weight: theme.font-weight("medium");
   }
 
+  .groupLabel {
+    padding: theme.spacing(2);
+    padding-top: theme.spacing(3);
+    position: sticky;
+    top: 0;
+    background-color: theme.color("background", "modal");
+    border-top-left-radius: theme.border-radius("lg");
+    z-index: 1;
+  }
+
   .listbox {
     outline: none;
     overflow: auto;
     padding: theme.spacing(1);
+    max-height: theme.spacing(120);
 
     .section {
       padding: theme.spacing(0.5) 0;
@@ -77,6 +89,7 @@
       outline: theme.spacing(0.5) solid transparent;
       outline-offset: 0;
       line-height: theme.spacing(4);
+      isolation: isolate;
 
       .check {
         width: theme.spacing(3);
@@ -108,8 +121,21 @@
     }
   }
 
-  &[data-group-label-hidden] .groupLabel {
-    @include theme.sr-only;
+  &[data-grouped] {
+    &:not([data-group-label-hidden]) {
+      .listbox {
+        padding-top: 0;
+        padding-bottom: 0;
+      }
+
+      .title {
+        @include theme.sr-only;
+      }
+    }
+
+    &[data-group-label-hidden] .groupLabel {
+      @include theme.sr-only;
+    }
   }
 
   &[data-placement="top"] {

+ 10 - 7
packages/component-library/src/Select/index.stories.tsx

@@ -90,14 +90,14 @@ export default meta;
 export const Flat = {
   args: {
     defaultSelectedKey: "foo",
-    options: ["foo", "bar", "baz"],
+    options: ["foo", "bar", "baz"].map((id) => ({ id })),
     variant: "primary",
     size: "md",
     isDisabled: false,
     isPending: false,
     rounded: false,
     hideText: false,
-    show: (value) => `The option ${value.toString()}`,
+    show: (value) => `The option ${value.id.toString()}`,
     label: "A SELECT!",
     hideLabel: true,
     buttonLabel: "",
@@ -114,11 +114,14 @@ export const Grouped = {
     },
   },
   args: {
-    defaultSelectedKey: "foo",
+    defaultSelectedKey: "foo1",
     optionGroups: [
-      { name: "All", options: ["foo1", "foo2", "Some"] },
-      { name: "bars", options: ["bar1", "bar2", "bar3"] },
-      { name: "bazzes", options: ["baz1", "baz2", "baz3"] },
+      { name: "All", options: ["foo1", "foo2", "Some"].map((id) => ({ id })) },
+      { name: "bars", options: ["bar1", "bar2", "bar3"].map((id) => ({ id })) },
+      {
+        name: "bazzes",
+        options: ["baz1", "baz2", "baz3"].map((id) => ({ id })),
+      },
     ],
     variant: "primary",
     size: "md",
@@ -126,7 +129,7 @@ export const Grouped = {
     isPending: false,
     rounded: false,
     hideText: false,
-    show: (value) => `The option ${value.toString()}`,
+    show: (value) => `The option ${value.id.toString()}`,
     label: "FOOS AND BARS",
     hideLabel: true,
     hideGroupLabel: true,

+ 106 - 32
packages/component-library/src/Select/index.tsx

@@ -1,3 +1,5 @@
+"use client";
+
 import { Check } from "@phosphor-icons/react/dist/ssr/Check";
 import clsx from "clsx";
 import type { ComponentProps, ReactNode } from "react";
@@ -21,7 +23,7 @@ import {
   ListBoxSection,
 } from "../unstyled/ListBox/index.js";
 
-type Props<T> = Omit<
+export type Props<T extends { id: string | number }> = Omit<
   ComponentProps<typeof BaseSelect>,
   "defaultSelectedKey" | "selectedKey" | "onSelectionChange"
 > &
@@ -30,18 +32,20 @@ type Props<T> = Omit<
     "variant" | "size" | "rounded" | "hideText" | "isPending"
   > &
   Pick<PopoverProps, "placement"> & {
-    show?: (value: T) => string;
+    show?: ((value: T) => ReactNode) | undefined;
+    textValue?: ((value: T) => string) | undefined;
     icon?: ComponentProps<typeof Button>["beforeIcon"];
     label: ReactNode;
     hideLabel?: boolean | undefined;
     buttonLabel?: ReactNode;
+    defaultButtonLabel?: ReactNode;
   } & (
     | {
-        defaultSelectedKey?: T | undefined;
+        defaultSelectedKey?: T["id"] | undefined;
       }
     | {
-        selectedKey: T;
-        onSelectionChange: (newValue: T) => void;
+        selectedKey: T["id"];
+        onSelectionChange: (newValue: T["id"]) => void;
       }
   ) &
   (
@@ -54,9 +58,10 @@ type Props<T> = Omit<
       }
   );
 
-export const Select = <T extends string | number>({
+export const Select = <T extends { id: string | number }>({
   className,
   show,
+  textValue,
   variant,
   size,
   rounded,
@@ -67,6 +72,7 @@ export const Select = <T extends string | number>({
   placement,
   isPending,
   buttonLabel,
+  defaultButtonLabel,
   ...props
 }: Props<T>) => (
   // @ts-expect-error react-aria coerces everything to Key for some reason...
@@ -88,40 +94,40 @@ export const Select = <T extends string | number>({
       beforeIcon={icon}
       isPending={isPending === true}
     >
-      {buttonLabel !== undefined && buttonLabel !== "" ? (
-        buttonLabel
-      ) : (
-        <SelectValue<{ id: T }>>
-          {({ selectedItem, selectedText }) =>
-            selectedItem
-              ? (show?.(selectedItem.id) ?? selectedItem.id)
-              : selectedText
-          }
-        </SelectValue>
-      )}
+      <ButtonLabel
+        buttonLabel={buttonLabel}
+        defaultButtonLabel={defaultButtonLabel}
+        show={show}
+      />
     </Button>
     <Popover
       {...(placement && { placement })}
-      data-group-label-hidden={
-        "hideGroupLabel" in props && props.hideGroupLabel ? "" : undefined
-      }
+      {...("optionGroups" in props && {
+        "data-grouped": "",
+        "data-group-label-hidden": props.hideGroupLabel ? "" : undefined,
+      })}
       className={styles.popover ?? ""}
     >
       <span className={styles.title}>{label}</span>
       {"options" in props ? (
-        <ListBox
-          className={styles.listbox ?? ""}
-          items={props.options.map((id) => ({ id }))}
-        >
-          {({ id }) => <Item show={show}>{id}</Item>}
+        <ListBox className={styles.listbox ?? ""} items={props.options}>
+          {(item) => (
+            <Item show={show} textValue={textValue}>
+              {item}
+            </Item>
+          )}
         </ListBox>
       ) : (
         <ListBox className={styles.listbox ?? ""} items={props.optionGroups}>
           {({ name, options }) => (
             <ListBoxSection className={styles.section ?? ""} id={name}>
               <Header className={styles.groupLabel ?? ""}>{name}</Header>
-              <Collection items={options.map((id) => ({ id }))}>
-                {({ id }) => <Item show={show}>{id}</Item>}
+              <Collection items={options}>
+                {(item) => (
+                  <Item show={show} textValue={textValue}>
+                    {item}
+                  </Item>
+                )}
               </Collection>
             </ListBoxSection>
           )}
@@ -133,16 +139,84 @@ export const Select = <T extends string | number>({
 
 type ItemProps<T> = {
   children: T;
-  show: ((value: T) => string) | undefined;
+  show: ((value: T) => ReactNode) | undefined;
+  textValue: ((value: T) => string) | undefined;
 };
 
-const Item = <T extends string | number>({ children, show }: ItemProps<T>) => (
+const Item = <T extends { id: string | number }>({
+  children,
+  show,
+  textValue,
+}: ItemProps<T>) => (
   <ListBoxItem
-    id={children}
+    id={typeof children === "object" ? children.id : children}
     className={styles.listboxItem ?? ""}
-    textValue={show?.(children) ?? children.toString()}
+    textValue={getTextValue({ children, show, textValue })}
   >
-    <span>{show?.(children) ?? children}</span>
+    <span>{show?.(children) ?? children.id}</span>
     <Check weight="bold" className={styles.check} />
   </ListBoxItem>
 );
+
+const getTextValue = <T extends { id: string | number }>({
+  children,
+  show,
+  textValue,
+}: ItemProps<T>) => {
+  if (textValue !== undefined) {
+    return textValue(children);
+  } else if (show === undefined) {
+    return children.id.toString();
+  } else {
+    const result = show(children);
+    return typeof result === "string" ? result : children.id.toString();
+  }
+};
+
+type ButtonLabelProps<T extends { id: string | number }> = Pick<
+  Props<T>,
+  "buttonLabel" | "defaultButtonLabel" | "show"
+>;
+
+const ButtonLabel = <T extends { id: string | number }>({
+  buttonLabel,
+  defaultButtonLabel,
+  show,
+}: ButtonLabelProps<T>) => {
+  if (buttonLabel !== undefined && buttonLabel !== "") {
+    return buttonLabel;
+  } else if (defaultButtonLabel !== undefined && defaultButtonLabel !== "") {
+    return (
+      <SelectValue<T>>
+        {(props) =>
+          props.selectedText === null ? (
+            defaultButtonLabel
+          ) : (
+            <SelectedValueLabel show={show} {...props} />
+          )
+        }
+      </SelectValue>
+    );
+  } else {
+    return (
+      <SelectValue<T>>
+        {(props) => <SelectedValueLabel show={show} {...props} />}
+      </SelectValue>
+    );
+  }
+};
+
+type SelectedValueLabelProps<T extends { id: string | number }> = Pick<
+  Props<T>,
+  "show"
+> & {
+  selectedItem: T | null;
+  selectedText: string | null;
+};
+
+const SelectedValueLabel = <T extends { id: string | number }>({
+  show,
+  selectedItem,
+  selectedText,
+}: SelectedValueLabelProps<T>) =>
+  selectedItem ? (show?.(selectedItem) ?? selectedItem.id) : selectedText;

+ 10 - 17
packages/component-library/src/Status/index.module.scss

@@ -1,7 +1,10 @@
 @use "../theme";
 
 .status {
-  display: inline flow-root;
+  display: inline-flex;
+  flex-flow: row nowrap;
+  align-items: center;
+  justify-content: center;
   border-radius: theme.border-radius("3xl");
   transition-property: color, background-color, border-color;
   transition-duration: 100ms;
@@ -11,45 +14,35 @@
   border-style: solid;
 
   .dot {
-    display: inline-block;
     border-radius: theme.border-radius("full");
-    position: relative;
     transition: background-color 100ms linear;
   }
 
   &[data-size="xs"] {
     height: theme.spacing(4);
-    padding: 0 theme.spacing(1);
+    padding: theme.spacing(1);
+    padding-right: theme.spacing(2);
     font-size: theme.font-size("xxs");
     font-weight: theme.font-weight("medium");
-
-    .text {
-      line-height: theme.spacing(4);
-      padding: 0 theme.spacing(1);
-    }
+    gap: theme.spacing(1);
 
     .dot {
       width: theme.spacing(2);
       height: theme.spacing(2);
-      top: theme.spacing(0.25);
     }
   }
 
   &[data-size="md"] {
     height: theme.spacing(6);
-    padding: 0 theme.spacing(1.5);
+    padding: theme.spacing(1.5);
+    padding-right: theme.spacing(3);
     font-size: theme.font-size("xs");
     font-weight: theme.font-weight("medium");
-
-    .text {
-      line-height: theme.spacing(6);
-      padding: 0 theme.spacing(1.5);
-    }
+    gap: theme.spacing(1.5);
 
     .dot {
       width: theme.spacing(3);
       height: theme.spacing(3);
-      top: theme.spacing(0.5);
     }
   }
 

+ 6 - 4
apps/insights/src/hooks/use-data.ts → packages/component-library/src/useData/index.ts

@@ -1,14 +1,15 @@
-import { useLogger } from "@pythnetwork/component-library/useLogger";
 import { useCallback } from "react";
 import type { KeyedMutator } from "swr";
 import useSWR from "swr";
 
+import { useLogger } from "../useLogger";
+
 export const useData = <T>(
   key: Parameters<typeof useSWR<T>>[0],
   fetcher?: Parameters<typeof useSWR<T>>[1],
   config?: Parameters<typeof useSWR<T>>[2],
 ) => {
-  const { data, isLoading, mutate, ...rest } = useSWR(
+  const { data, isLoading, isValidating, mutate, ...rest } = useSWR(
     key,
     // eslint-disable-next-line unicorn/no-null
     fetcher ?? null,
@@ -30,7 +31,7 @@ export const useData = <T>(
   } else if (isLoading) {
     return State.Loading();
   } else if (data) {
-    return State.Loaded(data, mutate);
+    return State.Loaded(data, isValidating, mutate);
   } else {
     return State.NotLoaded();
   }
@@ -46,8 +47,9 @@ export enum StateType {
 const State = {
   NotLoaded: () => ({ type: StateType.NotLoaded as const }),
   Loading: () => ({ type: StateType.Loading as const }),
-  Loaded: <T>(data: T, mutate: KeyedMutator<T>) => ({
+  Loaded: <T>(data: T, isValidating: boolean, mutate: KeyedMutator<T>) => ({
     type: StateType.Loaded as const,
+    isValidating,
     mutate,
     data,
   }),

+ 1 - 1
packages/component-library/src/useDrawer/index.module.scss

@@ -117,7 +117,7 @@
           display: none;
 
           @include theme.breakpoint("sm") {
-            display: unset;
+            display: inline-flex;
           }
         }
       }

+ 53 - 71
packages/component-library/src/useDrawer/index.tsx

@@ -61,9 +61,23 @@ const Drawer = ({
     variant,
     setMainContentOffset,
   );
+  const isLarge = useIsLarge();
+
+  const wasPreviouslyLarge = useRef<boolean | undefined>(undefined);
+  useEffect(() => {
+    if (isLarge !== undefined) {
+      if (wasPreviouslyLarge.current === undefined) {
+        wasPreviouslyLarge.current = isLarge;
+      } else if (isLarge !== wasPreviouslyLarge.current) {
+        wasPreviouslyLarge.current = isLarge;
+        setMainContentOffset(isLarge ? 0 : 100);
+      }
+    }
+  }, [isLarge, setMainContentOffset]);
 
   return (
     <ModalDialog
+      key={`modal-dialog-${isLarge ? "large" : "small"}`}
       data-variant={variant}
       overlayVariants={{
         unmounted: { backgroundColor: "#00000000" },
@@ -78,53 +92,42 @@ const Drawer = ({
       {...animationProps}
       {...props}
     >
-      {(...args) => (
-        <>
-          <OnResize
-            threshold={styles["breakpoint-sm"]}
-            onResize={() => {
-              setMainContentOffset(0);
-              args[0].state.close();
-            }}
-          />
-          <div
-            className={styles.handle}
-            onPointerDown={() => {
-              setIsHandlePressed(true);
-            }}
-            onPointerUp={() => {
-              setIsHandlePressed(false);
-            }}
-            data-is-pressed={isHandlePressed || isDragging ? "" : undefined}
-          />
-          <div className={clsx(styles.heading, headingClassName)}>
-            <div className={styles.headingTop}>
-              <Heading className={styles.title} slot="title">
-                {title}
-              </Heading>
-              <div className={styles.headingEnd}>
-                {headingExtra}
-                <Button
-                  className={styles.closeButton ?? ""}
-                  beforeIcon={(props) => <XCircle weight="fill" {...props} />}
-                  slot="close"
-                  hideText
-                  rounded
-                  variant="ghost"
-                  size="sm"
-                  {...(closeHref && { href: closeHref })}
-                >
-                  Close
-                </Button>
-              </div>
-            </div>
-            {headingAfter}
+      <div
+        className={styles.handle}
+        onPointerDown={() => {
+          setIsHandlePressed(true);
+        }}
+        onPointerUp={() => {
+          setIsHandlePressed(false);
+        }}
+        data-is-pressed={isHandlePressed || isDragging ? "" : undefined}
+      />
+      <div className={clsx(styles.heading, headingClassName)}>
+        <div className={styles.headingTop}>
+          <Heading className={styles.title} slot="title">
+            {title}
+          </Heading>
+          <div className={styles.headingEnd}>
+            {headingExtra}
+            <Button
+              className={styles.closeButton ?? ""}
+              beforeIcon={(props) => <XCircle weight="fill" {...props} />}
+              slot="close"
+              hideText
+              rounded
+              variant="ghost"
+              size="sm"
+              {...(closeHref && { href: closeHref })}
+            >
+              Close
+            </Button>
           </div>
-          <div className={clsx(styles.body, bodyClassName)}>{contents}</div>
-          {footer && (
-            <div className={clsx(styles.footer, footerClassName)}>{footer}</div>
-          )}
-        </>
+        </div>
+        {headingAfter}
+      </div>
+      <div className={clsx(styles.body, bodyClassName)}>{contents}</div>
+      {footer && (
+        <div className={clsx(styles.footer, footerClassName)}>{footer}</div>
       )}
     </ModalDialog>
   );
@@ -148,9 +151,7 @@ const useAnimationProps = (
       setMainContentOffset(100 - (100 * y) / modalRef.current.offsetHeight);
     }
   });
-  const isLarge = useMediaQuery(
-    `(min-width: ${styles["breakpoint-sm"] ?? ""})`,
-  );
+  const isLarge = useIsLarge();
 
   const commonProps = {
     ref: modalRef,
@@ -244,6 +245,9 @@ const useAnimationProps = (
       };
 };
 
+const useIsLarge = () =>
+  useMediaQuery(`(min-width: ${styles["breakpoint-sm"] ?? ""})`);
+
 const { Provider, useValue } = createModalDialogContext<
   Props,
   Pick<Props, "setMainContentOffset">
@@ -255,25 +259,3 @@ export type OpenDrawerArgs = OpenArgs<
   Props,
   Pick<Props, "setMainContentOffset">
 >;
-
-type OnResizeProps = {
-  threshold: string | undefined;
-  onResize: () => void;
-};
-
-const OnResize = ({ threshold, onResize }: OnResizeProps) => {
-  const isAboveThreshold = useMediaQuery(`(min-width: ${threshold ?? ""})`, {
-    initializeWithValue: false,
-  });
-  const previousValue = useRef<boolean | undefined>(undefined);
-  useEffect(() => {
-    if (previousValue.current === undefined) {
-      previousValue.current = isAboveThreshold;
-    } else if (isAboveThreshold !== previousValue.current) {
-      previousValue.current = isAboveThreshold;
-      onResize();
-    }
-  }, [isAboveThreshold, onResize]);
-  // eslint-disable-next-line unicorn/no-null
-  return null;
-};

+ 100 - 0
pnpm-lock.yaml

@@ -535,6 +535,103 @@ importers:
         specifier: 'catalog:'
         version: 41.4.1(encoding@0.1.13)
 
+  apps/entropy-explorer:
+    dependencies:
+      '@phosphor-icons/react':
+        specifier: 'catalog:'
+        version: 2.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      '@pythnetwork/component-library':
+        specifier: workspace:*
+        version: link:../../packages/component-library
+      clsx:
+        specifier: 'catalog:'
+        version: 2.1.1
+      connectkit:
+        specifier: 'catalog:'
+        version: 1.9.0(@babel/core@7.26.10)(@tanstack/react-query@5.71.5(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-is@18.3.1)(react@19.1.0)(viem@2.24.3(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@5.0.10)(zod@3.24.2))(wagmi@2.14.16(@react-native-async-storage/async-storage@1.24.0(react-native@0.78.2(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@19.1.0)(bufferutil@4.0.9)(react@19.1.0)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.71.5)(@tanstack/react-query@5.71.5(react@19.1.0))(@types/react@19.1.0)(bufferutil@4.0.9)(encoding@0.1.13)(immer@9.0.21)(react@19.1.0)(typescript@5.8.2)(utf-8-validate@5.0.10)(viem@2.24.3(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@5.0.10)(zod@3.24.2))(zod@3.24.2))
+      next:
+        specifier: 'catalog:'
+        version: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
+      nuqs:
+        specifier: 'catalog:'
+        version: 2.4.1(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(react@19.1.0)
+      react:
+        specifier: 'catalog:'
+        version: 19.1.0
+      react-aria:
+        specifier: 'catalog:'
+        version: 3.38.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      react-dom:
+        specifier: 'catalog:'
+        version: 19.1.0(react@19.1.0)
+      viem:
+        specifier: 'catalog:'
+        version: 2.24.3(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@5.0.10)(zod@3.24.2)
+      wagmi:
+        specifier: 'catalog:'
+        version: 2.14.16(@react-native-async-storage/async-storage@1.24.0(react-native@0.78.2(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@19.1.0)(bufferutil@4.0.9)(react@19.1.0)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.71.5)(@tanstack/react-query@5.71.5(react@19.1.0))(@types/react@19.1.0)(bufferutil@4.0.9)(encoding@0.1.13)(immer@9.0.21)(react@19.1.0)(typescript@5.8.2)(utf-8-validate@5.0.10)(viem@2.24.3(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@5.0.10)(zod@3.24.2))(zod@3.24.2)
+      zod:
+        specifier: 'catalog:'
+        version: 3.24.2
+    devDependencies:
+      '@cprussin/eslint-config':
+        specifier: 'catalog:'
+        version: 4.0.2(@testing-library/dom@10.4.0)(@typescript-eslint/eslint-plugin@8.29.0(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@1.21.7))(typescript@5.8.2))(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2))(turbo@2.4.4)(typescript@5.8.2)
+      '@cprussin/jest-config':
+        specifier: 'catalog:'
+        version: 2.0.2(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(bufferutil@4.0.9)(eslint@9.23.0(jiti@1.21.7))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2)))(next@15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@5.0.10)
+      '@cprussin/prettier-config':
+        specifier: 'catalog:'
+        version: 2.2.2(prettier@3.5.3)
+      '@cprussin/tsconfig':
+        specifier: 'catalog:'
+        version: 3.1.2(typescript@5.8.2)
+      '@svgr/webpack':
+        specifier: 'catalog:'
+        version: 8.1.0(typescript@5.8.2)
+      '@types/jest':
+        specifier: 'catalog:'
+        version: 29.5.14
+      '@types/node':
+        specifier: 'catalog:'
+        version: 22.14.0
+      '@types/react':
+        specifier: 'catalog:'
+        version: 19.1.0
+      '@types/react-dom':
+        specifier: 'catalog:'
+        version: 19.1.1(@types/react@19.1.0)
+      autoprefixer:
+        specifier: 'catalog:'
+        version: 10.4.21(postcss@8.5.3)
+      eslint:
+        specifier: 'catalog:'
+        version: 9.23.0(jiti@1.21.7)
+      jest:
+        specifier: 'catalog:'
+        version: 29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@types/node@22.14.0)(typescript@5.8.2))
+      postcss:
+        specifier: 'catalog:'
+        version: 8.5.3
+      prettier:
+        specifier: 'catalog:'
+        version: 3.5.3
+      sass:
+        specifier: 'catalog:'
+        version: 1.86.1
+      stylelint:
+        specifier: 'catalog:'
+        version: 16.17.0(typescript@5.8.2)
+      stylelint-config-standard-scss:
+        specifier: 'catalog:'
+        version: 14.0.0(postcss@8.5.3)(stylelint@16.17.0(typescript@5.8.2))
+      typescript:
+        specifier: 'catalog:'
+        version: 5.8.2
+      vercel:
+        specifier: 'catalog:'
+        version: 41.4.1(encoding@0.1.13)
+
   apps/hermes/client/js:
     dependencies:
       '@zodios/core':
@@ -1711,6 +1808,9 @@ importers:
       react-dom:
         specifier: 'catalog:'
         version: 19.1.0(react@19.1.0)
+      swr:
+        specifier: 'catalog:'
+        version: 2.3.3(react@19.1.0)
     devDependencies:
       '@cprussin/eslint-config':
         specifier: 'catalog:'