浏览代码

feat(entropy-explorer): implement some feedback items

Connor Prussin 6 月之前
父节点
当前提交
e78549ab80
共有 33 个文件被更改,包括 1563 次插入619 次删除
  1. 2 1
      apps/entropy-explorer/package.json
  2. 24 0
      apps/entropy-explorer/src/components/Address/index.module.scss
  3. 34 0
      apps/entropy-explorer/src/components/Address/index.tsx
  4. 29 10
      apps/entropy-explorer/src/components/Home/chain-select.tsx
  5. 6 0
      apps/entropy-explorer/src/components/Home/index.tsx
  6. 59 0
      apps/entropy-explorer/src/components/Home/request-drawer.module.scss
  7. 294 0
      apps/entropy-explorer/src/components/Home/request-drawer.tsx
  8. 13 47
      apps/entropy-explorer/src/components/Home/results.module.scss
  9. 86 258
      apps/entropy-explorer/src/components/Home/results.tsx
  10. 1 1
      apps/entropy-explorer/src/components/Home/search-bar.tsx
  11. 95 0
      apps/entropy-explorer/src/components/Home/status-select.tsx
  12. 43 4
      apps/entropy-explorer/src/components/Home/use-query.ts
  13. 35 0
      apps/entropy-explorer/src/components/Status/index.tsx
  14. 35 0
      apps/entropy-explorer/src/components/Timestamp/index.module.scss
  15. 36 0
      apps/entropy-explorer/src/components/Timestamp/index.tsx
  16. 53 0
      apps/entropy-explorer/src/errors.ts
  17. 0 71
      apps/entropy-explorer/src/get-requests-for-chain.ts
  18. 151 0
      apps/entropy-explorer/src/requests.ts
  19. 2 0
      apps/entropy-explorer/src/truncate.ts
  20. 4 0
      packages/component-library/src/CopyButton/index.module.scss
  21. 15 3
      packages/component-library/src/CopyButton/index.tsx
  22. 10 2
      packages/component-library/src/InfoBox/index.module.scss
  23. 17 2
      packages/component-library/src/InfoBox/index.tsx
  24. 3 1
      packages/component-library/src/Meter/index.tsx
  25. 8 0
      packages/component-library/src/Select/index.module.scss
  26. 11 3
      packages/component-library/src/Select/index.tsx
  27. 54 0
      packages/component-library/src/Term/index.module.scss
  28. 29 0
      packages/component-library/src/Term/index.stories.tsx
  29. 81 0
      packages/component-library/src/Term/index.tsx
  30. 8 0
      packages/component-library/src/theme.scss
  31. 9 9
      packages/component-library/src/useDrawer/index.module.scss
  32. 315 207
      pnpm-lock.yaml
  33. 1 0
      pnpm-workspace.yaml

+ 2 - 1
apps/entropy-explorer/package.json

@@ -11,7 +11,7 @@
     "fix:format": "prettier --write .",
     "fix:lint:eslint": "eslint --fix .",
     "fix:lint:stylelint": "stylelint --fix 'src/**/*.scss'",
-    "pull:env": "[ $CI ] || VERCEL_ORG_ID=team_BKQrg3JJFLxZyTqpuYtIY0rj VERCEL_PROJECT_ID=prj_TBkf9EyQjQF37gs4Vk0sQKJj97kE vercel env pull",
+    "pull:env": "[ $CI ] || VERCEL_ORG_ID=team_BKQrg3JJFLxZyTqpuYtIY0rj VERCEL_PROJECT_ID=prj_34F8THr7mZ3eAOQoCLdo8xWj9fdT vercel env pull",
     "start:dev": "next dev --port 3006",
     "start:prod": "next start --port 3006",
     "test:format": "prettier --check .",
@@ -29,6 +29,7 @@
     "react": "catalog:",
     "react-aria": "catalog:",
     "react-dom": "catalog:",
+    "react-timeago": "catalog:",
     "viem": "catalog:",
     "wagmi": "catalog:",
     "zod": "catalog:"

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

@@ -0,0 +1,24 @@
+@use "@pythnetwork/component-library/theme";
+
+.address {
+  display: flex;
+  flex-flow: row nowrap;
+  gap: theme.spacing(2);
+  font-size: theme.font-size("sm");
+
+  .full {
+    display: none;
+  }
+
+  &:not([data-always-truncate]) {
+    @include theme.breakpoint("xl") {
+      .truncated {
+        display: none;
+      }
+
+      .full {
+        display: unset;
+      }
+    }
+  }
+}

+ 34 - 0
apps/entropy-explorer/src/components/Address/index.tsx

@@ -0,0 +1,34 @@
+import { CopyButton } from "@pythnetwork/component-library/CopyButton";
+import { Link } from "@pythnetwork/component-library/Link";
+import { useMemo } from "react";
+
+import styles from "./index.module.scss";
+import { EntropyDeployments } from "../../entropy-deployments";
+import { truncate } from "../../truncate";
+
+type Props = {
+  value: string;
+  chain: keyof typeof EntropyDeployments;
+  alwaysTruncate?: boolean | undefined;
+};
+
+export const Address = ({ value, chain, alwaysTruncate }: Props) => {
+  const { explorer } = EntropyDeployments[chain];
+  const truncatedValue = useMemo(() => truncate(value), [value]);
+  return (
+    <div
+      data-always-truncate={alwaysTruncate ? "" : undefined}
+      className={styles.address}
+    >
+      <Link
+        href={explorer.replace("$ADDRESS", value)}
+        target="_blank"
+        rel="noreferrer"
+      >
+        <code className={styles.truncated}>{truncatedValue}</code>
+        <code className={styles.full}>{value}</code>
+      </Link>
+      <CopyButton text={value} iconOnly />
+    </div>
+  );
+};

+ 29 - 10
apps/entropy-explorer/src/components/Home/chain-select.tsx

@@ -31,7 +31,9 @@ export const ChainSelect = (
   </Suspense>
 );
 
