@@ -16,6 +17,11 @@ export const Home = () => (
toolbar={
<>
+
>
}
diff --git a/apps/entropy-explorer/src/components/Home/request-drawer.module.scss b/apps/entropy-explorer/src/components/Home/request-drawer.module.scss
new file mode 100644
index 0000000000..13ad88560a
--- /dev/null
+++ b/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);
+ }
+ }
+}
diff --git a/apps/entropy-explorer/src/components/Home/request-drawer.tsx b/apps/entropy-explorer/src/components/Home/request-drawer.tsx
new file mode 100644
index 0000000000..a20f7ab0ad
--- /dev/null
+++ b/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:
,
+ bodyClassName: styles.requestDrawer ?? "",
+ fill: true,
+ contents:
,
+});
+
+const RequestDrawerBody = ({ request }: { request: Request }) => {
+ const gasFormatter = useNumberFormatter({ maximumFractionDigits: 3 });
+
+ return (
+ <>
+
+
+ ) : (
+
+ {truncate(request.randomNumber)}
+
+ )
+ }
+ />
+
+
+ {request.status === Status.CallbackError && (
+
+ )}
+
,
+ },
+ ...(request.status === Status.Pending
+ ? []
+ : [
+ {
+ id: "callbackTimestamp",
+ field: "Callback Timestamp",
+ value:
,
+ },
+ {
+ id: "duration",
+ field: (
+
+ The amount of time between the request transaction and the
+ callback transaction.
+
+ ),
+ value: (
+
request.callbackTimestamp.getTime()}
+ date={request.requestTimestamp}
+ live={false}
+ formatter={(value, unit) =>
+ `${value.toString()} ${unit}${value === 1 ? "" : "s"}`
+ }
+ />
+ ),
+ },
+ ]),
+ {
+ id: "requestTx",
+ field: (
+
+ The transaction that requests a new random number from the
+ Entropy protocol.
+
+ ),
+ value: (
+
+ ),
+ },
+ {
+ id: "sender",
+ field: "Sender",
+ value: ,
+ },
+ ...(request.status === Status.Pending
+ ? []
+ : [
+ {
+ id: "callbackTx",
+ field: (
+
+ Entropy’s response transaction that returns the random
+ number to the requester.
+
+ ),
+ value: (
+
+ ),
+ },
+ ]),
+ {
+ id: "provider",
+ field: "Provider",
+ value: ,
+ },
+ {
+ id: "userContribution",
+ field: (
+
+ User-submitted randomness included in the request.
+
+ ),
+ value: (
+
+ {truncate(request.userRandomNumber)}
+
+ ),
+ },
+ {
+ id: "providerContribution",
+ field: (
+
+ Provider-submitted randomness used to calculate the random
+ number.
+
+ ),
+ value: (
+
+ {truncate(request.userRandomNumber)}
+
+ ),
+ },
+ {
+ id: "gas",
+ field: "Gas",
+ value:
+ request.status === Status.Pending ? (
+ `${gasFormatter.format(request.gasLimit)} max`
+ ) : (
+ request.gasLimit ? "error" : "default"
+ }
+ />
+ ),
+ },
+ ].map((data) => ({
+ id: data.id,
+ data: {
+ field: {data.field},
+ 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 `;
+
+ return (
+ <>
+ }
+ className={styles.message}
+ variant="warning"
+ >
+
+
+ }
+ 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.`}
+
+ Copy Forge Command
+
+
+
+
+ >
+ );
+};
+
+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 ? (
+ <>
+ The callback encountered the following error:
+
+ {details[0]} ({request.returnValue}): {details[1]}
+
+ >
+ ) : (
+ <>
+ The callback encountered an unknown error:{" "}
+ {request.returnValue}
+ >
+ );
+ }
+};
diff --git a/apps/entropy-explorer/src/components/Home/results.module.scss b/apps/entropy-explorer/src/components/Home/results.module.scss
index 30aef56ab3..d547872278 100644
--- a/apps/entropy-explorer/src/components/Home/results.module.scss
+++ b/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");
}
}
diff --git a/apps/entropy-explorer/src/components/Home/results.tsx b/apps/entropy-explorer/src/components/Home/results.tsx
index ddd1e7d37e..4dc1364580 100644
--- a/apps/entropy-explorer/src/components/Home/results.tsx
+++ b/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 = () => (
}>
@@ -31,27 +29,7 @@ export const Results = () => (
);
const MountedResults = () => {
- const { chain } = useQuery();
-
- return chain ? (
-
- ) : (
- }
- header={}
- 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 (
@@ -85,178 +62,70 @@ const ResultsForChain = ({
};
type ResolvedResultsProps = {
- chain: keyof typeof EntropyDeployments;
- data: Awaited>;
+ data: Awaited>;
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: ,
- className: styles.requestDrawer ?? "",
- fill: true,
- contents: (
- <>
-
- {request.callbackResult.randomNumber}
- ) : (
-
- )
- }
- />
-
-
- ,
- },
- {
- field: "Caller",
- value: ,
- },
- {
- field: "Provider",
- value: (
-
- ),
- },
- {
- field: "Gas",
- value: request.hasCallbackCompleted ? (
-
- {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: (
- {data.field}
- ),
- value: data.value,
- },
- }))}
- />
- >
- ),
- });
+ drawer.open(mkRequestDrawer(request));
},
data: {
+ chain: ,
timestamp: (
- {dateFormatter.format(request.timestamp)}
+
),
sequenceNumber: (
-
+
{request.sequenceNumber}
-
+
),
- caller: (
-
+ sender: (
+
),
- provider: (
-
+ requestTxHash: (
+
),
- txHash: (
-
+ callbackTxHash: request.status !== Status.Pending && (
+
),
- status: ,
+ status: ,
},
})),
- [data, search, chain, dateFormatter, drawer, filter, gasFormatter],
+ [data, search, drawer, filter, chain, status],
);
return ;
@@ -277,19 +146,25 @@ type ResultsImplProps =
const ResultsImpl = (props: ResultsImplProps) => (
<>
-
+
+ {!props.isLoading && props.rows.length === 0 ? (
+
+ ) : (
+
+ )}
+
) => (
>
);
-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 (
-
-
-
{truncatedValue}
-
{value}
-
-
+
+
+ {viemChain.name}
);
};
-const Status = ({
- request,
-}: {
- request: Awaited
>[number];
-}) => {
- switch (getStatus(request)) {
- case "error": {
- return FAILED;
- }
- case "success": {
- return SUCCESS;
- }
- case "pending": {
- return (
-
- PENDING
-
- );
- }
- }
-};
-
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>>;
-
-const truncate = (value: string) => `${value.slice(0, 6)}...${value.slice(-4)}`;
-
-const getStatus = (
- request: Awaited>[number],
-) => {
- if (request.hasCallbackCompleted) {
- return request.callbackResult.failed ? "error" : "success";
- } else {
- return "pending";
- }
-};
diff --git a/apps/entropy-explorer/src/components/Home/search-bar.tsx b/apps/entropy-explorer/src/components/Home/search-bar.tsx
index d14a02283f..353204939d 100644
--- a/apps/entropy-explorer/src/components/Home/search-bar.tsx
+++ b/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;
diff --git a/apps/entropy-explorer/src/components/Home/status-select.tsx b/apps/entropy-explorer/src/components/Home/status-select.tsx
new file mode 100644
index 0000000000..036a39f78f
--- /dev/null
+++ b/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,
+) => (
+
+ }
+ >
+
+
+);
+
+const ResolvedStatusSelect = (
+ props: ConstrainedOmit<
+ SelectProps<
+ ReturnType<
+ typeof useResolvedProps
+ >["optionGroups"][number]["options"][number]
+ >,
+ keyof typeof defaultProps | keyof ReturnType
+ >,
+) => {
+ const resolvedProps = useResolvedProps();
+
+ return ;
+};
+
+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"
+ ) : (
+
+ ),
+ [],
+ );
+
+ return {
+ selectedKey: status ?? ("all" as const),
+ onSelectionChange: setStatus,
+ optionGroups: chains,
+ show: showStatus,
+ buttonLabel:
+ status === null ? (
+ "Status"
+ ) : (
+
+ ),
+ };
+};
+
+const defaultProps = {
+ label: "Status",
+ hideLabel: true,
+ defaultButtonLabel: "Status",
+ hideGroupLabel: true,
+} as const;
diff --git a/apps/entropy-explorer/src/components/Home/use-query.ts b/apps/entropy-explorer/src/components/Home/use-query.ts
index 6630147226..f5d6144954 100644
--- a/apps/entropy-explorer/src/components/Home/use-query.ts
+++ b/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(
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[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,
};
};
diff --git a/apps/entropy-explorer/src/components/Status/index.tsx b/apps/entropy-explorer/src/components/Status/index.tsx
new file mode 100644
index 0000000000..9303d329b7
--- /dev/null
+++ b/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, "variant" | "style"> & {
+ status: StatusType;
+ prefix?: string | undefined;
+};
+
+export const Status = ({ status, prefix, ...props }: Props) => {
+ switch (status) {
+ case StatusType.Complete: {
+ return (
+
+ {prefix}COMPLETE
+
+ );
+ }
+ case StatusType.CallbackError: {
+ return (
+
+ {prefix}ERROR
+
+ );
+ }
+ case StatusType.Pending: {
+ return (
+
+ {prefix}PENDING
+
+ );
+ }
+ }
+};
diff --git a/apps/entropy-explorer/src/components/Timestamp/index.module.scss b/apps/entropy-explorer/src/components/Timestamp/index.module.scss
new file mode 100644
index 0000000000..ad46991a8a
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/apps/entropy-explorer/src/components/Timestamp/index.tsx b/apps/entropy-explorer/src/components/Timestamp/index.tsx
new file mode 100644
index 0000000000..44a1069a58
--- /dev/null
+++ b/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 (
+
+ );
+};
diff --git a/apps/entropy-explorer/src/errors.ts b/apps/entropy-explorer/src/errors.ts
new file mode 100644
index 0000000000..369c354a7b
--- /dev/null
+++ b/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)[error];
diff --git a/apps/entropy-explorer/src/get-requests-for-chain.ts b/apps/entropy-explorer/src/get-requests-for-chain.ts
deleted file mode 100644
index 75672467c9..0000000000
--- a/apps/entropy-explorer/src/get-requests-for-chain.ts
+++ /dev/null
@@ -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("");
diff --git a/apps/entropy-explorer/src/requests.ts b/apps/entropy-explorer/src/requests.ts
new file mode 100644
index 0000000000..968083b990
--- /dev/null
+++ b/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 => {
+ 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 = (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;
+export type CallbackErrorRequest = ReturnType;
+export type CompleteRequest = ReturnType;
diff --git a/apps/entropy-explorer/src/truncate.ts b/apps/entropy-explorer/src/truncate.ts
new file mode 100644
index 0000000000..54225768ee
--- /dev/null
+++ b/apps/entropy-explorer/src/truncate.ts
@@ -0,0 +1,2 @@
+export const truncate = (value: string) =>
+ `${value.slice(0, 6)}...${value.slice(-4)}`;
diff --git a/packages/component-library/src/CopyButton/index.module.scss b/packages/component-library/src/CopyButton/index.module.scss
index 323860e0cb..8fed410969 100644
--- a/packages/component-library/src/CopyButton/index.module.scss
+++ b/packages/component-library/src/CopyButton/index.module.scss
@@ -69,4 +69,8 @@
}
}
}
+
+ &[data-icon-only] .contents {
+ @include theme.sr-only;
+ }
}
diff --git a/packages/component-library/src/CopyButton/index.tsx b/packages/component-library/src/CopyButton/index.tsx
index f2ec1a9a4c..e1c913046b 100644
--- a/packages/component-library/src/CopyButton/index.tsx
+++ b/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) => {