diff --git a/src/App.tsx b/src/App.tsx index 2bf5e2a..4a4c323 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,9 @@ import { BrowserRouter as Router, Switch, Route } from "react-router-dom"; import Home from "./Home"; import Search from "./Search"; import Title from "./Title"; +import ConnectionErrorPanel from "./ConnectionErrorPanel"; +import Footer from "./Footer"; +import { ConnectionStatus } from "./types"; import { RuntimeContext, useRuntime } from "./useRuntime"; const Block = React.lazy(() => import("./Block")); @@ -15,33 +18,45 @@ const App = () => { return ( LOADING}> - - - - - - - - - - - - <Route path="/block/:blockNumberOrHash" exact> - <Block /> - </Route> - <Route path="/block/:blockNumber/txs" exact> - <BlockTransactions /> - </Route> - <Route path="/tx/:txhash"> - <Transaction /> - </Route> - <Route path="/address/:addressOrName/:direction?"> - <AddressTransactions /> - </Route> - </Route> - </Switch> - </Router> - </RuntimeContext.Provider> + {runtime.connStatus !== ConnectionStatus.CONNECTED ? ( + <ConnectionErrorPanel + connStatus={runtime.connStatus} + config={runtime.config} + /> + ) : ( + <RuntimeContext.Provider value={runtime}> + <div className="h-screen flex flex-col"> + <Router> + <Switch> + <Route path="/" exact> + <Home /> + </Route> + <Route path="/search" exact> + <Search /> + </Route> + <Route> + <div className="mb-auto"> + <Title /> + <Route path="/block/:blockNumberOrHash" exact> + <Block /> + </Route> + <Route path="/block/:blockNumber/txs" exact> + <BlockTransactions /> + </Route> + <Route path="/tx/:txhash"> + <Transaction /> + </Route> + <Route path="/address/:addressOrName/:direction?"> + <AddressTransactions /> + </Route> + </div> + </Route> + </Switch> + </Router> + <Footer /> + </div> + </RuntimeContext.Provider> + )} </Suspense> ); }; diff --git a/src/ConnectionErrorPanel.tsx b/src/ConnectionErrorPanel.tsx new file mode 100644 index 0000000..cd6b1f2 --- /dev/null +++ b/src/ConnectionErrorPanel.tsx @@ -0,0 +1,120 @@ +import React from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faClock, + faCheckCircle, + faTimesCircle, +} from "@fortawesome/free-solid-svg-icons"; +import { ConnectionStatus } from "./types"; +import { OtterscanConfig } from "./useConfig"; + +type ConnectionErrorPanelProps = { + connStatus: ConnectionStatus; + config?: OtterscanConfig; +}; + +const ConnectionErrorPanel: React.FC<ConnectionErrorPanelProps> = ({ + connStatus, + config, +}) => { + console.log("PRINT"); + return ( + <div className="h-screen flex flex-col bg-gray-300 font-sans"> + <div className="m-auto h-60 text-gray-700 text-lg min-w-lg max-w-lg"> + <Step type="wait" msg="Trying to connect to Erigon node..." /> + <div className="flex space-x-2"> + <span className="ml-7 text-base">{config?.erigonURL}</span> + </div> + {connStatus === ConnectionStatus.NOT_ETH_NODE && ( + <Step type="error" msg="It does not seem to be an ETH node"> + <p>Make sure your browser can access the URL above.</p> + <p> + If you want to customize the Erigon rpcdaemon endpoint, please + follow these{" "} + <a + href="https://github.com/wmitsuda/otterscan#run-otterscan-docker-image-from-docker-hub" + target="_blank" + rel="noreferrer noopener" + className="font-bold text-blue-800 hover:underline" + > + instructions + </a> + . + </p> + </Step> + )} + {connStatus === ConnectionStatus.NOT_ERIGON && ( + <> + <Step type="ok" msg="It is an ETH node" /> + <Step type="error" msg="It does not seem to be an Erigon node"> + Make sure you rpcdaemon with Otterscan patches is up and running + and the <strong>erigon_</strong> namespace is enabled according to + the{" "} + <a + href="https://github.com/wmitsuda/otterscan#install-otterscan-patches-on-top-of-erigon" + target="_blank" + rel="noreferrer noopener" + className="font-bold text-blue-800 hover:underline" + > + instructions + </a> + . + </Step> + </> + )} + {connStatus === ConnectionStatus.NOT_OTTERSCAN_PATCHED && ( + <> + <Step type="ok" msg="It is an Erigon node" /> + <Step + type="error" + msg="It does not seem to contain Otterscan patches" + > + Make sure you compiled rpcdaemon with Otterscan patches and + enabled <strong>ots_</strong> namespace according to the{" "} + <a + href="https://github.com/wmitsuda/otterscan#install-otterscan-patches-on-top-of-erigon" + target="_blank" + rel="noreferrer noopener" + className="font-bold text-blue-800 hover:underline" + > + instructions + </a> + . + </Step> + </> + )} + </div> + </div> + ); +}; + +type StepProps = { + type: "wait" | "ok" | "error"; + msg: string; +}; + +const Step: React.FC<StepProps> = React.memo(({ type, msg, children }) => ( + <> + <div className="flex space-x-2"> + {type === "wait" && ( + <span className="text-gray-600"> + <FontAwesomeIcon icon={faClock} size="1x" /> + </span> + )} + {type === "ok" && ( + <span className="text-green-600"> + <FontAwesomeIcon icon={faCheckCircle} size="1x" /> + </span> + )} + {type === "error" && ( + <span className="text-red-600"> + <FontAwesomeIcon icon={faTimesCircle} size="1x" /> + </span> + )} + <span>{msg}</span> + </div> + {children && <div className="ml-7 mt-4 text-sm">{children}</div>} + </> +)); + +export default React.memo(ConnectionErrorPanel); diff --git a/src/Footer.tsx b/src/Footer.tsx new file mode 100644 index 0000000..61d59d7 --- /dev/null +++ b/src/Footer.tsx @@ -0,0 +1,18 @@ +import React, { useContext } from "react"; +import { RuntimeContext } from "./useRuntime"; + +const Footer: React.FC = () => { + const { provider } = useContext(RuntimeContext); + + return ( + <div className="w-full px-2 py-1 border-t border-t-gray-100 text-xs bg-link-blue text-gray-200 text-center"> + {provider ? ( + <>Using Erigon node at {provider.connection.url}</> + ) : ( + <>Waiting for the provider...</> + )} + </div> + ); +}; + +export default React.memo(Footer); diff --git a/src/Home.tsx b/src/Home.tsx index 8626dce..7d74fcd 100644 --- a/src/Home.tsx +++ b/src/Home.tsx @@ -31,49 +31,38 @@ const Home: React.FC = () => { document.title = "Home | Otterscan"; return ( - <div className="h-screen flex m-auto"> - <div className="flex flex-col m-auto"> - <Logo /> - <form - className="flex flex-col m-auto" - onSubmit={handleSubmit} - autoComplete="off" - spellCheck={false} + <div className="m-auto"> + <Logo /> + <form + className="flex flex-col" + onSubmit={handleSubmit} + autoComplete="off" + spellCheck={false} + > + <input + className="w-full border rounded focus:outline-none px-2 py-1 mb-10" + type="text" + size={50} + placeholder="Search by address / txn hash / block number / ENS name" + onChange={handleChange} + autoFocus + ></input> + <button + className="mx-auto px-3 py-1 mb-10 rounded bg-gray-100 hover:bg-gray-200 focus:outline-none" + type="submit" > - <input - className="w-full border rounded focus:outline-none px-2 py-1 mb-10" - type="text" - size={50} - placeholder="Search by address / txn hash / block number / ENS name" - onChange={handleChange} - autoFocus - ></input> - <button - className="mx-auto px-3 py-1 mb-10 rounded bg-gray-100 hover:bg-gray-200 focus:outline-none" - type="submit" + Search + </button> + {latestBlock && ( + <NavLink + className="mx-auto flex flex-col items-center space-y-1 mt-5 text-sm text-gray-500 hover:text-link-blue" + to={`/block/${latestBlock.number}`} > - Search - </button> - {latestBlock && ( - <NavLink - className="mx-auto flex flex-col items-center space-y-1 mt-5 text-sm text-gray-500 hover:text-link-blue" - to={`/block/${latestBlock.number}`} - > - <div> - Latest block: {ethers.utils.commify(latestBlock.number)} - </div> - <Timestamp value={latestBlock.timestamp} /> - </NavLink> - )} - <span className="mx-auto mt-5 text-xs text-gray-500"> - {provider ? ( - <>Using Erigon node at {provider.connection.url}</> - ) : ( - <>Waiting for the provider...</> - )} - </span> - </form> - </div> + <div>Latest block: {ethers.utils.commify(latestBlock.number)}</div> + <Timestamp value={latestBlock.timestamp} /> + </NavLink> + )} + </form> </div> ); }; diff --git a/src/Logo.tsx b/src/Logo.tsx index 9d00d2c..905ee2c 100644 --- a/src/Logo.tsx +++ b/src/Logo.tsx @@ -1,7 +1,7 @@ import React from "react"; const Logo: React.FC = () => ( - <div className="mx-auto -mt-32 mb-16 text-6xl text-link-blue font-title font-bold cursor-default flex items-center space-x-4"> + <div className="mx-auto mb-16 text-6xl text-link-blue font-title font-bold cursor-default flex items-center space-x-4"> <img className="rounded-full" src="/otter.jpg" diff --git a/src/types.ts b/src/types.ts index 36537f8..9ee8d3d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,13 @@ import { ethers, BigNumber } from "ethers"; +export enum ConnectionStatus { + CONNECTING, + NOT_ETH_NODE, + NOT_ERIGON, + NOT_OTTERSCAN_PATCHED, + CONNECTED, +} + export type ProcessedTransaction = { blockNumber: number; timestamp: number; diff --git a/src/useProvider.ts b/src/useProvider.ts index cd4024d..b8eed30 100644 --- a/src/useProvider.ts +++ b/src/useProvider.ts @@ -1,24 +1,79 @@ -import { useMemo } from "react"; +import { useEffect, useState } from "react"; import { ethers } from "ethers"; +import { ConnectionStatus } from "./types"; export const DEFAULT_ERIGON_URL = "http://127.0.0.1:8545"; export const useProvider = ( erigonURL?: string -): ethers.providers.JsonRpcProvider | undefined => { - if (erigonURL === "") { - console.info(`Using default erigon URL: ${DEFAULT_ERIGON_URL}`); - erigonURL = DEFAULT_ERIGON_URL; - } else { - console.log(`Using configured erigon URL: ${erigonURL}`); +): [ConnectionStatus, ethers.providers.JsonRpcProvider | undefined] => { + const [connStatus, setConnStatus] = useState<ConnectionStatus>( + ConnectionStatus.CONNECTING + ); + + if (erigonURL !== undefined) { + if (erigonURL === "") { + console.info(`Using default erigon URL: ${DEFAULT_ERIGON_URL}`); + erigonURL = DEFAULT_ERIGON_URL; + } else { + console.log(`Using configured erigon URL: ${erigonURL}`); + } } - const provider = useMemo( - () => new ethers.providers.JsonRpcProvider(erigonURL, "mainnet"), - [erigonURL] - ); - if (!erigonURL) { - return undefined; - } - return provider; + const [provider, setProvider] = useState< + ethers.providers.JsonRpcProvider | undefined + >(); + useEffect(() => { + if (erigonURL === undefined) { + setConnStatus(ConnectionStatus.NOT_ETH_NODE); + setProvider(undefined); + return; + } + setConnStatus(ConnectionStatus.CONNECTING); + + const tryToConnect = async () => { + const provider = new ethers.providers.JsonRpcProvider( + erigonURL, + "mainnet" + ); + + // Check if it is at least a regular ETH node + let blockNumber: number = 0; + try { + blockNumber = await provider.getBlockNumber(); + } catch (err) { + console.log(err); + setConnStatus(ConnectionStatus.NOT_ETH_NODE); + setProvider(undefined); + return; + } + + // Check if it is an Erigon node by probing a lightweight method + try { + await provider.send("erigon_getHeaderByNumber", [blockNumber]); + } catch (err) { + console.log(err); + setConnStatus(ConnectionStatus.NOT_ERIGON); + setProvider(undefined); + return; + } + + // Check if it has Otterscan patches by probing a lightweight method + try { + // TODO: replace by a custom made method that works in all networks + await provider.send("ots_getTransactionTransfers", [ + "0x793e079fbc427cba0857b4e878194ab508f33983f45415e50af3c3c0e662fdf3", + ]); + setConnStatus(ConnectionStatus.CONNECTED); + setProvider(provider); + } catch (err) { + console.log(err); + setConnStatus(ConnectionStatus.NOT_OTTERSCAN_PATCHED); + setProvider(undefined); + } + }; + tryToConnect(); + }, [erigonURL]); + + return [connStatus, provider]; }; diff --git a/src/useRuntime.ts b/src/useRuntime.ts index ed2bcc9..77fef72 100644 --- a/src/useRuntime.ts +++ b/src/useRuntime.ts @@ -2,23 +2,27 @@ import React, { useMemo } from "react"; import { ethers } from "ethers"; import { OtterscanConfig, useConfig } from "./useConfig"; import { useProvider } from "./useProvider"; +import { ConnectionStatus } from "./types"; export type OtterscanRuntime = { config?: OtterscanConfig; + connStatus: ConnectionStatus; provider?: ethers.providers.JsonRpcProvider; }; export const useRuntime = (): OtterscanRuntime => { const [configOK, config] = useConfig(); - const provider = useProvider(configOK ? config?.erigonURL : undefined); + const [connStatus, provider] = useProvider( + configOK ? config?.erigonURL : undefined + ); const runtime = useMemo( - (): OtterscanRuntime => ({ config, provider }), - [config, provider] + (): OtterscanRuntime => ({ config, connStatus, provider }), + [config, connStatus, provider] ); if (!configOK) { - return {}; + return { connStatus: ConnectionStatus.CONNECTING }; } return runtime; };