Browse Source

Merge branch 'main' of github.com:pyth-network/pyth-crosschain into tb/argus/shared-mem-2

Tejas Badadare 6 months ago
parent
commit
98ff94f32d
100 changed files with 2077 additions and 238 deletions
  1. 1 1
      .github/workflows/ci-message-buffer.yml
  2. 1 1
      .github/workflows/ci-remote-executor.yml
  3. 1 1
      .github/workflows/ci-solana-contract.yml
  4. 1 0
      apps/entropy-explorer/.gitignore
  5. 7 0
      apps/entropy-explorer/.prettierignore
  6. 1 0
      apps/entropy-explorer/eslint.config.js
  7. 1 0
      apps/entropy-explorer/jest.config.js
  8. 5 0
      apps/entropy-explorer/next-env.d.ts
  9. 54 0
      apps/entropy-explorer/next.config.js
  10. 57 0
      apps/entropy-explorer/package.json
  11. 1 0
      apps/entropy-explorer/prettier.config.js
  12. BIN
      apps/entropy-explorer/public/android-chrome-192x192.png
  13. BIN
      apps/entropy-explorer/public/android-chrome-512x512.png
  14. BIN
      apps/entropy-explorer/public/apple-touch-icon.png
  15. BIN
      apps/entropy-explorer/public/favicon-16x16.png
  16. BIN
      apps/entropy-explorer/public/favicon-32x32.png
  17. BIN
      apps/entropy-explorer/public/favicon-light.ico
  18. BIN
      apps/entropy-explorer/public/favicon.ico
  19. 3 0
      apps/entropy-explorer/src/app/error.ts
  20. 16 0
      apps/entropy-explorer/src/app/global-error.tsx
  21. 2 0
      apps/entropy-explorer/src/app/layout.ts
  22. 25 0
      apps/entropy-explorer/src/app/manifest.ts
  23. 1 0
      apps/entropy-explorer/src/app/not-found.ts
  24. 1 0
      apps/entropy-explorer/src/app/page.ts
  25. 11 0
      apps/entropy-explorer/src/app/robots.ts
  26. 15 0
      apps/entropy-explorer/src/components/Home/chain-select.module.scss
  27. 107 0
      apps/entropy-explorer/src/components/Home/chain-select.tsx
  28. 30 0
      apps/entropy-explorer/src/components/Home/index.module.scss
  29. 27 0
      apps/entropy-explorer/src/components/Home/index.tsx
  30. 75 0
      apps/entropy-explorer/src/components/Home/results.module.scss
  31. 420 0
      apps/entropy-explorer/src/components/Home/results.tsx
  32. 37 0
      apps/entropy-explorer/src/components/Home/search-bar.tsx
  33. 48 0
      apps/entropy-explorer/src/components/Home/use-query.ts
  34. 21 0
      apps/entropy-explorer/src/components/Root/evm-provider.tsx
  35. 30 0
      apps/entropy-explorer/src/components/Root/index.tsx
  36. 13 0
      apps/entropy-explorer/src/config/isomorphic.ts
  37. 30 0
      apps/entropy-explorer/src/config/server.ts
  38. 471 0
      apps/entropy-explorer/src/entropy-deployments.ts
  39. 71 0
      apps/entropy-explorer/src/get-requests-for-chain.ts
  40. 52 0
      apps/entropy-explorer/src/metadata.ts
  41. 4 0
      apps/entropy-explorer/src/type-utils.ts
  42. 21 0
      apps/entropy-explorer/stylelint.config.js
  43. 6 0
      apps/entropy-explorer/svg.d.ts
  44. 5 0
      apps/entropy-explorer/tsconfig.json
  45. 41 0
      apps/entropy-explorer/turbo.json
  46. 5 0
      apps/entropy-explorer/vercel.json
  47. 2 2
      apps/hermes/client/js/README.md
  48. 1 1
      apps/insights/src/app/error.ts
  49. 3 4
      apps/insights/src/app/global-error.tsx
  50. 1 1
      apps/insights/src/app/not-found.ts
  51. 1 1
      apps/insights/src/app/price-feeds/[slug]/error.ts
  52. 1 1
      apps/insights/src/app/publishers/[cluster]/[key]/error.ts
  53. 1 1
      apps/insights/src/components/FeedKey/index.tsx
  54. 1 1
      apps/insights/src/components/PriceComponentDrawer/index.module.scss
  55. 2 2
      apps/insights/src/components/PriceComponentDrawer/index.tsx
  56. 8 8
      apps/insights/src/components/PriceComponentsCard/index.tsx
  57. 1 1
      apps/insights/src/components/PriceFeedChangePercent/index.tsx
  58. 7 4
      apps/insights/src/components/PriceFeeds/coming-soon-list.tsx
  59. 9 6
      apps/insights/src/components/PriceFeeds/price-feeds-card.tsx
  60. 1 1
      apps/insights/src/components/Publisher/layout.tsx
  61. 3 3
      apps/insights/src/components/Publisher/performance.tsx
  62. 1 1
      apps/insights/src/components/Publisher/top-feeds-table.tsx
  63. 1 2
      apps/insights/src/components/PublisherKey/index.tsx
  64. 3 3
      apps/insights/src/components/Publishers/publishers-card.tsx
  65. 2 2
      apps/insights/src/components/Root/search-button.module.scss
  66. 1 1
      apps/insights/src/components/Root/search-button.tsx
  67. 5 0
      contract_manager/store/chains/IotaChains.yaml
  68. 4 0
      contract_manager/store/contracts/IotaPriceFeedContracts.yaml
  69. 3 0
      contract_manager/store/contracts/IotaWormholeContracts.yaml
  70. 2 0
      governance/xc_admin/packages/xc_admin_common/src/chains.ts
  71. 3 1
      packages/component-library/package.json
  72. 4 0
      packages/component-library/src/AppShell/index.module.scss
  73. 1 1
      packages/component-library/src/AppShell/index.tsx
  74. 5 4
      packages/component-library/src/Badge/index.module.scss
  75. 5 9
      packages/component-library/src/Button/index.module.scss
  76. 5 7
      packages/component-library/src/Button/index.tsx
  77. 5 5
      packages/component-library/src/CopyButton/index.module.scss
  78. 2 2
      packages/component-library/src/CopyButton/index.tsx
  79. 2 0
      packages/component-library/src/EntityList/index.module.scss
  80. 2 2
      packages/component-library/src/EntityList/index.tsx
  81. 1 1
      packages/component-library/src/ErrorPage/index.module.scss
  82. 2 2
      packages/component-library/src/ErrorPage/index.tsx
  83. 3 2
      packages/component-library/src/Footer/index.tsx
  84. 1 1
      packages/component-library/src/Header/index.module.scss
  85. 2 2
      packages/component-library/src/Header/theme-switch.tsx
  86. 11 0
      packages/component-library/src/Meter/index.module.scss
  87. 13 4
      packages/component-library/src/Meter/index.tsx
  88. 1 1
      packages/component-library/src/MobileNavTabs/index.tsx
  89. 0 0
      packages/component-library/src/NoResults/index.module.scss
  90. 7 5
      packages/component-library/src/NoResults/index.tsx
  91. 1 1
      packages/component-library/src/NotFoundPage/index.module.scss
  92. 2 2
      packages/component-library/src/NotFoundPage/index.tsx
  93. 2 2
      packages/component-library/src/Paginator/index.tsx
  94. 29 3
      packages/component-library/src/Select/index.module.scss
  95. 10 7
      packages/component-library/src/Select/index.stories.tsx
  96. 106 32
      packages/component-library/src/Select/index.tsx
  97. 10 17
      packages/component-library/src/Status/index.module.scss
  98. 6 4
      packages/component-library/src/useData/index.ts
  99. 1 1
      packages/component-library/src/useDrawer/index.module.scss
  100. 53 71
      packages/component-library/src/useDrawer/index.tsx

