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
|
FROM node:12.22.3-alpine AS builder
|
||||||
|
RUN npm i -g npm@7.19.1
|
||||||
WORKDIR /otterscan-build
|
WORKDIR /otterscan-build
|
||||||
COPY ["package.json", "package-lock.json", "/otterscan-build/"]
|
COPY ["package.json", "package-lock.json", "/otterscan-build/"]
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
@ -7,10 +8,16 @@ COPY ["public", "/otterscan-build/public/"]
|
||||||
COPY ["src", "/otterscan-build/src/"]
|
COPY ["src", "/otterscan-build/src/"]
|
||||||
RUN npm run build
|
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
|
FROM nginx:1.21.1-alpine
|
||||||
RUN apk add jq
|
RUN apk add jq
|
||||||
COPY 4bytes/signatures /usr/share/nginx/html/signatures/
|
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 nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=builder /otterscan-build/build /usr/share/nginx/html/
|
COPY --from=builder /otterscan-build/build /usr/share/nginx/html/
|
||||||
COPY --from=builder /otterscan-build/run-nginx.sh /
|
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).
|
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.
|
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 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
|
## Future
|
||||||
|
|
||||||
Erigon keeps evolving at a fast pace, with weekly releases, sometimes with (necessary) breaking changes.
|
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
|
git clone --recurse-submodules git@github.com:wmitsuda/otterscan.git
|
||||||
cd otterscan
|
cd otterscan
|
||||||
git checkout <version-tag-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`.
|
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:
|
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:
|
To stop it, run:
|
||||||
|
|
||||||
```
|
```
|
||||||
npm run stop-assets
|
npm run assets-stop
|
||||||
```
|
```
|
||||||
|
|
||||||
To run Otterscan development build:
|
To run Otterscan development build:
|
||||||
|
|
|
@ -4,6 +4,11 @@ server {
|
||||||
|
|
||||||
#access_log /var/log/nginx/host.access.log main;
|
#access_log /var/log/nginx/host.access.log main;
|
||||||
|
|
||||||
|
location /static {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
expires max;
|
||||||
|
}
|
||||||
|
|
||||||
location /signatures {
|
location /signatures {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
expires 30d;
|
expires 30d;
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@chainlink/contracts": "^0.2.1",
|
||||||
"@craco/craco": "^6.2.0",
|
"@craco/craco": "^6.2.0",
|
||||||
"@fontsource/fira-code": "^4.5.0",
|
"@fontsource/fira-code": "^4.5.0",
|
||||||
"@fontsource/roboto": "^4.5.0",
|
"@fontsource/roboto": "^4.5.0",
|
||||||
|
@ -1203,6 +1204,11 @@
|
||||||
"version": "0.2.3",
|
"version": "0.2.3",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@cnakazawa/watch": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
@ -19913,6 +19919,11 @@
|
||||||
"@bcoe/v8-coverage": {
|
"@bcoe/v8-coverage": {
|
||||||
"version": "0.2.3"
|
"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": {
|
"@cnakazawa/watch": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"requires": {
|
"requires": {
|
||||||
|
|
11
package.json
11
package.json
|
@ -4,6 +4,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@chainlink/contracts": "^0.2.1",
|
||||||
"@craco/craco": "^6.2.0",
|
"@craco/craco": "^6.2.0",
|
||||||
"@fontsource/fira-code": "^4.5.0",
|
"@fontsource/fira-code": "^4.5.0",
|
||||||
"@fontsource/roboto": "^4.5.0",
|
"@fontsource/roboto": "^4.5.0",
|
||||||
|
@ -42,11 +43,11 @@
|
||||||
"build": "craco build",
|
"build": "craco build",
|
||||||
"test": "craco test",
|
"test": "craco test",
|
||||||
"eject": "react-scripts eject",
|
"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",
|
"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",
|
||||||
"stop-assets": "docker stop otterscan-assets",
|
"assets-stop": "docker stop otterscan-assets",
|
||||||
"build-docker": "DOCKER_BUILDKIT=1 docker build -t otterscan -f Dockerfile .",
|
"docker-build": "DOCKER_BUILDKIT=1 docker build -t otterscan -f Dockerfile .",
|
||||||
"start-docker": "docker run --rm -p 5000:80 --name otterscan -d otterscan",
|
"docker-start": "docker run --rm -p 5000:80 --name otterscan -d otterscan",
|
||||||
"stop-docker": "docker stop otterscan"
|
"docker-stop": "docker stop otterscan"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { SearchController } from "./search/search";
|
||||||
import { RuntimeContext } from "./useRuntime";
|
import { RuntimeContext } from "./useRuntime";
|
||||||
import { useENSCache } from "./useReverseCache";
|
import { useENSCache } from "./useReverseCache";
|
||||||
import { useFeeToggler } from "./search/useFeeToggler";
|
import { useFeeToggler } from "./search/useFeeToggler";
|
||||||
|
import { SelectionContext, useSelection } from "./useSelection";
|
||||||
|
|
||||||
type BlockParams = {
|
type BlockParams = {
|
||||||
addressOrName: string;
|
addressOrName: string;
|
||||||
|
@ -153,6 +154,8 @@ const AddressTransactions: React.FC = () => {
|
||||||
|
|
||||||
const [feeDisplay, feeDisplayToggler] = useFeeToggler();
|
const [feeDisplay, feeDisplayToggler] = useFeeToggler();
|
||||||
|
|
||||||
|
const selectionCtx = useSelection();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StandardFrame>
|
<StandardFrame>
|
||||||
{error ? (
|
{error ? (
|
||||||
|
@ -204,7 +207,7 @@ const AddressTransactions: React.FC = () => {
|
||||||
feeDisplayToggler={feeDisplayToggler}
|
feeDisplayToggler={feeDisplayToggler}
|
||||||
/>
|
/>
|
||||||
{controller ? (
|
{controller ? (
|
||||||
<>
|
<SelectionContext.Provider value={selectionCtx}>
|
||||||
{controller.getPage().map((tx) => (
|
{controller.getPage().map((tx) => (
|
||||||
<TransactionItem
|
<TransactionItem
|
||||||
key={tx.hash}
|
key={tx.hash}
|
||||||
|
@ -228,7 +231,7 @@ const AddressTransactions: React.FC = () => {
|
||||||
nextHash={page ? page[page.length - 1].hash : ""}
|
nextHash={page ? page[page.length - 1].hash : ""}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</SelectionContext.Provider>
|
||||||
) : (
|
) : (
|
||||||
<PendingResults />
|
<PendingResults />
|
||||||
)}
|
)}
|
||||||
|
|
69
src/App.tsx
69
src/App.tsx
|
@ -3,6 +3,9 @@ import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
|
||||||
import Home from "./Home";
|
import Home from "./Home";
|
||||||
import Search from "./Search";
|
import Search from "./Search";
|
||||||
import Title from "./Title";
|
import Title from "./Title";
|
||||||
|
import ConnectionErrorPanel from "./ConnectionErrorPanel";
|
||||||
|
import Footer from "./Footer";
|
||||||
|
import { ConnectionStatus } from "./types";
|
||||||
import { RuntimeContext, useRuntime } from "./useRuntime";
|
import { RuntimeContext, useRuntime } from "./useRuntime";
|
||||||
|
|
||||||
const Block = React.lazy(() => import("./Block"));
|
const Block = React.lazy(() => import("./Block"));
|
||||||
|
@ -15,33 +18,45 @@ const App = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<>LOADING</>}>
|
<Suspense fallback={<>LOADING</>}>
|
||||||
<RuntimeContext.Provider value={runtime}>
|
{runtime.connStatus !== ConnectionStatus.CONNECTED ? (
|
||||||
<Router>
|
<ConnectionErrorPanel
|
||||||
<Switch>
|
connStatus={runtime.connStatus}
|
||||||
<Route path="/" exact>
|
config={runtime.config}
|
||||||
<Home />
|
/>
|
||||||
</Route>
|
) : (
|
||||||
<Route path="/search" exact>
|
<RuntimeContext.Provider value={runtime}>
|
||||||
<Search />
|
<div className="h-screen flex flex-col">
|
||||||
</Route>
|
<Router>
|
||||||
<Route>
|
<Switch>
|
||||||
<Title />
|
<Route path="/" exact>
|
||||||
<Route path="/block/:blockNumberOrHash" exact>
|
<Home />
|
||||||
<Block />
|
</Route>
|
||||||
</Route>
|
<Route path="/search" exact>
|
||||||
<Route path="/block/:blockNumber/txs" exact>
|
<Search />
|
||||||
<BlockTransactions />
|
</Route>
|
||||||
</Route>
|
<Route>
|
||||||
<Route path="/tx/:txhash">
|
<div className="mb-auto">
|
||||||
<Transaction />
|
<Title />
|
||||||
</Route>
|
<Route path="/block/:blockNumberOrHash" exact>
|
||||||
<Route path="/address/:addressOrName/:direction?">
|
<Block />
|
||||||
<AddressTransactions />
|
</Route>
|
||||||
</Route>
|
<Route path="/block/:blockNumber/txs" exact>
|
||||||
</Route>
|
<BlockTransactions />
|
||||||
</Switch>
|
</Route>
|
||||||
</Router>
|
<Route path="/tx/:txhash">
|
||||||
</RuntimeContext.Provider>
|
<Transaction />
|
||||||
|
</Route>
|
||||||
|
<Route path="/address/:addressOrName/:direction?">
|
||||||
|
<AddressTransactions />
|
||||||
|
</Route>
|
||||||
|
</div>
|
||||||
|
</Route>
|
||||||
|
</Switch>
|
||||||
|
</Router>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</RuntimeContext.Provider>
|
||||||
|
)}
|
||||||
</Suspense>
|
</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 { ethers } from "ethers";
|
||||||
import queryString from "query-string";
|
import queryString from "query-string";
|
||||||
import StandardFrame from "./StandardFrame";
|
import StandardFrame from "./StandardFrame";
|
||||||
import StandardSubtitle from "./StandardSubtitle";
|
import BlockTransactionHeader from "./BlockTransactionHeader";
|
||||||
import ContentFrame from "./ContentFrame";
|
import BlockTransactionResults from "./BlockTransactionResults";
|
||||||
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 { ProcessedTransaction } from "./types";
|
import { ProcessedTransaction } from "./types";
|
||||||
import { PAGE_SIZE } from "./params";
|
import { PAGE_SIZE } from "./params";
|
||||||
import { useFeeToggler } from "./search/useFeeToggler";
|
|
||||||
import { RuntimeContext } from "./useRuntime";
|
import { RuntimeContext } from "./useRuntime";
|
||||||
import { useENSCache } from "./useReverseCache";
|
|
||||||
|
|
||||||
type BlockParams = {
|
type BlockParams = {
|
||||||
blockNumber: string;
|
blockNumber: string;
|
||||||
|
@ -110,62 +103,16 @@ const BlockTransactions: React.FC = () => {
|
||||||
}, [txs, pageNumber]);
|
}, [txs, pageNumber]);
|
||||||
const total = useMemo(() => txs?.length ?? 0, [txs]);
|
const total = useMemo(() => txs?.length ?? 0, [txs]);
|
||||||
|
|
||||||
const reverseCache = useENSCache(provider, page);
|
|
||||||
|
|
||||||
document.title = `Block #${blockNumber} Txns | Otterscan`;
|
document.title = `Block #${blockNumber} Txns | Otterscan`;
|
||||||
|
|
||||||
const [feeDisplay, feeDisplayToggler] = useFeeToggler();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StandardFrame>
|
<StandardFrame>
|
||||||
<StandardSubtitle>Transactions</StandardSubtitle>
|
<BlockTransactionHeader blockTag={blockNumber.toNumber()} />
|
||||||
<div className="pb-2 text-sm text-gray-500">
|
<BlockTransactionResults
|
||||||
For Block <BlockLink blockTag={blockNumber.toNumber()} />
|
page={page}
|
||||||
</div>
|
total={total}
|
||||||
<ContentFrame>
|
pageNumber={pageNumber}
|
||||||
<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 ? (
|
|
||||||
<>
|
|
||||||
{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>
|
</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);
|
71
src/Home.tsx
71
src/Home.tsx
|
@ -31,49 +31,38 @@ const Home: React.FC = () => {
|
||||||
document.title = "Home | Otterscan";
|
document.title = "Home | Otterscan";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex m-auto">
|
<div className="m-auto">
|
||||||
<div className="flex flex-col m-auto">
|
<Logo />
|
||||||
<Logo />
|
<form
|
||||||
<form
|
className="flex flex-col"
|
||||||
className="flex flex-col m-auto"
|
onSubmit={handleSubmit}
|
||||||
onSubmit={handleSubmit}
|
autoComplete="off"
|
||||||
autoComplete="off"
|
spellCheck={false}
|
||||||
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
|
Search
|
||||||
className="w-full border rounded focus:outline-none px-2 py-1 mb-10"
|
</button>
|
||||||
type="text"
|
{latestBlock && (
|
||||||
size={50}
|
<NavLink
|
||||||
placeholder="Search by address / txn hash / block number / ENS name"
|
className="mx-auto flex flex-col items-center space-y-1 mt-5 text-sm text-gray-500 hover:text-link-blue"
|
||||||
onChange={handleChange}
|
to={`/block/${latestBlock.number}`}
|
||||||
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
|
<div>Latest block: {ethers.utils.commify(latestBlock.number)}</div>
|
||||||
</button>
|
<Timestamp value={latestBlock.timestamp} />
|
||||||
{latestBlock && (
|
</NavLink>
|
||||||
<NavLink
|
)}
|
||||||
className="mx-auto flex flex-col items-center space-y-1 mt-5 text-sm text-gray-500 hover:text-link-blue"
|
</form>
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
const Logo: React.FC = () => (
|
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
|
<img
|
||||||
className="rounded-full"
|
className="rounded-full"
|
||||||
src="/otter.jpg"
|
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 React, { useState, useRef } from "react";
|
||||||
import { Link, useHistory } from "react-router-dom";
|
import { Link, useHistory } from "react-router-dom";
|
||||||
import useKeyboardShortcut from "use-keyboard-shortcut";
|
import useKeyboardShortcut from "use-keyboard-shortcut";
|
||||||
|
import PriceBox from "./PriceBox";
|
||||||
|
|
||||||
const Title: React.FC = () => {
|
const Title: React.FC = () => {
|
||||||
const [search, setSearch] = useState<string>();
|
const [search, setSearch] = useState<string>();
|
||||||
|
@ -41,27 +42,30 @@ const Title: React.FC = () => {
|
||||||
<span>Otterscan</span>
|
<span>Otterscan</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<form
|
<div className="flex items-baseline space-x-3">
|
||||||
className="flex"
|
<PriceBox />
|
||||||
onSubmit={handleSubmit}
|
<form
|
||||||
autoComplete="off"
|
className="flex"
|
||||||
spellCheck={false}
|
onSubmit={handleSubmit}
|
||||||
>
|
autoComplete="off"
|
||||||
<input
|
spellCheck={false}
|
||||||
className="w-full border-t border-b border-l rounded-l focus:outline-none px-2 py-1 text-sm"
|
|
||||||
type="text"
|
|
||||||
size={60}
|
|
||||||
placeholder='Type "/" to search by address / txn hash / block number / ENS name'
|
|
||||||
onChange={handleChange}
|
|
||||||
ref={searchRef}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="rounded-r border-t border-b border-r bg-gray-100 hover:bg-gray-200 focus:outline-none px-2 py-1 text-sm text-gray-500"
|
|
||||||
type="submit"
|
|
||||||
>
|
>
|
||||||
Search
|
<input
|
||||||
</button>
|
className="w-full border-t border-b border-l rounded-l focus:outline-none px-2 py-1 text-sm"
|
||||||
</form>
|
type="text"
|
||||||
|
size={60}
|
||||||
|
placeholder='Type "/" to search by address / txn hash / block number / ENS name'
|
||||||
|
onChange={handleChange}
|
||||||
|
ref={searchRef}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="rounded-r border-t border-b border-r bg-gray-100 hover:bg-gray-200 focus:outline-none px-2 py-1 text-sm text-gray-500"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faCaretRight } from "@fortawesome/free-solid-svg-icons";
|
import { faCaretRight } from "@fortawesome/free-solid-svg-icons";
|
||||||
import AddressLink from "./components/AddressLink";
|
import AddressHighlighter from "./components/AddressHighlighter";
|
||||||
import AddressOrENSName from "./components/AddressOrENSName";
|
import AddressOrENSName from "./components/AddressOrENSName";
|
||||||
|
import AddressLink from "./components/AddressLink";
|
||||||
import TokenLogo from "./components/TokenLogo";
|
import TokenLogo from "./components/TokenLogo";
|
||||||
import FormattedBalance from "./components/FormattedBalance";
|
import FormattedBalance from "./components/FormattedBalance";
|
||||||
import { TokenMetas, TokenTransfer } from "./types";
|
import { TokenMetas, TokenTransfer } from "./types";
|
||||||
|
@ -20,16 +21,20 @@ const TokenTransferItem: React.FC<TokenTransferItemProps> = ({
|
||||||
<span className="text-gray-500">
|
<span className="text-gray-500">
|
||||||
<FontAwesomeIcon icon={faCaretRight} size="1x" />
|
<FontAwesomeIcon icon={faCaretRight} size="1x" />
|
||||||
</span>
|
</span>
|
||||||
<div className="grid grid-cols-5">
|
<div className="grid grid-cols-5 gap-x-1">
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-1">
|
||||||
<span className="font-bold">From</span>
|
<span className="font-bold">From</span>
|
||||||
<AddressOrENSName address={t.from} />
|
<AddressHighlighter address={t.from}>
|
||||||
|
<AddressOrENSName address={t.from} />
|
||||||
|
</AddressHighlighter>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-1">
|
||||||
<span className="font-bold">To</span>
|
<span className="font-bold">To</span>
|
||||||
<AddressOrENSName address={t.to} />
|
<AddressHighlighter address={t.to}>
|
||||||
|
<AddressOrENSName address={t.to} />
|
||||||
|
</AddressHighlighter>
|
||||||
</div>
|
</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 className="font-bold">For</span>
|
||||||
<span>
|
<span>
|
||||||
<FormattedBalance
|
<FormattedBalance
|
||||||
|
|
|
@ -7,27 +7,15 @@ import React, {
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Route, Switch, useParams } from "react-router-dom";
|
import { Route, Switch, useParams } from "react-router-dom";
|
||||||
import { BigNumber, ethers } from "ethers";
|
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 StandardFrame from "./StandardFrame";
|
||||||
import StandardSubtitle from "./StandardSubtitle";
|
import StandardSubtitle from "./StandardSubtitle";
|
||||||
import Tab from "./components/Tab";
|
import Tab from "./components/Tab";
|
||||||
import ContentFrame from "./ContentFrame";
|
import Details from "./transaction/Details";
|
||||||
import BlockLink from "./components/BlockLink";
|
import Logs from "./transaction/Logs";
|
||||||
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 erc20 from "./erc20.json";
|
import erc20 from "./erc20.json";
|
||||||
import { TokenMetas, TokenTransfer, TransactionData, Transfer } from "./types";
|
import { TokenMetas, TokenTransfer, TransactionData, Transfer } from "./types";
|
||||||
import { RuntimeContext } from "./useRuntime";
|
import { RuntimeContext } from "./useRuntime";
|
||||||
|
import { SelectionContext, useSelection } from "./useSelection";
|
||||||
|
|
||||||
const TRANSFER_TOPIC =
|
const TRANSFER_TOPIC =
|
||||||
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
||||||
|
@ -136,7 +124,7 @@ const Transaction: React.FC = () => {
|
||||||
return false;
|
return false;
|
||||||
}, [txData, transfers]);
|
}, [txData, transfers]);
|
||||||
|
|
||||||
const traceTransfersUsingOtsTrace = useCallback(async () => {
|
const traceTransfers = useCallback(async () => {
|
||||||
if (!provider || !txData) {
|
if (!provider || !txData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -156,14 +144,16 @@ const Transaction: React.FC = () => {
|
||||||
setTransfers(_transfers);
|
setTransfers(_transfers);
|
||||||
}, [provider, txData]);
|
}, [provider, txData]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
traceTransfersUsingOtsTrace();
|
traceTransfers();
|
||||||
}, [traceTransfersUsingOtsTrace]);
|
}, [traceTransfers]);
|
||||||
|
|
||||||
|
const selectionCtx = useSelection();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StandardFrame>
|
<StandardFrame>
|
||||||
<StandardSubtitle>Transaction Details</StandardSubtitle>
|
<StandardSubtitle>Transaction Details</StandardSubtitle>
|
||||||
{txData && (
|
{txData && (
|
||||||
<>
|
<SelectionContext.Provider value={selectionCtx}>
|
||||||
<div className="flex space-x-2 border-l border-r border-t rounded-t-lg bg-white">
|
<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}`}>Overview</Tab>
|
||||||
<Tab href={`/tx/${txhash}/logs`}>
|
<Tab href={`/tx/${txhash}/logs`}>
|
||||||
|
@ -172,196 +162,20 @@ const Transaction: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/tx/:txhash/" exact>
|
<Route path="/tx/:txhash/" exact>
|
||||||
<ContentFrame tabs>
|
<Details
|
||||||
<InfoRow title="Transaction Hash">
|
txData={txData}
|
||||||
<div className="flex items-baseline space-x-2">
|
transfers={transfers}
|
||||||
<span className="font-hash">{txData.transactionHash}</span>
|
sendsEthToMiner={sendsEthToMiner}
|
||||||
<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}
|
|
||||||
txData={txData}
|
|
||||||
transfer={t}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</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>
|
||||||
<Route path="/tx/:txhash/logs/" exact>
|
<Route path="/tx/:txhash/logs/" exact>
|
||||||
<ContentFrame tabs>
|
<Logs txData={txData} />
|
||||||
<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>
|
|
||||||
</Route>
|
</Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
</>
|
</SelectionContext.Provider>
|
||||||
)}
|
)}
|
||||||
</StandardFrame>
|
</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);
|
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) => (
|
{shards.map((s, i) => (
|
||||||
<span
|
<span
|
||||||
|
key={i}
|
||||||
className={`font-hash ${
|
className={`font-hash ${
|
||||||
i % 2 === 0 ? "text-black" : "text-gray-400"
|
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 { ethers } from "ethers";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faAngleRight, faCoins } from "@fortawesome/free-solid-svg-icons";
|
import { faAngleRight, faCoins } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import AddressHighlighter from "./AddressHighlighter";
|
||||||
import AddressLink from "./AddressLink";
|
import AddressLink from "./AddressLink";
|
||||||
import { TransactionData, Transfer } from "../types";
|
import { TransactionData, Transfer } from "../types";
|
||||||
|
|
||||||
|
@ -24,31 +25,39 @@ const InternalTransfer: React.FC<InternalTransferProps> = ({
|
||||||
<FontAwesomeIcon icon={faAngleRight} size="1x" /> TRANSFER
|
<FontAwesomeIcon icon={faAngleRight} size="1x" /> TRANSFER
|
||||||
</span>
|
</span>
|
||||||
<span>{ethers.utils.formatEther(transfer.value)} Ether</span>
|
<span>{ethers.utils.formatEther(transfer.value)} Ether</span>
|
||||||
<span className="text-gray-500">From</span>
|
<div className="flex items-baseline">
|
||||||
<div
|
<span className="text-gray-500">From</span>
|
||||||
className={`flex items-baseline space-x-1 ${
|
<AddressHighlighter address={transfer.from}>
|
||||||
fromMiner ? "rounded px-2 py-1 bg-yellow-100" : ""
|
<div
|
||||||
}`}
|
className={`flex items-baseline space-x-1 ${
|
||||||
>
|
fromMiner ? "rounded px-2 py-1 bg-yellow-100" : ""
|
||||||
{fromMiner && (
|
}`}
|
||||||
<span className="text-yellow-400" title="Miner address">
|
>
|
||||||
<FontAwesomeIcon icon={faCoins} size="1x" />
|
{fromMiner && (
|
||||||
</span>
|
<span className="text-yellow-400" title="Miner address">
|
||||||
)}
|
<FontAwesomeIcon icon={faCoins} size="1x" />
|
||||||
<AddressLink address={transfer.from} />
|
</span>
|
||||||
|
)}
|
||||||
|
<AddressLink address={transfer.from} />
|
||||||
|
</div>
|
||||||
|
</AddressHighlighter>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-gray-500">To</span>
|
<div className="flex items-baseline">
|
||||||
<div
|
<span className="text-gray-500">To</span>
|
||||||
className={`flex items-baseline space-x-1 px-2 py-1 ${
|
<AddressHighlighter address={transfer.to}>
|
||||||
toMiner ? "rounded px-2 py-1 bg-yellow-100" : ""
|
<div
|
||||||
}`}
|
className={`flex items-baseline space-x-1 ${
|
||||||
>
|
toMiner ? "rounded px-2 py-1 bg-yellow-100" : ""
|
||||||
{toMiner && (
|
}`}
|
||||||
<span className="text-yellow-400" title="Miner address">
|
>
|
||||||
<FontAwesomeIcon icon={faCoins} size="1x" />
|
{toMiner && (
|
||||||
</span>
|
<span className="text-yellow-400" title="Miner address">
|
||||||
)}
|
<FontAwesomeIcon icon={faCoins} size="1x" />
|
||||||
<AddressLink address={transfer.to} />
|
</span>
|
||||||
|
)}
|
||||||
|
<AddressLink address={transfer.to} />
|
||||||
|
</div>
|
||||||
|
</AddressHighlighter>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,58 +1,28 @@
|
||||||
import React, { useState, useEffect, useContext } from "react";
|
import React from "react";
|
||||||
import { fourBytesURL } from "../url";
|
import { use4Bytes } from "../use4Bytes";
|
||||||
import { RuntimeContext } from "../useRuntime";
|
|
||||||
|
|
||||||
type MethodNameProps = {
|
type MethodNameProps = {
|
||||||
data: string;
|
data: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MethodName: React.FC<MethodNameProps> = ({ data }) => {
|
const MethodName: React.FC<MethodNameProps> = ({ data }) => {
|
||||||
const runtime = useContext(RuntimeContext);
|
const rawFourBytes = data.slice(0, 10);
|
||||||
|
const methodName = use4Bytes(rawFourBytes);
|
||||||
const [name, setName] = useState<string>();
|
const isSimpleTransfer = data === "0x";
|
||||||
useEffect(() => {
|
const methodTitle = isSimpleTransfer
|
||||||
if (data === "0x") {
|
? "ETH Transfer"
|
||||||
setName("Transfer");
|
: methodName === rawFourBytes
|
||||||
return;
|
? methodName
|
||||||
}
|
: `${methodName} [${rawFourBytes}]`;
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-blue-50 rounded-lg px-3 py-1 min-h-full flex items-baseline text-xs max-w-max">
|
<div
|
||||||
<p className="truncate" title={name}>
|
className={`${
|
||||||
{name}
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1 +1,3 @@
|
||||||
|
export const MIN_API_LEVEL = 1;
|
||||||
|
|
||||||
export const PAGE_SIZE = 25;
|
export const PAGE_SIZE = 25;
|
||||||
|
|
|
@ -15,8 +15,8 @@ const ResultHeader: React.FC<ResultHeaderProps> = ({
|
||||||
<div>Method</div>
|
<div>Method</div>
|
||||||
<div>Block</div>
|
<div>Block</div>
|
||||||
<div>Age</div>
|
<div>Age</div>
|
||||||
<div className="col-span-2">From</div>
|
<div className="col-span-2 ml-1">From</div>
|
||||||
<div className="col-span-2">To</div>
|
<div className="col-span-2 ml-1">To</div>
|
||||||
<div className="col-span-2">Value</div>
|
<div className="col-span-2">Value</div>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
|
|
|
@ -6,6 +6,7 @@ import BlockLink from "../components/BlockLink";
|
||||||
import TransactionLink from "../components/TransactionLink";
|
import TransactionLink from "../components/TransactionLink";
|
||||||
import AddressOrENSName from "../components/AddressOrENSName";
|
import AddressOrENSName from "../components/AddressOrENSName";
|
||||||
import TimestampAge from "../components/TimestampAge";
|
import TimestampAge from "../components/TimestampAge";
|
||||||
|
import AddressHighlighter from "../components/AddressHighlighter";
|
||||||
import TransactionDirection, {
|
import TransactionDirection, {
|
||||||
Direction,
|
Direction,
|
||||||
Flags,
|
Flags,
|
||||||
|
@ -67,14 +68,16 @@ const TransactionItem: React.FC<TransactionItemProps> = ({
|
||||||
</span>
|
</span>
|
||||||
<TimestampAge timestamp={tx.timestamp} />
|
<TimestampAge timestamp={tx.timestamp} />
|
||||||
<span className="col-span-2 flex justify-between items-baseline space-x-2 pr-2">
|
<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 && (
|
{tx.from && (
|
||||||
<AddressOrENSName
|
<AddressHighlighter address={tx.from}>
|
||||||
address={tx.from}
|
<AddressOrENSName
|
||||||
ensName={ensFrom}
|
address={tx.from}
|
||||||
selectedAddress={selectedAddress}
|
ensName={ensFrom}
|
||||||
minerAddress={tx.miner}
|
selectedAddress={selectedAddress}
|
||||||
/>
|
minerAddress={tx.miner}
|
||||||
|
/>
|
||||||
|
</AddressHighlighter>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
|
@ -84,15 +87,19 @@ const TransactionItem: React.FC<TransactionItemProps> = ({
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="col-span-2 truncate" title={tx.to}>
|
<span className="col-span-2 flex items-baseline" title={tx.to}>
|
||||||
{tx.to && (
|
<span className="truncate">
|
||||||
<AddressOrENSName
|
{tx.to && (
|
||||||
address={tx.to}
|
<AddressHighlighter address={tx.to}>
|
||||||
ensName={ensTo}
|
<AddressOrENSName
|
||||||
selectedAddress={selectedAddress}
|
address={tx.to}
|
||||||
minerAddress={tx.miner}
|
ensName={ensTo}
|
||||||
/>
|
selectedAddress={selectedAddress}
|
||||||
)}
|
minerAddress={tx.miner}
|
||||||
|
/>
|
||||||
|
</AddressHighlighter>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="col-span-2 truncate">
|
<span className="col-span-2 truncate">
|
||||||
<TransactionValue value={tx.value} />
|
<TransactionValue value={tx.value} />
|
||||||
|
|
|
@ -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";
|
import { ethers, BigNumber } from "ethers";
|
||||||
|
|
||||||
|
export enum ConnectionStatus {
|
||||||
|
CONNECTING,
|
||||||
|
NOT_ETH_NODE,
|
||||||
|
NOT_ERIGON,
|
||||||
|
NOT_OTTERSCAN_PATCHED,
|
||||||
|
CONNECTED,
|
||||||
|
}
|
||||||
|
|
||||||
export type ProcessedTransaction = {
|
export type ProcessedTransaction = {
|
||||||
blockNumber: number;
|
blockNumber: number;
|
||||||
timestamp: 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 { 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 DEFAULT_ERIGON_URL = "http://127.0.0.1:8545";
|
||||||
|
|
||||||
export const useProvider = (
|
export const useProvider = (
|
||||||
erigonURL?: string
|
erigonURL?: string
|
||||||
): ethers.providers.JsonRpcProvider | undefined => {
|
): [ConnectionStatus, ethers.providers.JsonRpcProvider | undefined] => {
|
||||||
if (erigonURL === "") {
|
const [connStatus, setConnStatus] = useState<ConnectionStatus>(
|
||||||
console.info(`Using default erigon URL: ${DEFAULT_ERIGON_URL}`);
|
ConnectionStatus.CONNECTING
|
||||||
erigonURL = DEFAULT_ERIGON_URL;
|
);
|
||||||
} else {
|
|
||||||
console.log(`Using configured erigon URL: ${erigonURL}`);
|
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(
|
const [provider, setProvider] = useState<
|
||||||
() => new ethers.providers.JsonRpcProvider(erigonURL, "mainnet"),
|
ethers.providers.JsonRpcProvider | undefined
|
||||||
[erigonURL]
|
>();
|
||||||
);
|
useEffect(() => {
|
||||||
if (!erigonURL) {
|
if (erigonURL === undefined) {
|
||||||
return undefined;
|
setConnStatus(ConnectionStatus.NOT_ETH_NODE);
|
||||||
}
|
setProvider(undefined);
|
||||||
return provider;
|
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 { ethers } from "ethers";
|
||||||
import { OtterscanConfig, useConfig } from "./useConfig";
|
import { OtterscanConfig, useConfig } from "./useConfig";
|
||||||
import { useProvider } from "./useProvider";
|
import { useProvider } from "./useProvider";
|
||||||
|
import { ConnectionStatus } from "./types";
|
||||||
|
|
||||||
export type OtterscanRuntime = {
|
export type OtterscanRuntime = {
|
||||||
config?: OtterscanConfig;
|
config?: OtterscanConfig;
|
||||||
|
connStatus: ConnectionStatus;
|
||||||
provider?: ethers.providers.JsonRpcProvider;
|
provider?: ethers.providers.JsonRpcProvider;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useRuntime = (): OtterscanRuntime => {
|
export const useRuntime = (): OtterscanRuntime => {
|
||||||
const [configOK, config] = useConfig();
|
const [configOK, config] = useConfig();
|
||||||
const provider = useProvider(configOK ? config?.erigonURL : undefined);
|
const [connStatus, provider] = useProvider(
|
||||||
|
configOK ? config?.erigonURL : undefined
|
||||||
|
);
|
||||||
|
|
||||||
const runtime = useMemo(
|
const runtime = useMemo(
|
||||||
(): OtterscanRuntime => ({ config, provider }),
|
(): OtterscanRuntime => ({ config, connStatus, provider }),
|
||||||
[config, provider]
|
[config, connStatus, provider]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!configOK) {
|
if (!configOK) {
|
||||||
return {};
|
return { connStatus: ConnectionStatus.CONNECTING };
|
||||||
}
|
}
|
||||||
return runtime;
|
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 = {
|
module.exports = {
|
||||||
purge: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"],
|
purge: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"],
|
||||||
darkMode: false, // or 'media' or 'class'
|
darkMode: false, // or 'media' or 'class'
|
||||||
|
@ -6,6 +8,7 @@ module.exports = {
|
||||||
colors: {
|
colors: {
|
||||||
"link-blue": "#3498db",
|
"link-blue": "#3498db",
|
||||||
"link-blue-hover": "#0468ab",
|
"link-blue-hover": "#0468ab",
|
||||||
|
orange: colors.orange,
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ["Roboto"],
|
sans: ["Roboto"],
|
||||||
|
|
Loading…
Reference in New Issue