Browse Source

Merge pull request #2766 from pyth-network/cprussin/entropy-explorer-live

feat(entropy-explorer): bind to live apis
Connor Prussin 3 months ago
parent
commit
b011aee221
35 changed files with 1626 additions and 1221 deletions
  1. 54 0
      apps/entropy-explorer/NOTES.org
  2. 7 0
      apps/entropy-explorer/next.config.js
  3. 0 3
      apps/entropy-explorer/package.json
  4. 0 16
      apps/entropy-explorer/src/components/Address/index.module.scss
  5. 18 10
      apps/entropy-explorer/src/components/Address/index.tsx
  6. 0 126
      apps/entropy-explorer/src/components/Home/chain-select.tsx
  7. 9 0
      apps/entropy-explorer/src/components/Home/index.module.scss
  8. 114 8
      apps/entropy-explorer/src/components/Home/index.tsx
  9. 12 0
      apps/entropy-explorer/src/components/Home/request-drawer.module.scss
  10. 134 72
      apps/entropy-explorer/src/components/Home/request-drawer.tsx
  11. 5 3
      apps/entropy-explorer/src/components/Home/results.module.scss
  12. 94 128
      apps/entropy-explorer/src/components/Home/results.tsx
  13. 0 37
      apps/entropy-explorer/src/components/Home/search-bar.tsx
  14. 0 7
      apps/entropy-explorer/src/components/Home/search-controls.module.scss
  15. 344 0
      apps/entropy-explorer/src/components/Home/search-controls.tsx
  16. 0 95
      apps/entropy-explorer/src/components/Home/status-select.tsx
  17. 0 87
      apps/entropy-explorer/src/components/Home/use-query.ts
  18. 0 21
      apps/entropy-explorer/src/components/Root/evm-provider.tsx
  19. 1 2
      apps/entropy-explorer/src/components/Root/index.tsx
  20. 12 6
      apps/entropy-explorer/src/components/Status/index.tsx
  21. 14 2
      apps/entropy-explorer/src/components/Timestamp/index.tsx
  22. 0 471
      apps/entropy-explorer/src/entropy-deployments.ts
  23. 456 0
      apps/entropy-explorer/src/entropy-deployments.tsx
  24. 32 5
      apps/entropy-explorer/src/errors.ts
  25. 3 0
      apps/entropy-explorer/src/pages.ts
  26. 242 99
      apps/entropy-explorer/src/requests.ts
  27. 1 1
      apps/insights/src/components/Publisher/performance.tsx
  28. 2 0
      packages/component-library/src/ErrorPage/index.tsx
  29. 12 6
      packages/component-library/src/Header/index.tsx
  30. 10 5
      packages/component-library/src/NoResults/index.tsx
  31. 7 0
      packages/component-library/src/Paginator/index.module.scss
  32. 19 0
      packages/component-library/src/Paginator/index.tsx
  33. 10 0
      packages/component-library/src/Select/index.module.scss
  34. 13 1
      packages/component-library/src/Select/index.tsx
  35. 1 10
      pnpm-lock.yaml

+ 54 - 0
apps/entropy-explorer/NOTES.org