+ 1 - 1
.github/workflows/ci-message-buffer.yml

@@ -29,7 +29,7 @@ jobs:
         run: |
           wget http://nz2.archive.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.1f-1ubuntu2_amd64.deb
           sudo dpkg -i libssl1.1_1.1.1f-1ubuntu2_amd64.deb
-          sh -c "$(curl -sSfL https://release.solana.com/v1.14.18/install)"
+          sh -c "$(curl -sSfL https://release.anza.xyz/v1.17.34/install)"
           echo "/home/runner/.local/share/solana/install/active_release/bin" >> $GITHUB_PATH
       - name: Install Anchor
         run: |

+ 1 - 1
.github/workflows/ci-remote-executor.yml

@@ -27,7 +27,7 @@ jobs:
           workspaces: "governance/remote_executor -> target"
       - name: Install Solana
         run: |
-          sh -c "$(curl -sSfL https://release.solana.com/v1.18.23/install)"
+          sh -c "$(curl -sSfL https://release.anza.xyz/v1.18.23/install)"
           echo "/home/runner/.local/share/solana/install/active_release/bin" >> $GITHUB_PATH
       - name: Format check
         run: cargo fmt --all -- --check

+ 1 - 1
.github/workflows/ci-solana-contract.yml

@@ -31,7 +31,7 @@ jobs:
           override: true
       - name: Install Solana
         run: |