-type Deployment = ReturnType<typeof entropyDeploymentsByNetwork>[number];
+type Deployment =
+  | ReturnType<typeof entropyDeploymentsByNetwork>[number]
+  | { id: "all" };
 
 const ResolvedChainSelect = (
   props: ConstrainedOmit<
@@ -49,6 +51,11 @@ const useResolvedProps = () => {
   const { chain, setChain } = useQuery();
   const chains = useMemo(
     () => [
+      {
+        name: "ALL",
+        options: [{ id: "all" as const }],
+        hideLabel: true,
+      },
       {
         name: "MAINNET",
         options: entropyDeploymentsByNetwork("mainnet", collator),
@@ -62,30 +69,42 @@ const useResolvedProps = () => {
   );
 
   const showChain = useCallback(
-    (chain: Deployment) => (
-      <div className={styles.chainSelectItem}>
-        <ChainIcon id={chain.chainId} />
-        {chain.name}
-      </div>
-    ),
+    (chain: Deployment) =>
+      chain.id === "all" ? (
+        "All"
+      ) : (
+        <div className={styles.chainSelectItem}>
+          <ChainIcon id={chain.chainId} />
+          {chain.name}
+        </div>
+      ),
     [],
   );
 
-  const chainTextValue = useCallback((chain: Deployment) => chain.name, []);
+  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 ?? undefined,
+    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: "Select Chain",
+  defaultButtonLabel: "Chain",
 } as const;
 
 const entropyDeploymentsByNetwork = (

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

@@ -5,6 +5,7 @@ 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";
 
 export const Home = () => (
   <div className={styles.home}>
@@ -16,6 +17,11 @@ export const Home = () => (
         toolbar={
           <>
             <ChainSelect variant="outline" size="sm" placement="bottom right" />
+            <StatusSelect
+              variant="outline"
+              size="sm"
+              placement="bottom right"
+            />
             <SearchBar className={styles.searchBar ?? ""} />
           </>
         }

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

@@ -0,0 +1,59 @@
+@use "@pythnetwork/component-library/theme";
+
+.requestDrawer {
+  gap: theme.spacing(8);
+  padding-bottom: theme.spacing(8);
+
+  .cards {
+    display: grid;
+    gap: theme.spacing(4);
+    grid-template-columns: repeat(2, 1fr);
+    padding-left: theme.spacing(4);
+    padding-right: theme.spacing(4);
+  }
+
+  .details {
+    width: 100%;
+    overflow: auto;
+
+    .field {
+      @include theme.text("sm", "normal");
+
+      color: theme.color("muted");
+    }
+
+    .gasMeter {
+      margin-right: 5%;
+
+      .gasMeterLabel {
+        @include theme.text("xs", "medium");
+      }
+    }
+  }
+
+  .message {
+    margin-left: theme.spacing(4);
+    margin-right: theme.spacing(4);
+    position: relative;
+
+    p {
+      margin: 0;
+
+      &.details {
+        margin-top: theme.spacing(2);
+      }
+    }
+
+    .code {
+      border-radius: theme.border-radius("lg");
+      font-size: theme.font-size("sm");
+      line-height: 125%;
+    }
+
+    .copyButton {
+      position: absolute;
+      top: theme.spacing(2);
+      right: calc(theme.spacing(2) + 0.25em);
+    }
+  }
+}

+ 294 - 0
apps/entropy-explorer/src/components/Home/request-drawer.tsx

@@ -0,0 +1,294 @@
+import { Code } from "@phosphor-icons/react/dist/ssr/Code";
+import { Question } from "@phosphor-icons/react/dist/ssr/Question";
+import { Warning } from "@phosphor-icons/react/dist/ssr/Warning";
+import { Button } from "@pythnetwork/component-library/Button";
+import { CopyButton } from "@pythnetwork/component-library/CopyButton";
+import { InfoBox } from "@pythnetwork/component-library/InfoBox";
+import { Meter } from "@pythnetwork/component-library/Meter";
+import { StatCard } from "@pythnetwork/component-library/StatCard";
+import { Table } from "@pythnetwork/component-library/Table";
+import { Term } from "@pythnetwork/component-library/Term";
+import type { OpenDrawerArgs } from "@pythnetwork/component-library/useDrawer";
+import { useNumberFormatter } from "react-aria";
+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 { Status } from "../../requests";
+import { truncate } from "../../truncate";
+import { Address } from "../Address";
+import { Status as StatusComponent } from "../Status";
+import { Timestamp } from "../Timestamp";
+
+export const mkRequestDrawer = (request: Request): OpenDrawerArgs => ({
+  title: `Request ${truncate(request.requestTxHash)}`,
+  headingExtra: <StatusComponent prefix="CALLBACK " status={request.status} />,
+  bodyClassName: styles.requestDrawer ?? "",
+  fill: true,
+  contents: <RequestDrawerBody request={request} />,
+});
+
+const RequestDrawerBody = ({ request }: { request: Request }) => {
+  const gasFormatter = useNumberFormatter({ maximumFractionDigits: 3 });
+
+  return (
+    <>
+      <div className={styles.cards}>
+        <StatCard
+          nonInteractive
+          header="Random Number"
+          small
+          variant="primary"
+          stat={
+            request.status === Status.Pending ? (
+              <StatusComponent prefix="CALLBACK " status={Status.Pending} />
+            ) : (
+              <CopyButton text={request.randomNumber}>
+                <code>{truncate(request.randomNumber)}</code>
+              </CopyButton>
+            )
+          }
+        />
+        <StatCard
+          nonInteractive
+          header="Sequence Number"
+          small
+          stat={request.sequenceNumber}
+        />
+      </div>
+      {request.status === Status.CallbackError && (
+        <CallbackFailedInfo request={request} />
+      )}
+      <Table
+        label="Details"
+        fill
+        className={styles.details ?? ""}
+        stickyHeader
+        columns={[
+          {
+            id: "field",
+            name: "Field",
+            alignment: "left",
+            isRowHeader: true,
+            sticky: true,
+          },
+          {
+            id: "value",
+            name: "Value",
+            fill: true,
+            alignment: "left",
+          },
+        ]}
+        rows={[
+          {
+            id: "requestTimestamp",
+            field: "Request Timestamp",
+            value: <Timestamp timestamp={request.requestTimestamp} />,
+          },
+          ...(request.status === Status.Pending
+            ? []
+            : [
+                {
+                  id: "callbackTimestamp",
+                  field: "Callback Timestamp",
+                  value: <Timestamp timestamp={request.callbackTimestamp} />,
+                },
+                {
+                  id: "duration",
+                  field: (
+                    <Term term="Duration">
+                      The amount of time between the request transaction and the
+                      callback transaction.
+                    </Term>
+                  ),
+                  value: (
+                    <TimeAgo
+                      now={() => request.callbackTimestamp.getTime()}
+                      date={request.requestTimestamp}
+                      live={false}
+                      formatter={(value, unit) =>
+                        `${value.toString()} ${unit}${value === 1 ? "" : "s"}`
+                      }
+                    />
+                  ),
+                },
+              ]),
+          {
+            id: "requestTx",
+            field: (
+              <Term term="Request Transaction">
+                The transaction that requests a new random number from the
+                Entropy protocol.
+              </Term>
+            ),
+            value: (
+              <Address chain={request.chain} value={request.requestTxHash} />
+            ),
+          },
+          {
+            id: "sender",
+            field: "Sender",
+            value: <Address chain={request.chain} value={request.sender} />,
+          },
+          ...(request.status === Status.Pending
+            ? []
+            : [
+                {
+                  id: "callbackTx",
+                  field: (
+                    <Term term="Callback Transaction">
+                      Entropy’s response transaction that returns the random
+                      number to the requester.
+                    </Term>
+                  ),
+                  value: (
+                    <Address
+                      chain={request.chain}
+                      value={request.callbackTxHash}
+                    />
+                  ),
+                },
+              ]),
+          {
+            id: "provider",
+            field: "Provider",
+            value: <Address chain={request.chain} value={request.provider} />,
+          },
+          {
+            id: "userContribution",
+            field: (
+              <Term term="User Contribution">
+                User-submitted randomness included in the 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>
+            ),
+          },
+          {
+            id: "gas",
+            field: "Gas",
+            value:
+              request.status === Status.Pending ? (
+                `${gasFormatter.format(request.gasLimit)} max`
+              ) : (
+                <Meter
+                  label="Gas"
+                  value={request.gasUsed}
+                  maxValue={request.gasLimit}
+                  className={styles.gasMeter ?? ""}
+                  startLabel={`${gasFormatter.format(request.gasUsed)} used`}
+                  endLabel={`${gasFormatter.format(request.gasLimit)} max`}
+                  labelClassName={styles.gasMeterLabel ?? ""}
+                  variant={
+                    request.gasUsed > request.gasLimit ? "error" : "default"
+                  }
+                />
+              ),
+          },
+        ].map((data) => ({
+          id: data.id,
+          data: {
+            field: <span className={styles.field}>{data.field}</span>,
+            value: data.value,
+          },
+        }))}
+      />
+    </>
+  );
+};
+
+const CallbackFailedInfo = ({ 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>`;
+
+  return (
+    <>
+      <InfoBox
+        header="Callback failed!"
+        icon={<Warning />}
+        className={styles.message}
+        variant="warning"
+      >
+        <CallbackFailureMessage request={request} />
+      </InfoBox>
+      <InfoBox
+        header="Retry the callback yourself"
+        icon={<Code />}
+        className={styles.message}
+        variant="info"
+      >
+        {`If you'd like to execute your callback, you can run the command in your
+        terminal or connect your wallet to run it here.`}
+        <div
+          style={{
+            display: "flex",
+            flexFlow: "row nowrap",
+            justifyContent: "end",
+            gap: "16px",
+            marginTop: "16px",
+          }}
+        >
+          <CopyButton text={retryCommand}>Copy Forge Command</CopyButton>
+          <Button size="sm" variant="outline">
+            Connect Wallet
+          </Button>
+          <Button
+            size="sm"
+            variant="ghost"
+            beforeIcon={Question}
+            rounded
+            hideText
+            href="https://docs.pyth.network/entropy/debug-callback-failures"
+            target="_blank"
+          >
+            Help
+          </Button>
+        </div>
+      </InfoBox>
+    </>
+  );
+};
+
+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>
+      </>
+    );
+  }
+};

+ 13 - 47
apps/entropy-explorer/src/components/Home/results.module.scss

@@ -8,8 +8,14 @@
   }
 }
 
+.sequenceNumber {
+  @include theme.text("base", "bold");
+
+  color: theme.color("heading");
+}
+
 .entityList {
-  background: white;
+  background: theme.color("background", "primary");
   border-radius: theme.border-radius("xl");
 
   @include theme.breakpoint("xl") {
@@ -17,59 +23,19 @@
   }
 }
 
-.timestamp {
+.chain {
   @include theme.text("sm", "medium");
 
-  @include theme.breakpoint("xl") {
-    @include theme.text("base", "medium");
-  }
-}
-
-.address {
   display: flex;
   flex-flow: row nowrap;
   gap: theme.spacing(2);
-  font-size: theme.font-size("sm");
-
-  .full {
-    display: none;
-  }
-
-  &:not([data-always-truncate]) {
-    @include theme.breakpoint("xl") {
-      .truncated {
-        display: none;
-      }
-
-      .full {
-        display: unset;
-      }
-    }
-  }
+  align-items: center;
 }
 
-.requestDrawer {
-  .cards {
-    display: grid;
-    gap: theme.spacing(4);
-    margin-bottom: theme.spacing(10);
-    grid-template-columns: repeat(2, 1fr);
-    padding-left: theme.spacing(4);
-    padding-right: theme.spacing(4);
-  }
-
-  .details {
-    width: 100%;
-    overflow: auto;
-
-    .field {
-      @include theme.text("sm", "normal");
-
-      color: theme.color("muted");
-    }
+.timestamp {
+  @include theme.text("sm", "medium");
 
-    .gasMeterLabel {
-      @include theme.text("xs", "medium");
-    }
+  @include theme.breakpoint("xl") {
+    @include theme.text("base", "medium");
   }
 }

+ 86 - 258
apps/entropy-explorer/src/components/Home/results.tsx

@@ -1,28 +1,26 @@
 "use client";
 
-import { Sparkle } from "@phosphor-icons/react/dist/ssr/Sparkle";
 import { Warning } from "@phosphor-icons/react/dist/ssr/Warning";
-import { Badge } from "@pythnetwork/component-library/Badge";
-import { CopyButton } from "@pythnetwork/component-library/CopyButton";
 import { EntityList } from "@pythnetwork/component-library/EntityList";
-import { Link } from "@pythnetwork/component-library/Link";
-import { Meter } from "@pythnetwork/component-library/Meter";
 import { NoResults } from "@pythnetwork/component-library/NoResults";
-import { StatCard } from "@pythnetwork/component-library/StatCard";
-import { Status as StatusImpl } from "@pythnetwork/component-library/Status";
 import type { RowConfig } from "@pythnetwork/component-library/Table";
 import { Table } from "@pythnetwork/component-library/Table";
 import { StateType, useData } from "@pythnetwork/component-library/useData";
 import { useDrawer } from "@pythnetwork/component-library/useDrawer";
+import { ChainIcon } from "connectkit";
 import type { ComponentProps } from "react";
-import { Suspense, useMemo, useCallback } from "react";
-import { useDateFormatter, useFilter, useNumberFormatter } from "react-aria";
+import { Suspense, useMemo } from "react";
+import { useFilter } from "react-aria";
+import * as viemChains from "viem/chains";
 
-import { ChainSelect } from "./chain-select";
+import { mkRequestDrawer } from "./request-drawer";
 import styles from "./results.module.scss";
 import { useQuery } from "./use-query";
 import { EntropyDeployments } from "../../entropy-deployments";
-import { getRequestsForChain } from "../../get-requests-for-chain";
+import { Status, getRequests } from "../../requests";
+import { Address } from "../Address";
+import { Status as StatusComponent } from "../Status";
+import { Timestamp } from "../Timestamp";
 
 export const Results = () => (
   <Suspense fallback={<ResultsImpl isLoading />}>
@@ -31,27 +29,7 @@ export const Results = () => (
 );
 
 const MountedResults = () => {
-  const { chain } = useQuery();
-
-  return chain ? (
-    <ResultsForChain chain={chain} />
-  ) : (
-    <Empty
-      icon={<Sparkle />}
-      header={<ChainSelect variant="primary" size="sm" placement="bottom" />}
-      body="Select a chain to list and search for Entropy requests"
-      variant="info"
-    />
-  );
-};
-
-const ResultsForChain = ({
-  chain,
-}: {
-  chain: keyof typeof EntropyDeployments;
-}) => {
-  const getTxData = useCallback(() => getRequestsForChain(chain), [chain]);
-  const results = useData(["requests", chain], getTxData, {
+  const results = useData(["requests"], getRequests, {
     refreshInterval: 0,
     revalidateIfStale: false,
     revalidateOnFocus: false,
@@ -75,7 +53,6 @@ const ResultsForChain = ({
     case StateType.Loaded: {
       return (
         <ResolvedResults
-          chain={chain}
           data={results.data}
           isUpdating={results.isValidating}
         />
@@ -85,178 +62,70 @@ const ResultsForChain = ({
 };
 
 type ResolvedResultsProps = {
-  chain: keyof typeof EntropyDeployments;
-  data: Awaited<ReturnType<typeof getRequestsForChain>>;
+  data: Awaited<ReturnType<typeof getRequests>>;
   isUpdating?: boolean | undefined;
 };
 
-const ResolvedResults = ({ chain, data, isUpdating }: ResolvedResultsProps) => {
+const ResolvedResults = ({ data, isUpdating }: ResolvedResultsProps) => {
   const drawer = useDrawer();
-  const { search } = useQuery();
-  const gasFormatter = useNumberFormatter({ maximumFractionDigits: 3 });
-  const dateFormatter = useDateFormatter({
-    dateStyle: "long",
-    timeStyle: "long",
-  });
+  const { search, chain, status } = useQuery();
   const filter = useFilter({ sensitivity: "base", usage: "search" });
   const rows = useMemo(
     () =>
       data
         .filter(
           (request) =>
-            filter.contains(request.txHash, search) ||
-            filter.contains(request.provider, search) ||
-            filter.contains(request.caller, search) ||
-            filter.contains(request.sequenceNumber.toString(), search),
+            (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.txHash,
+          textValue: request.requestTxHash,
           onAction: () => {
-            drawer.open({
-              title: `Request ${truncate(request.txHash)}`,
-              headingExtra: <Status request={request} />,
-              className: styles.requestDrawer ?? "",
-              fill: true,
-              contents: (
-                <>
-                  <div className={styles.cards}>
-                    <StatCard
-                      nonInteractive
-                      header="Result"
-                      small
-                      variant="primary"
-                      stat={
-                        request.hasCallbackCompleted ? (
-                          <code>{request.callbackResult.randomNumber}</code>
-                        ) : (
-                          <Status request={request} />
-                        )
-                      }
-                    />
-                    <StatCard
-                      nonInteractive
-                      header="Sequence Number"
-                      small
-                      stat={request.sequenceNumber}
-                    />
-                  </div>
-                  <Table
-                    label="Details"
-                    fill
-                    className={styles.details ?? ""}
-                    stickyHeader
-                    columns={[
-                      {
-                        id: "field",
-                        name: "Field",
-                        alignment: "left",
-                        isRowHeader: true,
-                        sticky: true,
-                      },
-                      {
-                        id: "value",
-                        name: "Value",
-                        fill: true,
-                        alignment: "left",
-                      },
-                    ]}
-                    rows={[
-                      {
-                        field: "Request Timestamp",
-                        value: dateFormatter.format(request.timestamp),
-                      },
-                      ...(request.hasCallbackCompleted
-                        ? [
-                            {
-                              field: "Result Timestamp",
-                              value: dateFormatter.format(
-                                request.callbackResult.timestamp,
-                              ),
-                            },
-                          ]
-                        : []),
-                      {
-                        field: "Transaction Hash",
-                        value: <Address chain={chain} value={request.txHash} />,
-                      },
-                      {
-                        field: "Caller",
-                        value: <Address chain={chain} value={request.caller} />,
-                      },
-                      {
-                        field: "Provider",
-                        value: (
-                          <Address chain={chain} value={request.provider} />
-                        ),
-                      },
-                      {
-                        field: "Gas",
-                        value: request.hasCallbackCompleted ? (
-                          <Meter
-                            label="Gas"
-                            value={request.callbackResult.gasUsed}
-                            maxValue={request.gasLimit}
-                            startLabel={
-                              <>
-                                {gasFormatter.format(
-                                  request.callbackResult.gasUsed,
-                                )}{" "}
-                                used
-                              </>
-                            }
-                            endLabel={
-                              <>{gasFormatter.format(request.gasLimit)} max</>
-                            }
-                            labelClassName={styles.gasMeterLabel ?? ""}
-                            variant={
-                              request.callbackResult.gasUsed > request.gasLimit
-                                ? "error"
-                                : "default"
-                            }
-                          />
-                        ) : (
-                          <>{gasFormatter.format(request.gasLimit)} max</>
-                        ),
-                      },
-                    ].map((data) => ({
-                      id: data.field,
-                      data: {
-                        field: (
-                          <span className={styles.field}>{data.field}</span>
-                        ),
-                        value: data.value,
-                      },
-                    }))}
-                  />
-                </>
-              ),
-            });
+            drawer.open(mkRequestDrawer(request));
           },
           data: {
+            chain: <Chain chain={request.chain} />,
             timestamp: (
               <div className={styles.timestamp}>
-                {dateFormatter.format(request.timestamp)}
+                <Timestamp timestamp={request.requestTimestamp} />
               </div>
             ),
             sequenceNumber: (
-              <Badge size="md" variant="info" style="outline">
+              <div className={styles.sequenceNumber}>
                 {request.sequenceNumber}
-              </Badge>
+              </div>
             ),
-            caller: (
-              <Address alwaysTruncate chain={chain} value={request.caller} />
+            sender: (
+              <Address
+                alwaysTruncate
+                chain={request.chain}
+                value={request.sender}
+              />
             ),
-            provider: (
-              <Address alwaysTruncate chain={chain} value={request.provider} />
+            requestTxHash: (
+              <Address
+                alwaysTruncate
+                chain={request.chain}
+                value={request.requestTxHash}
+              />
             ),
-            txHash: (
-              <Address alwaysTruncate chain={chain} value={request.txHash} />
+            callbackTxHash: request.status !== Status.Pending && (
+              <Address
+                alwaysTruncate
+                chain={request.chain}
+                value={request.callbackTxHash}
+              />
             ),
-            status: <Status request={request} />,
+            status: <StatusComponent status={request.status} />,
           },
         })),
-    [data, search, chain, dateFormatter, drawer, filter, gasFormatter],
+    [data, search, drawer, filter, chain, status],
   );
 
   return <ResultsImpl rows={rows} isUpdating={isUpdating} search={search} />;
@@ -277,19 +146,25 @@ type ResultsImplProps =
 
 const ResultsImpl = (props: ResultsImplProps) => (
   <>
-    <EntityList
-      label={defaultProps.label}
-      className={styles.entityList ?? ""}
-      fields={[
-        { id: "sequenceNumber", name: "Sequence Number" },
-        { id: "timestamp", name: "Timestamp" },
-        { id: "txHash", name: "Transaction Hash" },
-        { id: "provider", name: "Provider" },
-        { id: "caller", name: "Caller" },
-        { id: "status", name: "Status" },
-      ]}
-      {...(props.isLoading ? { isLoading: true } : { rows: props.rows })}
-    />
+    <div className={styles.entityList}>
+      {!props.isLoading && props.rows.length === 0 ? (
+        <NoResults query={props.search} />
+      ) : (
+        <EntityList
+          label={defaultProps.label}
+          fields={[
+            { id: "chain", name: "Chain" },
+            { id: "sequenceNumber", name: "Sequence Number" },
+            { id: "timestamp", name: "Timestamp" },
+            { id: "sender", name: "Sender" },
+            { id: "requestTxHash", name: "Request Transaction" },
+            { id: "callbackTxHash", name: "Callback Transaction" },
+            { id: "status", name: "Status" },
+          ]}
+          {...(props.isLoading ? { isLoading: true } : { rows: props.rows })}
+        />
+      )}
+    </div>
     <Table
       className={styles.table ?? ""}
       {...defaultProps}
@@ -317,62 +192,27 @@ const Empty = (props: ComponentProps<typeof NoResults>) => (
   </>
 );
 
-const Address = ({
-  value,
-  chain,
-  alwaysTruncate,
-}: {
-  value: string;
-  chain: keyof typeof EntropyDeployments;
-  alwaysTruncate?: boolean | undefined;
-}) => {
-  const { explorer } = EntropyDeployments[chain];
-  const truncatedValue = useMemo(() => truncate(value), [value]);
+const Chain = ({ chain }: { chain: keyof typeof EntropyDeployments }) => {
+  // eslint-disable-next-line import/namespace
+  const viemChain = viemChains[chain];
   return (
-    <div
-      data-always-truncate={alwaysTruncate ? "" : undefined}
-      className={styles.address}
-    >
-      <Link
-        href={explorer.replace("$ADDRESS", value)}
-        target="_blank"
-        rel="noreferrer"
-      >
-        <code className={styles.truncated}>{truncatedValue}</code>
-        <code className={styles.full}>{value}</code>
-      </Link>
-      <CopyButton text={value} />
+    <div className={styles.chain}>
+      <ChainIcon id={viemChain.id} />
+      {viemChain.name}
     </div>
   );
 };
 
-const Status = ({
-  request,
-}: {
-  request: Awaited<ReturnType<typeof getRequestsForChain>>[number];
-}) => {
-  switch (getStatus(request)) {
-    case "error": {
-      return <StatusImpl variant="error">FAILED</StatusImpl>;
-    }
-    case "success": {
-      return <StatusImpl variant="success">SUCCESS</StatusImpl>;
-    }
-    case "pending": {
-      return (
-        <StatusImpl variant="disabled" style="outline">
-          PENDING
-        </StatusImpl>
-      );
-    }
-  }
-};
-
 const defaultProps = {
   label: "Requests",
   rounded: true,
   fill: true,
   columns: [
+    {
+      id: "chain" as const,
+      name: "CHAIN",
+      width: 32,
+    },
     {
       id: "sequenceNumber" as const,
       name: "SEQUENCE NUMBER",
@@ -384,37 +224,25 @@ const defaultProps = {
       name: "TIMESTAMP",
     },
     {
-      id: "txHash" as const,
-      name: "TRANSACTION HASH",
-      width: 30,
+      id: "sender" as const,
+      name: "SENDER",
+      width: 35,
     },
     {
-      id: "provider" as const,
-      name: "PROVIDER",
-      width: 30,
+      id: "requestTxHash" as const,
+      name: "REQUEST TX",
+      width: 35,
     },
     {
-      id: "caller" as const,
-      name: "CALLER",
-      width: 30,
+      id: "callbackTxHash" as const,
+      name: "CALLBACK TX",
+      width: 35,
     },
     {
       id: "status" as const,
-      name: "STATUS",
+      name: "CALLBACK STATUS",
       alignment: "center",
       width: 25,
     },
   ],
 } satisfies Partial<ComponentProps<typeof Table<string>>>;
-
-const truncate = (value: string) => `${value.slice(0, 6)}...${value.slice(-4)}`;
-
-const getStatus = (
-  request: Awaited<ReturnType<typeof getRequestsForChain>>[number],
-) => {
-  if (request.hasCallbackCompleted) {
-    return request.callbackResult.failed ? "error" : "success";
-  } else {
-    return "pending";
-  }
-};

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

@@ -33,5 +33,5 @@ const ResolvedSearchBar = (
 
 const defaultProps = {
   size: "sm",
-  placeholder: "Sequence number, provider, caller or tx hash",
+  placeholder: "Sequence number, provider, sender or tx hash",
 } as const;

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

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

+ 43 - 4
apps/entropy-explorer/src/components/Home/use-query.ts

@@ -1,10 +1,20 @@
 import { useLogger } from "@pythnetwork/component-library/useLogger";
 import { useQueryStates, parseAsString, parseAsStringEnum } from "nuqs";
-import { useCallback } from "react";
+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)[],
@@ -13,7 +23,7 @@ const queryParams = {
 
 export const useQuery = () => {
   const logger = useLogger();
-  const [{ search, chain }, setQuery] = useQueryStates(queryParams);
+  const [{ search, chain, status }, setQuery] = useQueryStates(queryParams);
 
   const updateQuery = useCallback(
     (newQuery: Parameters<typeof setQuery>[0]) => {
@@ -32,9 +42,19 @@ export const useQuery = () => {
   );
 
   const setChain = useCallback(
-    (newChain: keyof typeof EntropyDeployments | undefined) => {
+    (newChain: keyof typeof EntropyDeployments | "all") => {
       // eslint-disable-next-line unicorn/no-null
-      updateQuery({ chain: newChain ?? 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],
   );
@@ -42,7 +62,26 @@ export const useQuery = () => {
   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,
   };
 };

+ 35 - 0
apps/entropy-explorer/src/components/Status/index.tsx

@@ -0,0 +1,35 @@
+import { Status as StatusImpl } from "@pythnetwork/component-library/Status";
+import type { ComponentProps } from "react";
+
+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) => {
+  switch (status) {
+    case StatusType.Complete: {
+      return (
+        <StatusImpl variant="success" {...props}>
+          {prefix}COMPLETE
+        </StatusImpl>
+      );
+    }
+    case StatusType.CallbackError: {
+      return (
+        <StatusImpl variant="error" {...props}>
+          {prefix}ERROR
+        </StatusImpl>
+      );
+    }
+    case StatusType.Pending: {
+      return (
+        <StatusImpl variant="disabled" style="outline" {...props}>
+          {prefix}PENDING
+        </StatusImpl>
+      );
+    }
+  }
+};

+ 35 - 0
apps/entropy-explorer/src/components/Timestamp/index.module.scss

@@ -0,0 +1,35 @@
+@use "@pythnetwork/component-library/theme";
+
+.timestamp {
+  background: transparent;
+  border: none;
+  outline: none;
+  padding: theme.spacing(1) theme.spacing(2);
+  margin: -#{theme.spacing(1)} -#{theme.spacing(2)};
+  cursor: pointer;
+  border-radius: theme.border-radius("base");
+  transition-property: background-color;
+  transition-duration: 100ms;
+  transition-timing-function: linear;
+  display: inline-flex;
+  flex-flow: row nowrap;
+  align-items: center;
+  gap: 0.25em;
+
+  .clock {
+    color: theme.color("muted");
+    opacity: 0.75;
+  }
+
+  &[data-hovered] {
+    background-color: theme.color("button", "outline", "background", "hover");
+  }
+
+  &[data-show-relative] .absolute {
+    display: none;
+  }
+
+  &:not([data-show-relative]) .relative {
+    display: none;
+  }
+}

+ 36 - 0
apps/entropy-explorer/src/components/Timestamp/index.tsx

@@ -0,0 +1,36 @@
+import { Clock } from "@phosphor-icons/react/dist/ssr/Clock";
+import { Button } from "@pythnetwork/component-library/unstyled/Button";
+import { useState } from "react";
+import TimeAgo from "react-timeago";
+
+import styles from "./index.module.scss";
+
+export const Timestamp = ({ timestamp }: { timestamp: Date }) => {
+  const [showRelative, setShowRelative] = useState(true);
+  const month = timestamp.toLocaleString("default", {
+    month: "long",
+    timeZone: "UTC",
+  });
+  const day = timestamp.getUTCDate();
+  const year = timestamp.getUTCFullYear();
+  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={() => {
+        setShowRelative((cur) => !cur);
+      }}
+      className={styles.timestamp ?? ""}
+      data-show-relative={showRelative ? "" : undefined}
+    >
+      <Clock className={styles.clock} />
+      <span className={styles.relative}>
+        <TimeAgo date={timestamp} />
+      </span>
+      <span className={styles.absolute}>
+        {month}-{day}-{year} {hour}:{minute}:{seconds} +UTC
+      </span>
+    </Button>
+  );
+};

+ 53 - 0
apps/entropy-explorer/src/errors.ts

@@ -0,0 +1,53 @@
+export const ERROR_DETAILS = {
+  "0xd82dd966": [
+    "AssertionFailure",
+    "An invariant of the contract failed to hold. This error indicates a software logic bug.",
+  ],
+  "0xda041bdf": [
+    "ProviderAlreadyRegistered",
+    "The provider being registered has already registered",
+  ],
+  "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.",
+  ],
+  "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.",
+  ],
+  "0xb463ce7a": [
+    "InvalidUpgradeMagic",
+    "Governance message is invalid (e.g., deserialization error).",
+  ],
+  "0x82b42900": [
+    "Unauthorized",
+    "The msg.sender is not allowed to invoke this call.",
+  ],
+  "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`",
+  ],
+  "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.",
+  ],
+  "0x5e5b3f1b": [
+    "UpdateTooOld",
+    "A more recent commitment is already revealed on-chain",
+  ],
+  "0x1c26714c": [
+    "InsufficientGas",
+    "Not enough gas was provided to the function to execute the callback with the desired amount of gas.",
+  ],
+  "0x9376b93b": [
+    "MaxGasLimitExceeded",
+    "A gas limit value was provided that was greater than the maximum possible limit of 655,350,000",
+  ],
+} as const;
+
+export const getErrorDetails = (error: string) =>
+  (ERROR_DETAILS as Record<string, readonly [string, string]>)[error];

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

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

+ 151 - 0
apps/entropy-explorer/src/requests.ts

@@ -0,0 +1,151 @@
+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(),
+        }),
+      };
+    }),
+  );
+};
+
+const range = (i: number) => [...Array.from({ length: i }).keys()];
+
+const randomBetween = (min: number, max: number) =>
+  Math.random() * (max - min) + min;
+
+const randomBoolean = (): boolean => Math.random() < 0.5;
+
+const randomHex = (length: number) =>
+  Array.from({ length })
+    .map(() => Math.floor(Math.random() * 16).toString(16))
+    .join("");
+
+const randomElem = <T>(arr: T[] | readonly T[]) =>
+  arr[Math.floor(randomBetween(0, arr.length))];
+
+const chains = [
+  "arbitrum",
+  "base",
+  "optimism",
+  "baseSepolia",
+  "optimismSepolia",
+] as const;
+
+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 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,
+  CallbackError,
+  Complete,
+}
+
+type BaseArgs = {
+  chain: keyof typeof EntropyDeployments;
+  sequenceNumber: number;
+  provider: `0x${string}`;
+  sender: `0x${string}`;
+  requestTxHash: `0x${string}`;
+  gasLimit: number;
+  requestTimestamp: Date;
+  userRandomNumber: `0x${string}`;
+};
+type PendingArgs = BaseArgs;
+type RevealedBaseArgs = BaseArgs & {
+  callbackTxHash: `0x${string}`;
+  randomNumber: `0x${string}`;
+  gasUsed: number;
+  callbackTimestamp: Date;
+};
+type CallbackErrorArgs = RevealedBaseArgs & {
+  returnValue: "" | `0x${string}`;
+};
+type CompleteArgs = RevealedBaseArgs;
+
+const Request = {
+  Pending: (args: PendingArgs) => ({
+    status: Status.Pending as const,
+    ...args,
+  }),
+  CallbackErrored: (args: CallbackErrorArgs) => ({
+    status: Status.CallbackError as const,
+    ...args,
+  }),
+  Complete: (args: CompleteArgs) => ({
+    status: Status.Complete as const,
+    ...args,
+  }),
+};
+export type Request = ReturnType<(typeof Request)[keyof typeof Request]>;
+export type PendingRequest = ReturnType<typeof Request.Pending>;
+export type CallbackErrorRequest = ReturnType<typeof Request.CallbackErrored>;
+export type CompleteRequest = ReturnType<typeof Request.Complete>;