@@ -0,0 +1,54 @@
+* TODO Lock timestamp to when page loaded & add sync button
+* TODO Add TODO items in requests.ts
+- providerContribution
+- gasUsed + randomNumber in errored state
+
+* TODO Connect the wallet to execute the transaction.
+* TODO Have a blinking yellow-coloured pending status that can be turned Green using an event listener.
+* TODO A dashboard on the home page to display reference material, such as current fees and usage.
+* TODO Many people use the Block number to search in explorers as well. We can think about it.
+
+* DONE Removing badges from the sequence number + making it bold
+CLOSED: [2025-05-09 Fri 20:06]
+* DONE Rename Caller -> From or Sender
+CLOSED: [2025-05-09 Fri 20:08]
+* DONE Option to filter Callback Status
+CLOSED: [2025-05-09 Fri 20:08]
+* DONE Reorder Failed Tx Fields.
+CLOSED: [2025-05-09 Fri 20:11]
+** Request Timestamp
+** Callback Timestamp
+** Request Tx +Hash+
+** From / Sender
+** Callback Tx +Hash+
+** Provider
+** +User Random Number+ User Contribution
+** Gas Used
+* DONE Put the cast command in the code background with syntax colouring/highlighting.
+CLOSED: [2025-05-09 Fri 20:11]
+* DONE Decoding [[https://docs.pyth.network/entropy/error-codes][Error codes]].
+CLOSED: [2025-05-12 Mon 13:53]
+* DONE The timestamp should be shown in the following format ~9 secs ago (May-09-2025 12:47:11 PM +utc:)~
+CLOSED: [2025-05-12 Mon 14:30]
+* DONE Tooltips
+CLOSED: [2025-05-12 Mon 22:37]
+** Request Tx
+** Callback Tx
+** User Random Number / User Contribution
+** Provider Random Number / User Contribution
+* DONE Show delay/latency (delta between callbackTimestamp and requestTimestamp) somehow
+CLOSED: [2025-05-12 Mon 22:45]
+* DONE As @Tejas mentioned above, a tooltip button for the code can be used, or it can redirect them [[https://docs.pyth.network/entropy/debug-callback-failures][here]].
+CLOSED: [2025-05-12 Mon 22:48]
+* DONE Finish paginator
+CLOSED: [2025-06-06 Fri 11:10]
+* DONE Improve use-search-params
+CLOSED: [2025-06-06 Fri 19:55]
+* DONE Fix callback error page for unknown callback errors (i.e. when fortuna sends a string that isn't an error code)
+CLOSED: [2025-06-06 Fri 20:07]
+* DONE Improve not found message, especially for invalid search
+CLOSED: [2025-06-10 Tue 12:24]
+* DONE Improve ErrorResult handling
+CLOSED: [2025-06-10 Tue 12:24]
+* DONE Add all chains + icons
+CLOSED: [2025-06-10 Tue 16:12]

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

@@ -7,6 +7,13 @@ const config = {
 
   pageExtensions: ["ts", "tsx", "mdx"],
 
+  images: {
+    remotePatterns: [
+      new URL("https://icons.llamao.fi/icons/chains/*?w=20&h=20"),
+      new URL("https://www.tabichain.com/images/new2/tabi.svg"),
+    ],
+  },
+
   logging: {
     fetches: {
       fullUrl: true,

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

@@ -23,15 +23,12 @@
     "@phosphor-icons/react": "catalog:",
     "@pythnetwork/component-library": "workspace:*",
     "clsx": "catalog:",
-    "connectkit": "catalog:",
     "next": "catalog:",
     "nuqs": "catalog:",
     "react": "catalog:",
     "react-aria": "catalog:",
     "react-dom": "catalog:",
     "react-timeago": "catalog:",
-    "viem": "catalog:",
-    "wagmi": "catalog:",
     "zod": "catalog:"
   },
   "devDependencies": {

+ 0 - 16
apps/entropy-explorer/src/components/Address/index.module.scss

@@ -5,20 +5,4 @@
   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;
-      }
-    }
-  }
 }

+ 18 - 10
apps/entropy-explorer/src/components/Address/index.tsx

@@ -9,24 +9,32 @@ import { truncate } from "../../truncate";
 type Props = {
   value: string;
   chain: keyof typeof EntropyDeployments;
-  alwaysTruncate?: boolean | undefined;
+  isAccount?: boolean | undefined;
 };
 
-export const Address = ({ value, chain, alwaysTruncate }: Props) => {
-  const { explorer } = EntropyDeployments[chain];
+export const Account = (props: Omit<Props, "isAccount">) => (
+  <Address {...props} isAccount />
+);
+
+export const Transaction = (props: Omit<Props, "isAccount">) => (
+  <Address {...props} />
+);
+
+const Address = ({ value, chain, isAccount }: Props) => {
+  const { explorerTxTemplate, explorerAccountTemplate } =
+    EntropyDeployments[chain];
+  const explorerTemplate = isAccount
+    ? explorerAccountTemplate
+    : explorerTxTemplate;
   const truncatedValue = useMemo(() => truncate(value), [value]);
   return (
-    <div
-      data-always-truncate={alwaysTruncate ? "" : undefined}
-      className={styles.address}
-    >
+    <div className={styles.address}>
       <Link
-        href={explorer.replace("$ADDRESS", value)}
+        href={explorerTemplate.replace("$ADDRESS", value)}
         target="_blank"
         rel="noreferrer"
       >
-        <code className={styles.truncated}>{truncatedValue}</code>
-        <code className={styles.full}>{value}</code>
+        <code>{truncatedValue}</code>
       </Link>
       <CopyButton text={value} iconOnly />
     </div>

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

@@ -1,126 +0,0 @@
-"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]
-  | { id: "all" };
-
-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: "ALL",
-        options: [{ id: "all" as const }],
-        hideLabel: true,
-      },
-      {
-        name: "MAINNET",
-        options: entropyDeploymentsByNetwork("mainnet", collator),
-      },
-      {
-        name: "TESTNET",
-        options: entropyDeploymentsByNetwork("testnet", collator),
-      },
-    ],
-    [collator],
-  );
-
-  const showChain = useCallback(
-    (chain: Deployment) =>
-      chain.id === "all" ? (
-        "All"
-      ) : (
-        <div className={styles.chainSelectItem}>
-          <ChainIcon id={chain.chainId} />
-          {chain.name}
-        </div>
-      ),
-    [],
-  );
-
-  const chainTextValue = useCallback(
-    (chain: Deployment) => (chain.id === "all" ? "All" : chain.name),
-    [],
-  );
-  // eslint-disable-next-line import/namespace
-  const viemChain = chain ? viemChains[chain] : undefined;
-
-  return {
-    selectedKey: chain ?? ("all" as const),
-    onSelectionChange: setChain,
-    optionGroups: chains,
-    show: showChain,
-    textValue: chainTextValue,
-    buttonLabel: viemChain?.name ?? "Chain",
-    ...(viemChain && {
-      icon: <ChainIcon id={viemChain.id} />,
-    }),
-  };
-};
-
-const defaultProps = {
-  label: "Chain",
-  hideLabel: true,
-  defaultButtonLabel: "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));

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

@@ -19,6 +19,10 @@
   .body {
     @include theme.max-width;
 
+    .statusSelect {
+      width: theme.spacing(44);
+    }
+
     .searchBar {
       width: 100%;
 
@@ -26,5 +30,10 @@
         width: theme.spacing(100);
       }
     }
+
+    .cardBody {
+      background: theme.color("background", "primary");
+      border-radius: theme.border-radius("xl");
+    }
   }
 }

+ 114 - 8
apps/entropy-explorer/src/components/Home/index.tsx

@@ -1,13 +1,26 @@
 import { ListDashes } from "@phosphor-icons/react/dist/ssr/ListDashes";
 import { Card } from "@pythnetwork/component-library/Card";
+import { ErrorPage } from "@pythnetwork/component-library/ErrorPage";
+import { NoResults } from "@pythnetwork/component-library/NoResults";
+import { Suspense } from "react";
 
-import { ChainSelect } from "./chain-select";
 import styles from "./index.module.scss";
-import { Results } from "./results";
-import { SearchBar } from "./search-bar";
-import { StatusSelect } from "./status-select";
+import { Results as ResultsImpl, ResultsLoading } from "./results";
+import {
+  SearchBar,
+  Paginator as PaginatorImpl,
+  ChainSelect,
+  StatusSelect,
+} from "./search-controls";
+import { isValidDeployment } from "../../entropy-deployments";
+import type { Args } from "../../requests";
+import { getRequests, ResultType } from "../../requests";
 
-export const Home = () => (
+type Props = {
+  searchParams: Promise<Args>;
+};
+
+export const Home = (props: Props) => (
   <div className={styles.home}>
     <h1 className={styles.header}>Requests</h1>
     <div className={styles.body}>
@@ -16,18 +29,111 @@ export const Home = () => (
         icon={<ListDashes />}
         toolbar={
           <>
-            <ChainSelect variant="outline" size="sm" placement="bottom right" />
+            <ChainSelect
+              label="Chain"
+              hideLabel
+              variant="outline"
+              size="sm"
+              placement="bottom right"
+            />
             <StatusSelect
+              label="Status"
+              hideLabel
+              defaultButtonLabel="Status"
+              hideGroupLabel
               variant="outline"
               size="sm"
               placement="bottom right"
+              className={styles.statusSelect ?? ""}
+            />
+            <SearchBar
+              size="sm"
+              placeholder="Sequence number, provider, sender or tx hash"
+              className={styles.searchBar ?? ""}
             />
-            <SearchBar className={styles.searchBar ?? ""} />
           </>
         }
+        footer={
+          <Suspense>
+            <Paginator {...props} />
+          </Suspense>
+        }
       >
-        <Results />
+        <div className={styles.cardBody}>
+          <Suspense fallback={<ResultsLoading />}>
+            <Results {...props} />
+          </Suspense>
+        </div>
       </Card>
     </div>
   </div>
 );
+
+const Results = async (props: Props) => {
+  try {
+    const searchParams = await props.searchParams;
+    const results = await getRequests(searchParams);
+    switch (results.type) {
+      case ResultType.BadSearch: {
+        return (
+          <NoResults
+            header="Invalid Search"
+            body="Your search query is not a valid transaction hash, sequence number, or sender."
+            query={results.search}
+          />
+        );
+      }
+      case ResultType.ErrorResult: {
+        return <ErrorPage error={results.error} />;
+      }
+      case ResultType.Success: {
+        return (
+          <ResultsImpl
+            key={[
+              searchParams.chain,
+              searchParams.search,
+              searchParams.status,
+            ].join(",")}
+            chain={
+              searchParams.chain !== undefined &&
+              isValidDeployment(searchParams.chain)
+                ? searchParams.chain
+                : undefined
+            }
+            search={searchParams.search}
+            currentPage={results.currentPage}
+            now={new Date()}
+          />
+        );
+      }
+    }
+  } catch (error) {
+    if (error instanceof Error) {
+      return <ErrorPage error={error} />;
+    } else {
+      const err = new Error("Unknown Error");
+      err.cause = error;
+      return <ErrorPage error={err} />;
+    }
+  }
+};
+
+const Paginator = async (props: Props) => {
+  try {
+    const searchParams = await props.searchParams;
+    const results = await getRequests(searchParams);
+    switch (results.type) {
+      case ResultType.Success: {
+        return <PaginatorImpl numPages={results.numPages} />;
+      }
+      case ResultType.BadSearch: {
+        return <PaginatorImpl numPages={0} />;
+      }
+      case ResultType.ErrorResult: {
+        return <></>;
+      }
+    }
+  } catch {
+    return <></>;
+  }
+};

+ 12 - 0
apps/entropy-explorer/src/components/Home/request-drawer.module.scss

@@ -36,6 +36,18 @@
     margin-right: theme.spacing(4);
     position: relative;
 
+    .helpButton {
+      position: absolute;
+      top: theme.spacing(2);
+      right: theme.spacing(2);
+    }
+
+    .failureMessage {
+      overflow: auto;
+      width: 100%;
+      word-break: break-word;
+    }
+
     p {
       margin: 0;
 

+ 134 - 72
apps/entropy-explorer/src/components/Home/request-drawer.tsx

@@ -15,22 +15,35 @@ import TimeAgo from "react-timeago";
 import styles from "./request-drawer.module.scss";
 import { EntropyDeployments } from "../../entropy-deployments";
 import { getErrorDetails } from "../../errors";
-import type { Request, CallbackErrorRequest } from "../../requests";
+import type {
+  Request,
+  CallbackErrorRequest,
+  FailedRequest,
+} from "../../requests";
 import { Status } from "../../requests";
 import { truncate } from "../../truncate";
-import { Address } from "../Address";
+import { Account, Transaction } from "../Address";
 import { Status as StatusComponent } from "../Status";
 import { Timestamp } from "../Timestamp";
 
-export const mkRequestDrawer = (request: Request): OpenDrawerArgs => ({
+export const mkRequestDrawer = (
+  request: Request,
+  now: Date,
+): OpenDrawerArgs => ({
   title: `Request ${truncate(request.requestTxHash)}`,
-  headingExtra: <StatusComponent prefix="CALLBACK " status={request.status} />,
+  headingExtra: <StatusComponent status={request.status} />,
   bodyClassName: styles.requestDrawer ?? "",
   fill: true,
-  contents: <RequestDrawerBody request={request} />,
+  contents: <RequestDrawerBody request={request} now={now} />,
 });
 
-const RequestDrawerBody = ({ request }: { request: Request }) => {
+const RequestDrawerBody = ({
+  request,
+  now,
+}: {
+  request: Request;
+  now: Date;
+}) => {
   const gasFormatter = useNumberFormatter({ maximumFractionDigits: 3 });
 
   return (
@@ -42,12 +55,12 @@ const RequestDrawerBody = ({ request }: { request: Request }) => {
           small
           variant="primary"
           stat={
-            request.status === Status.Pending ? (
-              <StatusComponent prefix="CALLBACK " status={Status.Pending} />
-            ) : (
+            "randomNumber" in request ? (
               <CopyButton text={request.randomNumber}>
                 <code>{truncate(request.randomNumber)}</code>
               </CopyButton>
+            ) : (
+              <StatusComponent status={request.status} />
             )
           }
         />
@@ -59,7 +72,10 @@ const RequestDrawerBody = ({ request }: { request: Request }) => {
         />
       </div>
       {request.status === Status.CallbackError && (
-        <CallbackFailedInfo request={request} />
+        <CallbackErrorInfo request={request} />
+      )}
+      {request.status === Status.Failed && (
+        <RevealFailedInfo request={request} />
       )}
       <Table
         label="Details"
@@ -84,15 +100,19 @@ const RequestDrawerBody = ({ request }: { request: Request }) => {
           {
             id: "requestTimestamp",
             field: "Request Timestamp",
-            value: <Timestamp timestamp={request.requestTimestamp} />,
+            value: <Timestamp timestamp={request.requestTimestamp} now={now} />,
           },
-          ...(request.status === Status.Pending
-            ? []
-            : [
+          ...("callbackTimestamp" in request
+            ? [
                 {
                   id: "callbackTimestamp",
                   field: "Callback Timestamp",
-                  value: <Timestamp timestamp={request.callbackTimestamp} />,
+                  value: (
+                    <Timestamp
+                      timestamp={request.callbackTimestamp}
+                      now={now}
+                    />
+                  ),
                 },
                 {
                   id: "duration",
@@ -113,7 +133,8 @@ const RequestDrawerBody = ({ request }: { request: Request }) => {
                     />
                   ),
                 },
-              ]),
+              ]
+            : []),
           {
             id: "requestTx",
             field: (
@@ -123,17 +144,19 @@ const RequestDrawerBody = ({ request }: { request: Request }) => {
               </Term>
             ),
             value: (
-              <Address chain={request.chain} value={request.requestTxHash} />
+              <Transaction
+                chain={request.chain}
+                value={request.requestTxHash}
+              />
             ),
           },
           {
             id: "sender",
             field: "Sender",
-            value: <Address chain={request.chain} value={request.sender} />,
+            value: <Account chain={request.chain} value={request.sender} />,
           },
-          ...(request.status === Status.Pending
-            ? []
-            : [
+          ...(request.status === Status.Complete
+            ? [
                 {
                   id: "callbackTx",
                   field: (
@@ -143,17 +166,18 @@ const RequestDrawerBody = ({ request }: { request: Request }) => {
                     </Term>
                   ),
                   value: (
-                    <Address
+                    <Transaction
                       chain={request.chain}
                       value={request.callbackTxHash}
                     />
                   ),
                 },
-              ]),
+              ]
+            : []),
           {
             id: "provider",
             field: "Provider",
-            value: <Address chain={request.chain} value={request.provider} />,
+            value: <Account chain={request.chain} value={request.provider} />,
           },
           {
             id: "userContribution",
@@ -163,32 +187,34 @@ const RequestDrawerBody = ({ request }: { request: Request }) => {
               </Term>
             ),
             value: (
-              <CopyButton text={request.userRandomNumber}>
-                <code>{truncate(request.userRandomNumber)}</code>
-              </CopyButton>
-            ),
-          },
-          {
-            id: "providerContribution",
-            field: (
-              <Term term="Provider Contribution">
-                Provider-submitted randomness used to calculate the random
-                number.
-              </Term>
-            ),
-            value: (
-              <CopyButton text={request.userRandomNumber}>
-                <code>{truncate(request.userRandomNumber)}</code>
+              <CopyButton text={request.userContribution}>
+                <code>{truncate(request.userContribution)}</code>
               </CopyButton>
             ),
           },
+          ...("providerContribution" in request
+            ? [
+                {
+                  id: "providerContribution",
+                  field: (
+                    <Term term="Provider Contribution">
+                      Provider-submitted randomness used to calculate the random
+                      number.
+                    </Term>
+                  ),
+                  value: (
+                    <CopyButton text={request.providerContribution}>
+                      <code>{truncate(request.providerContribution)}</code>
+                    </CopyButton>
+                  ),
+                },
+              ]
+            : []),
           {
             id: "gas",
             field: "Gas",
             value:
-              request.status === Status.Pending ? (
-                `${gasFormatter.format(request.gasLimit)} max`
-              ) : (
+              "gasUsed" in request ? (
                 <Meter
                   label="Gas"
                   value={request.gasUsed}
@@ -201,6 +227,8 @@ const RequestDrawerBody = ({ request }: { request: Request }) => {
                     request.gasUsed > request.gasLimit ? "error" : "default"
                   }
                 />
+              ) : (
+                `${gasFormatter.format(request.gasLimit)} max`
               ),
           },
         ].map((data) => ({
@@ -215,9 +243,9 @@ const RequestDrawerBody = ({ request }: { request: Request }) => {
   );
 };
 
-const CallbackFailedInfo = ({ request }: { request: CallbackErrorRequest }) => {
+const CallbackErrorInfo = ({ request }: { request: CallbackErrorRequest }) => {
   const deployment = EntropyDeployments[request.chain];
-  const retryCommand = `cast send ${deployment.address} 'revealWithCallback(address, uint64, bytes32, bytes32)' ${request.provider} ${request.sequenceNumber.toString()} ${request.userRandomNumber} ${request.randomNumber} -r ${deployment.rpc} --private-key <YOUR_PRIVATE_KEY>`;
+  const retryCommand = `cast send ${deployment.address} 'revealWithCallback(address, uint64, bytes32, bytes32)' ${request.provider} ${request.sequenceNumber.toString()} ${request.userContribution} ${request.randomNumber} -r ${deployment.rpc} --private-key <YOUR_PRIVATE_KEY>`;
 
   return (
     <>
@@ -227,7 +255,21 @@ const CallbackFailedInfo = ({ request }: { request: CallbackErrorRequest }) => {
         className={styles.message}
         variant="warning"
       >
-        <CallbackFailureMessage request={request} />
+        <Button
+          hideText
+          beforeIcon={<Question />}
+          rounded
+          size="sm"
+          variant="ghost"
+          className={styles.helpButton ?? ""}
+          href={getHelpLink(request.returnValue)}
+          target="_blank"
+        >
+          Help
+        </Button>
+        <div className={styles.failureMessage}>
+          <CallbackFailureMessage reason={request.returnValue} />
+        </div>
       </InfoBox>
       <InfoBox
         header="Retry the callback yourself"
@@ -247,9 +289,6 @@ const CallbackFailedInfo = ({ request }: { request: CallbackErrorRequest }) => {
           }}
         >
           <CopyButton text={retryCommand}>Copy Forge Command</CopyButton>
-          <Button size="sm" variant="outline">
-            Connect Wallet
-          </Button>
           <Button
             size="sm"
             variant="ghost"
@@ -258,6 +297,7 @@ const CallbackFailedInfo = ({ request }: { request: CallbackErrorRequest }) => {
             hideText
             href="https://docs.pyth.network/entropy/debug-callback-failures"
             target="_blank"
+            className={styles.helpButton ?? ""}
           >
             Help
           </Button>
@@ -267,27 +307,49 @@ const CallbackFailedInfo = ({ request }: { request: CallbackErrorRequest }) => {
   );
 };
 
-const CallbackFailureMessage = ({
-  request,
-}: {
-  request: CallbackErrorRequest;
-}) => {
-  if (request.returnValue === "" && request.gasUsed > request.gasLimit) {
-    return "The callback used more gas than the gas limit.";
-  } else {
-    const details = getErrorDetails(request.returnValue);
-    return details ? (
-      <>
-        <p>The callback encountered the following error:</p>
-        <p className={styles.details}>
-          <b>{details[0]}</b> (<code>{request.returnValue}</code>): {details[1]}
-        </p>
-      </>
-    ) : (
-      <>
-        The callback encountered an unknown error:{" "}
-        <code>{request.returnValue}</code>
-      </>
-    );
-  }
+const RevealFailedInfo = ({ request }: { request: FailedRequest }) => (
+  <InfoBox
+    header="Reveal failed!"
+    icon={<Warning />}
+    className={styles.message}
+    variant="warning"
+  >
+    <Button
+      hideText
+      beforeIcon={<Question />}
+      rounded
+      size="sm"
+      variant="ghost"
+      className={styles.helpButton ?? ""}
+      href={getHelpLink(request.reason)}
+      target="_blank"
+    >
+      Help
+    </Button>
+    <div className={styles.failureMessage}>
+      <CallbackFailureMessage reason={request.reason} />
+    </div>
+  </InfoBox>
+);
+
+const getHelpLink = (reason: string) => {
+  const details = getErrorDetails(reason);
+  return (
+    details?.[2] ??
+    "https://docs.pyth.network/entropy/best-practices#handling-callback-failures"
+  );
+};
+
+const CallbackFailureMessage = ({ reason }: { reason: string }) => {
+  const details = getErrorDetails(reason);
+  return details ? (
+    <>
+      <p>The callback encountered the following error:</p>
+      <p className={styles.details}>
+        <b>{details[0]}</b> (<code>{reason}</code>): {details[1]}
+      </p>
+    </>
+  ) : (
+    reason
+  );
 };

+ 5 - 3
apps/entropy-explorer/src/components/Home/results.module.scss

@@ -15,9 +15,6 @@
 }
 
 .entityList {
-  background: theme.color("background", "primary");
-  border-radius: theme.border-radius("xl");
-
   @include theme.breakpoint("xl") {
     display: none;
   }
@@ -39,3 +36,8 @@
     @include theme.text("base", "medium");
   }
 }
+
+.noResultsChainSelect {
+  margin-top: theme.spacing(4);
+  justify-self: center;
+}

+ 94 - 128
apps/entropy-explorer/src/components/Home/results.tsx

@@ -1,154 +1,104 @@
 "use client";
 
-import { Warning } from "@phosphor-icons/react/dist/ssr/Warning";
 import { EntityList } from "@pythnetwork/component-library/EntityList";
-import { NoResults } from "@pythnetwork/component-library/NoResults";
+import { NoResults as NoResultsImpl } from "@pythnetwork/component-library/NoResults";
 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 { ChainIcon } from "connectkit";
+import Image from "next/image";
 import type { ComponentProps } from "react";
-import { Suspense, useMemo } from "react";
-import { useFilter } from "react-aria";
-import * as viemChains from "viem/chains";
+import { useMemo } from "react";
 
 import { mkRequestDrawer } from "./request-drawer";
 import styles from "./results.module.scss";
-import { useQuery } from "./use-query";
 import { EntropyDeployments } from "../../entropy-deployments";
-import { Status, getRequests } from "../../requests";
-import { Address } from "../Address";
+import type { Request } from "../../requests";
+import { Status } from "../../requests";
+import { Account, Transaction } from "../Address";
 import { Status as StatusComponent } from "../Status";
 import { Timestamp } from "../Timestamp";
+import { ChainSelect } from "./search-controls";
 
-export const Results = () => (
-  <Suspense fallback={<ResultsImpl isLoading />}>
-    <MountedResults />
-  </Suspense>
-);
-
-const MountedResults = () => {
-  const results = useData(["requests"], getRequests, {
-    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
-          data={results.data}
-          isUpdating={results.isValidating}
-        />
-      );
-    }
-  }
-};
-
-type ResolvedResultsProps = {
-  data: Awaited<ReturnType<typeof getRequests>>;
+type Props = {
+  currentPage: Request[];
+  search?: string | undefined;
   isUpdating?: boolean | undefined;
+  now: Date;
+  chain?: keyof typeof EntropyDeployments | undefined;
 };
 
-const ResolvedResults = ({ data, isUpdating }: ResolvedResultsProps) => {
+export const Results = ({
+  currentPage,
+  isUpdating,
+  search,
+  now,
+  chain,
+}: Props) => {
   const drawer = useDrawer();
-  const { search, chain, status } = useQuery();
-  const filter = useFilter({ sensitivity: "base", usage: "search" });
   const rows = useMemo(
     () =>
-      data
-        .filter(
-          (request) =>
-            (status === null || status === request.status) &&
-            (chain === null || chain === request.chain) &&
-            (filter.contains(request.requestTxHash, search) ||
-              (request.status !== Status.Pending &&
-                filter.contains(request.callbackTxHash, search)) ||
-              filter.contains(request.sender, search) ||
-              filter.contains(request.sequenceNumber.toString(), search)),
-        )
-        .map((request) => ({
-          id: request.sequenceNumber.toString(),
-          textValue: request.requestTxHash,
-          onAction: () => {
-            drawer.open(mkRequestDrawer(request));
-          },
-          data: {
-            chain: <Chain chain={request.chain} />,
-            timestamp: (
-              <div className={styles.timestamp}>
-                <Timestamp timestamp={request.requestTimestamp} />
-              </div>
-            ),
-            sequenceNumber: (
-              <div className={styles.sequenceNumber}>
-                {request.sequenceNumber}
-              </div>
-            ),
-            sender: (
-              <Address
-                alwaysTruncate
-                chain={request.chain}
-                value={request.sender}
-              />
-            ),
-            requestTxHash: (
-              <Address
-                alwaysTruncate
-                chain={request.chain}
-                value={request.requestTxHash}
-              />
-            ),
-            callbackTxHash: request.status !== Status.Pending && (
-              <Address
-                alwaysTruncate
-                chain={request.chain}
-                value={request.callbackTxHash}
-              />
-            ),
-            status: <StatusComponent status={request.status} />,
-          },
-        })),
-    [data, search, drawer, filter, chain, status],
+      currentPage.map((request) => ({
+        id: request.sequenceNumber.toString(),
+        textValue: request.requestTxHash,
+        onAction: () => {
+          drawer.open(mkRequestDrawer(request, now));
+        },
+        data: {
+          chain: <Chain chain={request.chain} />,
+          timestamp: (
+            <div className={styles.timestamp}>
+              <Timestamp timestamp={request.requestTimestamp} now={now} />
+            </div>
+          ),
+          sequenceNumber: (
+            <div className={styles.sequenceNumber}>
+              {request.sequenceNumber}
+            </div>
+          ),
+          sender: <Account chain={request.chain} value={request.sender} />,
+          requestTxHash: (
+            <Transaction chain={request.chain} value={request.requestTxHash} />
+          ),
+          callbackTxHash: request.status === Status.Complete && (
+            <Transaction chain={request.chain} value={request.callbackTxHash} />
+          ),
+          status: <StatusComponent status={request.status} size="xs" />,
+        },
+      })),
+    [currentPage, drawer, now],
   );
 
-  return <ResultsImpl rows={rows} isUpdating={isUpdating} search={search} />;
+  return (
+    <ResultsImpl
+      rows={rows}
+      search={search}
+      chain={chain}
+      isUpdating={isUpdating}
+    />
+  );
 };
 
+export const ResultsLoading = () => <ResultsImpl isLoading />;
+
 type ResultsImplProps =
   | {
       isLoading: true;
     }
   | {
       isLoading?: false | undefined;
+      chain?: keyof typeof EntropyDeployments | undefined;
       rows: (RowConfig<(typeof defaultProps)["columns"][number]["id"]> & {
         textValue: string;
       })[];
       isUpdating?: boolean | undefined;
-      search: string;
+      search?: string | undefined;
     };
 
 const ResultsImpl = (props: ResultsImplProps) => (
   <>
     <div className={styles.entityList}>
       {!props.isLoading && props.rows.length === 0 ? (
-        <NoResults query={props.search} />
+        <NoResults search={props.search} chain={props.chain} />
       ) : (
         <EntityList
           label={defaultProps.label}
@@ -173,32 +123,48 @@ const ResultsImpl = (props: ResultsImplProps) => (
         : {
             rows: props.rows,
             isUpdating: props.isUpdating,
-            emptyState: <NoResults query={props.search} />,
+            emptyState: <NoResults search={props.search} chain={props.chain} />,
             className: styles.table ?? "",
           })}
     />
   </>
 );
 
-const Empty = (props: ComponentProps<typeof NoResults>) => (
-  <>
-    <NoResults className={styles.entityList} {...props} />
-    <Table
-      className={styles.table ?? ""}
-      rows={[]}
-      emptyState={<NoResults {...props} />}
-      {...defaultProps}
+type NoResultsProps = {
+  search?: string | undefined;
+  chain?: keyof typeof EntropyDeployments | undefined;
+};
+
+const NoResults = ({ search, chain }: NoResultsProps) => {
+  return (
+    <NoResultsImpl
+      query={search ?? ""}
+      body={
+        <>
+          <p>
+            We couldn{"'"}t find any results for your query on{" "}
+            {chain ? EntropyDeployments[chain].name : "any chain"}.
+          </p>
+          <p>Would you like to try your search on a different chain?</p>
+          <ChainSelect
+            className={styles.noResultsChainSelect ?? ""}
+            label="Chain"
+            hideLabel
+            variant="outline"
+            size="sm"
+          />
+        </>
+      }
     />
-  </>
-);
+  );
+};
 
 const Chain = ({ chain }: { chain: keyof typeof EntropyDeployments }) => {
-  // eslint-disable-next-line import/namespace
-  const viemChain = viemChains[chain];
+  const chainInfo = EntropyDeployments[chain];
   return (
     <div className={styles.chain}>
-      <ChainIcon id={viemChain.id} />
-      {viemChain.name}
+      <Image alt="" src={chainInfo.icon} width={20} height={20} />
+      {chainInfo.name}
     </div>
   );
 };
@@ -241,9 +207,9 @@ const defaultProps = {
     },
     {
       id: "status" as const,
-      name: "CALLBACK STATUS",
+      name: "STATUS",
       alignment: "center",
-      width: 25,
+      width: 32,
     },
   ],
 } satisfies Partial<ComponentProps<typeof Table<string>>>;

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

@@ -1,37 +0,0 @@
-"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, sender or tx hash",
-} as const;

+ 0 - 7
apps/entropy-explorer/src/components/Home/chain-select.module.scss → apps/entropy-explorer/src/components/Home/search-controls.module.scss

@@ -1,12 +1,5 @@
 @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;

+ 344 - 0
apps/entropy-explorer/src/components/Home/search-controls.tsx

@@ -0,0 +1,344 @@
+"use client";
+
+import { Paginator as PaginatorImpl } from "@pythnetwork/component-library/Paginator";
+import { SearchInput } from "@pythnetwork/component-library/SearchInput";
+import type { Props as SelectProps } from "@pythnetwork/component-library/Select";
+import { Select } from "@pythnetwork/component-library/Select";
+import Image from "next/image";
+import { usePathname, useSearchParams, useRouter } from "next/navigation";
+import type { ComponentProps } from "react";
+import { Suspense, useCallback, useMemo, useTransition } from "react";
+import { useCollator } from "react-aria";
+
+import {
+  EntropyDeployments,
+  isValidDeployment,
+} from "../../entropy-deployments";
+import { DEFAULT_PAGE_SIZE, PAGE_SIZES } from "../../pages";
+import { Status, StatusParams } from "../../requests";
+import type { ConstrainedOmit } from "../../type-utils";
+import { Status as StatusComponent } from "../Status";
+import styles from "./search-controls.module.scss";
+
+export const SearchBar = (props: ComponentProps<typeof ResolvedSearchBar>) => (
+  <Suspense fallback={<SearchInput isPending {...props} />}>
+    <ResolvedSearchBar {...props} />
+  </Suspense>
+);
+
+const ResolvedSearchBar = (
+  props: ConstrainedOmit<
+    ComponentProps<typeof SearchInput>,
+    keyof ReturnType<typeof useSearchBar>
+  >,
+) => <SearchInput {...useSearchBar()} {...props} />;
+
+const useSearchBar = () => {
+  const search = useSearchParam({
+    paramName: "search",
+    parse: id,
+    defaultValue: "",
+    serialize: id,
+  });
+
+  return {
+    onChange: search.onChange,
+    defaultValue: search.value,
+    isPending: search.isTransitioning,
+  };
+};
+
+export const Paginator = (props: ComponentProps<typeof ResolvedPaginator>) => (
+  <Suspense fallback={<Paginator {...props} />}>
+    <ResolvedPaginator {...props} />
+  </Suspense>
+);
+
+const ResolvedPaginator = (
+  props: ConstrainedOmit<
+    ComponentProps<typeof PaginatorImpl>,
+    keyof ReturnType<typeof usePaginator>
+  >,
+) => <PaginatorImpl {...usePaginator()} {...props} />;
+
+const usePaginator = () => {
+  const pageSize = useSearchParam({
+    paramName: "pageSize",
+    parse: parseInt,
+    defaultValue: DEFAULT_PAGE_SIZE,
+    serialize: toString,
+  });
+  const page = useSearchParam({
+    paramName: "page",
+    parse: parseInt,
+    defaultValue: 1,
+    preservePageOnChange: true,
+    serialize: toString,
+  });
+
+  return {
+    currentPage: page.value,
+    onPageChange: page.onChange,
+    isPageTransitioning: page.isTransitioning,
+    onPageSizeChange: pageSize.onChange,
+    isPageSizeTransitioning: pageSize.isTransitioning,
+    pageSize: pageSize.value,
+    pageSizeOptions: PAGE_SIZES as unknown as number[],
+  };
+};
+
+export const StatusSelect = (
+  props: ComponentProps<typeof ResolvedStatusSelect>,
+) => (
+  <Suspense fallback={<Select isPending options={[]} {...props} />}>
+    <ResolvedStatusSelect {...props} />
+  </Suspense>
+);
+
+const ResolvedStatusSelect = (
+  props: ConstrainedOmit<
+    SelectProps<{ id: Status | "all" }>,
+    keyof ReturnType<typeof useStatusSelect>
+  >,
+) => <Select {...useStatusSelect()} {...props} />;
+
+const useStatusSelect = () => {
+  const status = useSearchParam({
+    paramName: "status",
+    parse: parseStatus,
+    defaultValue: "all",
+    serialize: serializeStatus,
+  });
+
+  return {
+    selectedKey: status.value,
+    onSelectionChange: status.onChange,
+    isPending: status.isTransitioning,
+    optionGroups: useMemo(
+      () => [
+        {
+          name: "All",
+          options: [{ id: "all" as const }],
+        },
+        {
+          name: "Statuses",
+          options: [
+            { id: Status.Complete },
+            { id: Status.Pending },
+            { id: Status.Failed },
+            { id: Status.CallbackError },
+          ],
+        },
+      ],
+      [],
+    ),
+    show: useCallback(
+      (status: { id: Status | "all" }) =>
+        status.id === "all" ? (
+          "All"
+        ) : (
+          <StatusComponent size="xs" status={status.id} />
+        ),
+      [],
+    ),
+    buttonLabel:
+      status.value === "all" ? (
+        "Status"
+      ) : (
+        <StatusComponent size="xs" status={status.value} />
+      ),
+  };
+};
+
+const parseStatus = (value: string) => {
+  switch (value) {
+    case StatusParams[Status.Pending]: {
+      return Status.Pending;
+    }
+    case StatusParams[Status.CallbackError]: {
+      return Status.CallbackError;
+    }
+    case StatusParams[Status.Complete]: {
+      return Status.Complete;
+    }
+    case StatusParams[Status.Failed]: {
+      return Status.Failed;
+    }
+    default: {
+      return "all";
+    }
+  }
+};
+
+const serializeStatus = (value: ReturnType<typeof parseStatus>) =>
+  value === "all" ? "" : StatusParams[value];
+
+type Deployment =
+  | ReturnType<typeof entropyDeploymentsByNetwork>[number]
+  | { id: "all" };
+
+export const ChainSelect = (
+  props: ComponentProps<typeof ResolvedChainSelect>,
+) => (
+  <Suspense fallback={<Select isPending options={[]} {...props} />}>
+    <ResolvedChainSelect {...props} />
+  </Suspense>
+);
+
+const ResolvedChainSelect = (
+  props: ConstrainedOmit<
+    SelectProps<Deployment>,
+    keyof ReturnType<typeof useChainSelect>
+  >,
+) => <Select {...useChainSelect()} {...props} />;
+
+const useChainSelect = () => {
+  const chain = useSearchParam({
+    paramName: "chain",
+    parse: parseChain,
+    defaultValue: "all",
+    serialize: toString,
+  });
+  const collator = useCollator();
+
+  return {
+    selectedKey: chain.value,
+    onSelectionChange: chain.onChange,
+    isPending: chain.isTransitioning,
+    buttonLabel:
+      chain.value === "all"
+        ? "All Chains"
+        : EntropyDeployments[chain.value].name,
+    optionGroups: useMemo(
+      () => [
+        {
+          name: "ALL",
+          options: [{ id: "all" as const }],
+          hideLabel: true,
+        },
+        {
+          name: "MAINNET",
+          options: entropyDeploymentsByNetwork(collator, false),
+        },
+        {
+          name: "TESTNET",
+          options: entropyDeploymentsByNetwork(collator, true),
+        },
+      ],
+      [collator],
+    ),
+    show: useCallback(
+      (chain: Deployment) =>
+        chain.id === "all" ? (
+          "All Chains"
+        ) : (
+          <div className={styles.chainSelectItem}>
+            <Image alt={chain.name} src={chain.icon} width={20} height={20} />
+            {chain.name}
+          </div>
+        ),
+      [],
+    ),
+    textValue: useCallback(
+      (chain: Deployment) => (chain.id === "all" ? "All" : chain.name),
+      [],
+    ),
+    ...(chain.value !== "all" && {
+      icon: (
+        <Image
+          alt={EntropyDeployments[chain.value].name}
+          src={EntropyDeployments[chain.value].icon}
+          width={20}
+          height={20}
+        />
+      ),
+    }),
+  };
+};
+
+const entropyDeploymentsByNetwork = (
+  collator: ReturnType<typeof useCollator>,
+  isTestnet: boolean,
+) =>
+  Object.entries(EntropyDeployments)
+    .map(([slug, chain]) => {
+      return {
+        ...chain,
+        id: Number.parseInt(slug, 10) as keyof typeof EntropyDeployments,
+      };
+    })
+    .filter((chain) => chain.isTestnet === isTestnet)
+    .toSorted((a, b) => collator.compare(a.name, b.name));
+
+const id = <T,>(value: T) => value;
+const parseInt = (value: string) => Number.parseInt(value, 10);
+// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
+const toString = <T extends { toString: () => string }>(value: T) =>
+  value.toString();
+
+const parseChain = (value: string) => {
+  if (value === "all") {
+    return "all";
+  } else {
+    const parsedId = Number.parseInt(value, 10);
+    return isValidDeployment(parsedId) ? parsedId : "all";
+  }
+};
+
+const useSearchParam = <T,>({
+  paramName,
+  parse,
+  defaultValue,
+  preservePageOnChange,
+  serialize,
+}: {
+  paramName: string;
+  parse: (value: string) => T;
+  defaultValue: T;
+  preservePageOnChange?: boolean | undefined;
+  serialize: (value: T) => string;
+}) => {
+  const router = useRouter();
+  const searchParams = useSearchParams();
+  const pathname = usePathname();
+  const [isTransitioning, startTransition] = useTransition();
+
+  const value = useMemo(() => {
+    const paramValue = searchParams.get(paramName);
+    return paramValue ? parse(paramValue) : defaultValue;
+  }, [searchParams, paramName, parse, defaultValue]);
+
+  return {
+    value,
+    isTransitioning,
+    onChange: useCallback(
+      (newValue: T) => {
+        if (newValue !== value) {
+          startTransition(() => {
+            const params = new URLSearchParams(searchParams);
+            if (newValue === defaultValue) {
+              params.delete(paramName);
+            } else {
+              params.set(paramName, serialize(newValue));
+              if (!preservePageOnChange) {
+                params.delete("page");
+              }
+            }
+            router.replace(`${pathname}?${params.toString()}`);
+          });
+        }
+      },
+      [
+        searchParams,
+        pathname,
+        router,
+        value,
+        startTransition,
+        defaultValue,
+        paramName,
+        preservePageOnChange,
+        serialize,
+      ],
+    ),
+  };
+};

+ 0 - 95
apps/entropy-explorer/src/components/Home/status-select.tsx

@@ -1,95 +0,0 @@
-"use client";
-
-import type { Props as SelectProps } from "@pythnetwork/component-library/Select";
-import { Select } from "@pythnetwork/component-library/Select";
-import type { ComponentProps } from "react";
-import { Suspense, useCallback, useMemo } from "react";
-
-import { useQuery } from "./use-query";
-import { Status } from "../../requests";
-import type { ConstrainedOmit } from "../../type-utils";
-import { Status as StatusComponent } from "../Status";
-
-export const StatusSelect = (
-  props: ComponentProps<typeof ResolvedStatusSelect>,
-) => (
-  <Suspense
-    fallback={
-      <Select
-        {...defaultProps}
-        {...props}
-        isPending
-        options={[]}
-        defaultSelectedKey={undefined}
-      />
-    }
-  >
-    <ResolvedStatusSelect {...props} />
-  </Suspense>
-);
-
-const ResolvedStatusSelect = (
-  props: ConstrainedOmit<
-    SelectProps<
-      ReturnType<
-        typeof useResolvedProps
-      >["optionGroups"][number]["options"][number]
-    >,
-    keyof typeof defaultProps | keyof ReturnType<typeof useResolvedProps>
-  >,
-) => {
-  const resolvedProps = useResolvedProps();
-
-  return <Select {...defaultProps} {...resolvedProps} {...props} />;
-};
-
-const useResolvedProps = () => {
-  const { status, setStatus } = useQuery();
-  const chains = useMemo(
-    () => [
-      {
-        name: "All",
-        options: [{ id: "all" as const }],
-      },
-      {
-        name: "Statuses",
-        options: [
-          { id: Status.Complete },
-          { id: Status.Pending },
-          { id: Status.CallbackError },
-        ],
-      },
-    ],
-    [],
-  );
-
-  const showStatus = useCallback(
-    (status: (typeof chains)[number]["options"][number]) =>
-      status.id === "all" ? (
-        "All"
-      ) : (
-        <StatusComponent size="xs" status={status.id} />
-      ),
-    [],
-  );
-
-  return {
-    selectedKey: status ?? ("all" as const),
-    onSelectionChange: setStatus,
-    optionGroups: chains,
-    show: showStatus,
-    buttonLabel:
-      status === null ? (
-        "Status"
-      ) : (
-        <StatusComponent size="xs" status={status} />
-      ),
-  };
-};
-
-const defaultProps = {
-  label: "Status",
-  hideLabel: true,
-  defaultButtonLabel: "Status",
-  hideGroupLabel: true,
-} as const;

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

@@ -1,87 +0,0 @@
-import { useLogger } from "@pythnetwork/component-library/useLogger";
-import { useQueryStates, parseAsString, parseAsStringEnum } from "nuqs";
-import { useCallback, useMemo } from "react";
-
-import { EntropyDeployments } from "../../entropy-deployments";
-import { Status } from "../../requests";
-
-const StatusParams = {
-  [Status.Pending]: "pending",
-  [Status.Complete]: "complete",
-  [Status.CallbackError]: "callback-error",
-} as const;
-
-const queryParams = {
-  status: parseAsStringEnum<(typeof StatusParams)[Status]>(
-    Object.values(StatusParams),
-  ),
-  search: parseAsString.withDefault(""),
-  chain: parseAsStringEnum<keyof typeof EntropyDeployments>(
-    Object.keys(EntropyDeployments) as (keyof typeof EntropyDeployments)[],
-  ),
-};
-
-export const useQuery = () => {
-  const logger = useLogger();
-  const [{ search, chain, status }, 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 | "all") => {
-      // eslint-disable-next-line unicorn/no-null
-      updateQuery({ chain: newChain === "all" ? null : newChain });
-    },
-    [updateQuery],
-  );
-
-  const setStatus = useCallback(
-    (newStatus: Status | "all") => {
-      updateQuery({
-        // eslint-disable-next-line unicorn/no-null
-        status: newStatus === "all" ? null : StatusParams[newStatus],
-      });
-    },
-    [updateQuery],
-  );
-
-  return {
-    search,
-    chain,
-    status: useMemo(() => {
-      switch (status) {
-        case "pending": {
-          return Status.Pending;
-        }
-        case "callback-error": {
-          return Status.CallbackError;
-        }
-        case "complete": {
-          return Status.Complete;
-        }
-        // eslint-disable-next-line unicorn/no-null
-        case null: {
-          // eslint-disable-next-line unicorn/no-null
-          return null;
-        }
-      }
-    }, [status]),
-    setSearch,
-    setChain,
-    setStatus,
-  };
-};

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

@@ -1,21 +0,0 @@
-"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>
-);

+ 1 - 2
apps/entropy-explorer/src/components/Root/index.tsx

@@ -2,7 +2,6 @@ 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,
@@ -23,7 +22,7 @@ export const Root = ({ children }: Props) => (
       label: "Entropy Docs",
       href: "https://docs.pyth.network/entropy",
     }}
-    providers={[EvmProvider, NuqsAdapter]}
+    providers={[NuqsAdapter]}
   >
     {children}
   </AppShell>

+ 12 - 6
apps/entropy-explorer/src/components/Status/index.tsx

@@ -5,29 +5,35 @@ import { Status as StatusType } from "../../requests";
 
 type Props = Omit<ComponentProps<typeof StatusImpl>, "variant" | "style"> & {
   status: StatusType;
-  prefix?: string | undefined;
 };
 
-export const Status = ({ status, prefix, ...props }: Props) => {
+export const Status = ({ status, ...props }: Props) => {
   switch (status) {
     case StatusType.Complete: {
       return (
         <StatusImpl variant="success" {...props}>
-          {prefix}COMPLETE
+          COMPLETE
         </StatusImpl>
       );
     }
-    case StatusType.CallbackError: {
+    case StatusType.Failed: {
       return (
         <StatusImpl variant="error" {...props}>
-          {prefix}ERROR
+          REVEAL ERROR
+        </StatusImpl>
+      );
+    }
+    case StatusType.CallbackError: {
+      return (
+        <StatusImpl variant="warning" {...props}>
+          CALLBACK FAILED
         </StatusImpl>
       );
     }
     case StatusType.Pending: {
       return (
         <StatusImpl variant="disabled" style="outline" {...props}>
-          {prefix}PENDING
+          PENDING
         </StatusImpl>
       );
     }

+ 14 - 2
apps/entropy-explorer/src/components/Timestamp/index.tsx

@@ -1,11 +1,19 @@
 import { Clock } from "@phosphor-icons/react/dist/ssr/Clock";
 import { Button } from "@pythnetwork/component-library/unstyled/Button";
 import { useState } from "react";
+import { useIsSSR } from "react-aria";
 import TimeAgo from "react-timeago";
 
 import styles from "./index.module.scss";
 
-export const Timestamp = ({ timestamp }: { timestamp: Date }) => {
+export const Timestamp = ({
+  timestamp,
+  now,
+}: {
+  timestamp: Date;
+  now: Date;
+}) => {
+  const isSSR = useIsSSR();
   const [showRelative, setShowRelative] = useState(true);
   const month = timestamp.toLocaleString("default", {
     month: "long",
@@ -16,6 +24,7 @@ export const Timestamp = ({ timestamp }: { timestamp: Date }) => {
   const hour = timestamp.getUTCHours().toString().padStart(2, "0");
   const minute = timestamp.getUTCMinutes().toString().padStart(2, "0");
   const seconds = timestamp.getUTCSeconds().toString().padStart(2, "0");
+
   return (
     <Button
       onPress={() => {
@@ -26,7 +35,10 @@ export const Timestamp = ({ timestamp }: { timestamp: Date }) => {
     >
       <Clock className={styles.clock} />
       <span className={styles.relative}>
-        <TimeAgo date={timestamp} />
+        <TimeAgo
+          date={timestamp}
+          {...(isSSR && { now: () => now.getTime() })}
+        />
       </span>
       <span className={styles.absolute}>
         {month}-{day}-{year} {hour}:{minute}:{seconds} +UTC

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

@@ -1,471 +0,0 @@
-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);

+ 456 - 0
apps/entropy-explorer/src/entropy-deployments.tsx

@@ -0,0 +1,456 @@
+export type EntropyDeployment = {
+  address: string;
+  rpc: string;
+  explorerTxTemplate: string;
+  explorerAccountTemplate: string;
+  name: string;
+  icon: string;
+  isTestnet: boolean;
+};
+
+export const EntropyDeployments = {
+  [10]: {
+    address: "0xdF21D137Aadc95588205586636710ca2890538d5",
+    name: "OP Mainnet",
+    rpc: "https://optimism.llamarpc.com",
+    explorerTxTemplate: "https://optimistic.etherscan.io/tx/$ADDRESS",
+    explorerAccountTemplate: "https://optimistic.etherscan.io/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_optimism.jpg?w=20&h=20",
+    isTestnet: false,
+  },
+  [130]: {
+    address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+    name: "Unichain",
+    rpc: "https://mainnet.unichain.org",
+    explorerTxTemplate: "https://unichain.blockscout.com/tx/$ADDRESS",
+    explorerAccountTemplate: "https://unichain.blockscout.com/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_unichain.jpg?w=20&h=20",
+    isTestnet: false,
+  },
+  [146]: {
+    address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+    name: "Sonic Mainnet",
+    rpc: "https://rpc.soniclabs.com",
+    explorerTxTemplate: "https://sonicscan.org/tx/$ADDRESS",
+    explorerAccountTemplate: "https://sonicscan.org/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_sonic.jpg?w=20&h=20",
+    isTestnet: false,
+  },
+  [919]: {
+    address: "0x98046Bd286715D3B0BC227Dd7a956b83D8978603",
+    name: "Mode Testnet",
+    rpc: "https://sepolia.mode.network/",
+    explorerTxTemplate: "https://testnet.modescan.io/tx/$ADDRESS",
+    explorerAccountTemplate: "https://testnet.modescan.io/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_mode?w=20&h=20",
+    isTestnet: true,
+  },
+  [999]: {
+    address: "0xfA25E653b44586dBbe27eE9d252192F0e4956683",
+    name: "HyperEVM",
+    rpc: "https://rpc.hyperliquid.xyz/evm",
+    explorerTxTemplate: "https://hyperliquid.cloud.blockscout.com/tx/$ADDRESS",
+    explorerAccountTemplate:
+      "https://hyperliquid.cloud.blockscout.com/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_hyperevm?w=20&h=20",
+    isTestnet: false,
+  },
+  [1001]: {
+    address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+    name: "Kaia Kairos Testnet",
+    rpc: "https://rpc.ankr.com/klaytn_testnet",
+    explorerTxTemplate: "https://kairos.kaiascan.io/tx/$ADDRESS",
+    explorerAccountTemplate: "https://kairos.kaiascan.io/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_klaytn.jpg?w=20&h=20",
+    isTestnet: true,
+  },
+  [1301]: {
+    address: "0x8D254a21b3C86D32F7179855531CE99164721933",
+    name: "Unichain Sepolia Testnet",
+    rpc: "https://sepolia.unichain.org",
+    explorerTxTemplate: "https://unichain-sepolia.blockscout.com/tx/$ADDRESS",
+    explorerAccountTemplate:
+      "https://unichain-sepolia.blockscout.com/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_unichain.jpg?w=20&h=20",
+    isTestnet: true,
+  },
+  [1315]: {
+    address: "0x5744Cbf430D99456a0A8771208b674F27f8EF0Fb",
+    name: "Story Aeneid Testnet",
+    rpc: "https://aeneid.storyrpc.io",
+    explorerTxTemplate: "https://aeneid.storyscan.xyz/tx/$ADDRESS",
+    explorerAccountTemplate: "https://aeneid.storyscan.xyz/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_story?w=20&h=20",
+    isTestnet: true,
+  },
+  [1328]: {
+    address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+    name: "Sei Testnet",
+    rpc: "https://evm-rpc-testnet.sei-apis.com",
+    explorerTxTemplate: "https://seitrace.com/tx/$ADDRESS?chain=atlantic-2",
+    explorerAccountTemplate:
+      "https://seitrace.com/address/$ADDRESS?chain=atlantic-2",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_sei.jpg?w=20&h=20",
+    isTestnet: true,
+  },
+  [1329]: {
+    address: "0x98046Bd286715D3B0BC227Dd7a956b83D8978603",
+    name: "Sei Network",
+    rpc: "https://evm-rpc.sei-apis.com",
+    explorerTxTemplate: "https://seitrace.com/tx/$ADDRESS?chain=pacific-1",
+    explorerAccountTemplate:
+      "https://seitrace.com/address/$ADDRESS?chain=pacific-1",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_sei.jpg?w=20&h=20",
+    isTestnet: false,
+  },
+  [1514]: {
+    address: "0xdF21D137Aadc95588205586636710ca2890538d5",
+    name: "Story",
+    rpc: "https://mainnet.storyrpc.io",
+    explorerTxTemplate: "https://storyscan.xyz/tx/$ADDRESS",
+    explorerAccountTemplate: "https://storyscan.xyz/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_story?w=20&h=20",
+    isTestnet: false,
+  },
+  [1868]: {
+    address: "0x0708325268dF9F66270F1401206434524814508b",
+    name: "Soneium",
+    rpc: "https://soneium.drpc.org",
+    explorerTxTemplate: "https://soneium.blockscout.com/tx/$ADDRESS",
+    explorerAccountTemplate: "https://soneium.blockscout.com/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_soneium.jpg?w=20&h=20",
+    isTestnet: false,
+  },
+  [1890]: {
+    address: "0x98046Bd286715D3B0BC227Dd7a956b83D8978603",
+    name: "Lightlink Phoenix Mainnet",
+    rpc: "https://replicator.phoenix.lightlink.io/rpc/v1",
+    explorerTxTemplate: "https://phoenix.lightlink.io/tx/$ADDRESS",
+    explorerAccountTemplate: "https://phoenix.lightlink.io/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_lightlink?w=20&h=20",
+    isTestnet: false,
+  },
+  [1891]: {
+    address: "0x8250f4aF4B972684F7b336503E2D6dFeDeB1487a",
+    name: "Lightlink Pegasus Testnet",
+    rpc: "https://replicator.pegasus.lightlink.io/rpc/v1",
+    explorerTxTemplate: "https://pegasus.lightlink.io/tx/$ADDRESS",
+    explorerAccountTemplate: "https://pegasus.lightlink.io/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_lightlink?w=20&h=20",
+    isTestnet: true,
+  },
+  [1946]: {
+    address: "0x23f0e8FAeE7bbb405E7A7C3d60138FCfd43d7509",
+    name: "Soneium Testnet Minato",
+    rpc: "https://rpc.minato.soneium.org/",
+    explorerTxTemplate: "https://explorer-testnet.soneium.org/tx/$ADDRESS",
+    explorerAccountTemplate:
+      "https://explorer-testnet.soneium.org/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_soneium.jpg?w=20&h=20",
+    isTestnet: true,
+  },
+  [1992]: {
+    address: "0x5744Cbf430D99456a0A8771208b674F27f8EF0Fb",
+    name: "Sanko Testnet",
+    rpc: "https://sanko-arb-sepolia.rpc.caldera.xyz/http",
+    explorerTxTemplate:
+      "https://sanko-arb-sepolia.explorer.caldera.xyz/tx/$ADDRESS",
+    explorerAccountTemplate:
+      "https://sanko-arb-sepolia.explorer.caldera.xyz/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_sanko.jpg?w=20&h=20",
+    isTestnet: true,
+  },
+  [1993]: {
+    address: "0x5744Cbf430D99456a0A8771208b674F27f8EF0Fb",
+    name: "B3 Sepolia Testnet",
+    rpc: "https://sepolia.b3.fun/http/",
+    explorerTxTemplate: "https://sepolia.explorer.b3.fun/tx/$ADDRESS",
+    explorerAccountTemplate: "https://sepolia.explorer.b3.fun/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_b3?w=20&h=20",
+    isTestnet: true,
+  },
+  [1996]: {
+    address: "0x5744Cbf430D99456a0A8771208b674F27f8EF0Fb",
+    name: "Sanko",
+    rpc: "https://mainnet.sanko.xyz",
+    explorerTxTemplate: "https://explorer.sanko.xyz/tx/$ADDRESS",
+    explorerAccountTemplate: "https://explorer.sanko.xyz/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_sanko.jpg?w=20&h=20",
+    isTestnet: false,
+  },
+  [2741]: {
+    address: "0x5a4a369F4db5df2054994AF031b7b23949b98c0e",
+    name: "Abstract",
+    rpc: "https://api.mainnet.abs.xyz",
+    explorerTxTemplate: "https://abscan.org/tx/$ADDRESS",
+    explorerAccountTemplate: "https://abscan.org/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_abstract.jpg?w=20&h=20",
+    isTestnet: false,
+  },
+  [4200]: {
+    address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+    name: "Merlin Mainnet",
+    rpc: "https://rpc.merlinchain.io",
+    explorerTxTemplate: "https://scan.merlinchain.io/tx/$ADDRESS",
+    explorerAccountTemplate: "https://scan.merlinchain.io/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_merlin.jpg?w=20&h=20",
+    isTestnet: false,
+  },
+  [7000]: {
+    address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+    name: "ZetaChain Mainnet",
+    rpc: "https://zetachain-evm.blockpi.network/v1/rpc/public",
+    explorerTxTemplate: "https://zetachain.blockscout.com/tx/$ADDRESS",
+    explorerAccountTemplate:
+      "https://zetachain.blockscout.com/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_zetachain.jpg?w=20&h=20",
+    isTestnet: false,
+  },
+  [7001]: {
+    address: "0x4374e5a8b9C22271E9EB878A2AA31DE97DF15DAF",
+    name: "ZetaChain Testnet",
+    rpc: "https://zetachain-athens-evm.blockpi.network/v1/rpc/public",
+    explorerTxTemplate: "https://explorer.zetachain.com/tx/$ADDRESS",
+    explorerAccountTemplate: "https://explorer.zetachain.com/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_zetachain.jpg?w=20&h=20",
+    isTestnet: true,
+  },
+  [8217]: {
+    address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+    name: "Kaia Mainnet",
+    rpc: "https://rpc.ankr.com/klaytn",
+    explorerTxTemplate: "https://kaiascan.io/tx/$ADDRESS",
+    explorerAccountTemplate: "https://kaiascan.io/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_klaytn.jpg?w=20&h=20",
+    isTestnet: false,
+  },
+  [8333]: {
+    address: "0x5744Cbf430D99456a0A8771208b674F27f8EF0Fb",
+    name: "B3",
+    rpc: "https://mainnet-rpc.b3.fun/http",
+    explorerTxTemplate: "https://explorer.b3.fun/tx/$ADDRESS",
+    explorerAccountTemplate: "https://explorer.b3.fun/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_b3?w=20&h=20",
+    isTestnet: false,
+  },
+  [8453]: {
+    address: "0x6E7D74FA7d5c90FEF9F0512987605a6d546181Bb",
+    name: "Base",
+    rpc: "https://developer-access-mainnet.base.org/",
+    explorerTxTemplate: "https://basescan.org/tx/$ADDRESS",
+    explorerAccountTemplate: "https://basescan.org/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_base.jpg?w=20&h=20",
+    isTestnet: false,
+  },
+  [9788]: {
+    address: "0xEbe57e8045F2F230872523bbff7374986E45C486",
+    name: "Tabi Testnet v2",
+    rpc: "https://rpc.testnetv2.tabichain.com",
+    explorerTxTemplate: "https://testnetv2.tabiscan.com/tx/$ADDRESS",
+    explorerAccountTemplate: "https://testnetv2.tabiscan.com/address/$ADDRESS",
+    icon: "https://www.tabichain.com/images/new2/tabi.svg",
+    isTestnet: true,
+  },
+  [10_143]: {
+    address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+    name: "Monad Testnet",
+    rpc: "https://testnet-rpc.monad.xyz",
+    explorerTxTemplate: "https://testnet.monadexplorer.com/tx/$ADDRESS",
+    explorerAccountTemplate:
+      "https://testnet.monadexplorer.com/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_monad.jpg?w=20&h=20",
+    isTestnet: true,
+  },
+  [11_124]: {
+    address: "0x858687fD592112f7046E394A3Bf10D0C11fF9e63",
+    name: "Abstract Sepolia Testnet",
+    rpc: "https://api.testnet.abs.xyz",
+    explorerTxTemplate: "https://explorer.testnet.abs.xyz/tx/$ADDRESS",
+    explorerAccountTemplate:
+      "https://explorer.testnet.abs.xyz/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_abstract.jpg?w=20&h=20",
+    isTestnet: true,
+  },
+  [33_111]: {
+    address: "0x23f0e8FAeE7bbb405E7A7C3d60138FCfd43d7509",
+    name: "Curtis",
+    rpc: "https://curtis.rpc.caldera.xyz/http",
+    explorerTxTemplate: "https://curtis.explorer.caldera.xyz/tx/$ADDRESS",
+    explorerAccountTemplate:
+      "https://curtis.explorer.caldera.xyz/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_apechain.jpg?w=20&h=20",
+    isTestnet: true,
+  },
+  [33_139]: {
+    address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+    name: "ApeChain",
+    rpc: "https://apechain.calderachain.xyz/http",
+    explorerTxTemplate: "https://apescan.io/tx/$ADDRESS",
+    explorerAccountTemplate: "https://apescan.io/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_apechain.jpg?w=20&h=20",
+    isTestnet: false,
+  },
+  [34_443]: {
+    address: "0x8D254a21b3C86D32F7179855531CE99164721933",
+    name: "Mode",
+    rpc: "https://mainnet.mode.network/",
+    explorerTxTemplate: "https://explorer.mode.network/tx/$ADDRESS",
+    explorerAccountTemplate: "https://explorer.mode.network/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_mode?w=20&h=20",
+    isTestnet: false,
+  },
+  [42_161]: {
+    address: "0x7698E925FfC29655576D0b361D75Af579e20AdAc",
+    name: "Arbitrum One",
+    rpc: "https://arb1.arbitrum.io/rpc",
+    explorerTxTemplate: "https://arbiscan.io/tx/$ADDRESS",
+    explorerAccountTemplate: "https://arbiscan.io/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_arbitrum.jpg?w=20&h=20",
+    isTestnet: false,
+  },
+  [42_793]: {
+    address: "0x23f0e8FAeE7bbb405E7A7C3d60138FCfd43d7509",
+    name: "Etherlink Mainnet",
+    rpc: "https://node.mainnet.etherlink.com/",
+    explorerTxTemplate: "https://explorer.etherlink.com/tx/$ADDRESS",
+    explorerAccountTemplate: "https://explorer.etherlink.com/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_etherlink.jpg?w=20&h=20",
+    isTestnet: false,
+  },
+  [57_054]: {
+    address: "0xEbe57e8045F2F230872523bbff7374986E45C486",
+    name: "Sonic Blaze Testnet",
+    rpc: "https://rpc.blaze.soniclabs.com",
+    explorerTxTemplate: "https://blaze.soniclabs.com/tx/$ADDRESS",
+    explorerAccountTemplate: "https://blaze.soniclabs.com/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_sonic.jpg?w=20&h=20",
+    isTestnet: true,
+  },
+  [80_069]: {
+    address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+    name: "Berachain Bepolia",
+    rpc: "https://bepolia.rpc.berachain.com",
+    explorerTxTemplate: "https://bepolia.beratrail.io/tx/$ADDRESS",
+    explorerAccountTemplate: "https://bepolia.beratrail.io/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_berachain?w=20&h=20",
+    isTestnet: true,
+  },
+  [80_094]: {
+    address: "0x36825bf3Fbdf5a29E2d5148bfe7Dcf7B5639e320",
+    name: "Berachain",
+    rpc: "https://rpc.berachain.com/",
+    explorerTxTemplate: "https://berascan.com/tx/$ADDRESS",
+    explorerAccountTemplate: "https://berascan.com/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_berachain?w=20&h=20",
+    isTestnet: false,
+  },
+  [81_457]: {
+    address: "0x5744Cbf430D99456a0A8771208b674F27f8EF0Fb",
+    name: "Blast",
+    rpc: "https://rpc.blast.io",
+    explorerTxTemplate: "https://blastscan.io/tx/$ADDRESS",
+    explorerAccountTemplate: "https://blastscan.io/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_blast.jpg?w=20&h=20",
+    isTestnet: false,
+  },
+  [84_532]: {
+    address: "0x41c9e39574F40Ad34c79f1C99B66A45eFB830d4c",
+    name: "Base Sepolia Testnet",
+    rpc: "https://sepolia.base.org",
+    explorerTxTemplate: "https://base-sepolia.blockscout.com/tx/$ADDRESS",
+    explorerAccountTemplate:
+      "https://base-sepolia.blockscout.com/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_base.jpg?w=20&h=20",
+    isTestnet: true,
+  },
+  [88_882]: {
+    address: "0xD458261E832415CFd3BAE5E416FdF3230ce6F134",
+    name: "Chiliz Spicy Testnet",
+    rpc: "https://spicy-rpc.chiliz.com",
+    explorerTxTemplate: "https://spicy-explorer.chiliz.com/tx/$ADDRESS",
+    explorerAccountTemplate:
+      "https://spicy-explorer.chiliz.com/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_chiliz.jpg?w=20&h=20",
+    isTestnet: true,
+  },
+  [88_888]: {
+    address: "0x0708325268dF9F66270F1401206434524814508b",
+    name: "Chiliz Chain",
+    rpc: "https://rpc.ankr.com/chiliz",
+    explorerTxTemplate: "https://scan.chiliz.com/tx/$ADDRESS",
+    explorerAccountTemplate: "https://scan.chiliz.com/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_chiliz.jpg?w=20&h=20",
+    isTestnet: false,
+  },
+  [128_123]: {
+    address: "0x23f0e8FAeE7bbb405E7A7C3d60138FCfd43d7509",
+    name: "Etherlink Testnet",
+    rpc: "https://node.ghostnet.etherlink.com",
+    explorerTxTemplate: "https://testnet.explorer.etherlink.com/tx/$ADDRESS",
+    explorerAccountTemplate:
+      "https://testnet.explorer.etherlink.com/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_etherlink.jpg?w=20&h=20",
+    isTestnet: true,
+  },
+  [167_000]: {
+    address: "0x26DD80569a8B23768A1d80869Ed7339e07595E85",
+    name: "Taiko Alethia",
+    rpc: "https://rpc.mainnet.taiko.xyz",
+    explorerTxTemplate: "https://taikoscan.network/tx/$ADDRESS",
+    explorerAccountTemplate: "https://taikoscan.network/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_taiko.jpg?w=20&h=20",
+    isTestnet: false,
+  },
+  [167_009]: {
+    address: "0x98046Bd286715D3B0BC227Dd7a956b83D8978603",
+    name: "Taiko Hekla",
+    rpc: "https://rpc.hekla.taiko.xyz/",
+    explorerTxTemplate: "https://hekla.taikoscan.network/tx/$ADDRESS",
+    explorerAccountTemplate: "https://hekla.taikoscan.network/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_taiko.jpg?w=20&h=20",
+    isTestnet: true,
+  },
+  [421_614]: {
+    address: "0x549Ebba8036Ab746611B4fFA1423eb0A4Df61440",
+    name: "Arbitrum Sepolia",
+    rpc: "https://sepolia-rollup.arbitrum.io/rpc",
+    explorerTxTemplate: "https://sepolia.arbiscan.io/tx/$ADDRESS",
+    explorerAccountTemplate: "https://sepolia.arbiscan.io/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_arbitrum.jpg?w=20&h=20",
+    isTestnet: true,
+  },
+  [686_868]: {
+    address: "0x5744Cbf430D99456a0A8771208b674F27f8EF0Fb",
+    name: "Merlin Testnet",
+    rpc: "https://testnet-rpc.merlinchain.io/",
+    explorerTxTemplate: "https://testnet-scan.merlinchain.io/tx/$ADDRESS",
+    explorerAccountTemplate:
+      "https://testnet-scan.merlinchain.io/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_merlin?w=20&h=20",
+    isTestnet: true,
+  },
+  [11_155_420]: {
+    address: "0x4821932D0CDd71225A6d914706A621e0389D7061",
+    name: "OP Sepolia Testnet",
+    rpc: "https://api.zan.top/opt-sepolia",
+    explorerTxTemplate: "https://optimism-sepolia.blockscout.com/tx/$ADDRESS",
+    explorerAccountTemplate:
+      "https://optimism-sepolia.blockscout.com/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_optimism.jpg?w=20&h=20",
+    isTestnet: true,
+  },
+  [168_587_773]: {
+    address: "0x98046Bd286715D3B0BC227Dd7a956b83D8978603",
+    name: "Blast Sepolia Testnet",
+    rpc: "https://sepolia.blast.io",
+    explorerTxTemplate: "https://sepolia.blastscan.io/tx/$ADDRESS",
+    explorerAccountTemplate: "https://sepolia.blastscan.io/address/$ADDRESS",
+    icon: "https://icons.llamao.fi/icons/chains/rsz_blast.jpg?w=20&h=20",
+    isTestnet: true,
+  },
+} as const satisfies Record<string, EntropyDeployment>;
+
+export const isValidDeployment = (
+  name: number,
+): name is keyof typeof EntropyDeployments =>
+  Object.prototype.hasOwnProperty.call(EntropyDeployments, name);

+ 32 - 5
apps/entropy-explorer/src/errors.ts

@@ -2,52 +2,79 @@ export const ERROR_DETAILS = {
   "0xd82dd966": [
     "AssertionFailure",
     "An invariant of the contract failed to hold. This error indicates a software logic bug.",
+    "https://docs.pyth.network/entropy/best-practices#handling-callback-failures",
   ],
   "0xda041bdf": [
     "ProviderAlreadyRegistered",
     "The provider being registered has already registered",
+    "https://docs.pyth.network/entropy/best-practices#handling-callback-failures",
+  ],
+  "0xdf51c431": [
+    "NoSuchProvider",
+    "The requested provider does not exist.",
+    "https://docs.pyth.network/entropy/contract-addresses",
+  ],
+  "0xc4237352": [
+    "NoSuchRequest",
+    "The specified request does not exist.",
+    "https://docs.pyth.network/entropy/best-practices#handling-callback-failures",
   ],
-  "0xdf51c431": ["NoSuchProvider", "The requested provider does not exist."],
-  "0xc4237352": ["NoSuchRequest", "The specified request does not exist."],
   "0x3e515085": [
     "OutOfRandomness",
     "The randomness provider is out of commited random numbers. The provider needs to rotate their on-chain commitment to resolve this error.",
+    "https://docs.pyth.network/entropy/best-practices#handling-callback-failures",
+  ],
+  "0x025dbdd4": [
+    "InsufficientFee",
+    "The transaction fee was not sufficient",
+    "https://docs.pyth.network/entropy/current-fees",
   ],
-  "0x025dbdd4": ["InsufficientFee", "The transaction fee was not sufficient"],
   "0xb8be1a8d": [
     "IncorrectRevelation",
     "Either the user's or the provider's revealed random values did not match their commitment.",
+    "https://docs.pyth.network/entropy/best-practices#handling-callback-failures",
   ],
   "0xb463ce7a": [
     "InvalidUpgradeMagic",
     "Governance message is invalid (e.g., deserialization error).",
+    "https://docs.pyth.network/entropy/best-practices#handling-callback-failures",
   ],
   "0x82b42900": [
     "Unauthorized",
     "The msg.sender is not allowed to invoke this call.",
+    "https://docs.pyth.network/entropy/best-practices#handling-callback-failures",
+  ],
+  "0x92555c0e": [
+    "BlockhashUnavailable",
+    "The blockhash is 0.",
+    "https://docs.pyth.network/entropy/best-practices#handling-callback-failures",
   ],
-  "0x92555c0e": ["BlockhashUnavailable", "The blockhash is 0."],
   "0x50f0dc92": [
     "InvalidRevealCall",
     "if a request was made using `requestWithCallback`, request should be fulfilled using `revealWithCallback` else if a request was made using `request`, request should be fulfilled using `reveal`",
+    "https://docs.pyth.network/entropy/best-practices#handling-callback-failures",
   ],
   "0xb28d9c76": [
     "LastRevealedTooOld",
     "The last random number revealed from the provider is too old. Therefore, too many hashes are required for any new reveal. Please update the currentCommitment before making more requests.",
+    "https://docs.pyth.network/entropy/best-practices#handling-callback-failures",
   ],
   "0x5e5b3f1b": [
     "UpdateTooOld",
     "A more recent commitment is already revealed on-chain",
+    "https://docs.pyth.network/entropy/best-practices#handling-callback-failures",
   ],
   "0x1c26714c": [
     "InsufficientGas",
     "Not enough gas was provided to the function to execute the callback with the desired amount of gas.",
+    "https://docs.pyth.network/entropy/best-practices#limit-gas-usage-on-the-callback",
   ],
   "0x9376b93b": [
     "MaxGasLimitExceeded",
     "A gas limit value was provided that was greater than the maximum possible limit of 655,350,000",
+    "https://docs.pyth.network/entropy/best-practices#limit-gas-usage-on-the-callback",
   ],
 } as const;
 
 export const getErrorDetails = (error: string) =>
-  (ERROR_DETAILS as Record<string, readonly [string, string]>)[error];
+  (ERROR_DETAILS as Record<string, readonly [string, string, string]>)[error];

+ 3 - 0
apps/entropy-explorer/src/pages.ts

@@ -0,0 +1,3 @@
+export const PAGE_SIZES = [5, 10, 25, 50, 75, 100] as const;
+export type PAGE_SIZE = (typeof PAGE_SIZES)[number];
+export const DEFAULT_PAGE_SIZE: PAGE_SIZE = 50;

+ 242 - 99
apps/entropy-explorer/src/requests.ts

@@ -1,110 +1,217 @@
 import { z } from "zod";
 
-import type { EntropyDeployments } from "./entropy-deployments";
-import { ERROR_DETAILS } from "./errors";
-
-const MOCK_DATA_SIZE = 20;
-
-export const getRequests = async (): Promise<Request[]> => {
-  await new Promise((resolve) => setTimeout(resolve, 1000));
-
-  return requestsSchema.parse(
-    range(MOCK_DATA_SIZE).map(() => {
-      const completed = randomBoolean();
-      const gasLimit = randomBetween(10_000, 1_000_000);
-      const gasUsed = randomBetween(1000, 500_000);
-      const fail = gasUsed > gasLimit || randomBoolean();
-
-      return {
-        chain: randomElem(chains),
-        sequenceNumber: Math.floor(randomBetween(10_000, 100_100)),
-        provider: `0x${randomHex(42)}`,
-        sender: `0x${randomHex(42)}`,
-        requestTxHash: `0x${randomHex(42)}`,
-        gasLimit,
-        requestTimestamp: new Date(),
-        hasCallbackCompleted: completed,
-        userRandomNumber: `0x${randomHex(42)}`,
-        ...(completed && {
-          callbackTxHash: `0x${randomHex(42)}`,
-          callbackFailed: fail,
-          randomNumber: `0x${randomHex(10)}`,
-          returnValue:
-            !fail || gasUsed > gasLimit
-              ? ""
-              : randomElem(Object.keys(ERROR_DETAILS)),
-          gasUsed,
-          callbackTimestamp: new Date(),
-        }),
-      };
-    }),
-  );
-};
+import { EntropyDeployments, isValidDeployment } from "./entropy-deployments";
+import type { PAGE_SIZE } from "./pages";
+import { DEFAULT_PAGE_SIZE } from "./pages";
 
-const range = (i: number) => [...Array.from({ length: i }).keys()];
+const FORTUNA_URL = "https://fortuna-staging.dourolabs.app/";
 
-const randomBetween = (min: number, max: number) =>
-  Math.random() * (max - min) + min;
+export type Args = Partial<{
+  search: string;
+  chain: number;
+  status: string;
+  pageSize: PAGE_SIZE;
+  page: number;
+}>;
+
+export const getRequests = async ({
+  search,
+  chain,
+  status,
+  pageSize = DEFAULT_PAGE_SIZE,
+  page,
+}: Args): Promise<Result> => {
+  const url = new URL("/v1/logs", FORTUNA_URL);
+  url.searchParams.set("min_timestamp", new Date("2023-10-01").toISOString());
+  url.searchParams.set("max_timestamp", new Date("2033-10-01").toISOString());
+  url.searchParams.set("limit", pageSize.toString());
+  if (page) {
+    url.searchParams.set("offset", ((page - 1) * pageSize).toString());
+  }
+  if (search) {
+    url.searchParams.set("query", search);
+  }
+  if (chain) {
+    url.searchParams.set("network_id", chain.toString());
+  }
+  const fortunaStatus = status ? toFortunaStatus(status) : undefined;
+  if (fortunaStatus) {
+    url.searchParams.set("state", fortunaStatus);
+  }
+  try {
+    const response = await fetch(url);
+    if (response.status === 400) {
+      const text = await response.text();
+      return text ===
+        "The query string is not parsable to a transaction hash, address, or sequence number" &&
+        search !== undefined
+        ? Result.BadSearch(search)
+        : Result.ErrorResult(toError(text));
+    } else if (response.status === 200) {
+      try {
+        const parsed = fortunaSchema.safeParse(await response.json());
+        return parsed.success
+          ? Result.Success({
+              numPages: Math.ceil(parsed.data.total_results / pageSize),
+              currentPage: parsed.data.requests.map((request) => {
+                const common = {
+                  chain: request.network_id,
+                  gasLimit: request.gas_limit,
+                  provider: request.provider,
+                  requestTimestamp: request.created_at,
+                  requestTxHash: request.request_tx_hash,
+                  sender: request.sender,
+                  sequenceNumber: request.sequence,
+                  userContribution: request.user_random_number,
+                };
+                switch (request.state.state) {
+                  case "completed": {
+                    const completedCommon = {
+                      ...common,
+                      callbackTxHash: request.state.reveal_tx_hash,
+                      callbackTimestamp: request.last_updated_at,
+                      gasUsed: request.state.gas_used,
+                      randomNumber: request.state.combined_random_number,
+                      providerContribution:
+                        request.state.provider_random_number,
+                    };
+                    return request.state.callback_failed
+                      ? Request.CallbackErrored({
+                          ...completedCommon,
+                          returnValue: request.state.callback_return_value,
+                        })
+                      : Request.Complete({
+                          ...completedCommon,
+                        });
+                  }
+                  case "pending": {
+                    return Request.Pending(common);
+                  }
+                  case "failed": {
+                    return Request.Failed({
+                      ...common,
+                      reason: request.state.reason.replace(/^Reverted: /, ""),
+                      providerContribution:
+                        request.state.provider_random_number,
+                    });
+                  }
+                }
+              }),
+            })
+          : Result.ErrorResult(parsed.error);
+      } catch (error) {
+        return Result.ErrorResult(toError(error));
+      }
+    } else {
+      return Result.ErrorResult(new NotOKError(response));
+    }
+  } catch (error) {
+    return Result.ErrorResult(toError(error));
+  }
+};
 
-const randomBoolean = (): boolean => Math.random() < 0.5;
+class NotOKError extends Error {
+  constructor(result: Response) {
+    super(`Received a ${result.status.toString()} response for ${result.url}`);
+    this.cause = result;
+    this.name = "NotOKError";
+  }
+}
+
+const toError = (e: unknown) => {
+  if (e instanceof Error) {
+    return e;
+  } else if (typeof e === "string") {
+    return new UnknownError(e);
+  } else {
+    return new UnknownError("Unknown Error");
+  }
+};
 
-const randomHex = (length: number) =>
-  Array.from({ length })
-    .map(() => Math.floor(Math.random() * 16).toString(16))
-    .join("");
+class UnknownError extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = "UnknownError";
+  }
+}
 
-const randomElem = <T>(arr: T[] | readonly T[]) =>
-  arr[Math.floor(randomBetween(0, arr.length))];
+export enum ResultType {
+  Success,
+  BadSearch,
+  ErrorResult,
+}
 
-const chains = [
-  "arbitrum",
-  "base",
-  "optimism",
-  "baseSepolia",
-  "optimismSepolia",
-] as const;
+const Result = {
+  Success: ({
+    numPages,
+    currentPage,
+  }: {
+    numPages: number;
+    currentPage: Request[];
+  }) => ({ type: ResultType.Success as const, numPages, currentPage }),
+  BadSearch: (search: string) => ({
+    type: ResultType.BadSearch as const,
+    search,
+  }),
+  ErrorResult: (error: Error) => ({
+    type: ResultType.ErrorResult as const,
+    error,
+  }),
+};
+type Result = ReturnType<(typeof Result)[keyof typeof Result]>;
 
 const hexStringSchema = z.custom<`0x${string}`>(
   (val) => typeof val === "string" && val.startsWith("0x"),
 );
-const schemaBase = z.strictObject({
-  chain: z.enum(chains),
-  sequenceNumber: z.number(),
-  provider: hexStringSchema,
-  sender: hexStringSchema,
-  requestTxHash: hexStringSchema,
-  gasLimit: z.number(),
-  userRandomNumber: hexStringSchema,
-  requestTimestamp: z.date(),
+
+const completedStateSchema = z.strictObject({
+  combined_random_number: z.string(),
+  gas_used: z.string().transform((value) => Number.parseInt(value, 10)),
+  provider_random_number: z.string(),
+  reveal_block_number: z.number(),
+  reveal_tx_hash: hexStringSchema,
+  state: z.literal("completed"),
+  callback_failed: z.boolean(),
+  callback_return_value: z.string(),
+  callback_gas_used: z.string(),
+});
+
+const pendingStateSchema = z.strictObject({
+  state: z.literal("pending"),
+});
+
+const failedSchema = z.strictObject({
+  provider_random_number: z.string(),
+  reason: z.string(),
+  state: z.literal("failed"),
+});
+
+const fortunaSchema = z.strictObject({
+  total_results: z.number(),
+  requests: z.array(
+    z.strictObject({
+      chain_id: z.string(),
+      created_at: z.string().transform((value) => new Date(value)),
+      gas_limit: z.number(),
+      last_updated_at: z.string().transform((value) => new Date(value)),
+      network_id: z.number().refine(
+        (value) => isValidDeployment(value),
+        (value) => ({ message: `Unrecognized chain id: ${value.toString()}` }),
+      ),
+      provider: hexStringSchema,
+      request_block_number: z.number(),
+      request_tx_hash: hexStringSchema,
+      sender: hexStringSchema,
+      sequence: z.number(),
+      user_random_number: z.string(),
+      state: z.union([completedStateSchema, pendingStateSchema, failedSchema]),
+    }),
+  ),
 });
-const inProgressRequestScehma = schemaBase
-  .extend({
-    hasCallbackCompleted: z.literal(false),
-  })
-  .transform((args) => Request.Pending(args));
-const completedRequestSchema = schemaBase
-  .extend({
-    hasCallbackCompleted: z.literal(true),
-    callbackTxHash: hexStringSchema,
-    callbackFailed: z.boolean(),
-    randomNumber: hexStringSchema,
-    returnValue: z.union([hexStringSchema, z.literal("")]),
-    gasUsed: z.number(),
-    callbackTimestamp: z.date(),
-  })
-  .transform((args) =>
-    args.callbackFailed
-      ? Request.CallbackErrored(args)
-      : Request.Complete(args),
-  );
-const requestSchema = z.union([
-  inProgressRequestScehma,
-  completedRequestSchema,
-]);
-const requestsSchema = z.array(requestSchema);
 
 export enum Status {
   Pending,
+  Failed,
   CallbackError,
   Complete,
 }
@@ -117,35 +224,71 @@ type BaseArgs = {
   requestTxHash: `0x${string}`;
   gasLimit: number;
   requestTimestamp: Date;
-  userRandomNumber: `0x${string}`;
+  userContribution: string;
 };
 type PendingArgs = BaseArgs;
-type RevealedBaseArgs = BaseArgs & {
-  callbackTxHash: `0x${string}`;
-  randomNumber: `0x${string}`;
+type FailedArgs = BaseArgs & {
+  reason: string;
+  providerContribution: string;
+};
+type CompletedArgs = BaseArgs & {
+  randomNumber: string;
   gasUsed: number;
   callbackTimestamp: Date;
+  providerContribution: string;
+  callbackTxHash: `0x${string}`;
 };
-type CallbackErrorArgs = RevealedBaseArgs & {
-  returnValue: "" | `0x${string}`;
+type CallbackErrorArgs = CompletedArgs & {
+  returnValue: string;
 };
-type CompleteArgs = RevealedBaseArgs;
 
 const Request = {
   Pending: (args: PendingArgs) => ({
     status: Status.Pending as const,
     ...args,
   }),
+  Failed: (args: FailedArgs) => ({
+    status: Status.Failed as const,
+    ...args,
+  }),
   CallbackErrored: (args: CallbackErrorArgs) => ({
     status: Status.CallbackError as const,
     ...args,
   }),
-  Complete: (args: CompleteArgs) => ({
+  Complete: (args: CompletedArgs) => ({
     status: Status.Complete as const,
     ...args,
   }),
 };
 export type Request = ReturnType<(typeof Request)[keyof typeof Request]>;
 export type PendingRequest = ReturnType<typeof Request.Pending>;
+export type FailedRequest = ReturnType<typeof Request.Failed>;
 export type CallbackErrorRequest = ReturnType<typeof Request.CallbackErrored>;
 export type CompleteRequest = ReturnType<typeof Request.Complete>;
+
+export const StatusParams = {
+  [Status.Pending]: "pending",
+  [Status.Failed]: "failed",
+  [Status.Complete]: "complete",
+  [Status.CallbackError]: "callback-error",
+} as const;
+
+const toFortunaStatus = (status: string) => {
+  switch (status) {
+    case StatusParams[Status.Pending]: {
+      return "Pending";
+    }
+    case StatusParams[Status.Failed]: {
+      return "Failed";
+    }
+    case StatusParams[Status.Complete]: {
+      return "Completed";
+    }
+    case StatusParams[Status.CallbackError]: {
+      return "CallbackErrored";
+    }
+    default: {
+      return;
+    }
+  }
+};

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

@@ -378,7 +378,7 @@ const TopFeedsCard = ({
       <NoResults
         icon={emptyIcon}
         header={emptyHeader}
-        body={emptyBody}
+        body={<p>{emptyBody}</p>}
         variant={emptyVariant}
       />
     )}

+ 2 - 0
packages/component-library/src/ErrorPage/index.tsx

@@ -1,3 +1,5 @@
+"use client";
+
 import { Warning } from "@phosphor-icons/react/dist/ssr/Warning";
 import { useEffect } from "react";
 

+ 12 - 6
packages/component-library/src/Header/index.tsx

@@ -64,7 +64,7 @@ export const Header = ({
           Support
         </Button>
         {extraCta}
-        <MobileMenu className={styles.mobileMenu} />
+        <MobileMenu className={styles.mobileMenu} mainCta={mainCta} />
         <Button
           href={mainCta?.href ?? "https://docs.pyth.network"}
           size="sm"
@@ -80,7 +80,13 @@ export const Header = ({
   </header>
 );
 
-const MobileMenu = ({ className }: { className?: string | undefined }) => (
+const MobileMenu = ({
+  className,
+  mainCta,
+}: {
+  className?: string | undefined;
+  mainCta: Props["mainCta"];
+}) => (
   <Button
     className={className ?? ""}
     beforeIcon={<List />}
@@ -91,14 +97,14 @@ const MobileMenu = ({ className }: { className?: string | undefined }) => (
     drawer={{
       hideHeading: true,
       title: "Menu",
-      contents: <MobileMenuContents />,
+      contents: <MobileMenuContents mainCta={mainCta} />,
     }}
   >
     Menu
   </Button>
 );
 
-const MobileMenuContents = () => (
+const MobileMenuContents = ({ mainCta }: { mainCta: Props["mainCta"] }) => (
   <div className={styles.mobileMenuContents}>
     <div className={styles.buttons}>
       <Button
@@ -111,12 +117,12 @@ const MobileMenuContents = () => (
         Support
       </Button>
       <Button
-        href="https://docs.pyth.network"
+        href={mainCta?.href ?? "https://docs.pyth.network"}
         size="md"
         rounded
         target="_blank"
       >
-        Dev Docs
+        {mainCta?.label ?? "Dev Docs"}
       </Button>
     </div>
     <div className={styles.theme}>

+ 10 - 5
packages/component-library/src/NoResults/index.tsx

@@ -34,11 +34,16 @@ export const NoResults = ({ className, onClearSearch, ...props }: Props) => (
       <h3 className={styles.header}>
         {"header" in props ? props.header : "No results found"}
       </h3>
-      <p className={styles.body}>
-        {"body" in props
-          ? props.body
-          : `We couldn't find any results for ${props.query === "" ? "your query" : `"${props.query}"`}.`}
-      </p>
+      <div className={styles.body}>
+        {"body" in props ? (
+          props.body
+        ) : (
+          <p>
+            We couldn{"'"}t find any results for{" "}
+            {props.query === "" ? "your query" : `"${props.query}"`}.
+          </p>
+        )}
+      </div>
     </div>
     {onClearSearch && (
       <Button variant="outline" size="sm" onPress={onClearSearch}>

+ 7 - 0
packages/component-library/src/Paginator/index.module.scss

@@ -37,7 +37,14 @@
   .paginatorToolbar {
     display: flex;
     flex-flow: row nowrap;
+    align-items: center;
     gap: theme.spacing(1);
+    position: relative;
+
+    .spinner {
+      position: absolute;
+      left: -#{theme.spacing(8)};
+    }
 
     .selectedPage {
       cursor: text;

+ 19 - 0
packages/component-library/src/Paginator/index.tsx

@@ -10,15 +10,18 @@ import type { Props as ButtonProps } from "../Button/index.jsx";
 import { Button } from "../Button/index.jsx";
 import buttonStyles from "../Button/index.module.scss";
 import { Select } from "../Select/index.jsx";
+import { Spinner } from "../Spinner/index.jsx";
 import { Toolbar } from "../unstyled/Toolbar/index.jsx";
 
 type Props = {
   numPages: number;
   currentPage: number;
   onPageChange: (newPage: number) => void;
+  isPageTransitioning?: boolean | undefined;
   pageSize: number;
   pageSizeOptions: number[];
   onPageSizeChange: (newPageSize: number) => void;
+  isPageSizeTransitioning?: boolean | undefined;
   mkPageLink?: ((page: number) => string) | undefined;
   className?: string | undefined;
 };
@@ -26,10 +29,12 @@ type Props = {
 export const Paginator = ({
   numPages,
   currentPage,
+  isPageTransitioning,
   pageSize,
   pageSizeOptions,
   onPageChange,
   onPageSizeChange,
+  isPageSizeTransitioning,
   mkPageLink,
   className,
 }: Props) => (
@@ -38,6 +43,7 @@ export const Paginator = ({
       pageSize={pageSize}
       pageSizeOptions={pageSizeOptions}
       onPageSizeChange={onPageSizeChange}
+      isPending={isPageSizeTransitioning}
     />
     {numPages > 1 && (
       <PaginatorToolbar
@@ -45,6 +51,7 @@ export const Paginator = ({
         numPages={numPages}
         onPageChange={onPageChange}
         mkPageLink={mkPageLink}
+        isPending={isPageTransitioning}
       />
     )}
   </div>
@@ -54,12 +61,14 @@ type PageSizeSelectProps = {
   pageSize: number;
   pageSizeOptions: number[];
   onPageSizeChange: (newPageSize: number) => void;
+  isPending?: boolean | undefined;
 };
 
 const PageSizeSelect = ({
   pageSize,
   onPageSizeChange,
   pageSizeOptions,
+  isPending,
 }: PageSizeSelectProps) => (
   <Select
     className={styles.pageSizeSelect ?? ""}
@@ -71,6 +80,7 @@ const PageSizeSelect = ({
     show={(value) => `${value.id.toString()} per page`}
     variant="ghost"
     size="sm"
+    isPending={isPending ?? false}
   />
 );
 
@@ -79,6 +89,7 @@ type PaginatorProps = {
   currentPage: number;
   onPageChange: (newPage: number) => void;
   mkPageLink: ((page: number) => string) | undefined;
+  isPending?: boolean | undefined;
 };
 
 const PaginatorToolbar = ({
@@ -86,6 +97,7 @@ const PaginatorToolbar = ({
   currentPage,
   onPageChange,
   mkPageLink,
+  isPending,
 }: PaginatorProps) => {
   const first = useMemo(
     () =>
@@ -105,6 +117,13 @@ const PaginatorToolbar = ({
 
   return (
     <Toolbar aria-label="Page" className={styles.paginatorToolbar ?? ""}>
+      {isPending && (
+        <Spinner
+          isIndeterminate
+          label="Loading page..."
+          className={styles.spinner ?? ""}
+        />
+      )}
       <PageSelector
         hideText
         beforeIcon={<CaretLeft />}

+ 10 - 0
packages/component-library/src/Select/index.module.scss

@@ -1,6 +1,16 @@
 @use "../theme";
 
 .select {
+  .trigger {
+    width: 100%;
+    justify-content: space-between;
+  }
+
+  .spinner {
+    width: 1em;
+    height: 1em;
+  }
+
   .caret {
     transition: transform 300ms ease;
   }

+ 13 - 1
packages/component-library/src/Select/index.tsx

@@ -17,6 +17,7 @@ import styles from "./index.module.scss";
 import type { Props as ButtonProps } from "../Button/index.jsx";
 import { Button } from "../Button/index.jsx";
 import { DropdownCaretDown } from "../DropdownCaretDown/index.jsx";
+import { Spinner } from "../Spinner/index.jsx";
 import {
   ListBox,
   ListBoxItem,
@@ -88,7 +89,18 @@ export const Select = <T extends { id: string | number }>({
   >
     <Label className={styles.label}>{label}</Label>
     <Button
-      afterIcon={<DropdownCaretDown className={styles.caret} />}
+      className={styles.trigger ?? ""}
+      afterIcon={
+        isPending ? (
+          <Spinner
+            label="Loading..."
+            isIndeterminate
+            className={styles.spinner ?? ""}
+          />
+        ) : (
+          <DropdownCaretDown className={styles.caret} />
+        )
+      }
       variant={variant}
       size={size}
       rounded={rounded}

+ 1 - 10
pnpm-lock.yaml

@@ -709,9 +709,6 @@ importers:
       clsx:
         specifier: 'catalog:'
         version: 2.1.1
-      connectkit:
-        specifier: 'catalog:'
-        version: 1.9.0(@babel/core@7.27.1)(@tanstack/react-query@5.71.5(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react-is@18.3.1)(react@19.1.0)(viem@2.24.3(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@5.0.10)(zod@3.24.4))(wagmi@2.14.16(@react-native-async-storage/async-storage@1.24.0(react-native@0.78.2(@babel/core@7.27.1)(@babel/preset-env@7.26.9(@babel/core@7.27.1))(@types/react@19.1.0)(bufferutil@4.0.9)(react@19.1.0)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.71.5)(@tanstack/react-query@5.71.5(react@19.1.0))(@types/react@19.1.0)(bufferutil@4.0.9)(encoding@0.1.13)(immer@9.0.21)(react@19.1.0)(typescript@5.8.2)(utf-8-validate@5.0.10)(viem@2.24.3(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@5.0.10)(zod@3.24.4))(zod@3.24.4))
       next:
         specifier: 'catalog:'
         version: 15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@19.1.0-rc.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1)
@@ -730,12 +727,6 @@ importers:
       react-timeago:
         specifier: 'catalog:'
         version: 8.2.0(react@19.1.0)
-      viem:
-        specifier: 'catalog:'
-        version: 2.24.3(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@5.0.10)(zod@3.24.4)
-      wagmi:
-        specifier: 'catalog:'
-        version: 2.14.16(@react-native-async-storage/async-storage@1.24.0(react-native@0.78.2(@babel/core@7.27.1)(@babel/preset-env@7.26.9(@babel/core@7.27.1))(@types/react@19.1.0)(bufferutil@4.0.9)(react@19.1.0)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.71.5)(@tanstack/react-query@5.71.5(react@19.1.0))(@types/react@19.1.0)(bufferutil@4.0.9)(encoding@0.1.13)(immer@9.0.21)(react@19.1.0)(typescript@5.8.2)(utf-8-validate@5.0.10)(viem@2.24.3(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@5.0.10)(zod@3.24.4))(zod@3.24.4)
       zod:
         specifier: 'catalog:'
         version: 3.24.4
@@ -745,7 +736,7 @@ importers:
         version: 4.0.2(@testing-library/dom@10.4.0)(@typescript-eslint/eslint-plugin@8.29.0(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.2))(@typescript-eslint/parser@8.29.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.23.0(jiti@2.4.2))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.13.2)(@types/node@22.14.0)(typescript@5.8.2)))(ts-node@10.9.2(@swc/core@1.13.2)(@types/node@22.14.0)(typescript@5.8.2))(turbo@2.4.4)(typescript@5.8.2)
       '@cprussin/jest-config':
         specifier: 'catalog:'
-        version: 2.0.2(@babel/core@7.27.1)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.1))(bufferutil@4.0.9)(eslint@9.23.0(jiti@2.4.2))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.13.2)(@types/node@22.14.0)(typescript@5.8.2)))(next@15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@5.0.10)
+        version: 2.0.2(@babel/core@7.27.1)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.1))(bufferutil@4.0.9)(esbuild@0.25.4)(eslint@9.23.0(jiti@2.4.2))(jest@29.7.0(@types/node@22.14.0)(ts-node@10.9.2(@swc/core@1.13.2)(@types/node@22.14.0)(typescript@5.8.2)))(next@15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@19.1.0-rc.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.86.1))(prettier@3.5.3)(typescript@5.8.2)(utf-8-validate@6.0.3)
       '@cprussin/prettier-config':
         specifier: 'catalog:'
         version: 2.2.2(prettier@3.5.3)