Merge branch 'release/v2021.07.03-otterscan'

This commit is contained in:
Willian Mitsuda 2021-07-15 16:31:32 -03:00
commit 21a2d0a907
35 changed files with 1034 additions and 479 deletions

View File

@ -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 /

View File

@ -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.

View File

@ -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:

View File

@ -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;

11
package-lock.json generated
View File

@ -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": {

View File

@ -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": [

View File

@ -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 />
)}

View File

@ -3,6 +3,9 @@ import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import Home from "./Home";
import Search from "./Search";
import Title from "./Title";
import ConnectionErrorPanel from "./ConnectionErrorPanel";
import Footer from "./Footer";
import { ConnectionStatus } from "./types";
import { RuntimeContext, useRuntime } from "./useRuntime";
const Block = React.lazy(() => import("./Block"));
@ -15,33 +18,45 @@ const App = () => {
return (
<Suspense fallback={<>LOADING</>}>
<RuntimeContext.Provider value={runtime}>
<Router>
<Switch>
<Route path="/" exact>
<Home />
</Route>
<Route path="/search" exact>
<Search />
</Route>
<Route>
<Title />
<Route path="/block/:blockNumberOrHash" exact>
<Block />
</Route>
<Route path="/block/:blockNumber/txs" exact>
<BlockTransactions />
</Route>
<Route path="/tx/:txhash">
<Transaction />
</Route>
<Route path="/address/:addressOrName/:direction?">
<AddressTransactions />
</Route>
</Route>
</Switch>
</Router>
</RuntimeContext.Provider>
{runtime.connStatus !== ConnectionStatus.CONNECTED ? (
<ConnectionErrorPanel
connStatus={runtime.connStatus}
config={runtime.config}
/>
) : (
<RuntimeContext.Provider value={runtime}>
<div className="h-screen flex flex-col">
<Router>
<Switch>
<Route path="/" exact>
<Home />
</Route>
<Route path="/search" exact>
<Search />
</Route>
<Route>
<div className="mb-auto">
<Title />
<Route path="/block/:blockNumberOrHash" exact>
<Block />
</Route>
<Route path="/block/:blockNumber/txs" exact>
<BlockTransactions />
</Route>
<Route path="/tx/:txhash">
<Transaction />
</Route>
<Route path="/address/:addressOrName/:direction?">
<AddressTransactions />
</Route>
</div>
</Route>
</Switch>
</Router>
<Footer />
</div>
</RuntimeContext.Provider>
)}
</Suspense>
);
};

View File

@ -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);

View File

@ -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);

View File

@ -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}
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>
<BlockTransactionHeader blockTag={blockNumber.toNumber()} />
<BlockTransactionResults
page={page}
total={total}
pageNumber={pageNumber}
/>
</StandardFrame>
);
};

View File

@ -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);

18
src/Footer.tsx Normal file
View File

@ -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);

View File

@ -31,49 +31,38 @@ const Home: React.FC = () => {
document.title = "Home | Otterscan";
return (
<div className="h-screen flex m-auto">
<div className="flex flex-col m-auto">
<Logo />
<form
className="flex flex-col m-auto"
onSubmit={handleSubmit}
autoComplete="off"
spellCheck={false}
<div className="m-auto">
<Logo />
<form
className="flex flex-col"
onSubmit={handleSubmit}
autoComplete="off"
spellCheck={false}
>
<input
className="w-full border rounded focus:outline-none px-2 py-1 mb-10"
type="text"
size={50}
placeholder="Search by address / txn hash / block number / ENS name"
onChange={handleChange}
autoFocus
></input>
<button
className="mx-auto px-3 py-1 mb-10 rounded bg-gray-100 hover:bg-gray-200 focus:outline-none"
type="submit"
>
<input
className="w-full border rounded focus:outline-none px-2 py-1 mb-10"
type="text"
size={50}
placeholder="Search by address / txn hash / block number / ENS name"
onChange={handleChange}
autoFocus
></input>
<button
className="mx-auto px-3 py-1 mb-10 rounded bg-gray-100 hover:bg-gray-200 focus:outline-none"
type="submit"
Search
</button>
{latestBlock && (
<NavLink
className="mx-auto flex flex-col items-center space-y-1 mt-5 text-sm text-gray-500 hover:text-link-blue"
to={`/block/${latestBlock.number}`}
>
Search
</button>
{latestBlock && (
<NavLink
className="mx-auto flex flex-col items-center space-y-1 mt-5 text-sm text-gray-500 hover:text-link-blue"
to={`/block/${latestBlock.number}`}
>
<div>
Latest block: {ethers.utils.commify(latestBlock.number)}
</div>
<Timestamp value={latestBlock.timestamp} />
</NavLink>
)}
<span className="mx-auto mt-5 text-xs text-gray-500">
{provider ? (
<>Using Erigon node at {provider.connection.url}</>
) : (
<>Waiting for the provider...</>
)}
</span>
</form>
</div>
<div>Latest block: {ethers.utils.commify(latestBlock.number)}</div>
<Timestamp value={latestBlock.timestamp} />
</NavLink>
)}
</form>
</div>
);
};

View File

@ -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"

109
src/PriceBox.tsx Normal file
View File

@ -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);

View File