+ 2 - 0
apps/entropy-explorer/src/truncate.ts

@@ -0,0 +1,2 @@
+export const truncate = (value: string) =>
+  `${value.slice(0, 6)}...${value.slice(-4)}`;

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

@@ -69,4 +69,8 @@
       }
     }
   }
+
+  &[data-icon-only] .contents {
+    @include theme.sr-only;
+  }
 }

+ 15 - 3
packages/component-library/src/CopyButton/index.tsx

@@ -14,6 +14,7 @@ const COPY_INDICATOR_TIME = 1000;
 
 type OwnProps = {
   text: string;
+  iconOnly?: boolean | undefined;
 };
 
 type Props = Omit<
@@ -22,7 +23,13 @@ type Props = Omit<
 > &
   OwnProps;
 
-export const CopyButton = ({ text, children, className, ...props }: Props) => {
+export const CopyButton = ({
+  text,
+  iconOnly,
+  children,
+  className,
+  ...props
+}: Props) => {
   const [isCopied, setIsCopied] = useState(false);
   const logger = useLogger();
   const copy = useCallback(() => {
@@ -58,12 +65,17 @@ export const CopyButton = ({ text, children, className, ...props }: Props) => {
     <Button
       onPress={copy}
       className={clsx(styles.copyButton, className)}
-      {...(isCopied && { "data-is-copied": true })}
+      data-is-copied={isCopied ? "" : undefined}
+      data-icon-only={iconOnly ? "" : undefined}
       {...props}
     >
       {(...args) => (
         <>
-          {typeof children === "function" ? children(...args) : children}
+          <span className={styles.contents}>
+            {typeof children === "function"
+              ? children(...args)
+              : (children ?? "Copy")}
+          </span>
           <div className={styles.iconContainer}>
             <Copy className={styles.copyIcon} />
             <Check className={styles.checkIcon} />

+ 10 - 2
packages/component-library/src/InfoBox/index.module.scss

@@ -1,7 +1,6 @@
 @use "../theme";
 
 .infoBox {
-  background: theme.color("states", "info", "background");
   padding: theme.spacing(4);
   border-radius: theme.border-radius("xl");
   display: flex;
@@ -11,7 +10,6 @@
   .icon {
     flex: none;
     font-size: theme.spacing(6);
-    color: theme.color("states", "info", "icon");
   }
 
   .body {
@@ -34,4 +32,14 @@
       line-height: theme.spacing(6);
     }
   }
+
+  @each $variant in ("neutral", "info", "warning", "error", "data", "success") {
+    &[data-variant="#{$variant}"] {
+      background-color: theme.color("states", $variant, "background");
+
+      .icon {
+        color: theme.color("states", $variant, "normal");
+      }
+    }
+  }
 }

+ 17 - 2
packages/component-library/src/InfoBox/index.tsx

@@ -3,9 +3,19 @@ import type { ComponentProps, ReactNode } from "react";
 
 import styles from "./index.module.scss";
 
+export const VARIANTS = [
+  "neutral",
+  "info",
+  "warning",
+  "error",
+  "data",
+  "success",
+] as const;
+
 type Props = ComponentProps<"div"> & {
   icon: ReactNode;
   header: ReactNode;
+  variant?: (typeof VARIANTS)[number] | undefined;
 };
 
 export const InfoBox = ({
@@ -13,13 +23,18 @@ export const InfoBox = ({
   header,
   children,
   className,
+  variant = "info",
   ...props
 }: Props) => (
-  <div className={clsx(className, styles.infoBox)} {...props}>
+  <div
+    className={clsx(className, styles.infoBox)}
+    data-variant={variant}
+    {...props}
+  >
     <div className={styles.icon}>{icon}</div>
     <div className={styles.body}>
       <h3 className={styles.header}>{header}</h3>
-      <p className={styles.contents}>{children}</p>
+      <div className={styles.contents}>{children}</div>
     </div>
   </div>
 );

+ 3 - 1
packages/component-library/src/Meter/index.tsx

@@ -1,6 +1,7 @@
 "use client";
 
 import { Meter as MeterComponent } from "@pythnetwork/component-library/unstyled/Meter";
+import clsx from "clsx";
 import type { ComponentProps, ReactNode } from "react";
 
 import styles from "./index.module.scss";
@@ -21,11 +22,12 @@ export const Meter = ({
   endLabel,
   labelClassName,
   variant = "default",
+  className,
   ...props
 }: Props) => (
   <MeterComponent aria-label={label} {...props}>
     {({ percentage }) => (
-      <div data-variant={variant} className={styles.meter}>
+      <div data-variant={variant} className={clsx(styles.meter, className)}>
         {(startLabel !== undefined || endLabel !== undefined) && (
           <div className={styles.labels}>
             <div className={labelClassName}>{startLabel}</div>

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

@@ -69,6 +69,14 @@
       &:not(:first-child) {
         border-top: 1px solid theme.color("border");
       }
+
+      &[data-label-hidden] {
+        padding-top: theme.spacing(1);
+
+        .groupLabel {
+          @include theme.sr-only;
+        }
+      }
     }
 
     .listboxItem {

+ 11 - 3
packages/component-library/src/Select/index.tsx

@@ -54,7 +54,11 @@ export type Props<T extends { id: string | number }> = Omit<
       }
     | {
         hideGroupLabel?: boolean | undefined;
-        optionGroups: { name: string; options: readonly T[] }[];
+        optionGroups: {
+          name: string;
+          options: readonly T[];
+          hideLabel?: boolean | undefined;
+        }[];
       }
   );
 
@@ -119,8 +123,12 @@ export const Select = <T extends { id: string | number }>({
         </ListBox>
       ) : (
         <ListBox className={styles.listbox ?? ""} items={props.optionGroups}>
-          {({ name, options }) => (
-            <ListBoxSection className={styles.section ?? ""} id={name}>
+          {({ name, options, hideLabel }) => (
+            <ListBoxSection
+              data-label-hidden={hideLabel ? "" : undefined}
+              className={styles.section ?? ""}
+              id={name}
+            >
               <Header className={styles.groupLabel ?? ""}>{name}</Header>
               <Collection items={options}>
                 {(item) => (

+ 54 - 0
packages/component-library/src/Term/index.module.scss

@@ -0,0 +1,54 @@
+@use "../theme";
+
+.term {
+  border: none;
+  background: none;
+  margin: 0;
+  padding: 0;
+  border-bottom: 2px dotted theme.color("link", "primary");
+  cursor: help;
+  outline: none;
+
+  .question {
+    font-size: theme.font-size("xs");
+    vertical-align: super;
+    margin-left: 0.25em;
+    color: theme.color("link", "primary");
+  }
+}
+
+.popover {
+  cursor: help;
+
+  .dialog {
+    @include theme.text("xs", "normal");
+    @include theme.elevation("default", 2);
+
+    background: theme.color("background", "tooltip");
+    border-radius: theme.border-radius("lg");
+    padding: theme.spacing(2) theme.spacing(3);
+    max-width: theme.spacing(45);
+    color: theme.color("tooltip");
+    outline: none;
+  }
+
+  .arrow svg {
+    display: block;
+    fill: theme.color("background", "tooltip");
+    stroke: theme.color("background", "tooltip");
+    stroke-width: 1px;
+    position: relative;
+  }
+
+  &[data-placement="bottom"] .arrow svg {
+    transform: rotate(180deg);
+  }
+
+  &[data-placement="right"] .arrow svg {
+    transform: rotate(90deg);
+  }
+
+  &[data-placement="left"] .arrow svg {
+    transform: rotate(-90deg);
+  }
+}

+ 29 - 0
packages/component-library/src/Term/index.stories.tsx

@@ -0,0 +1,29 @@
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { Term as TermComponent } from "./index.js";
+
+const meta = {
+  component: TermComponent,
+  argTypes: {
+    children: {
+      control: "text",
+      table: {
+        category: "Contents",
+      },
+    },
+    term: {
+      control: "text",
+      table: {
+        category: "Contents",
+      },
+    },
+  },
+} satisfies Meta<typeof TermComponent>;
+export default meta;
+
+export const Term = {
+  args: {
+    term: "Term",
+    children: "This is a description",
+  },
+} satisfies StoryObj<typeof TermComponent>;

+ 81 - 0
packages/component-library/src/Term/index.tsx

@@ -0,0 +1,81 @@
+"use client";
+
+import clsx from "clsx";
+import type { ComponentProps, ReactNode } from "react";
+import { useState, useRef } from "react";
+import {
+  Dialog,
+  DialogTrigger,
+  OverlayArrow,
+  Popover,
+  Button,
+} from "react-aria-components";
+
+import styles from "./index.module.scss";
+
+const HOVER_END_DELAY = 250;
+
+type Props = Omit<ComponentProps<typeof Button>, "children"> & {
+  term: ReactNode;
+  children: ReactNode;
+};
+
+export const Term = ({ className, children, term, ...props }: Props) => {
+  const didEscape = useRef(false);
+  const closeTimeout = useRef<undefined | ReturnType<typeof setTimeout>>(
+    undefined,
+  );
+  const [isOpen, setIsOpen] = useState(false);
+
+  return (
+    <DialogTrigger
+      isOpen={isOpen}
+      onOpenChange={(newValue) => {
+        if (!newValue) {
+          setIsOpen(false);
+          didEscape.current = true;
+        }
+      }}
+    >
+      <Button
+        className={clsx(className, styles.term)}
+        onHoverStart={() => {
+          setIsOpen(true);
+          if (closeTimeout.current) {
+            clearTimeout(closeTimeout.current);
+            closeTimeout.current = undefined;
+          }
+        }}
+        onHoverEnd={() => {
+          closeTimeout.current = setTimeout(() => {
+            setIsOpen(false);
+          }, HOVER_END_DELAY);
+          didEscape.current = false;
+        }}
+        onFocus={() => {
+          if (!didEscape.current) {
+            setIsOpen(true);
+          }
+        }}
+        onBlur={() => {
+          didEscape.current = false;
+        }}
+        onPress={() => {
+          setIsOpen(true);
+        }}
+        {...props}
+      >
+        {term}
+        <span className={styles.question}>?</span>
+      </Button>
+      <Popover className={styles.popover ?? ""} isNonModal placement="top">
+        <OverlayArrow className={styles.arrow ?? ""}>
+          <svg width={12} height={12} viewBox="0 0 12 12">
+            <path d="M0 0 L6 6 L12 0" />
+          </svg>
+        </OverlayArrow>
+        <Dialog className={styles.dialog ?? ""}>{children}</Dialog>
+      </Popover>
+    </DialogTrigger>
+  );
+};

+ 8 - 0
packages/component-library/src/theme.scss

@@ -422,6 +422,10 @@ $color: (
         pallette-color("white"),
         pallette-color("steel", 950)
       ),
+    "tooltip": light-dark(
+        pallette-color("steel", 700),
+        pallette-color("steel", 200)
+      ),
   ),
   "foreground": light-dark(
       pallette-color("steel", 900),
@@ -435,6 +439,10 @@ $color: (
       pallette-color("steel", 700),
       pallette-color("steel", 300)
     ),
+  "tooltip": light-dark(
+      pallette-color("steel", 200),
+      pallette-color("steel", 800)
+    ),
   "link": (
     "primary": light-dark(
         pallette-color("violet", 700),

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

@@ -94,7 +94,6 @@
       }
 
       @include theme.breakpoint("sm") {
-        border-bottom: 1px solid theme.color("border");
         padding-left: theme.spacing(6);
       }
 
@@ -127,21 +126,22 @@
       display: grid;
       flex: 1;
       overflow-y: auto;
-      padding: theme.spacing(4);
       grid-auto-rows: minmax(min-content, max-content);
-
-      @include theme.breakpoint("sm") {
-        padding: theme.spacing(6);
-      }
     }
 
-    &[data-fill] {
+    &:not([data-fill]) {
       .heading {
-        border: none;
+        @include theme.breakpoint("sm") {
+          border-bottom: 1px solid theme.color("border");
+        }
       }
 
       .body {
-        padding: 0;
+        padding: theme.spacing(4);
+
+        @include theme.breakpoint("sm") {
+          padding: theme.spacing(6);
+        }
       }
     }
 

文件差异内容过多而无法显示
+ 315 - 207
pnpm-lock.yaml


+ 1 - 0
pnpm-workspace.yaml

@@ -117,6 +117,7 @@ catalog:
   react-aria-components: ^1.7.1
   react-dom: ^19.1.0
   react-markdown: ^10.1.0
+  react-timeago: ^8.2.0
   recharts: ^2.15.1
   sass: ^1.86.1
   sass-loader: ^16.0.5

部分文件因为文件数量过多而无法显示