Merge branch 'release/v2021.07.03-otterscan'
This commit is contained in:
commit
21a2d0a907
|
@ -1,4 +1,5 @@
|
|||
FROM node:12.22.3-alpine AS builder
|
||||
RUN npm i -g npm@7.19.1
|
||||
WORKDIR /otterscan-build
|
||||
COPY ["package.json", "package-lock.json", "/otterscan-build/"]
|
||||
RUN npm install
|
||||
|
@ -7,10 +8,16 @@ COPY ["public", "/otterscan-build/public/"]
|
|||
COPY ["src", "/otterscan-build/src/"]
|
||||
RUN npm run build
|
||||
|
||||
FROM alpine:3.14.0 AS logobuilder
|
||||
RUN apk add imagemagick parallel
|
||||
WORKDIR /assets
|
||||
COPY trustwallet/blockchains/ethereum/assets /assets/
|
||||
RUN find . -name logo.png | parallel magick convert {} -filter Lanczos -resize 32x32 {}
|
||||
|
||||
FROM nginx:1.21.1-alpine
|
||||
RUN apk add jq
|
||||
COPY 4bytes/signatures /usr/share/nginx/html/signatures/
|
||||
COPY trustwallet/blockchains/ethereum/assets /usr/share/nginx/html/assets/
|
||||
COPY --from=logobuilder /assets /usr/share/nginx/html/assets/
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /otterscan-build/build /usr/share/nginx/html/
|
||||
COPY --from=builder /otterscan-build/run-nginx.sh /
|
||||
|
|
|
@ -75,7 +75,7 @@ Add our forked Erigon git tree as an additional remote and checkout the correspo
|
|||
The repository with Otterscan patches is [here](https://github.com/wmitsuda/erigon).
|
||||
|
||||
```
|
||||
git remote add otterscan git@github.com:wmitsuda/erigon.git
|
||||
git remote add otterscan https://github.com/wmitsuda/erigon.git
|
||||
```
|
||||
|
||||
Checkout the tag corresponding to the stable version you are running. For each supported Erigon version, there should be a corresponding tag containing Otterscan patches.
|
||||
|
@ -149,6 +149,8 @@ To [Trust Wallet](https://github.com/trustwallet/assets) who sponsor and make av
|
|||
|
||||
To the owners of the [4bytes repository](https://github.com/ethereum-lists/4bytes) that we import and use to translate the method selectors to human-friendly strings.
|
||||
|
||||
To [Ethers](https://github.com/ethers-io/ethers.js/) which is the client library we used to interact with the ETH node. It is high level enough to hide most jsonrpc particularities, but flexible enough to allow easy interaction with custom jsonrpc methods.
|
||||
|
||||
## Future
|
||||
|
||||
Erigon keeps evolving at a fast pace, with weekly releases, sometimes with (necessary) breaking changes.
|
||||
|
|
|
@ -16,7 +16,7 @@ Clone Otterscan repo and its submodules. Checkout the tag corresponding to your
|
|||
git clone --recurse-submodules git@github.com:wmitsuda/otterscan.git
|
||||
cd otterscan
|
||||
git checkout <version-tag-otterscan>
|
||||
docker build -t otterscan -f Dockerfile .
|
||||
DOCKER_BUILDKIT=1 docker build -t otterscan -f Dockerfile .
|
||||
```
|
||||
|
||||
This will run the entire build process inside a build container, merge the production build of the React app with the 4bytes and trustwallet assets into the same image format it is published in Docker Hub, but locally under the name `otterscan`.
|
||||
|
@ -47,13 +47,13 @@ By default, it assumes your Erigon `rpcdaemon` processs is serving requests at `
|
|||
Start serving 4bytes and trustwallet assets at `localhost:3001` using a dockerized nginx:
|
||||
|
||||
```
|
||||
npm run start-assets
|
||||
npm run assets-start
|
||||
```
|
||||
|
||||
To stop it, run:
|
||||
|
||||
```
|
||||
npm run stop-assets
|
||||
npm run assets-stop
|
||||
```
|
||||
|
||||
To run Otterscan development build:
|
||||
|
|
|
@ -4,6 +4,11 @@ server {
|
|||
|
||||
#access_log /var/log/nginx/host.access.log main;
|
||||
|
||||
location /static {
|
||||
root /usr/share/nginx/html;
|
||||
expires max;
|
||||
}
|
||||
|
||||
location /signatures {
|
||||
root /usr/share/nginx/html;
|
||||
expires 30d;
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@chainlink/contracts": "^0.2.1",
|
||||
"@craco/craco": "^6.2.0",
|
||||
"@fontsource/fira-code": "^4.5.0",
|
||||
"@fontsource/roboto": "^4.5.0",
|
||||
|
@ -1203,6 +1204,11 @@
|
|||
"version": "0.2.3",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@chainlink/contracts": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@chainlink/contracts/-/contracts-0.2.1.tgz",
|
||||
"integrity": "sha512-mAQgPQKiqW3tLMlp31NgcnXpwG3lttgKU0izAqKiirJ9LH7rQ+O0oHIVR5Qp2yuqgmfbLsgfdLo4GcVC8IFz3Q=="
|
||||
},
|
||||
"node_modules/@cnakazawa/watch": {
|
||||
"version": "1.0.4",
|
||||
"license": "Apache-2.0",
|
||||
|
@ -19913,6 +19919,11 @@
|
|||
"@bcoe/v8-coverage": {
|
||||
"version": "0.2.3"
|
||||
},
|
||||
"@chainlink/contracts": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@chainlink/contracts/-/contracts-0.2.1.tgz",
|
||||
"integrity": "sha512-mAQgPQKiqW3tLMlp31NgcnXpwG3lttgKU0izAqKiirJ9LH7rQ+O0oHIVR5Qp2yuqgmfbLsgfdLo4GcVC8IFz3Q=="
|
||||
},
|
||||
"@cnakazawa/watch": {
|
||||
"version": "1.0.4",
|
||||
"requires": {
|
||||
|
|
11
package.json
11
package.json
|
@ -4,6 +4,7 @@
|
|||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@chainlink/contracts": "^0.2.1",
|
||||
"@craco/craco": "^6.2.0",
|
||||
"@fontsource/fira-code": "^4.5.0",
|
||||
"@fontsource/roboto": "^4.5.0",
|
||||
|
@ -42,11 +43,11 @@
|
|||
"build": "craco build",
|
||||
"test": "craco test",
|
||||
"eject": "react-scripts eject",
|
||||
"start-assets": "docker run --rm -p 3001:80 --name otterscan-assets -d -v$(pwd)/4bytes/signatures:/usr/share/nginx/html/signatures/ -v$(pwd)/trustwallet/blockchains/ethereum/assets:/usr/share/nginx/html/assets -v$(pwd)/nginx.conf:/etc/nginx/conf.d/default.conf nginx:1.21.1-alpine",
|
||||
"stop-assets": "docker stop otterscan-assets",
|
||||
"build-docker": "DOCKER_BUILDKIT=1 docker build -t otterscan -f Dockerfile .",
|
||||
"start-docker": "docker run --rm -p 5000:80 --name otterscan -d otterscan",
|
||||
"stop-docker": "docker stop otterscan"
|
||||
"assets-start": "docker run --rm -p 3001:80 --name otterscan-assets -d -v$(pwd)/4bytes/signatures:/usr/share/nginx/html/signatures/ -v$(pwd)/trustwallet/blockchains/ethereum/assets:/usr/share/nginx/html/assets -v$(pwd)/nginx.conf:/etc/nginx/conf.d/default.conf nginx:1.21.1-alpine",
|
||||
"assets-stop": "docker stop otterscan-assets",
|
||||
"docker-build": "DOCKER_BUILDKIT=1 docker build -t otterscan -f Dockerfile .",
|
||||
"docker-start": "docker run --rm -p 5000:80 --name otterscan -d otterscan",
|
||||
"docker-stop": "docker stop otterscan"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
|
|
|
@ -15,6 +15,7 @@ import { SearchController } from "./search/search";
|
|||
import { RuntimeContext } from "./useRuntime";
|
||||
import { useENSCache } from "./useReverseCache";
|
||||
import { useFeeToggler } from "./search/useFeeToggler";
|
||||
import { SelectionContext, useSelection } from "./useSelection";
|
||||
|
||||
type BlockParams = {
|
||||
addressOrName: string;
|
||||
|
@ -153,6 +154,8 @@ const AddressTransactions: React.FC = () => {
|
|||
|
||||
const [feeDisplay, feeDisplayToggler] = useFeeToggler();
|
||||
|
||||
const selectionCtx = useSelection();
|
||||
|
||||
return (
|
||||
<StandardFrame>
|
||||
{error ? (
|
||||
|
@ -204,7 +207,7 @@ const AddressTransactions: React.FC = () => {
|
|||
feeDisplayToggler={feeDisplayToggler}
|
||||
/>
|
||||
{controller ? (
|
||||
<>
|
||||
<SelectionContext.Provider value={selectionCtx}>
|
||||
{controller.getPage().map((tx) => (
|
||||
<TransactionItem
|
||||
key={tx.hash}
|
||||
|
@ -228,7 +231,7 @@ const AddressTransactions: React.FC = () => {
|
|||
nextHash={page ? page[page.length - 1].hash : ""}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</SelectionContext.Provider>
|
||||
) : (
|
||||
<PendingResults />
|
||||
)}
|
||||
|
|
15
src/App.tsx
15
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,7 +18,14 @@ const App = () => {
|
|||
|
||||
return (
|
||||
<Suspense fallback={<>LOADING</>}>
|
||||
{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>
|
||||
|
@ -25,6 +35,7 @@ const App = () => {
|
|||
<Search />
|
||||
</Route>
|
||||
<Route>
|
||||
<div className="mb-auto">
|
||||
<Title />
|
||||
<Route path="/block/:blockNumberOrHash" exact>
|
||||
<Block />
|
||||
|
@ -38,10 +49,14 @@ const App = () => {
|
|||
<Route path="/address/:addressOrName/:direction?">
|
||||
<AddressTransactions />
|
||||
</Route>
|
||||
</div>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
<Footer />
|
||||
</div>
|
||||
</RuntimeContext.Provider>
|
||||
)}
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
import React from "react";
|
||||
import { ethers } from "ethers";
|
||||
import StandardSubtitle from "./StandardSubtitle";
|
||||
import BlockLink from "./components/BlockLink";
|
||||
|
||||
type BlockTransactionHeaderProps = {
|
||||
blockTag: ethers.providers.BlockTag;
|
||||
};
|
||||
|
||||
const BlockTransactionHeader: React.FC<BlockTransactionHeaderProps> = ({
|
||||
blockTag,
|
||||
}) => (
|
||||
<>
|
||||
<StandardSubtitle>Transactions</StandardSubtitle>
|
||||
<div className="pb-2 text-sm text-gray-500">
|
||||
For Block <BlockLink blockTag={blockTag} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default React.memo(BlockTransactionHeader);
|
|
@ -0,0 +1,78 @@
|
|||
import React, { useContext } from "react";
|
||||
import ContentFrame from "./ContentFrame";
|
||||
import PageControl from "./search/PageControl";
|
||||
import ResultHeader from "./search/ResultHeader";
|
||||
import PendingResults from "./search/PendingResults";
|
||||
import TransactionItem from "./search/TransactionItem";
|
||||
import { useFeeToggler } from "./search/useFeeToggler";
|
||||
import { RuntimeContext } from "./useRuntime";
|
||||
import { SelectionContext, useSelection } from "./useSelection";
|
||||
import { useENSCache } from "./useReverseCache";
|
||||
import { ProcessedTransaction } from "./types";
|
||||
import { PAGE_SIZE } from "./params";
|
||||
|
||||
type BlockTransactionResultsProps = {
|
||||
page?: ProcessedTransaction[];
|
||||
total: number;
|
||||
pageNumber: number;
|
||||
};
|
||||
|
||||
const BlockTransactionResults: React.FC<BlockTransactionResultsProps> = ({
|
||||
page,
|
||||
total,
|
||||
pageNumber,
|
||||
}) => {
|
||||
const selectionCtx = useSelection();
|
||||
const [feeDisplay, feeDisplayToggler] = useFeeToggler();
|
||||
const { provider } = useContext(RuntimeContext);
|
||||
const reverseCache = useENSCache(provider, page);
|
||||
|
||||
return (
|
||||
<ContentFrame>
|
||||
<div className="flex justify-between items-baseline py-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
{page === undefined ? (
|
||||
<>Waiting for search results...</>
|
||||
) : (
|
||||
<>A total of {total} transactions found</>
|
||||
)}
|
||||
</div>
|
||||
<PageControl
|
||||
pageNumber={pageNumber}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
<ResultHeader
|
||||
feeDisplay={feeDisplay}
|
||||
feeDisplayToggler={feeDisplayToggler}
|
||||
/>
|
||||
{page ? (
|
||||
<SelectionContext.Provider value={selectionCtx}>
|
||||
{page.map((tx) => (
|
||||
<TransactionItem
|
||||
key={tx.hash}
|
||||
tx={tx}
|
||||
ensCache={reverseCache}
|
||||
feeDisplay={feeDisplay}
|
||||
/>
|
||||
))}
|
||||
<div className="flex justify-between items-baseline py-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
A total of {total} transactions found
|
||||
</div>
|
||||
<PageControl
|
||||
pageNumber={pageNumber}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
</SelectionContext.Provider>
|
||||
) : (
|
||||
<PendingResults />
|
||||
)}
|
||||
</ContentFrame>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(BlockTransactionResults);
|
|
@ -3,18 +3,11 @@ import { useParams, useLocation } from "react-router";
|
|||
import { ethers } from "ethers";
|
||||
import queryString from "query-string";
|
||||
import StandardFrame from "./StandardFrame";
|
||||
import StandardSubtitle from "./StandardSubtitle";
|
||||
import ContentFrame from "./ContentFrame";
|
||||
import PageControl from "./search/PageControl";
|
||||
import ResultHeader from "./search/ResultHeader";
|
||||
import PendingResults from "./search/PendingResults";
|
||||
import TransactionItem from "./search/TransactionItem";
|
||||
import BlockLink from "./components/BlockLink";
|
||||
import BlockTransactionHeader from "./BlockTransactionHeader";
|
||||
import BlockTransactionResults from "./BlockTransactionResults";
|
||||
import { ProcessedTransaction } from "./types";
|
||||
import { PAGE_SIZE } from "./params";
|
||||
import { useFeeToggler } from "./search/useFeeToggler";
|
||||
import { RuntimeContext } from "./useRuntime";
|
||||
import { useENSCache } from "./useReverseCache";
|
||||
|
||||
type BlockParams = {
|
||||
blockNumber: string;
|
||||
|
@ -110,62 +103,16 @@ const BlockTransactions: React.FC = () => {
|
|||
}, [txs, pageNumber]);
|
||||
const total = useMemo(() => txs?.length ?? 0, [txs]);
|
||||
|
||||
const reverseCache = useENSCache(provider, page);
|
||||
|
||||
document.title = `Block #${blockNumber} Txns | Otterscan`;
|
||||
|
||||
const [feeDisplay, feeDisplayToggler] = useFeeToggler();
|
||||
|
||||
return (
|
||||
<StandardFrame>
|
||||
<StandardSubtitle>Transactions</StandardSubtitle>
|
||||
<div className="pb-2 text-sm text-gray-500">
|
||||
For Block <BlockLink blockTag={blockNumber.toNumber()} />
|
||||
</div>
|
||||
<ContentFrame>
|
||||
<div className="flex justify-between items-baseline py-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
{page === undefined ? (
|
||||
<>Waiting for search results...</>
|
||||
) : (
|
||||
<>A total of {total} transactions found</>
|
||||
)}
|
||||
</div>
|
||||
<PageControl
|
||||
pageNumber={pageNumber}
|
||||
pageSize={PAGE_SIZE}
|
||||
<BlockTransactionHeader blockTag={blockNumber.toNumber()} />
|
||||
<BlockTransactionResults
|
||||
page={page}
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
<ResultHeader
|
||||
feeDisplay={feeDisplay}
|
||||
feeDisplayToggler={feeDisplayToggler}
|
||||
/>
|
||||
{page ? (
|
||||
<>
|
||||
{page.map((tx) => (
|
||||
<TransactionItem
|
||||
key={tx.hash}
|
||||
tx={tx}
|
||||
ensCache={reverseCache}
|
||||
feeDisplay={feeDisplay}
|
||||
/>
|
||||
))}
|
||||
<div className="flex justify-between items-baseline py-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
A total of {total} transactions found
|
||||
</div>
|
||||
<PageControl
|
||||
pageNumber={pageNumber}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<PendingResults />
|
||||
)}
|
||||
</ContentFrame>
|
||||
</StandardFrame>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,119 @@
|
|||
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,
|
||||
}) => {
|
||||
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 up-to-date Otterscan patches"
|
||||
>
|
||||
Make sure you compiled rpcdaemon with compatible 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);
|
|
@ -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);
|
17
src/Home.tsx
17
src/Home.tsx
|
@ -31,11 +31,10 @@ const Home: React.FC = () => {
|
|||
document.title = "Home | Otterscan";
|
||||
|
||||
return (
|
||||
<div className="h-screen flex m-auto">
|
||||
<div className="flex flex-col m-auto">
|
||||
<div className="m-auto">
|
||||
<Logo />
|
||||
<form
|
||||
className="flex flex-col m-auto"
|
||||
className="flex flex-col"
|
||||
onSubmit={handleSubmit}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
|
@ -59,22 +58,12 @@ const Home: React.FC = () => {
|
|||
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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -0,0 +1,109 @@
|
|||
import React, { useState, useEffect, useMemo, useContext } from "react";
|
||||
import { ethers } from "ethers";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faGasPump } from "@fortawesome/free-solid-svg-icons";
|
||||
import AggregatorV3Interface from "@chainlink/contracts/abi/v0.8/AggregatorV3Interface.json";
|
||||
import { RuntimeContext } from "./useRuntime";
|
||||
import { formatValue } from "./components/formatter";
|
||||
import { useLatestBlock } from "./useLatestBlock";
|
||||
|
||||
const ETH_FEED_DECIMALS = 8;
|
||||
|
||||
const PriceBox: React.FC = () => {
|
||||
const { provider } = useContext(RuntimeContext);
|
||||
const latestBlock = useLatestBlock(provider);
|
||||
|
||||
const maybeOutdated: boolean =
|
||||
latestBlock !== undefined &&
|
||||
Date.now() / 1000 - latestBlock.timestamp > 3600;
|
||||
const ethFeed = useMemo(
|
||||
() =>
|
||||
provider &&
|
||||
new ethers.Contract("eth-usd.data.eth", AggregatorV3Interface, provider),
|
||||
[provider]
|
||||
);
|
||||
const gasFeed = useMemo(
|
||||
() =>
|
||||
provider &&
|
||||
new ethers.Contract(
|
||||
"fast-gas-gwei.data.eth",
|
||||
AggregatorV3Interface,
|
||||
provider
|
||||
),
|
||||
[provider]
|
||||
);
|
||||
|
||||
const [latestPriceData, setLatestPriceData] = useState<any>();
|
||||
const [latestGasData, setLatestGasData] = useState<any>();
|
||||
useEffect(() => {
|
||||
if (!ethFeed || !gasFeed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const readData = async () => {
|
||||
const [priceData, gasData] = await Promise.all([
|
||||
ethFeed.latestRoundData(),
|
||||
await gasFeed.latestRoundData(),
|
||||
]);
|
||||
setLatestPriceData(priceData);
|
||||
setLatestGasData(gasData);
|
||||
};
|
||||
readData();
|
||||
}, [ethFeed, gasFeed]);
|
||||
|
||||
const [latestPrice, latestPriceTimestamp] = useMemo(() => {
|
||||
if (!latestPriceData) {
|
||||
return [undefined, undefined];
|
||||
}
|
||||
|
||||
const price = latestPriceData.answer.div(10 ** (ETH_FEED_DECIMALS - 2));
|
||||
const formattedPrice = ethers.utils.commify(
|
||||
ethers.utils.formatUnits(price, 2)
|
||||
);
|
||||
|
||||
const timestamp = new Date(latestPriceData.updatedAt * 1000);
|
||||
return [formattedPrice, timestamp];
|
||||
}, [latestPriceData]);
|
||||
|
||||
const [latestGasPrice, latestGasPriceTimestamp] = useMemo(() => {
|
||||
if (!latestGasData) {
|
||||
return [undefined, undefined];
|
||||
}
|
||||
|
||||
const formattedGas = formatValue(latestGasData.answer, 9);
|
||||
const timestamp = new Date(latestGasData.updatedAt * 1000);
|
||||
return [formattedGas, timestamp];
|
||||
}, [latestGasData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{latestPriceData && (
|
||||
<div
|
||||
className={`flex rounded-lg px-2 py-1 space-x-2 ${
|
||||
maybeOutdated ? "bg-orange-200" : "bg-gray-100"
|
||||
} font-sans text-xs text-gray-800`}
|
||||
>
|
||||
<span
|
||||
title={`ETH/USD last updated at: ${latestPriceTimestamp?.toString()}`}
|
||||
>
|
||||
Eth: $<span className="font-balance">{latestPrice}</span>
|
||||
</span>
|
||||
{latestGasData && (
|
||||
<>
|
||||
<span>|</span>
|
||||
<span
|
||||
className="text-gray-400"
|
||||
title={`Fast gas price last updated at: ${latestGasPriceTimestamp?.toString()}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faGasPump} size="1x" />
|
||||
<span className="ml-1">{latestGasPrice} Gwei</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(PriceBox);
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useRef } from "react";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
import useKeyboardShortcut from "use-keyboard-shortcut";
|
||||
import PriceBox from "./PriceBox";
|
||||
|
||||
const Title: React.FC = () => {
|
||||
const [search, setSearch] = useState<string>();
|
||||
|
@ -41,6 +42,8 @@ const Title: React.FC = () => {
|
|||
<span>Otterscan</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex items-baseline space-x-3">
|
||||
<PriceBox />
|
||||
<form
|
||||
className="flex"
|
||||
onSubmit={handleSubmit}
|
||||
|
@ -63,6 +66,7 @@ const Title: React.FC = () => {
|
|||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import React from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCaretRight } from "@fortawesome/free-solid-svg-icons";
|
||||
import AddressLink from "./components/AddressLink";
|
||||
import AddressHighlighter from "./components/AddressHighlighter";
|
||||
import AddressOrENSName from "./components/AddressOrENSName";
|
||||
import AddressLink from "./components/AddressLink";
|
||||
import TokenLogo from "./components/TokenLogo";
|
||||
import FormattedBalance from "./components/FormattedBalance";
|
||||
import { TokenMetas, TokenTransfer } from "./types";
|
||||
|
@ -20,16 +21,20 @@ const TokenTransferItem: React.FC<TokenTransferItemProps> = ({
|
|||
<span className="text-gray-500">
|
||||
<FontAwesomeIcon icon={faCaretRight} size="1x" />
|
||||
</span>
|
||||
<div className="grid grid-cols-5">
|
||||
<div className="flex space-x-2">
|
||||
<div className="grid grid-cols-5 gap-x-1">
|
||||
<div className="flex space-x-1">
|
||||
<span className="font-bold">From</span>
|
||||
<AddressHighlighter address={t.from}>
|
||||
<AddressOrENSName address={t.from} />
|
||||
</AddressHighlighter>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex space-x-1">
|
||||
<span className="font-bold">To</span>
|
||||
<AddressHighlighter address={t.to}>
|
||||
<AddressOrENSName address={t.to} />
|
||||
</AddressHighlighter>
|
||||
</div>
|
||||
<div className="col-span-3 flex space-x-2">
|
||||
<div className="col-span-3 flex space-x-1">
|
||||
<span className="font-bold">For</span>
|
||||
<span>
|
||||
<FormattedBalance
|
||||
|
|
|
@ -7,27 +7,15 @@ import React, {
|
|||
} from "react";
|
||||
import { Route, Switch, useParams } from "react-router-dom";
|
||||
import { BigNumber, ethers } from "ethers";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faCheckCircle,
|
||||
faTimesCircle,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import StandardFrame from "./StandardFrame";
|
||||
import StandardSubtitle from "./StandardSubtitle";
|
||||
import Tab from "./components/Tab";
|
||||
import ContentFrame from "./ContentFrame";
|
||||
import BlockLink from "./components/BlockLink";
|
||||
import AddressOrENSName from "./components/AddressOrENSName";
|
||||
import AddressLink from "./components/AddressLink";
|
||||
import Copy from "./components/Copy";
|
||||
import Timestamp from "./components/Timestamp";
|
||||
import InternalTransfer from "./components/InternalTransfer";
|
||||
import GasValue from "./components/GasValue";
|
||||
import FormattedBalance from "./components/FormattedBalance";
|
||||
import TokenTransferItem from "./TokenTransferItem";
|
||||
import Details from "./transaction/Details";
|
||||
import Logs from "./transaction/Logs";
|
||||
import erc20 from "./erc20.json";
|
||||
import { TokenMetas, TokenTransfer, TransactionData, Transfer } from "./types";
|
||||
import { RuntimeContext } from "./useRuntime";
|
||||
import { SelectionContext, useSelection } from "./useSelection";
|
||||
|
||||
const TRANSFER_TOPIC =
|
||||
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
||||
|
@ -136,7 +124,7 @@ const Transaction: React.FC = () => {
|
|||
return false;
|
||||
}, [txData, transfers]);
|
||||
|
||||
const traceTransfersUsingOtsTrace = useCallback(async () => {
|
||||
const traceTransfers = useCallback(async () => {
|
||||
if (!provider || !txData) {
|
||||
return;
|
||||
}
|
||||
|
@ -156,14 +144,16 @@ const Transaction: React.FC = () => {
|
|||
setTransfers(_transfers);
|
||||
}, [provider, txData]);
|
||||
useEffect(() => {
|
||||
traceTransfersUsingOtsTrace();
|
||||
}, [traceTransfersUsingOtsTrace]);
|
||||
traceTransfers();
|
||||
}, [traceTransfers]);
|
||||
|
||||
const selectionCtx = useSelection();
|
||||
|
||||
return (
|
||||
<StandardFrame>
|
||||
<StandardSubtitle>Transaction Details</StandardSubtitle>
|
||||
{txData && (
|
||||
<>
|
||||
<SelectionContext.Provider value={selectionCtx}>
|
||||
<div className="flex space-x-2 border-l border-r border-t rounded-t-lg bg-white">
|
||||
<Tab href={`/tx/${txhash}`}>Overview</Tab>
|
||||
<Tab href={`/tx/${txhash}/logs`}>
|
||||
|
@ -172,196 +162,20 @@ const Transaction: React.FC = () => {
|
|||
</div>
|
||||
<Switch>
|
||||
<Route path="/tx/:txhash/" exact>
|
||||
<ContentFrame tabs>
|
||||
<InfoRow title="Transaction Hash">
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<span className="font-hash">{txData.transactionHash}</span>
|
||||
<Copy value={txData.transactionHash} />
|
||||
</div>
|
||||
</InfoRow>
|
||||
<InfoRow title="Status">
|
||||
{txData.status ? (
|
||||
<span className="flex items-center w-min rounded-lg space-x-1 px-3 py-1 bg-green-50 text-green-500 text-xs">
|
||||
<FontAwesomeIcon icon={faCheckCircle} size="1x" />
|
||||
<span>Success</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center w-min rounded-lg space-x-1 px-3 py-1 bg-red-50 text-red-500 text-xs">
|
||||
<FontAwesomeIcon icon={faTimesCircle} size="1x" />
|
||||
<span>Fail</span>
|
||||
</span>
|
||||
)}
|
||||
</InfoRow>
|
||||
<InfoRow title="Block">
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<BlockLink blockTag={txData.blockNumber} />
|
||||
<span className="rounded text-xs bg-gray-100 text-gray-500 px-2 py-1">
|
||||
{txData.confirmations} Block Confirmations
|
||||
</span>
|
||||
</div>
|
||||
</InfoRow>
|
||||
<InfoRow title="Timestamp">
|
||||
<Timestamp value={txData.timestamp} />
|
||||
</InfoRow>
|
||||
<InfoRow title="From">
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<AddressOrENSName
|
||||
address={txData.from}
|
||||
minerAddress={txData.miner}
|
||||
/>
|
||||
<Copy value={txData.from} />
|
||||
</div>
|
||||
</InfoRow>
|
||||
<InfoRow title="Interacted With (To)">
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<AddressOrENSName
|
||||
address={txData.to}
|
||||
minerAddress={txData.miner}
|
||||
/>
|
||||
<Copy value={txData.to} />
|
||||
</div>
|
||||
{transfers && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{transfers.map((t, i) => (
|
||||
<InternalTransfer
|
||||
key={i}
|
||||
<Details
|
||||
txData={txData}
|
||||
transfer={t}
|
||||
transfers={transfers}
|
||||
sendsEthToMiner={sendsEthToMiner}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</InfoRow>
|
||||
<InfoRow title="Transaction Action"></InfoRow>
|
||||
{txData.tokenTransfers.length > 0 && (
|
||||
<InfoRow
|
||||
title={`Tokens Transferred (${txData.tokenTransfers.length})`}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{txData.tokenTransfers.map((t, i) => (
|
||||
<TokenTransferItem
|
||||
key={i}
|
||||
t={t}
|
||||
tokenMetas={txData.tokenMetas}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</InfoRow>
|
||||
)}
|
||||
<InfoRow title="Value">
|
||||
<span className="rounded bg-gray-100 px-2 py-1 text-xs">
|
||||
{ethers.utils.formatEther(txData.value)} Ether
|
||||
</span>
|
||||
</InfoRow>
|
||||
<InfoRow title="Transaction Fee">
|
||||
<FormattedBalance value={txData.fee} /> Ether
|
||||
</InfoRow>
|
||||
<InfoRow title="Gas Price">
|
||||
<div className="flex items-baseline space-x-1">
|
||||
<span>
|
||||
<FormattedBalance value={txData.gasPrice} /> Ether (
|
||||
<FormattedBalance
|
||||
value={txData.gasPrice}
|
||||
decimals={9}
|
||||
/>{" "}
|
||||
Gwei)
|
||||
</span>
|
||||
{sendsEthToMiner && (
|
||||
<span className="rounded text-yellow-500 bg-yellow-100 text-xs px-2 py-1">
|
||||
Flashbots
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</InfoRow>
|
||||
<InfoRow title="Ether Price">N/A</InfoRow>
|
||||
<InfoRow title="Gas Limit">
|
||||
<GasValue value={txData.gasLimit} />
|
||||
</InfoRow>
|
||||
<InfoRow title="Gas Used by Transaction">
|
||||
<GasValue value={txData.gasUsed} /> (
|
||||
{(txData.gasUsedPerc * 100).toFixed(2)}%)
|
||||
</InfoRow>
|
||||
<InfoRow title="Nonce">{txData.nonce}</InfoRow>
|
||||
<InfoRow title="Position in Block">
|
||||
<span className="rounded px-2 py-1 bg-gray-100 text-gray-500 text-xs">
|
||||
{txData.transactionIndex}
|
||||
</span>
|
||||
</InfoRow>
|
||||
<InfoRow title="Input Data">
|
||||
<textarea
|
||||
className="w-full h-40 bg-gray-50 text-gray-500 font-mono focus:outline-none border rounded p-2"
|
||||
value={txData.data}
|
||||
readOnly
|
||||
/>
|
||||
</InfoRow>
|
||||
</ContentFrame>
|
||||
</Route>
|
||||
<Route path="/tx/:txhash/logs/" exact>
|
||||
<ContentFrame tabs>
|
||||
<div className="text-sm py-4">
|
||||
Transaction Receipt Event Logs
|
||||
</div>
|
||||
{txData &&
|
||||
txData.logs.map((l, i) => (
|
||||
<div className="flex space-x-10 py-5" key={i}>
|
||||
<div>
|
||||
<span className="rounded-full w-12 h-12 flex items-center justify-center bg-green-50 text-green-500">
|
||||
{l.logIndex}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full space-y-2">
|
||||
<div className="grid grid-cols-12 gap-x-3 gap-y-5 text-sm">
|
||||
<div className="font-bold text-right">Address</div>
|
||||
<div className="col-span-11">
|
||||
<AddressLink address={l.address} />
|
||||
</div>
|
||||
</div>
|
||||
{l.topics.map((t, i) => (
|
||||
<div
|
||||
className="grid grid-cols-12 gap-x-3 gap-y-5 text-sm"
|
||||
key={i}
|
||||
>
|
||||
<div className="text-right">
|
||||
{i === 0 && "Topics"}
|
||||
</div>
|
||||
<div className="flex space-x-2 items-center col-span-11 font-mono">
|
||||
<span className="rounded bg-gray-100 text-gray-500 px-2 py-1 text-xs">
|
||||
{i}
|
||||
</span>
|
||||
<span>{t}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="grid grid-cols-12 gap-x-3 gap-y-5 text-sm">
|
||||
<div className="text-right pt-2">Data</div>
|
||||
<div className="col-span-11">
|
||||
<textarea
|
||||
className="w-full h-20 bg-gray-50 font-mono focus:outline-none border rounded p-2"
|
||||
value={l.data}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ContentFrame>
|
||||
<Logs txData={txData} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
</SelectionContext.Provider>
|
||||
)}
|
||||
</StandardFrame>
|
||||
);
|
||||
};
|
||||
|
||||
type InfoRowProps = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
const InfoRow: React.FC<InfoRowProps> = ({ title, children }) => (
|
||||
<div className="grid grid-cols-4 py-4 text-sm">
|
||||
<div>{title}:</div>
|
||||
<div className="col-span-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default React.memo(Transaction);
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
import React from "react";
|
||||
import { useSelectionContext } from "../useSelection";
|
||||
|
||||
type AddressHighlighterProps = React.PropsWithChildren<{
|
||||
address: string;
|
||||
}>;
|
||||
|
||||
const AddressHighlighter: React.FC<AddressHighlighterProps> = ({
|
||||
address,
|
||||
children,
|
||||
}) => {
|
||||
const [selection, setSelection] = useSelectionContext();
|
||||
const select = () => {
|
||||
setSelection({ type: "address", content: address });
|
||||
};
|
||||
const deselect = () => {
|
||||
setSelection(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border border-dashed rounded hover:bg-transparent hover:border-transparent px-1 truncate ${
|
||||
selection !== null &&
|
||||
selection.type === "address" &&
|
||||
selection.content === address
|
||||
? "border-orange-400 bg-yellow-100"
|
||||
: "border-transparent"
|
||||
}`}
|
||||
onMouseEnter={select}
|
||||
onMouseLeave={deselect}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(AddressHighlighter);
|
|
@ -14,6 +14,7 @@ const HexValue: React.FC<HexValueProps> = ({ value }) => {
|
|||
<>
|
||||
{shards.map((s, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`font-hash ${
|
||||
i % 2 === 0 ? "text-black" : "text-gray-400"
|
||||
}`}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
import React from "react";
|
||||
|
||||
type InfoRowProps = React.PropsWithChildren<{
|
||||
title: string;
|
||||
}>;
|
||||
|
||||
const InfoRow: React.FC<InfoRowProps> = ({ title, children }) => (
|
||||
<div className="grid grid-cols-4 py-4 text-sm">
|
||||
<div>{title}:</div>
|
||||
<div className="col-span-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default React.memo(InfoRow);
|
|
@ -2,6 +2,7 @@ import React from "react";
|
|||
import { ethers } from "ethers";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faAngleRight, faCoins } from "@fortawesome/free-solid-svg-icons";
|
||||
import AddressHighlighter from "./AddressHighlighter";
|
||||
import AddressLink from "./AddressLink";
|
||||
import { TransactionData, Transfer } from "../types";
|
||||
|
||||
|
@ -24,7 +25,9 @@ const InternalTransfer: React.FC<InternalTransferProps> = ({
|
|||
<FontAwesomeIcon icon={faAngleRight} size="1x" /> TRANSFER
|
||||
</span>
|
||||
<span>{ethers.utils.formatEther(transfer.value)} Ether</span>
|
||||
<div className="flex items-baseline">
|
||||
<span className="text-gray-500">From</span>
|
||||
<AddressHighlighter address={transfer.from}>
|
||||
<div
|
||||
className={`flex items-baseline space-x-1 ${
|
||||
fromMiner ? "rounded px-2 py-1 bg-yellow-100" : ""
|
||||
|
@ -37,9 +40,13 @@ const InternalTransfer: React.FC<InternalTransferProps> = ({
|
|||
)}
|
||||
<AddressLink address={transfer.from} />
|
||||
</div>
|
||||
</AddressHighlighter>
|
||||
</div>
|
||||
<div className="flex items-baseline">
|
||||
<span className="text-gray-500">To</span>
|
||||
<AddressHighlighter address={transfer.to}>
|
||||
<div
|
||||
className={`flex items-baseline space-x-1 px-2 py-1 ${
|
||||
className={`flex items-baseline space-x-1 ${
|
||||
toMiner ? "rounded px-2 py-1 bg-yellow-100" : ""
|
||||
}`}
|
||||
>
|
||||
|
@ -50,6 +57,8 @@ const InternalTransfer: React.FC<InternalTransferProps> = ({
|
|||
)}
|
||||
<AddressLink address={transfer.to} />
|
||||
</div>
|
||||
</AddressHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,58 +1,28 @@
|
|||
import React, { useState, useEffect, useContext } from "react";
|
||||
import { fourBytesURL } from "../url";
|
||||
import { RuntimeContext } from "../useRuntime";
|
||||
import React from "react";
|
||||
import { use4Bytes } from "../use4Bytes";
|
||||
|
||||
type MethodNameProps = {
|
||||
data: string;
|
||||
};
|
||||
|
||||
const MethodName: React.FC<MethodNameProps> = ({ data }) => {
|
||||
const runtime = useContext(RuntimeContext);
|
||||
|
||||
const [name, setName] = useState<string>();
|
||||
useEffect(() => {
|
||||
if (data === "0x") {
|
||||
setName("Transfer");
|
||||
return;
|
||||
}
|
||||
|
||||
let _name = data.slice(0, 10);
|
||||
|
||||
// Try to resolve 4bytes name
|
||||
const fourBytes = _name.slice(2);
|
||||
const { config } = runtime;
|
||||
if (!config) {
|
||||
setName(_name);
|
||||
return;
|
||||
}
|
||||
|
||||
const signatureURL = fourBytesURL(config.assetsURLPrefix ?? "", fourBytes);
|
||||
fetch(signatureURL)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
console.error(`Signature does not exist in 4bytes DB: ${fourBytes}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const sig = await res.text();
|
||||
const cut = sig.indexOf("(");
|
||||
let method = sig.slice(0, cut);
|
||||
method = method.charAt(0).toUpperCase() + method.slice(1);
|
||||
setName(method);
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Couldn't fetch signature URL ${signatureURL}`, err);
|
||||
});
|
||||
|
||||
// Use the default 4 bytes as name
|
||||
setName(_name);
|
||||
}, [runtime, data]);
|
||||
const rawFourBytes = data.slice(0, 10);
|
||||
const methodName = use4Bytes(rawFourBytes);
|
||||
const isSimpleTransfer = data === "0x";
|
||||
const methodTitle = isSimpleTransfer
|
||||
? "ETH Transfer"
|
||||
: methodName === rawFourBytes
|
||||
? methodName
|
||||
: `${methodName} [${rawFourBytes}]`;
|
||||
|
||||
return (
|
||||
<div className="bg-blue-50 rounded-lg px-3 py-1 min-h-full flex items-baseline text-xs max-w-max">
|
||||
<p className="truncate" title={name}>
|
||||
{name}
|
||||
<div
|
||||
className={`${
|
||||
isSimpleTransfer ? "bg-yellow-100" : "bg-blue-50"
|
||||
} rounded-lg px-3 py-1 min-h-full flex items-baseline text-xs max-w-max`}
|
||||
>
|
||||
<p className="truncate" title={methodTitle}>
|
||||
{methodName}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1 +1,3 @@
|
|||
export const MIN_API_LEVEL = 1;
|
||||
|
||||
export const PAGE_SIZE = 25;
|
||||
|
|
|
@ -15,8 +15,8 @@ const ResultHeader: React.FC<ResultHeaderProps> = ({
|
|||
<div>Method</div>
|
||||
<div>Block</div>
|
||||
<div>Age</div>
|
||||
<div className="col-span-2">From</div>
|
||||
<div className="col-span-2">To</div>
|
||||
<div className="col-span-2 ml-1">From</div>
|
||||
<div className="col-span-2 ml-1">To</div>
|
||||
<div className="col-span-2">Value</div>
|
||||
<div>
|
||||
<button
|
||||
|
|
|
@ -6,6 +6,7 @@ import BlockLink from "../components/BlockLink";
|
|||
import TransactionLink from "../components/TransactionLink";
|
||||
import AddressOrENSName from "../components/AddressOrENSName";
|
||||
import TimestampAge from "../components/TimestampAge";
|
||||
import AddressHighlighter from "../components/AddressHighlighter";
|
||||
import TransactionDirection, {
|
||||
Direction,
|
||||
Flags,
|
||||
|
@ -67,14 +68,16 @@ const TransactionItem: React.FC<TransactionItemProps> = ({
|
|||
</span>
|
||||
<TimestampAge timestamp={tx.timestamp} />
|
||||
<span className="col-span-2 flex justify-between items-baseline space-x-2 pr-2">
|
||||
<span className="truncate" title={tx.from}>
|
||||
<span className="truncate">
|
||||
{tx.from && (
|
||||
<AddressHighlighter address={tx.from}>
|
||||
<AddressOrENSName
|
||||
address={tx.from}
|
||||
ensName={ensFrom}
|
||||
selectedAddress={selectedAddress}
|
||||
minerAddress={tx.miner}
|
||||
/>
|
||||
</AddressHighlighter>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
|
@ -84,16 +87,20 @@ const TransactionItem: React.FC<TransactionItemProps> = ({
|
|||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span className="col-span-2 truncate" title={tx.to}>
|
||||
<span className="col-span-2 flex items-baseline" title={tx.to}>
|
||||
<span className="truncate">
|
||||
{tx.to && (
|
||||
<AddressHighlighter address={tx.to}>
|
||||
<AddressOrENSName
|
||||
address={tx.to}
|
||||
ensName={ensTo}
|
||||
selectedAddress={selectedAddress}
|
||||
minerAddress={tx.miner}
|
||||
/>
|
||||
</AddressHighlighter>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="col-span-2 truncate">
|
||||
<TransactionValue value={tx.value} />
|
||||
</span>
|
||||
|
|
|
@ -0,0 +1,144 @@
|
|||
import React from "react";
|
||||
import { ethers } from "ethers";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faCheckCircle,
|
||||
faTimesCircle,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import ContentFrame from "../ContentFrame";
|
||||
import InfoRow from "../components/InfoRow";
|
||||
import BlockLink from "../components/BlockLink";
|
||||
import AddressHighlighter from "../components/AddressHighlighter";
|
||||
import AddressOrENSName from "../components/AddressOrENSName";
|
||||
import Copy from "../components/Copy";
|
||||
import Timestamp from "../components/Timestamp";
|
||||
import InternalTransfer from "../components/InternalTransfer";
|
||||
import MethodName from "../components/MethodName";
|
||||
import GasValue from "../components/GasValue";
|
||||
import FormattedBalance from "../components/FormattedBalance";
|
||||
import TokenTransferItem from "../TokenTransferItem";
|
||||
import { TransactionData, Transfer } from "../types";
|
||||
|
||||
type DetailsProps = {
|
||||
txData: TransactionData;
|
||||
transfers?: Transfer[];
|
||||
sendsEthToMiner: boolean;
|
||||
};
|
||||
|
||||
const Details: React.FC<DetailsProps> = ({
|
||||
txData,
|
||||
transfers,
|
||||
sendsEthToMiner,
|
||||
}) => (
|
||||
<ContentFrame tabs>
|
||||
<InfoRow title="Transaction Hash">
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<span className="font-hash">{txData.transactionHash}</span>
|
||||
<Copy value={txData.transactionHash} />
|
||||
</div>
|
||||
</InfoRow>
|
||||
<InfoRow title="Status">
|
||||
{txData.status ? (
|
||||
<span className="flex items-center w-min rounded-lg space-x-1 px-3 py-1 bg-green-50 text-green-500 text-xs">
|
||||
<FontAwesomeIcon icon={faCheckCircle} size="1x" />
|
||||
<span>Success</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center w-min rounded-lg space-x-1 px-3 py-1 bg-red-50 text-red-500 text-xs">
|
||||
<FontAwesomeIcon icon={faTimesCircle} size="1x" />
|
||||
<span>Fail</span>
|
||||
</span>
|
||||
)}
|
||||
</InfoRow>
|
||||
<InfoRow title="Block">
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<BlockLink blockTag={txData.blockNumber} />
|
||||
<span className="rounded text-xs bg-gray-100 text-gray-500 px-2 py-1">
|
||||
{txData.confirmations} Block Confirmations
|
||||
</span>
|
||||
</div>
|
||||
</InfoRow>
|
||||
<InfoRow title="Timestamp">
|
||||
<Timestamp value={txData.timestamp} />
|
||||
</InfoRow>
|
||||
<InfoRow title="From">
|
||||
<div className="flex items-baseline space-x-2 -ml-1">
|
||||
<AddressHighlighter address={txData.from}>
|
||||
<AddressOrENSName address={txData.from} minerAddress={txData.miner} />
|
||||
</AddressHighlighter>
|
||||
<Copy value={txData.from} />
|
||||
</div>
|
||||
</InfoRow>
|
||||
<InfoRow title="Interacted With (To)">
|
||||
<div className="flex items-baseline space-x-2 -ml-1">
|
||||
<AddressHighlighter address={txData.to}>
|
||||
<AddressOrENSName address={txData.to} minerAddress={txData.miner} />
|
||||
</AddressHighlighter>
|
||||
<Copy value={txData.to} />
|
||||
</div>
|
||||
{transfers && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{transfers.map((t, i) => (
|
||||
<InternalTransfer key={i} txData={txData} transfer={t} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</InfoRow>
|
||||
<InfoRow title="Transaction Action">
|
||||
<MethodName data={txData.data} />
|
||||
</InfoRow>
|
||||
{txData.tokenTransfers.length > 0 && (
|
||||
<InfoRow title={`Tokens Transferred (${txData.tokenTransfers.length})`}>
|
||||
<div className="space-y-2">
|
||||
{txData.tokenTransfers.map((t, i) => (
|
||||
<TokenTransferItem key={i} t={t} tokenMetas={txData.tokenMetas} />
|
||||
))}
|
||||
</div>
|
||||
</InfoRow>
|
||||
)}
|
||||
<InfoRow title="Value">
|
||||
<span className="rounded bg-gray-100 px-2 py-1 text-xs">
|
||||
{ethers.utils.formatEther(txData.value)} Ether
|
||||
</span>
|
||||
</InfoRow>
|
||||
<InfoRow title="Transaction Fee">
|
||||
<FormattedBalance value={txData.fee} /> Ether
|
||||
</InfoRow>
|
||||
<InfoRow title="Gas Price">
|
||||
<div className="flex items-baseline space-x-1">
|
||||
<span>
|
||||
<FormattedBalance value={txData.gasPrice} /> Ether (
|
||||
<FormattedBalance value={txData.gasPrice} decimals={9} /> Gwei)
|
||||
</span>
|
||||
{sendsEthToMiner && (
|
||||
<span className="rounded text-yellow-500 bg-yellow-100 text-xs px-2 py-1">
|
||||
Flashbots
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</InfoRow>
|
||||
<InfoRow title="Ether Price">N/A</InfoRow>
|
||||
<InfoRow title="Gas Limit">
|
||||
<GasValue value={txData.gasLimit} />
|
||||
</InfoRow>
|
||||
<InfoRow title="Gas Used by Transaction">
|
||||
<GasValue value={txData.gasUsed} /> (
|
||||
{(txData.gasUsedPerc * 100).toFixed(2)}%)
|
||||
</InfoRow>
|
||||
<InfoRow title="Nonce">{txData.nonce}</InfoRow>
|
||||
<InfoRow title="Position in Block">
|
||||
<span className="rounded px-2 py-1 bg-gray-100 text-gray-500 text-xs">
|
||||
{txData.transactionIndex}
|
||||
</span>
|
||||
</InfoRow>
|
||||
<InfoRow title="Input Data">
|
||||
<textarea
|
||||
className="w-full h-40 bg-gray-50 text-gray-500 font-mono focus:outline-none border rounded p-2"
|
||||
value={txData.data}
|
||||
readOnly
|
||||
/>
|
||||
</InfoRow>
|
||||
</ContentFrame>
|
||||
);
|
||||
|
||||
export default React.memo(Details);
|
|
@ -0,0 +1,57 @@
|
|||
import React from "react";
|
||||
import ContentFrame from "../ContentFrame";
|
||||
import AddressLink from "../components/AddressLink";
|
||||
import { TransactionData } from "../types";
|
||||
|
||||
type LogsProps = {
|
||||
txData: TransactionData;
|
||||
};
|
||||
|
||||
const Logs: React.FC<LogsProps> = ({ txData }) => (
|
||||
<ContentFrame tabs>
|
||||
<div className="text-sm py-4">Transaction Receipt Event Logs</div>
|
||||
{txData &&
|
||||
txData.logs.map((l, i) => (
|
||||
<div className="flex space-x-10 py-5" key={i}>
|
||||
<div>
|
||||
<span className="rounded-full w-12 h-12 flex items-center justify-center bg-green-50 text-green-500">
|
||||
{l.logIndex}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full space-y-2">
|
||||
<div className="grid grid-cols-12 gap-x-3 gap-y-5 text-sm">
|
||||
<div className="font-bold text-right">Address</div>
|
||||
<div className="col-span-11">
|
||||
<AddressLink address={l.address} />
|
||||
</div>
|
||||
</div>
|
||||
{l.topics.map((t, i) => (
|
||||
<div
|
||||
className="grid grid-cols-12 gap-x-3 gap-y-5 text-sm"
|
||||
key={i}
|
||||
>
|
||||
<div className="text-right">{i === 0 && "Topics"}</div>
|
||||
<div className="flex space-x-2 items-center col-span-11 font-mono">
|
||||
<span className="rounded bg-gray-100 text-gray-500 px-2 py-1 text-xs">
|
||||
{i}
|
||||
</span>
|
||||
<span>{t}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="grid grid-cols-12 gap-x-3 gap-y-5 text-sm">
|
||||
<div className="text-right pt-2">Data</div>
|
||||
<div className="col-span-11">
|
||||
<textarea
|
||||
className="w-full h-20 bg-gray-50 font-mono focus:outline-none border rounded p-2"
|
||||
value={l.data}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ContentFrame>
|
||||
);
|
||||
|
||||
export default React.memo(Logs);
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
import { useState, useEffect, useContext } from "react";
|
||||
import { RuntimeContext } from "./useRuntime";
|
||||
import { fourBytesURL } from "./url";
|
||||
|
||||
const cache = new Map<string, string | null>();
|
||||
|
||||
export const use4Bytes = (rawFourBytes: string) => {
|
||||
const runtime = useContext(RuntimeContext);
|
||||
const assetsURLPrefix = runtime.config?.assetsURLPrefix;
|
||||
|
||||
const [name, setName] = useState<string>();
|
||||
const [fourBytes, setFourBytes] = useState<string>();
|
||||
useEffect(() => {
|
||||
if (assetsURLPrefix === undefined || fourBytes === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const signatureURL = fourBytesURL(assetsURLPrefix, fourBytes);
|
||||
fetch(signatureURL)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
console.error(`Signature does not exist in 4bytes DB: ${fourBytes}`);
|
||||
|
||||
// Use the default 4 bytes as name
|
||||
setName(rawFourBytes);
|
||||
cache.set(fourBytes, null);
|
||||
return;
|
||||
}
|
||||
|
||||
const sig = await res.text();
|
||||
const cut = sig.indexOf("(");
|
||||
let method = sig.slice(0, cut);
|
||||
method = method.charAt(0).toUpperCase() + method.slice(1);
|
||||
setName(method);
|
||||
cache.set(fourBytes, method);
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Couldn't fetch signature URL ${signatureURL}`, err);
|
||||
|
||||
// Use the default 4 bytes as name
|
||||
setName(rawFourBytes);
|
||||
});
|
||||
}, [rawFourBytes, assetsURLPrefix, fourBytes]);
|
||||
|
||||
if (rawFourBytes === "0x") {
|
||||
return "Transfer";
|
||||
}
|
||||
if (assetsURLPrefix === undefined) {
|
||||
return rawFourBytes;
|
||||
}
|
||||
|
||||
// Try to resolve 4bytes name
|
||||
const entry = cache.get(rawFourBytes.slice(2));
|
||||
if (entry === null) {
|
||||
return rawFourBytes;
|
||||
}
|
||||
if (entry !== undefined) {
|
||||
// Simulates LRU
|
||||
cache.delete(entry);
|
||||
cache.set(rawFourBytes.slice(2), entry);
|
||||
return entry;
|
||||
}
|
||||
if (name === undefined && fourBytes === undefined) {
|
||||
setFourBytes(rawFourBytes.slice(2));
|
||||
return "";
|
||||
}
|
||||
|
||||
return name;
|
||||
};
|
|
@ -1,24 +1,82 @@
|
|||
import { useMemo } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ethers } from "ethers";
|
||||
import { ConnectionStatus } from "./types";
|
||||
import { MIN_API_LEVEL } from "./params";
|
||||
|
||||
export const DEFAULT_ERIGON_URL = "http://127.0.0.1:8545";
|
||||
|
||||
export const useProvider = (
|
||||
erigonURL?: string
|
||||
): ethers.providers.JsonRpcProvider | undefined => {
|
||||
): [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 {
|
||||
const level = await provider.send("ots_getApiLevel", []);
|
||||
if (level < MIN_API_LEVEL) {
|
||||
setConnStatus(ConnectionStatus.NOT_OTTERSCAN_PATCHED);
|
||||
setProvider(undefined);
|
||||
} else {
|
||||
setConnStatus(ConnectionStatus.CONNECTED);
|
||||
setProvider(provider);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
setConnStatus(ConnectionStatus.NOT_OTTERSCAN_PATCHED);
|
||||
setProvider(undefined);
|
||||
}
|
||||
};
|
||||
tryToConnect();
|
||||
}, [erigonURL]);
|
||||
|
||||
return [connStatus, provider];
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import React, { useState, useContext } from "react";
|
||||
|
||||
export type Selection = {
|
||||
type: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export const useSelection = (): [
|
||||
Selection | null,
|
||||
React.Dispatch<React.SetStateAction<Selection | null>>
|
||||
] => {
|
||||
const [selection, setSelection] = useState<Selection | null>(null);
|
||||
return [selection, setSelection];
|
||||
};
|
||||
|
||||
export const SelectionContext = React.createContext<
|
||||
ReturnType<typeof useSelection>
|
||||
>(null!);
|
||||
|
||||
export const useSelectionContext = () => {
|
||||
const ctx = useContext(SelectionContext);
|
||||
return ctx;
|
||||
};
|
|
@ -1,3 +1,5 @@
|
|||
const colors = require("tailwindcss/colors");
|
||||
|
||||
module.exports = {
|
||||
purge: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"],
|
||||
darkMode: false, // or 'media' or 'class'
|
||||
|
@ -6,6 +8,7 @@ module.exports = {
|
|||
colors: {
|
||||
"link-blue": "#3498db",
|
||||
"link-blue-hover": "#0468ab",
|
||||
orange: colors.orange,
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Roboto"],
|
||||
|
|
Loading…
Reference in New Issue