-          sh -c "$(curl -sSfL https://release.solana.com/v1.16.20/install)"
+          sh -c "$(curl -sSfL https://release.anza.xyz/v1.17.34/install)"
           echo "/home/runner/.local/share/solana/install/active_release/bin" >> $GITHUB_PATH
       - name: Format check
         run: cargo fmt --all -- --check

+ 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";

BIN
apps/entropy-explorer/public/android-chrome-192x192.png


BIN
apps/entropy-explorer/public/android-chrome-512x512.png


BIN
apps/entropy-explorer/public/apple-touch-icon.png


BIN
apps/entropy-explorer/public/favicon-16x16.png


BIN
apps/entropy-explorer/public/favicon-32x32.png


BIN
apps/entropy-explorer/public/favicon-light.ico


BIN
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"
+}

+ 2 - 2
apps/hermes/client/js/README.md

@@ -9,13 +9,13 @@ This library is a client for interacting with Hermes, allowing your application
 ### npm
 
 ```
-$ npm install --save @pythnetwork/hermes-client
+npm install --save @pythnetwork/hermes-client
 ```
 
 ### Yarn
 
 ```
-$ yarn add @pythnetwork/hermes-client
+yarn add @pythnetwork/hermes-client
 ```
 
 ## Quickstart

+ 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";

+ 5 - 0
contract_manager/store/chains/IotaChains.yaml

@@ -3,3 +3,8 @@
   mainnet: false
   rpcUrl: https://api.testnet.iota.cafe/
   type: IotaChain
+- id: iota_mainnet
+  wormholeChainName: iota_sui_mainnet
+  mainnet: true
+  rpcUrl: https://api.mainnet.iota.cafe/
+  type: IotaChain

+ 4 - 0
contract_manager/store/contracts/IotaPriceFeedContracts.yaml

@@ -2,3 +2,7 @@
   stateId: "0x68dda579251917b3db28e35c4df495c6e664ccc085ede867a9b773c8ebedc2c1"
   wormholeStateId: "0x8bc490f69520a97ca1b3de864c96aa2265a0cf5d90f5f3f016b2eddf0cf2af2b"
   type: IotaPriceFeedContract
+- chain: iota_mainnet
+  stateId: "0x6bc33855c7675e006f55609f61eebb1c8a104d8973a698ee9efd3127c210b37f"
+  wormholeStateId: "0xd43b448afc9dd01deb18273ec39d8f27ddd4dd46b0922383874331771b70df73"
+  type: IotaPriceFeedContract

+ 3 - 0
contract_manager/store/contracts/IotaWormholeContracts.yaml

@@ -1,3 +1,6 @@
 - chain: iota_testnet
   stateId: "0x8bc490f69520a97ca1b3de864c96aa2265a0cf5d90f5f3f016b2eddf0cf2af2b"
   type: IotaWormholeContract
+- chain: iota_mainnet
+  stateId: "0xd43b448afc9dd01deb18273ec39d8f27ddd4dd46b0922383874331771b70df73"
+  type: IotaWormholeContract

+ 2 - 0
governance/xc_admin/packages/xc_admin_common/src/chains.ts

@@ -111,6 +111,8 @@ export const RECEIVER_CHAINS = {
   xion: 60081,
   worldchain: 60082,
   swellchain: 60083,
+  // empty chain id 60084
+  iota_sui_mainnet: 60085,
 
   // Testnets as a separate chain ids (to use stable data sources and governance for them)
   injective_testnet: 60013,

+ 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;
-};

Some files were not shown because too many files changed in this diff