@ -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,27 +42,30 @@ const Title: React.FC = () => {
<span>Otterscan</span>
</div>
</Link>
<form
className="flex"
onSubmit={handleSubmit}
autoComplete="off"
spellCheck={false}
>
<input
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"
<div className="flex items-baseline space-x-3">
<PriceBox />
<form
className="flex"
onSubmit={handleSubmit}
autoComplete="off"
spellCheck={false}
>
Search
</button>
</form>
<input
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
</button>
</form>
</div>
</div>
);
};

View File

@ -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>
<AddressOrENSName address={t.from} />
<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>
<AddressOrENSName address={t.to} />
<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

View File

@ -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}
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>
<Details
txData={txData}
transfers={transfers}
sendsEthToMiner={sendsEthToMiner}
/>
</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);

View File

@ -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);

View File

@ -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"
}`}

View File

@ -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);

View File

@ -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,31 +25,39 @@ const InternalTransfer: React.FC<InternalTransferProps> = ({
<FontAwesomeIcon icon={faAngleRight} size="1x" /> TRANSFER
</span>
<span>{ethers.utils.formatEther(transfer.value)} Ether</span>
<span className="text-gray-500">From</span>
<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" />
</span>
)}
<AddressLink address={transfer.from} />
<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" : ""
}`}
>
{fromMiner && (
<span className="text-yellow-400" title="Miner address">
<FontAwesomeIcon icon={faCoins} size="1x" />
</span>
)}
<AddressLink address={transfer.from} />
</div>
</AddressHighlighter>
</div>
<span className="text-gray-500">To</span>
<div
className={`flex items-baseline space-x-1 px-2 py-1 ${
toMiner ? "rounded px-2 py-1 bg-yellow-100" : ""
}`}
>
{toMiner && (
<span className="text-yellow-400" title="Miner address">
<FontAwesomeIcon icon={faCoins} size="1x" />
</span>
)}
<AddressLink address={transfer.to} />
<div className="flex items-baseline">
<span className="text-gray-500">To</span>
<AddressHighlighter address={transfer.to}>
<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" />
</span>
)}
<AddressLink address={transfer.to} />
</div>
</AddressHighlighter>
</div>
</div>
);

View File

@ -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>
);

View File

@ -1 +1,3 @@
export const MIN_API_LEVEL = 1;
export const PAGE_SIZE = 25;

View File

@ -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

View File

@ -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 && (
<AddressOrENSName
address={tx.from}
ensName={ensFrom}
selectedAddress={selectedAddress}
minerAddress={tx.miner}
/>
<AddressHighlighter address={tx.from}>
<AddressOrENSName
address={tx.from}
ensName={ensFrom}
selectedAddress={selectedAddress}
minerAddress={tx.miner}
/>
</AddressHighlighter>
)}
</span>
<span>
@ -84,15 +87,19 @@ const TransactionItem: React.FC<TransactionItemProps> = ({
/>
</span>
</span>
<span className="col-span-2 truncate" title={tx.to}>
{tx.to && (
<AddressOrENSName
address={tx.to}
ensName={ensTo}
selectedAddress={selectedAddress}
minerAddress={tx.miner}
/>
)}
<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} />

144
src/transaction/Details.tsx Normal file
View File

@ -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);

57
src/transaction/Logs.tsx Normal file
View File

@ -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);

View File

@ -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;

70
src/use4Bytes.ts Normal file
View File

@ -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;
};

View File

@ -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 => {
if (erigonURL === "") {
console.info(`Using default erigon URL: ${DEFAULT_ERIGON_URL}`);
erigonURL = DEFAULT_ERIGON_URL;
} else {
console.log(`Using configured erigon URL: ${erigonURL}`);
): [ConnectionStatus, ethers.providers.JsonRpcProvider | undefined] => {
const [connStatus, setConnStatus] = useState<ConnectionStatus>(
ConnectionStatus.CONNECTING
);
if (erigonURL !== undefined) {
if (erigonURL === "") {
console.info(`Using default erigon URL: ${DEFAULT_ERIGON_URL}`);
erigonURL = DEFAULT_ERIGON_URL;
} else {
console.log(`Using configured erigon URL: ${erigonURL}`);
}
}
const provider = useMemo(
() => new ethers.providers.JsonRpcProvider(erigonURL, "mainnet"),
[erigonURL]
);
if (!erigonURL) {
return undefined;
}
return provider;
const [provider, setProvider] = useState<
ethers.providers.JsonRpcProvider | undefined
>();
useEffect(() => {
if (erigonURL === undefined) {
setConnStatus(ConnectionStatus.NOT_ETH_NODE);
setProvider(undefined);
return;
}
setConnStatus(ConnectionStatus.CONNECTING);
const tryToConnect = async () => {
const provider = new ethers.providers.JsonRpcProvider(
erigonURL,
"mainnet"
);
// Check if it is at least a regular ETH node
let blockNumber: number = 0;
try {
blockNumber = await provider.getBlockNumber();
} catch (err) {
console.log(err);
setConnStatus(ConnectionStatus.NOT_ETH_NODE);
setProvider(undefined);
return;
}
// Check if it is an Erigon node by probing a lightweight method
try {
await provider.send("erigon_getHeaderByNumber", [blockNumber]);
} catch (err) {
console.log(err);
setConnStatus(ConnectionStatus.NOT_ERIGON);
setProvider(undefined);
return;
}
// Check if it has Otterscan patches by probing a lightweight method
try {
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];
};

View File

@ -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;
};

23
src/useSelection.ts Normal file
View File

@ -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;
};

View File

@ -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"],