diff --git a/apps/entropy-explorer/package.json b/apps/entropy-explorer/package.json index f96d2064eb..04f566ab96 100644 --- a/apps/entropy-explorer/package.json +++ b/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:" diff --git a/apps/entropy-explorer/src/components/Address/index.module.scss b/apps/entropy-explorer/src/components/Address/index.module.scss new file mode 100644 index 0000000000..8c3aa684b1 --- /dev/null +++ b/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; + } + } + } +} diff --git a/apps/entropy-explorer/src/components/Address/index.tsx b/apps/entropy-explorer/src/components/Address/index.tsx new file mode 100644 index 0000000000..e1005cf5f3 --- /dev/null +++ b/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 ( +
+ + {truncatedValue} + {value} + + +
+ ); +}; diff --git a/apps/entropy-explorer/src/components/Home/chain-select.tsx b/apps/entropy-explorer/src/components/Home/chain-select.tsx index 4ac63d4f98..e3f7bcf8bd 100644 --- a/apps/entropy-explorer/src/components/Home/chain-select.tsx +++ b/apps/entropy-explorer/src/components/Home/chain-select.tsx @@ -31,7 +31,9 @@ export const ChainSelect = ( ); -type Deployment = ReturnType[number]; +type Deployment = + | ReturnType[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) => ( -
- - {chain.name} -
- ), + (chain: Deployment) => + chain.id === "all" ? ( + "All" + ) : ( +
+ + {chain.name} +
+ ), [], ); - 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: () => , + }), }; }; const defaultProps = { label: "Chain", hideLabel: true, - defaultButtonLabel: "Select Chain", + defaultButtonLabel: "Chain", } as const; const entropyDeploymentsByNetwork = ( diff --git a/apps/entropy-explorer/src/components/Home/index.tsx b/apps/entropy-explorer/src/components/Home/index.tsx index f0e5bad577..1f79dd6bef 100644 --- a/apps/entropy-explorer/src/components/Home/index.tsx +++ b/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 = () => (
@@ -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