Initial public alpha version

This commit is contained in:
Willian Mitsuda 2021-07-01 15:21:40 -03:00
parent 4cf8252e86
commit 8b54cfb71b
63 changed files with 34846 additions and 0 deletions

6
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Willian Mitsuda
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

164
README.md Normal file
View File

@ -0,0 +1,164 @@
# Otterscan
An open-source, fast, local, laptop-friendly Ethereum block explorer.
## What?
This is an Ethereum block explorer designed to be run locally with an archive node companion, more specifically, with [Erigon](https://github.com/ledgerwatch/erigon).
This approach brings many advantages, as follows.
### Privacy
You are querying your own node, so you are not sending your IP address or queries to an external third-party node.
### Fast
Since you are querying your local archive node, everything is fast, no network roundtrips are necessary.
### Actually, very fast
This software was designed to be a companion of Erigon, a blazingly fast archive node.
### Really, it is even faster
The standard web3 jsonrpc methods are quite verbose and generic requiring many calls to gather many pieces of information at client side.
We've implemented some custom methods at rpcdaemon level, less information is needed to be json-marshalled and transmitted over network.
## Why?
Current offerings are either closed source or lack many features the most famous Ethereum block explorer has, or simply have high requirements like having an archive node + additional indexers.
Otterscan requires only a patched Erigon installation + running Otterscan itself (a simple React app), which makes it a laptop-friendly block explorer.
## Why the name?
3 reasons:
- It is heavily based on Erigon, whose mascot is an otter (Erigon, the otter), think about an otter scanning your transactions inside blocks.
- It is an homage to the most famous and used ethereum block explorer.
- The author loves wordplays and bad puns.
## It looks familiar...
The UI was intentionally made very similar to the most popular Ethereum block explorer so users do not strugle trying to find where the information is.
However, you will see that we made many UI improvements.
## Install instructions
This software is currently available as compile-only form.
It depends heavily on a working Erigon installation with Otterscan patches applied, so let's begin with it first.
### Install Erigon
You will need an Erigon installation with Otterscan patches. Since setting up an Erigon environment itself can take some work, make sure to follow their instructions and have a working archive node before continuing.
My personal experience: at the moment of this writing (~block 12,700,000), setting up an archive node takes over 5-6 days and ~1.3 TB of SSD.
They have weekly stable releases, make sure you are running on of them, not development ones.
### Install Otterscan patches on top of Erigon
Add our forked Erigon git tree as an additional remote and checkout the corresponding branch.
```
git remote add otterscan git@github.com:wmitsuda/erigon.git
```
Checkout the branch corresponding to the Erigon's stable tag you are using, or the `otterscan-develop` branch to the current development branch (be sure to check from which tag it is branched from to be sure it is a compatible branch).
```
git checkout otterscan-develop
```
Build the patched `rpcdaemon` binary.
```
make rpcdaemon
```
Run it paying attention to enable the `erigon`, `ots`, `eth` apis to whatever cli options you are using to start `rpcdaemon`.
`ots` stands for Otterscan and it is the namespace we use for our own custom APIs.
```
./build/bin/rpcdaemon --http.api "eth,erigon,ots,<your-other-apis>" --private.api.addr 127.0.0.1:9090 --chaindata <erigon-chaindata-dir> --http.corsdomain "*"
```
Be sure to include both `--private.api.addr` and `--chaindata` parameter so you run it in dual mode, otherwise the performance will be much worse.
Also pay attention to the `--http.corsdomain` parameter, CORS is required for the browser to call the node directly.
Now you should have an Erigon node with Otterscan jsonrpc APIs enabled, running in dual mode with CORS enabled.
### Clone Otterscan repository and build the project
Make sure you have a working node 12/npm installation.
Clone Otterscan repo and its submodules. For now, only the default `develop` branch is available (it is alpha...).
```
git clone --recurse-submodules git@github.com:wmitsuda/otterscan.git
cd otterscan
npm install
npm run build
```
### Run it from the source
First, as brief explanation about the app:
- The app itself is a simple React app which will be run locally and communicates with your Erigon node.
- The app makes use of two sources of external databases for cosmetic reasons:
- Token icons come from the trustwallet public assets repository.
- Method names come from the 4bytes database.
- These 2 repositories were cloned as submodules and are made available to the app through separate http services. They are accessed at browser level and are optional, if the service is down the result will be broken icons and default 4bytes method selectors instead of human-readable names.
These instructions are subjected to changes in future for the sake of simplification.
Open a new terminal and start the 4bytes method decoding service:
```
npm run serve-4bytes
```
Open another terminal and start the trustwallet assets service:
```
npm run serve-trustwallet-assets
```
In another terminal start the Otterscan app:
```
npm run serve
```
Otterscan should now be running at http://localhost:5000/.
You can make sure it is working correctly if the homepage is able to show the latest block/timestamp your Erigon node is at just bellow the search button.
## Kudos
To the [Geth](https://geth.ethereum.org/) team whose code Erigon is based on.
To the [Erigon](https://github.com/ledgerwatch/erigon) team that made possible for regular humans to run an archive node in a retail laptop. Also, they have been very helpful explaining Erigon's internals which made possible the modifications Otterscan requires.
To the [mdbx](https://github.com/erthink/libmdbx) team which is the blazingly fast database that empowers Erigon.
To [Trust Wallet](https://github.com/trustwallet/assets) who sponsor and make available their icons under a permissive license.
To the owners of the [4bytes repository](https://github.com/ethereum-lists/4bytes) that we import and use to translate and method selector to human-friendly strings.
## Future
Erigon keeps evolving at a fast pace, with weekly releases, sometimes with (necessary) breaking changes.
This project intends to keep following their progress and mantaining compatibility as the availability of the author permits.
Erigon itself is alpha, so I consider this software is also in alpha state, however it is pretty usable.
Also there is room for many improvements that are not possible in the current centralized, closed source block explorer offerings and the author of this software would like to have.

11
craco.config.js Normal file
View File

@ -0,0 +1,11 @@
// craco.config.js
module.exports = {
style: {
postcss: {
plugins: [
require('tailwindcss'),
require('autoprefixer'),
],
},
},
}

32111
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

72
package.json Normal file
View File

@ -0,0 +1,72 @@
{
"name": "otterscan",
"version": "0.1.0",
"private": true,
"license": "MIT",
"dependencies": {
"@craco/craco": "^6.1.2",
"@fontsource/fira-code": "^4.4.5",
"@fontsource/roboto": "^4.4.5",
"@fontsource/roboto-mono": "^4.4.5",
"@fontsource/space-grotesk": "^4.4.5",
"@fortawesome/fontawesome-svg-core": "^1.2.35",
"@fortawesome/free-brands-svg-icons": "^5.15.3",
"@fortawesome/free-regular-svg-icons": "^5.15.3",
"@fortawesome/free-solid-svg-icons": "^5.15.3",
"@fortawesome/react-fontawesome": "^0.1.14",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/jest": "^26.0.15",
"@types/node": "^12.0.0",
"@types/react": "^17.0.0",
"@types/react-blockies": "^1.4.0",
"@types/react-dom": "^17.0.8",
"@types/react-router-dom": "^5.1.7",
"ethers": "^5.4.0",
"query-string": "^7.0.1",
"react": "^17.0.2",
"react-blockies": "^1.4.1",
"react-dom": "^17.0.2",
"react-error-boundary": "^3.1.3",
"react-image": "^4.0.3",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"serve": "^12.0.0",
"typescript": "^4.3.5",
"use-keyboard-shortcut": "^1.0.6",
"web-vitals": "^1.0.1"
},
"scripts": {
"start": "craco start",
"build": "craco build",
"test": "craco test",
"eject": "react-scripts eject",
"serve-4bytes": "serve -p 3001 -C -c serve-4bytes.json",
"serve-trustwallet-assets": "serve -p 3002 -C -c serve-trustwallet-assets.json",
"serve": "serve -s build"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"autoprefixer": "^9.8.6",
"postcss": "^7.0.36",
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4"
}
}

2
public/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
signatures

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

43
public/index.html Normal file
View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Erigon based block explorer" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Otterscan</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

15
public/manifest.json Normal file
View File

@ -0,0 +1,15 @@
{
"short_name": "Otterscan",
"name": "Otterscan",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

BIN
public/otter.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

3
public/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

15
serve-4bytes.json Normal file
View File

@ -0,0 +1,15 @@
{
"public": "4bytes/signatures",
"headers": [
{
"source": "**",
"headers": [
{
"key": "Cache-Control",
"value": "max-age=600"
}
]
}
],
"directoryListing": false
}

View File

@ -0,0 +1,15 @@
{
"public": "trustwallet/blockchains/ethereum/assets",
"headers": [
{
"source": "**",
"headers": [
{
"key": "Cache-Control",
"value": "max-age=600"
}
]
}
],
"directoryListing": false
}

178
src/AddressTransactions.tsx Normal file
View File

@ -0,0 +1,178 @@
import React, { useState, useEffect, useMemo } from "react";
import { useParams, useLocation, useHistory } from "react-router-dom";
import queryString from "query-string";
import Blockies from "react-blockies";
import StandardFrame from "./StandardFrame";
import StandardSubtitle from "./StandardSubtitle";
import Copy from "./components/Copy";
import ContentFrame from "./ContentFrame";
import UndefinedPageControl from "./search/UndefinedPageControl";
import ResultHeader from "./search/ResultHeader";
import PendingResults from "./search/PendingResults";
import TransactionItem from "./search/TransactionItem";
import { SearchController } from "./search/search";
import { useFeeToggler } from "./search/useFeeToggler";
import { ethers } from "ethers";
type BlockParams = {
address: string;
direction?: string;
};
type PageParams = {
p?: number;
};
const AddressTransactions: React.FC = () => {
const params = useParams<BlockParams>();
const location = useLocation<PageParams>();
const history = useHistory();
const qs = queryString.parse(location.search);
let hash: string | undefined;
if (qs.h) {
hash = qs.h as string;
}
// Normalize to checksummed address
const checksummedAddress = useMemo(
() => ethers.utils.getAddress(params.address),
[params.address]
);
if (params.address !== checksummedAddress) {
console.log("NORMALIZE");
history.replace(
`/address/${checksummedAddress}${
params.direction ? "/" + params.direction : ""
}${location.search}`
);
}
const [controller, setController] = useState<SearchController>();
useEffect(() => {
const readFirstPage = async () => {
const _controller = await SearchController.firstPage(checksummedAddress);
setController(_controller);
};
const readMiddlePage = async (next: boolean) => {
const _controller = await SearchController.middlePage(
checksummedAddress,
hash!,
next
);
setController(_controller);
};
const readLastPage = async () => {
const _controller = await SearchController.lastPage(checksummedAddress);
setController(_controller);
};
const prevPage = async () => {
const _controller = await controller!.prevPage(hash!);
setController(_controller);
};
const nextPage = async () => {
const _controller = await controller!.nextPage(hash!);
setController(_controller);
};
// Page load from scratch
if (params.direction === "first" || params.direction === undefined) {
if (!controller?.isFirst || controller.address !== checksummedAddress) {
readFirstPage();
}
} else if (params.direction === "prev") {
if (controller && controller.address === checksummedAddress) {
prevPage();
} else {
readMiddlePage(false);
}
} else if (params.direction === "next") {
if (controller && controller.address === checksummedAddress) {
nextPage();
} else {
readMiddlePage(true);
}
} else if (params.direction === "last") {
if (!controller?.isLast || controller.address !== checksummedAddress) {
readLastPage();
}
}
}, [checksummedAddress, params.direction, hash, controller]);
const page = useMemo(() => controller?.getPage(), [controller]);
document.title = `Address ${params.address} | Otterscan`;
const [feeDisplay, feeDisplayToggler] = useFeeToggler();
return (
<StandardFrame>
<StandardSubtitle>
<div className="flex space-x-2 items-baseline">
<Blockies
className="self-center rounded"
seed={params.address.toLowerCase()}
scale={3}
/>
<span>Address</span>
<span className="font-address text-base text-gray-500">
{params.address}
</span>
<Copy value={params.address} rounded />
</div>
</StandardSubtitle>
<ContentFrame>
<div className="flex justify-between items-baseline py-3">
<div className="text-sm text-gray-500">
{page === undefined ? (
<>Waiting for search results...</>
) : (
<>{page.length} transactions on this page</>
)}
</div>
<UndefinedPageControl
address={params.address}
isFirst={controller?.isFirst}
isLast={controller?.isLast}
prevHash={page ? page[0].hash : ""}
nextHash={page ? page[page.length - 1].hash : ""}
disabled={controller === undefined}
/>
</div>
<ResultHeader
feeDisplay={feeDisplay}
feeDisplayToggler={feeDisplayToggler}
/>
{controller ? (
<>
{controller.getPage().map((tx) => (
<TransactionItem
key={tx.hash}
tx={tx}
selectedAddress={params.address}
feeDisplay={feeDisplay}
/>
))}
<div className="flex justify-between items-baseline py-3">
<div className="text-sm text-gray-500">
{page !== undefined && (
<>{page.length} transactions on this page</>
)}
</div>
<UndefinedPageControl
address={params.address}
isFirst={controller.isFirst}
isLast={controller.isLast}
prevHash={page ? page[0].hash : ""}
nextHash={page ? page[page.length - 1].hash : ""}
/>
</div>
</>
) : (
<PendingResults />
)}
</ContentFrame>
</StandardFrame>
);
};
export default React.memo(AddressTransactions);

42
src/App.tsx Normal file
View File

@ -0,0 +1,42 @@
import React, { Suspense } from "react";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import Home from "./Home";
import Search from "./Search";
import Title from "./Title";
const Block = React.lazy(() => import("./Block"));
const BlockTransactions = React.lazy(() => import("./BlockTransactions"));
const AddressTransactions = React.lazy(() => import("./AddressTransactions"));
const Transaction = React.lazy(() => import("./Transaction"));
const App = () => (
<Suspense fallback={<>LOADING</>}>
<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/:address/:direction?">
<AddressTransactions />
</Route>
</Route>
</Switch>
</Router>
</Suspense>
);
export default React.memo(App);

158
src/Block.tsx Normal file
View File

@ -0,0 +1,158 @@
import React, { useEffect, useState, useMemo } from "react";
import { useParams, NavLink } from "react-router-dom";
import { ethers, BigNumber } from "ethers";
import { provider } from "./ethersconfig";
import StandardFrame from "./StandardFrame";
import StandardSubtitle from "./StandardSubtitle";
import ContentFrame from "./ContentFrame";
import Timestamp from "./components/Timestamp";
import GasValue from "./components/GasValue";
import BlockLink from "./components/BlockLink";
import AddressLink from "./components/AddressLink";
type BlockParams = {
blockNumberOrHash: string;
};
interface ExtendedBlock extends ethers.providers.Block {
size: number;
sha3Uncles: string;
stateRoot: string;
totalDifficulty: BigNumber;
}
const Block: React.FC = () => {
const params = useParams<BlockParams>();
const [block, setBlock] = useState<ExtendedBlock>();
useEffect(() => {
const readBlock = async () => {
let _rawBlock: any;
if (ethers.utils.isHexString(params.blockNumberOrHash, 32)) {
_rawBlock = await provider.send("eth_getBlockByHash", [
params.blockNumberOrHash,
false,
]);
} else {
_rawBlock = await provider.send("eth_getBlockByNumber", [
params.blockNumberOrHash,
false,
]);
}
const _block = provider.formatter.block(_rawBlock);
const extBlock: ExtendedBlock = {
size: provider.formatter.number(_rawBlock.size),
sha3Uncles: _rawBlock.sha3Uncles,
stateRoot: _rawBlock.stateRoot,
totalDifficulty: provider.formatter.bigNumber(
_rawBlock.totalDifficulty
),
..._block,
};
setBlock(extBlock);
};
readBlock();
}, [params.blockNumberOrHash]);
useEffect(() => {
if (block) {
document.title = `Block #${block.number} | Otterscan`;
}
}, [block]);
const extraStr = useMemo(() => {
try {
return block && ethers.utils.toUtf8String(block.extraData);
} catch (err) {
console.error(err);
}
}, [block]);
return (
<StandardFrame>
<StandardSubtitle>
Block{" "}
<span className="text-base text-gray-500">
#{params.blockNumberOrHash}
</span>
</StandardSubtitle>
{block && (
<ContentFrame>
<InfoRow title="Block Height">
<span className="font-bold">
{ethers.utils.commify(block.number)}
</span>
</InfoRow>
<InfoRow title="Timestamp">
<Timestamp value={block.timestamp} />
</InfoRow>
<InfoRow title="Transactions">
<NavLink
className="bg-link-blue bg-opacity-10 text-link-blue hover:bg-opacity-100 hover:text-white rounded-lg px-2 py-1 text-xs"
to={`/block/${block.number}/txs`}
>
{block.transactions.length} transactions
</NavLink>{" "}
in this block
</InfoRow>
<InfoRow title="Mined by">
<div className="flex">
<AddressLink address={block.miner} />
</div>
</InfoRow>
<InfoRow title="Block Reward">N/A</InfoRow>
<InfoRow title="Uncles Reward">N/A</InfoRow>
<InfoRow title="Difficult">
{ethers.utils.commify(block.difficulty)}
</InfoRow>
<InfoRow title="Total Difficult">
{ethers.utils.commify(block.totalDifficulty.toString())}
</InfoRow>
<InfoRow title="Size">
{ethers.utils.commify(block.size)} bytes
</InfoRow>
<InfoRow title="Gas Used">
<GasValue value={block.gasUsed} />
</InfoRow>
<InfoRow title="Gas Limit">
<GasValue value={block.gasLimit} />
</InfoRow>
<InfoRow title="Extra Data">
{extraStr} (Hex:{" "}
<span className="font-data">{block.extraData}</span>)
</InfoRow>
<InfoRow title="Ether Price">N/A</InfoRow>
<InfoRow title="Hash">
<span className="font-hash">{block.hash}</span>
</InfoRow>
<InfoRow title="Parent Hash">
<BlockLink blockTag={block.parentHash} />
</InfoRow>
<InfoRow title="Sha3Uncles">
<span className="font-hash">{block.sha3Uncles}</span>
</InfoRow>
<InfoRow title="StateRoot">
<span className="font-hash">{block.stateRoot}</span>
</InfoRow>
<InfoRow title="Nonce">
<span className="font-data">{block.nonce}</span>
</InfoRow>
</ContentFrame>
)}
</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(Block);

136
src/BlockTransactions.tsx Normal file
View File

@ -0,0 +1,136 @@
import React, { useEffect, useState, useMemo } from "react";
import { useParams, useLocation } from "react-router";
import { ethers } from "ethers";
import queryString from "query-string";
import { provider } from "./ethersconfig";
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 { ProcessedTransaction } from "./types";
import { PAGE_SIZE } from "./params";
import { useFeeToggler } from "./search/useFeeToggler";
type BlockParams = {
blockNumber: string;
};
type PageParams = {
p?: number;
};
const BlockTransactions: React.FC = () => {
const params = useParams<BlockParams>();
const location = useLocation<PageParams>();
const qs = queryString.parse(location.search);
let pageNumber = 1;
if (qs.p) {
try {
pageNumber = parseInt(qs.p as string);
} catch (err) {}
}
const blockNumber = useMemo(
() => ethers.BigNumber.from(params.blockNumber),
[params.blockNumber]
);
const [txs, setTxs] = useState<ProcessedTransaction[]>();
useEffect(() => {
const readBlock = async () => {
const [_block, _receipts] = await Promise.all([
provider.getBlockWithTransactions(blockNumber.toNumber()),
provider.send("eth_getBlockReceipts", [blockNumber.toNumber()]),
]);
document.title = `Block #${_block.number} Transactions | Otterscan`;
setTxs(
_block.transactions
.map((t, i) => {
return {
blockNumber: blockNumber.toNumber(),
timestamp: _block.timestamp,
idx: i,
hash: t.hash,
from: t.from,
to: t.to,
value: t.value,
fee: t.gasLimit.mul(t.gasPrice!),
gasPrice: t.gasPrice!,
data: t.data,
status: _receipts[i].status,
};
})
.reverse()
);
};
readBlock();
}, [blockNumber]);
const page = useMemo(() => {
if (!txs) {
return undefined;
}
const pageStart = (pageNumber - 1) * PAGE_SIZE;
return txs.slice(pageStart, pageStart + PAGE_SIZE);
}, [txs, pageNumber]);
const total = useMemo(() => txs?.length ?? 0, [txs]);
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} 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>
);
};
export default React.memo(BlockTransactions);

15
src/ContentFrame.tsx Normal file
View File

@ -0,0 +1,15 @@
import React from "react";
type ContentFrameProps = {
tabs?: boolean;
};
const ContentFrame: React.FC<ContentFrameProps> = ({ tabs, children }) => {
return tabs ? (
<div className="divide-y border rounded-b-lg px-3 bg-white">{children}</div>
) : (
<div className="divide-y border rounded-lg px-3 bg-white">{children}</div>
);
};
export default ContentFrame;

93
src/Home.tsx Normal file
View File

@ -0,0 +1,93 @@
import React, { useState, useEffect } from "react";
import { NavLink, useHistory } from "react-router-dom";
import { ethers } from "ethers";
import Logo from "./Logo";
import Timestamp from "./components/Timestamp";
import { provider } from "./ethersconfig";
const Home: React.FC = () => {
const [search, setSearch] = useState<string>();
const [canSubmit, setCanSubmit] = useState<boolean>(false);
const history = useHistory();
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
setCanSubmit(e.target.value.trim().length > 0);
setSearch(e.target.value.trim());
};
const handleSubmit: React.FormEventHandler<HTMLFormElement> = (e) => {
e.preventDefault();
if (!canSubmit) {
return;
}
history.push(`/search?q=${search}`);
};
const [latestBlock, setLatestBlock] = useState<ethers.providers.Block>();
useEffect(() => {
const readLatestBlock = async () => {
const blockNum = await provider.getBlockNumber();
const _raw = await provider.send("erigon_getHeaderByNumber", [blockNum]);
const _block = provider.formatter.block(_raw);
setLatestBlock(_block);
};
readLatestBlock();
const listener = async (blockNumber: number) => {
const _raw = await provider.send("erigon_getHeaderByNumber", [
blockNumber,
]);
const _block = provider.formatter.block(_raw);
setLatestBlock(_block);
};
provider.on("block", listener);
return () => {
provider.removeListener("block", listener);
};
}, []);
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}
>
<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"
onChange={handleChange}
></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}`}
>
<div>
Latest block: {ethers.utils.commify(latestBlock.number)}
</div>
<Timestamp value={latestBlock.timestamp} />
</NavLink>
)}
</form>
</div>
</div>
);
};
export default React.memo(Home);

17
src/Logo.tsx Normal file
View File

@ -0,0 +1,17 @@
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">
<img
className="rounded-full"
src="/otter.jpg"
width={96}
height={96}
alt="An otter scanning"
title="An otter scanning"
/>
<span>Otterscan</span>
</div>
);
export default React.memo(Logo);

34
src/Search.tsx Normal file
View File

@ -0,0 +1,34 @@
import { useLocation, useHistory } from "react-router-dom";
import { ethers } from "ethers";
import queryString from "query-string";
type SearchParams = {
q: string;
};
const Search: React.FC = () => {
const location = useLocation<SearchParams>();
const history = useHistory();
const qs = queryString.parse(location.search);
const q = (qs.q ?? "").toString();
if (ethers.utils.isAddress(q)) {
history.replace(`/address/${q}`);
return <></>;
}
if (ethers.utils.isHexString(q, 32)) {
history.replace(`/tx/${q}`);
return <></>;
}
const blockNumber = parseInt(q);
if (!isNaN(blockNumber)) {
history.replace(`/block/${blockNumber}`);
return <></>;
}
history.replace("/");
return <></>;
};
export default Search;

7
src/StandardFrame.tsx Normal file
View File

@ -0,0 +1,7 @@
import React from "react";
const StandardFrame: React.FC = ({ children }) => (
<div className="bg-gray-100 px-9 pt-3 pb-12">{children}</div>
);
export default StandardFrame;

7
src/StandardSubtitle.tsx Normal file
View File

@ -0,0 +1,7 @@
import React from "react";
const StandardSubtitle: React.FC = ({ children }) => (
<div className="pb-2 text-xl text-gray-700">{children}</div>
);
export default StandardSubtitle;

69
src/Title.tsx Normal file
View File

@ -0,0 +1,69 @@
import React, { useState, useRef } from "react";
import { Link, useHistory } from "react-router-dom";
import useKeyboardShortcut from "use-keyboard-shortcut";
const Title: React.FC = () => {
const [search, setSearch] = useState<string>();
const [canSubmit, setCanSubmit] = useState<boolean>(false);
const history = useHistory();
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
setCanSubmit(e.target.value.trim().length > 0);
setSearch(e.target.value.trim());
};
const handleSubmit: React.FormEventHandler<HTMLFormElement> = (e) => {
e.preventDefault();
if (!canSubmit) {
return;
}
history.push(`/search?q=${search}`);
};
const searchRef = useRef<HTMLInputElement>(null);
useKeyboardShortcut(["/"], () => {
searchRef.current?.focus();
});
return (
<div className="px-9 py-2 flex justify-between items-baseline">
<Link className="self-center" to="/">
<div className="text-2xl text-link-blue font-title font-bold flex items-center space-x-2">
<img
className="rounded-full"
src="/otter.jpg"
width={32}
height={32}
alt="An otter scanning"
title="An otter scanning"
/>
<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={50}
placeholder='Type "/" to search by address / txn hash / block number'
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>
);
};
export default React.memo(Title);

470
src/Transaction.tsx Normal file
View File

@ -0,0 +1,470 @@
import React, { useState, useEffect, useCallback } from "react";
import { Route, Switch, useParams } from "react-router-dom";
import { BigNumber, ethers } from "ethers";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCheckCircle,
faTimesCircle,
faAngleRight,
faCaretRight,
} from "@fortawesome/free-solid-svg-icons";
import { provider } from "./ethersconfig";
import StandardFrame from "./StandardFrame";
import StandardSubtitle from "./StandardSubtitle";
import Tab from "./components/Tab";
import ContentFrame from "./ContentFrame";
import BlockLink from "./components/BlockLink";
import AddressLink from "./components/AddressLink";
import Copy from "./components/Copy";
import Timestamp from "./components/Timestamp";
import TokenLogo from "./components/TokenLogo";
import GasValue from "./components/GasValue";
import FormattedBalance from "./components/FormattedBalance";
import erc20 from "./erc20.json";
const USE_OTS = true;
const TRANSFER_TOPIC =
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
type TransactionParams = {
txhash: string;
};
type TransactionData = {
transactionHash: string;
status: boolean;
blockNumber: number;
transactionIndex: number;
confirmations: number;
timestamp: number;
from: string;
to: string;
value: BigNumber;
tokenTransfers: TokenTransfer[];
tokenMetas: TokenMetas;
fee: BigNumber;
gasPrice: BigNumber;
gasLimit: BigNumber;
gasUsed: BigNumber;
gasUsedPerc: number;
nonce: number;
data: string;
logs: ethers.providers.Log[];
};
type From = {
current: string;
depth: number;
};
type Transfer = {
from: string;
to: string;
value: BigNumber;
};
type TokenTransfer = {
token: string;
from: string;
to: string;
value: BigNumber;
};
type TokenMeta = {
name: string;
symbol: string;
decimals: number;
};
type TokenMetas = {
[tokenAddress: string]: TokenMeta;
};
const Transaction: React.FC = () => {
const params = useParams<TransactionParams>();
const { txhash } = params;
const [txData, setTxData] = useState<TransactionData>();
useEffect(() => {
const readBlock = async () => {
const [_response, _receipt] = await Promise.all([
provider.getTransaction(txhash),
provider.getTransactionReceipt(txhash),
]);
const _block = await provider.getBlock(_receipt.blockNumber);
document.title = `Transaction ${_response.hash} | Otterscan`;
// Extract token transfers
const tokenTransfers: TokenTransfer[] = [];
for (const l of _receipt.logs) {
if (l.topics.length !== 3) {
continue;
}
if (l.topics[0] !== TRANSFER_TOPIC) {
continue;
}
tokenTransfers.push({
token: l.address,
from: ethers.utils.hexDataSlice(
ethers.utils.arrayify(l.topics[1]),
12
),
to: ethers.utils.hexDataSlice(ethers.utils.arrayify(l.topics[2]), 12),
value: BigNumber.from(l.data),
});
}
// Extract token meta
const tokenMetas: TokenMetas = {};
for (const t of tokenTransfers) {
if (tokenMetas[t.token]) {
continue;
}
const erc20Contract = new ethers.Contract(t.token, erc20, provider);
const [name, symbol, decimals] = await Promise.all([
erc20Contract.name(),
erc20Contract.symbol(),
erc20Contract.decimals(),
]);
tokenMetas[t.token] = {
name,
symbol,
decimals,
};
}
setTxData({
transactionHash: _receipt.transactionHash,
status: _receipt.status === 1,
blockNumber: _receipt.blockNumber,
transactionIndex: _receipt.transactionIndex,
confirmations: _receipt.confirmations,
timestamp: _block.timestamp,
from: _receipt.from,
to: _receipt.to,
value: _response.value,
tokenTransfers,
tokenMetas,
fee: _response.gasPrice!.mul(_receipt.gasUsed),
gasPrice: _response.gasPrice!,
gasLimit: _response.gasLimit,
gasUsed: _receipt.gasUsed,
gasUsedPerc:
_receipt.gasUsed.toNumber() / _response.gasLimit.toNumber(),
nonce: _response.nonce,
data: _response.data,
logs: _receipt.logs,
});
};
readBlock();
}, [txhash]);
const [transfers, setTransfers] = useState<Transfer[]>();
const traceTransfersUsingDebugTrace = async () => {
const r = await provider.send("debug_traceTransaction", [
txData?.transactionHash,
{ disableStorage: true, disableMemory: true },
]);
const fromStack: From[] = [
{
current: txData!.to,
depth: 0,
},
];
const _transfers: Transfer[] = [];
for (const l of r.structLogs) {
if (l.op !== "CALL") {
if (parseInt(l.depth) === fromStack[fromStack.length - 1].depth) {
fromStack.pop();
}
continue;
}
const { stack } = l;
const addr = stack[stack.length - 2].slice(24);
const value = BigNumber.from("0x" + stack[stack.length - 3]);
if (!value.isZero()) {
const t: Transfer = {
from: ethers.utils.getAddress(
fromStack[fromStack.length - 1].current
),
to: ethers.utils.getAddress(addr),
value,
};
_transfers.push(t);
}
fromStack.push({
current: addr,
depth: parseInt(l.depth),
});
}
setTransfers(_transfers);
};
const traceTransfersUsingOtsTrace = useCallback(async () => {
if (!txData) {
return;
}
const r = await provider.send("ots_getTransactionTransfers", [
txData.transactionHash,
]);
const _transfers: Transfer[] = [];
for (const t of r) {
_transfers.push({
from: t.from,
to: t.to,
value: t.value,
});
}
setTransfers(_transfers);
}, [txData]);
useEffect(() => {
if (USE_OTS) {
traceTransfersUsingOtsTrace();
}
}, [traceTransfersUsingOtsTrace]);
return (
<StandardFrame>
<StandardSubtitle>Transaction Details</StandardSubtitle>
{txData && (
<>
<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`}>
Logs{txData && ` (${txData.logs.length})`}
</Tab>
</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">
<AddressLink address={txData.from} />
<Copy value={txData.from} />
</div>
</InfoRow>
<InfoRow title="Interacted With (To)">
<div className="flex items-baseline space-x-2">
<AddressLink address={txData.to} />
<Copy value={txData.to} />
</div>
{transfers ? (
<div className="mt-2 space-y-1">
{transfers.map((t, i) => (
<div key={i} className="flex space-x-1 text-xs">
<span className="text-gray-500">
<FontAwesomeIcon icon={faAngleRight} size="1x" />{" "}
TRANSFER
</span>
<span>{ethers.utils.formatEther(t.value)} Ether</span>
<span className="text-gray-500">From</span>
<AddressLink address={t.from} />
<span className="text-gray-500">To</span>
<AddressLink address={t.to} />
</div>
))}
</div>
) : (
!USE_OTS && (
<button
className="rounded focus:outline-none bg-gray-100 mt-2 px-3 py-2"
onClick={traceTransfersUsingDebugTrace}
>
Trace transfers
</button>
)
)}
</InfoRow>
<InfoRow title="Transaction Action"></InfoRow>
{txData.tokenTransfers.length > 0 && (
<InfoRow
title={`Tokens Transferred (${txData.tokenTransfers.length})`}
>
<div className="space-y-2">
{txData.tokenTransfers &&
txData.tokenTransfers.map((t, i) => (
<div
className="flex items-baseline space-x-2 truncate"
key={i}
>
<span className="text-gray-500">
<FontAwesomeIcon icon={faCaretRight} size="1x" />
</span>
<span className="font-bold">From</span>
<AddressLink address={t.from} />
<span className="font-bold">To</span>
<AddressLink address={t.to} />
<span className="font-bold">For</span>
<span>
<FormattedBalance
value={t.value}
decimals={txData.tokenMetas[t.token].decimals}
/>
</span>
<span className="flex space-x-1 items-baseline truncate">
{txData.tokenMetas[t.token] ? (
<>
<div className="self-center">
<TokenLogo
address={t.token}
name={txData.tokenMetas[t.token].name}
/>
</div>
<AddressLink
address={t.token}
text={`${
txData.tokenMetas[t.token].name
} (${txData.tokenMetas[t.token].symbol})`}
/>
</>
) : (
<AddressLink address={t.token} />
)}
</span>
</div>
))}
</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">
<FormattedBalance value={txData.gasPrice} /> Ether (
<FormattedBalance value={txData.gasPrice} decimals={9} />{" "}
Gwei)
</InfoRow>
<InfoRow title="Ether Price">N/A</InfoRow>
<InfoRow title="Gas Limit">
<GasValue value={txData.gasLimit} />
</InfoRow>
<InfoRow title="Gas Used by Transaction">
<GasValue value={txData.gasUsed} /> (
{(txData.gasUsedPerc * 100).toFixed(2)}%)
</InfoRow>
<InfoRow title="Nonce">{txData.nonce}</InfoRow>
<InfoRow title="Position in Block">
<span className="rounded px-2 py-1 bg-gray-100 text-gray-500 text-xs">
{txData.transactionIndex}
</span>
</InfoRow>
<InfoRow title="Input Data">
<textarea
className="w-full h-40 bg-gray-50 text-gray-500 font-mono focus:outline-none border rounded p-2"
value={txData.data}
readOnly
/>
</InfoRow>
</ContentFrame>
</Route>
<Route path="/tx/:txhash/logs/" exact>
<ContentFrame tabs>
<div className="text-sm py-4">
Transaction Receipt Event Logs
</div>
{txData &&
txData.logs.map((l, i) => (
<div className="flex space-x-10 py-5" key={i}>
<div>
<span className="rounded-full w-12 h-12 flex items-center justify-center bg-green-50 text-green-500">
{l.logIndex}
</span>
</div>
<div className="w-full space-y-2">
<div className="grid grid-cols-12 gap-x-3 gap-y-5 text-sm">
<div className="font-bold text-right">Address</div>
<div className="col-span-11">
<AddressLink address={l.address} />
</div>
</div>
{l.topics.map((t, i) => (
<div
className="grid grid-cols-12 gap-x-3 gap-y-5 text-sm"
key={i}
>
<div className="text-right">
{i === 0 && "Topics"}
</div>
<div className="flex space-x-2 items-center col-span-11 font-mono">
<span className="rounded bg-gray-100 text-gray-500 px-2 py-1 text-xs">
{i}
</span>
<span>{t}</span>
</div>
</div>
))}
<div className="grid grid-cols-12 gap-x-3 gap-y-5 text-sm">
<div className="text-right pt-2">Data</div>
<div className="col-span-11">
<textarea
className="w-full h-20 bg-gray-50 font-mono focus:outline-none border rounded p-2"
value={l.data}
/>
</div>
</div>
</div>
</div>
))}
</ContentFrame>
</Route>
</Switch>
</>
)}
</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,9 @@
import React from "react";
const Address: React.FC = ({ children }) => (
<span className="font-address text-gray-400 truncate">
<p className="truncate">{children}</p>
</span>
);
export default Address;

View File

@ -0,0 +1,20 @@
import React from "react";
import { NavLink } from "react-router-dom";
type AddressLinkProps = {
address: string;
text?: string;
};
const AddressLink: React.FC<AddressLinkProps> = ({ address, text }) => (
<NavLink
className="text-link-blue hover:text-link-blue-hover font-address truncate"
to={`/address/${address}`}
>
<p className="truncate" title={text}>
{text ?? address}
</p>
</NavLink>
);
export default React.memo(AddressLink);

View File

@ -0,0 +1,28 @@
import React from "react";
import { NavLink } from "react-router-dom";
import { ethers } from "ethers";
type BlockLinkProps = {
blockTag: ethers.providers.BlockTag;
};
const BlockLink: React.FC<BlockLinkProps> = ({ blockTag }) => {
const isNum = typeof blockTag === "number";
let text = blockTag;
if (isNum) {
text = ethers.utils.commify(blockTag);
}
return (
<NavLink
className={`text-link-blue hover:text-link-blue-hover ${
isNum ? "font-blocknum" : "font-hash"
}`}
to={`/block/${blockTag}`}
>
{text}
</NavLink>
);
};
export default React.memo(BlockLink);

48
src/components/Copy.tsx Normal file
View File

@ -0,0 +1,48 @@
import React, { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCopy, faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import { faCheck } from "@fortawesome/free-solid-svg-icons";
type CopyProps = {
value: string;
rounded?: boolean;
};
const Copy: React.FC<CopyProps> = ({ value, rounded }) => {
const [copying, setCopying] = useState<boolean>(false);
const doCopy = () => {
navigator.clipboard.writeText(value);
setCopying(true);
setTimeout(() => {
setCopying(false);
}, 1000);
};
return (
<button
className={`text-gray-500 focus:outline-none ${
rounded
? "transition-colors transition-shadows bg-gray-200 hover:bg-gray-500 hover:text-gray-200 hover:shadow w-7 h-7 rounded-full text-xs"
: "text-sm"
}`}
title="Click to copy to clipboard"
onClick={doCopy}
>
{copying ? (
rounded ? (
<FontAwesomeIcon icon={faCheck} size="1x" />
) : (
<div className="space-x-1">
<FontAwesomeIcon icon={faCheckCircle} size="1x" />
{!rounded && <span>Copied</span>}
</div>
)
) : (
<FontAwesomeIcon icon={faCopy} size="1x" />
)}
</button>
);
};
export default React.memo(Copy);

View File

@ -0,0 +1,23 @@
import React from "react";
import { ethers, BigNumber } from "ethers";
type FormatterBalanceProps = {
value: BigNumber;
decimals?: number;
};
const FormattedBalance: React.FC<FormatterBalanceProps> = ({
value,
decimals = 18,
}) => {
const formatted = ethers.utils.commify(
ethers.utils.formatUnits(value, decimals)
);
const stripZeroDec = formatted.endsWith(".0")
? formatted.slice(0, formatted.length - 2)
: formatted;
return <>{stripZeroDec}</>;
};
export default React.memo(FormattedBalance);

View File

@ -0,0 +1,12 @@
import React from "react";
import { BigNumber, ethers } from "ethers";
type GasValueProps = {
value: BigNumber;
};
const GasValue: React.FC<GasValueProps> = ({ value }) => {
return <>{ethers.utils.commify(ethers.utils.formatUnits(value, 0))}</>;
};
export default React.memo(GasValue);

View File

@ -0,0 +1,51 @@
import React, { useState, useEffect } from "react";
type MethodNameProps = {
data: string;
};
const MethodName: React.FC<MethodNameProps> = ({ data }) => {
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 signatureURL = `http://localhost:3001/${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);
}, [data]);
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}
</p>
</div>
);
};
export default React.memo(MethodName);

20
src/components/Tab.tsx Normal file
View File

@ -0,0 +1,20 @@
import React from "react";
import { NavLink } from "react-router-dom";
type TabProps = {
href: string;
};
const Tab: React.FC<TabProps> = ({ href, children }) => (
<NavLink
className="text-gray-500 border-transparent hover:text-link-blue text-sm font-bold px-3 py-3 border-b-2"
activeClassName="text-link-blue border-link-blue"
to={href}
exact
replace
>
{children}
</NavLink>
);
export default Tab;

View File

@ -0,0 +1,55 @@
import React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClock } from "@fortawesome/free-regular-svg-icons";
import TimestampAge from "./TimestampAge";
type TimestampProps = {
value: number;
};
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const Timestamp: React.FC<TimestampProps> = ({ value }) => {
const d = new Date(value * 1000);
let hour = d.getUTCHours() % 12;
if (hour === 0) {
hour = 12;
}
const am = d.getUTCHours() < 12;
const tsString = `${months[d.getUTCMonth()]}-${d
.getUTCDate()
.toLocaleString(undefined, {
minimumIntegerDigits: 2,
})}-${d.getUTCFullYear()} ${hour.toLocaleString(undefined, {
minimumIntegerDigits: 2,
})}:${d.getUTCMinutes().toLocaleString(undefined, {
minimumIntegerDigits: 2,
})}:${d.getUTCSeconds().toLocaleString(undefined, {
minimumIntegerDigits: 2,
})} ${am ? "AM" : "PM"} +UTC`;
return (
<div className="flex space-x-1 items-baseline">
<FontAwesomeIcon className="self-center" icon={faClock} size="sm" />
<span>
<TimestampAge timestamp={value} /> ({tsString})
</span>
</div>
);
};
export default React.memo(Timestamp);

View File

@ -0,0 +1,43 @@
import React from "react";
type TimestampAgeProps = {
timestamp: number;
};
const TimestampAge: React.FC<TimestampAgeProps> = ({ timestamp }) => {
const now = Date.now() / 1000;
let diff = now - timestamp;
let desc = "";
if (diff <= 1) {
desc = "1 sec ago";
} else if (diff < 60) {
desc = `${Math.trunc(diff)} secs ago`;
} else {
const days = Math.trunc(diff / 86400);
diff %= 86400;
const hours = Math.trunc(diff / 3600);
diff %= 3600;
const mins = Math.trunc(diff / 60);
desc = "";
if (days > 0) {
desc += `${days} ${days === 1 ? "day" : "days"} `;
}
if (hours > 0) {
desc += `${hours} ${hours === 1 ? "hr" : "hrs"} `;
}
if (days === 0 && mins > 0) {
desc += `${mins} ${mins === 1 ? "min" : "mins"} `;
}
desc += "ago";
}
return (
<span className="truncate" title={desc}>
{desc}
</span>
);
};
export default React.memo(TimestampAge);

View File

@ -0,0 +1,30 @@
import React, { Suspense } from "react";
import { useImage } from "react-image";
type TokenLogoProps = {
address: string;
name: string;
};
const TokenLogo: React.FC<TokenLogoProps> = (props) => (
<Suspense fallback={<></>}>
<InternalTokenLogo {...props} />
</Suspense>
);
const InternalTokenLogo: React.FC<TokenLogoProps> = ({ address, name }) => {
const { src } = useImage({
srcList: [
`http://localhost:3002/${address}/logo.png`,
"/eth-diamond-black.png",
],
});
return (
<div className="flex items-center justify-center w-5 h-5">
<img className="max-w-full max-h-full" src={src} alt={`${name} logo`} />
</div>
);
};
export default React.memo(TokenLogo);

View File

@ -0,0 +1,54 @@
import React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faLongArrowAltRight } from "@fortawesome/free-solid-svg-icons";
export enum Direction {
IN,
OUT,
SELF,
INTERNAL,
}
type TransactionDirectionProps = {
direction?: Direction;
};
const TransactionDirection: React.FC<TransactionDirectionProps> = ({
direction,
}) => {
let bgColor = "bg-green-50";
let fgColor = "text-green-500";
let msg: string | null = null;
if (direction === Direction.IN) {
msg = "IN";
} else if (direction === Direction.OUT) {
bgColor = "bg-yellow-100";
fgColor = "text-yellow-600";
msg = "OUT";
} else if (direction === Direction.SELF) {
bgColor = "bg-gray-200";
fgColor = "text-gray-500";
msg = "SELF";
} else if (direction === Direction.INTERNAL) {
msg = "INT";
}
return (
<span
className={`${bgColor} ${fgColor} ${
direction !== undefined
? "px-2 py-1 rounded-lg"
: "w-5 h-5 rounded-full flex justify-center items-center"
} text-xs font-bold`}
>
{msg ?? (
<span>
<FontAwesomeIcon icon={faLongArrowAltRight} />
</span>
)}
</span>
);
};
export default React.memo(TransactionDirection);

View File

@ -0,0 +1,17 @@
import React from "react";
import { NavLink } from "react-router-dom";
type TransactionLinkProps = {
txHash: string;
};
const TransactionLink: React.FC<TransactionLinkProps> = ({ txHash }) => (
<NavLink
className="text-link-blue hover:text-link-blue-hover font-hash"
to={`/tx/${txHash}`}
>
<p className="truncate">{txHash}</p>
</NavLink>
);
export default React.memo(TransactionLink);

View File

@ -0,0 +1,26 @@
import React from "react";
import { BigNumber } from "ethers";
import { formatValue } from "./formatter";
type TransactionValueProps = {
value: BigNumber;
decimals?: number;
};
const TransactionValue: React.FC<TransactionValueProps> = ({
value,
decimals = 18,
}) => {
const formattedValue = formatValue(value, decimals);
return (
<span
className={`text-sm ${value.isZero() ? "text-gray-400" : ""}`}
title={`${formattedValue} Ether`}
>
<span className={`font-balance`}>{formattedValue}</span> Ether
</span>
);
};
export default React.memo(TransactionValue);

View File

@ -0,0 +1,10 @@
import { ethers, BigNumber } from "ethers";
export const formatValue = (value: BigNumber, decimals: number): string => {
const formatted = ethers.utils.commify(
ethers.utils.formatUnits(value, decimals)
);
return formatted.endsWith(".0")
? formatted.slice(0, formatted.length - 2)
: formatted;
};

44
src/erc20.json Normal file
View File

@ -0,0 +1,44 @@
[
{
"constant": true,
"inputs": [],
"name": "name",
"outputs": [
{
"name": "",
"type": "string"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [],
"name": "decimals",
"outputs": [
{
"name": "",
"type": "uint8"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [],
"name": "symbol",
"outputs": [
{
"name": "",
"type": "string"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
}
]

6
src/ethersconfig.ts Normal file
View File

@ -0,0 +1,6 @@
import { ethers } from "ethers";
export const provider = new ethers.providers.JsonRpcProvider(
"http://127.0.0.1:8545",
"mainnet"
);

3
src/index.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

20
src/index.tsx Normal file
View File

@ -0,0 +1,20 @@
import React from "react";
import ReactDOM from "react-dom";
import "@fontsource/space-grotesk/index.css";
import "@fontsource/roboto/index.css";
import "@fontsource/roboto-mono/index.css";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById("root")
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

1
src/params.ts Normal file
View File

@ -0,0 +1 @@
export const PAGE_SIZE = 25;

2
src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="react-scripts" />
declare module "use-keyboard-shortcut";

15
src/reportWebVitals.ts Normal file
View File

@ -0,0 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

32
src/search/PageButton.tsx Normal file
View File

@ -0,0 +1,32 @@
import React from "react";
import { NavLink } from "react-router-dom";
type PageButtonProps = {
goToPage: number;
disabled?: boolean;
};
const PageButton: React.FC<PageButtonProps> = ({
goToPage,
disabled,
children,
}) => {
if (disabled) {
return (
<span className="bg-link-blue bg-opacity-10 text-gray-400 rounded-lg px-3 py-2 text-xs">
{children}
</span>
);
}
return (
<NavLink
className="transition-colors bg-link-blue bg-opacity-10 text-link-blue hover:bg-opacity-100 hover:text-white disabled:bg-link-blue disabled:text-gray-400 disabled:cursor-default rounded-lg px-3 py-2 text-xs"
to={`?p=${goToPage}`}
>
{children}
</NavLink>
);
};
export default PageButton;

View File

@ -0,0 +1,40 @@
import React from "react";
import PageButton from "./PageButton";
type PageControlProps = {
pageNumber: number;
pageSize: number;
total: number;
};
const PageControl: React.FC<PageControlProps> = ({
pageNumber,
pageSize,
total,
}) => {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const isFirst = pageNumber === 1;
const isLast = pageNumber === totalPages;
return (
<div className="flex items-baseline space-x-1 text-xs">
<PageButton goToPage={1} disabled={isFirst}>
First
</PageButton>
<PageButton goToPage={pageNumber - 1} disabled={isFirst}>
{"<"}
</PageButton>
<PageButton goToPage={1} disabled>
Page {pageNumber} of {totalPages}
</PageButton>
<PageButton goToPage={pageNumber + 1} disabled={isLast}>
{">"}
</PageButton>
<PageButton goToPage={totalPages} disabled={isLast}>
Last
</PageButton>
</div>
);
};
export default React.memo(PageControl);

View File

@ -0,0 +1,16 @@
import React from "react";
const PendingResults: React.FC = () => (
<div className="animate-pulse grid grid-cols-12 gap-x-1 items-baseline text-sm border-t border-gray-200 px-2 py-3">
<div className="w-full h-5 rounded bg-gradient-to-r from-gray-100 to-transparent col-span-2"></div>
<div className="w-full h-5 rounded bg-gradient-to-r from-gray-100 to-transparent col-span-1"></div>
<div className="w-full h-5 rounded bg-gradient-to-r from-gray-100 to-transparent col-span-1"></div>
<div className="w-full h-5 rounded bg-gradient-to-r from-gray-100 to-transparent col-span-1"></div>
<div className="w-full h-5 rounded bg-gradient-to-r from-gray-100 to-transparent col-span-2"></div>
<div className="w-full h-5 rounded bg-gradient-to-r from-gray-100 to-transparent col-span-2"></div>
<div className="w-full h-5 rounded bg-gradient-to-r from-gray-100 to-transparent col-span-2"></div>
<div className="w-full h-5 rounded bg-gradient-to-r from-gray-100 to-transparent col-span-1"></div>
</div>
);
export default React.memo(PendingResults);

View File

@ -0,0 +1,32 @@
import React from "react";
import { FeeDisplay } from "./useFeeToggler";
export type ResultHeaderProps = {
feeDisplay: FeeDisplay;
feeDisplayToggler: () => void;
};
const ResultHeader: React.FC<ResultHeaderProps> = ({
feeDisplay,
feeDisplayToggler,
}) => (
<div className="grid grid-cols-12 gap-x-1 bg-gray-100 border-t border-b border-gray-200 px-2 py-2 font-bold text-gray-500 text-sm">
<div className="col-span-2">Txn Hash</div>
<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">Value</div>
<div>
<button
className="text-link-blue hover:text-link-blue-hover"
onClick={feeDisplayToggler}
>
{feeDisplay === FeeDisplay.TX_FEE ? "Txn Fee" : "Gas Price"}
</button>
</div>
</div>
);
export default React.memo(ResultHeader);

View File

@ -0,0 +1,92 @@
import React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons";
import MethodName from "../components/MethodName";
import BlockLink from "../components/BlockLink";
import TransactionLink from "../components/TransactionLink";
import Address from "../components/Address";
import AddressLink from "../components/AddressLink";
import TimestampAge from "../components/TimestampAge";
import TransactionDirection, {
Direction,
} from "../components/TransactionDirection";
import TransactionValue from "../components/TransactionValue";
import { ProcessedTransaction } from "../types";
import { FeeDisplay } from "./useFeeToggler";
import { formatValue } from "../components/formatter";
type TransactionItemProps = {
tx: ProcessedTransaction;
selectedAddress?: string;
feeDisplay: FeeDisplay;
};
const TransactionItem: React.FC<TransactionItemProps> = ({
tx,
selectedAddress,
feeDisplay,
}) => {
let direction: Direction | undefined;
if (selectedAddress) {
if (tx.from === selectedAddress && tx.to === selectedAddress) {
direction = Direction.SELF;
} else if (tx.from === selectedAddress) {
direction = Direction.OUT;
} else if (tx.to === selectedAddress) {
direction = Direction.IN;
} else {
direction = Direction.INTERNAL;
}
}
return (
<div className="grid grid-cols-12 gap-x-1 items-baseline text-sm border-t border-gray-200 hover:bg-gray-100 px-2 py-3">
<div className="col-span-2 flex space-x-1 items-baseline">
{tx.status === 0 && (
<span className="text-red-600" title="Transaction reverted">
<FontAwesomeIcon icon={faExclamationCircle} />
</span>
)}
<span className="truncate">
<TransactionLink txHash={tx.hash} />
</span>
</div>
<MethodName data={tx.data} />
<span>
<BlockLink blockTag={tx.blockNumber} />
</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}>
{tx.from &&
(tx.from === selectedAddress ? (
<Address>{tx.from}</Address>
) : (
<AddressLink address={tx.from} />
))}
</span>
<span>
<TransactionDirection direction={direction} />
</span>
</span>
<span className="col-span-2 truncate" title={tx.to}>
{tx.to &&
(tx.to === selectedAddress ? (
<Address>{tx.to}</Address>
) : (
<AddressLink address={tx.to} />
))}
</span>
<span className="col-span-2 truncate">
<TransactionValue value={tx.value} />
</span>
<span className="font-balance text-xs text-gray-500 truncate">
{feeDisplay === FeeDisplay.TX_FEE
? formatValue(tx.fee, 18)
: formatValue(tx.gasPrice, 9)}
</span>
</div>
);
};
export default React.memo(TransactionItem);

View File

@ -0,0 +1,38 @@
import React from "react";
import { NavLink } from "react-router-dom";
type UndefinedPageButtonProps = {
address: string;
direction: "first" | "last" | "prev" | "next";
hash?: string;
disabled?: boolean;
};
const UndefinedPageButton: React.FC<UndefinedPageButtonProps> = ({
address,
direction,
hash,
disabled,
children,
}) => {
if (disabled) {
return (
<span className="bg-link-blue bg-opacity-10 text-gray-400 rounded-lg px-3 py-2 text-xs">
{children}
</span>
);
}
return (
<NavLink
className="transition-colors bg-link-blue bg-opacity-10 text-link-blue hover:bg-opacity-100 hover:text-white disabled:bg-link-blue disabled:text-gray-400 disabled:cursor-default rounded-lg px-3 py-2 text-xs"
to={`/address/${address}/${direction}${
direction === "prev" || direction === "next" ? `?h=${hash}` : ""
}`}
>
{children}
</NavLink>
);
};
export default UndefinedPageButton;

View File

@ -0,0 +1,57 @@
import React from "react";
import UndefinedPageButton from "./UndefinedPageButton";
type UndefinedPageControlProps = {
isFirst?: boolean;
isLast?: boolean;
address: string;
prevHash: string;
nextHash: string;
disabled?: boolean;
};
const UndefinedPageControl: React.FC<UndefinedPageControlProps> = ({
isFirst,
isLast,
address,
prevHash,
nextHash,
disabled,
}) => {
return (
<div className="flex items-baseline space-x-1 text-xs">
<UndefinedPageButton
address={address}
direction="first"
disabled={disabled || isFirst}
>
First
</UndefinedPageButton>
<UndefinedPageButton
address={address}
direction="prev"
hash={prevHash}
disabled={disabled || isFirst}
>
{"<"}
</UndefinedPageButton>
<UndefinedPageButton
address={address}
direction="next"
hash={nextHash}
disabled={disabled || isLast}
>
{">"}
</UndefinedPageButton>
<UndefinedPageButton
address={address}
direction="last"
disabled={disabled || isLast}
>
Last
</UndefinedPageButton>
</div>
);
};
export default React.memo(UndefinedPageControl);

175
src/search/search.ts Normal file
View File

@ -0,0 +1,175 @@
import { ethers } from "ethers";
import { provider } from "../ethersconfig";
import { PAGE_SIZE } from "../params";
import { ProcessedTransaction, TransactionChunk } from "../types";
export class SearchController {
private txs: ProcessedTransaction[];
private pageStart: number;
private pageEnd: number;
private constructor(
readonly address: string,
txs: ProcessedTransaction[],
readonly isFirst: boolean,
readonly isLast: boolean,
boundToStart: boolean
) {
this.txs = txs;
if (boundToStart) {
this.pageStart = 0;
this.pageEnd = Math.min(txs.length, PAGE_SIZE);
} else {
this.pageEnd = txs.length;
this.pageStart = Math.max(0, txs.length - PAGE_SIZE);
}
}
private static rawToProcessed = (_rawRes: any) => {
const _res: ethers.providers.TransactionResponse[] = _rawRes.txs.map(
(t: any) => provider.formatter.transactionResponse(t)
);
return {
txs: _res.map((t, i): ProcessedTransaction => {
const _rawReceipt = _rawRes.receipts[i];
const _receipt = provider.formatter.receipt(_rawReceipt);
return {
blockNumber: t.blockNumber!,
timestamp: provider.formatter.number(_rawReceipt.timestamp),
idx: _receipt.transactionIndex,
hash: t.hash,
from: t.from,
to: t.to,
value: t.value,
fee: _receipt.gasUsed.mul(t.gasPrice!),
gasPrice: t.gasPrice!,
data: t.data,
status: _receipt.status!,
};
}),
firstPage: _rawRes.firstPage,
lastPage: _rawRes.lastPage,
};
};
private static async readBackPage(
address: string,
baseBlock: number
): Promise<TransactionChunk> {
const _rawRes = await provider.send("ots_searchTransactionsBefore", [
address,
baseBlock,
PAGE_SIZE,
]);
return this.rawToProcessed(_rawRes);
}
private static async readForwardPage(
address: string,
baseBlock: number
): Promise<TransactionChunk> {
const _rawRes = await provider.send("ots_searchTransactionsAfter", [
address,
baseBlock,
PAGE_SIZE,
]);
return this.rawToProcessed(_rawRes);
}
static async firstPage(address: string): Promise<SearchController> {
const newTxs = await SearchController.readBackPage(address, 0);
return new SearchController(
address,
newTxs.txs,
newTxs.firstPage,
newTxs.lastPage,
true
);
}
static async middlePage(
address: string,
hash: string,
next: boolean
): Promise<SearchController> {
const tx = await provider.getTransaction(hash);
const newTxs = next
? await SearchController.readBackPage(address, tx.blockNumber!)
: await SearchController.readForwardPage(address, tx.blockNumber!);
return new SearchController(
address,
newTxs.txs,
newTxs.firstPage,
newTxs.lastPage,
next
);
}
static async lastPage(address: string): Promise<SearchController> {
const newTxs = await SearchController.readForwardPage(address, 0);
return new SearchController(
address,
newTxs.txs,
newTxs.firstPage,
newTxs.lastPage,
false
);
}
getPage(): ProcessedTransaction[] {
return this.txs.slice(this.pageStart, this.pageEnd);
}
async prevPage(hash: string): Promise<SearchController> {
// Already on this page
if (this.txs[this.pageEnd - 1].hash === hash) {
return this;
}
if (this.txs[this.pageStart].hash === hash) {
const overflowPage = this.txs.slice(0, this.pageStart);
const baseBlock = this.txs[0].blockNumber;
const prevPage = await SearchController.readForwardPage(
this.address,
baseBlock
);
return new SearchController(
this.address,
prevPage.txs.concat(overflowPage),
prevPage.firstPage,
prevPage.lastPage,
false
);
}
return this;
}
async nextPage(hash: string): Promise<SearchController> {
// Already on this page
if (this.txs[this.pageStart].hash === hash) {
return this;
}
if (this.txs[this.pageEnd - 1].hash === hash) {
const overflowPage = this.txs.slice(this.pageEnd);
const baseBlock = this.txs[this.txs.length - 1].blockNumber;
const nextPage = await SearchController.readBackPage(
this.address,
baseBlock
);
return new SearchController(
this.address,
overflowPage.concat(nextPage.txs),
nextPage.firstPage,
nextPage.lastPage,
true
);
}
return this;
}
}

View File

@ -0,0 +1,19 @@
import { useState } from "react";
export enum FeeDisplay {
TX_FEE,
GAS_PRICE,
}
export const useFeeToggler = (): [FeeDisplay, () => void] => {
const [feeDisplay, setFeeDisplay] = useState<FeeDisplay>(FeeDisplay.TX_FEE);
const feeDisplayToggler = () => {
if (feeDisplay === FeeDisplay.TX_FEE) {
setFeeDisplay(FeeDisplay.GAS_PRICE);
} else {
setFeeDisplay(FeeDisplay.TX_FEE);
}
};
return [feeDisplay, feeDisplayToggler];
};

5
src/setupTests.ts Normal file
View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

21
src/types.ts Normal file
View File

@ -0,0 +1,21 @@
import { BigNumber } from "ethers";
export type ProcessedTransaction = {
blockNumber: number;
timestamp: number;
idx: number;
hash: string;
from?: string;
to?: string;
value: BigNumber;
fee: BigNumber;
gasPrice: BigNumber;
data: string;
status: number;
};
export type TransactionChunk = {
txs: ProcessedTransaction[];
firstPage: boolean;
lastPage: boolean;
};

29
tailwind.config.js Normal file
View File

@ -0,0 +1,29 @@
module.exports = {
purge: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {
colors: {
"link-blue": "#3498db",
"link-blue-hover": "#0468ab",
},
fontFamily: {
sans: ["Roboto"],
title: ["Space Grotesk"],
address: ["Roboto Mono"],
hash: ["Roboto Mono"],
data: ["Roboto Mono"],
balance: ["Fira Code"],
blocknum: ["Roboto"],
},
},
},
variants: {
extend: {
cursor: ["disabled"],
backgroundColor: ["disabled"],
textColor: ["disabled"],
},
},
plugins: [],
};

26
tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}