Explorer: Move code to `solana-labs/explorer` (#30264)
This commit is contained in:
parent
3c01f4dd76
commit
7b2e1769f2
|
@ -1,28 +0,0 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
/wasm/target
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.eslintcache
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
#comment
|
|
@ -1,3 +0,0 @@
|
|||
build
|
||||
src/serumMarketRegistry.ts
|
||||
package-lock.json
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": false
|
||||
}
|
|
@ -1,81 +0,0 @@
|
|||
<p align="center">
|
||||
<img alt="Solana" src="https://i.imgur.com/IKyzQ6T.png" width="250" />
|
||||
</p>
|
||||
|
||||
# Solana Explorer
|
||||
|
||||
## Development
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.<br />
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.<br />
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.<br />
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.<br />
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.<br />
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
# Disclaimer
|
||||
|
||||
All claims, content, designs, algorithms, estimates, roadmaps,
|
||||
specifications, and performance measurements described in this project
|
||||
are done with the Solana Foundation's ("SF") best efforts. It is up to
|
||||
the reader to check and validate their accuracy and truthfulness.
|
||||
Furthermore nothing in this project constitutes a solicitation for
|
||||
investment.
|
||||
|
||||
Any content produced by SF or developer resources that SF provides, are
|
||||
for educational and inspiration purposes only. SF does not encourage,
|
||||
induce or sanction the deployment, integration or use of any such
|
||||
applications (including the code comprising the Solana blockchain
|
||||
protocol) in violation of applicable laws or regulations and hereby
|
||||
prohibits any such deployment, integration or use. This includes use of
|
||||
any such applications by the reader (a) in violation of export control
|
||||
or sanctions laws of the United States or any other applicable
|
||||
jurisdiction, (b) if the reader is located in or ordinarily resident in
|
||||
a country or territory subject to comprehensive sanctions administered
|
||||
by the U.S. Office of Foreign Assets Control (OFAC), or (c) if the
|
||||
reader is or is working on behalf of a Specially Designated National
|
||||
(SDN) or a person subject to similar blocking or denied party
|
||||
prohibitions.
|
||||
|
||||
The reader should be aware that U.S. export control and sanctions laws
|
||||
prohibit U.S. persons (and other persons that are subject to such laws)
|
||||
from transacting with persons in certain countries and territories or
|
||||
that are on the SDN list. As a project based primarily on open-source
|
||||
software, it is possible that such sanctioned persons may nevertheless
|
||||
bypass prohibitions, obtain the code comprising the Solana blockchain
|
||||
protocol (or other project code or applications) and deploy, integrate,
|
||||
or otherwise use it. Accordingly, there is a risk to individuals that
|
||||
other persons using the Solana blockchain protocol may be sanctioned
|
||||
persons and that transactions with such persons would be a violation of
|
||||
U.S. export controls and sanctions law. This risk applies to
|
||||
individuals, organizations, and other ecosystem participants that
|
||||
deploy, integrate, or use the Solana blockchain protocol code directly
|
||||
(e.g., as a node operator), and individuals that transact on the Solana
|
||||
blockchain through light clients, third party interfaces, and/or wallet
|
||||
software.
|
File diff suppressed because it is too large
Load Diff
|
@ -1,87 +0,0 @@
|
|||
{
|
||||
"name": "explorer",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"format": "prettier -c \"**/*.+(js|jsx|ts|tsx|json|css|md)\"",
|
||||
"format:fix": "prettier --write \"**/*.+(js|jsx|ts|tsx|json|css|md)\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@blockworks-foundation/mango-client": "^3.6.7",
|
||||
"@bonfida/spl-name-service": "^0.1.30",
|
||||
"@cloudflare/stream-react": "^1.2.0",
|
||||
"@metamask/jazzicon": "^2.0.0",
|
||||
"@metaplex/js": "^4.12.0",
|
||||
"@project-serum/anchor": "^0.23.0",
|
||||
"@project-serum/serum": "^0.13.61",
|
||||
"@react-hook/debounce": "^4.0.0",
|
||||
"@sentry/react": "^7.6.0",
|
||||
"@solana/buffer-layout": "^3.0.0",
|
||||
"@solana/spl-token": "^0.0.13",
|
||||
"@solana/spl-token-registry": "^0.2.3736",
|
||||
"@solana/web3.js": "^1.66.0",
|
||||
"axios": "^0.27.2",
|
||||
"bignumber.js": "^9.0.2",
|
||||
"bn.js": "^5.2.0",
|
||||
"bootstrap": "^5.1.3",
|
||||
"bs58": "^4.0.1",
|
||||
"chart.js": "^2.9.4",
|
||||
"classnames": "^2.3.1",
|
||||
"coingecko-api": "^1.0.10",
|
||||
"cross-fetch": "^3.1.4",
|
||||
"humanize-duration-ts": "^2.1.1",
|
||||
"p-limit": "^3.0.0",
|
||||
"react": "^18.1.0",
|
||||
"react-chartjs-2": "^2.11.2",
|
||||
"react-content-loader": "^6.1.0",
|
||||
"react-countup": "^6.4.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-json-view": "^1.21.3",
|
||||
"react-moment": "^1.1.1",
|
||||
"react-router-dom": "^5.3.0",
|
||||
"react-scripts": "^4.0.3",
|
||||
"react-select": "^4.3.1",
|
||||
"superstruct": "^0.15.3",
|
||||
"swr": "^1.3.0",
|
||||
"tweetnacl": "^1.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.3.0",
|
||||
"@testing-library/user-event": "^14.2.3",
|
||||
"@types/bn.js": "^5.1.0",
|
||||
"@types/bs58": "^4.0.1",
|
||||
"@types/chai": "^4.3.1",
|
||||
"@types/chart.js": "^2.9.34",
|
||||
"@types/classnames": "^2.3.1",
|
||||
"@types/jest": "^28.1.4",
|
||||
"@types/node": "^18.0.3",
|
||||
"@types/react": "^18.0.8",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-select": "^3.1.2",
|
||||
"@types/socket.io-client": "^3.0.0",
|
||||
"chai": "^4.3.6",
|
||||
"prettier": "^2.7.1",
|
||||
"sass": "^1.53.0",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 5.3 KiB |
|
@ -1,56 +0,0 @@
|
|||
<!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="Look up transactions and accounts on the various Solana clusters"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/rainbow192.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" />
|
||||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-112467444-2"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'UA-112467444-2');
|
||||
</script>
|
||||
<!--
|
||||
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>Explorer | Solana</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Oswald|Rubik|Exo+2:300,400,700|Saira|Saira+Condensed|Saira+Extra+Condensed|Saira+Semi+Condensed&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</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>
|
|
@ -1,25 +0,0 @@
|
|||
{
|
||||
"short_name": "Solana Explorer",
|
||||
"name": "Explorer for Solana clusters",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "rainbow192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "rainbow512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#75FBB4",
|
||||
"background_color": "#ffffff"
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 11 KiB |
Binary file not shown.
Before Width: | Height: | Size: 82 KiB |
|
@ -1,3 +0,0 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
|
@ -1,111 +0,0 @@
|
|||
import React from "react";
|
||||
import { Switch, Route, Redirect } from "react-router-dom";
|
||||
|
||||
import { ClusterModal } from "components/ClusterModal";
|
||||
import { MessageBanner } from "components/MessageBanner";
|
||||
import { Navbar } from "components/Navbar";
|
||||
import { ClusterStatusBanner } from "components/ClusterStatusButton";
|
||||
import { SearchBar } from "components/SearchBar";
|
||||
|
||||
import { AccountDetailsPage } from "pages/AccountDetailsPage";
|
||||
import { TransactionInspectorPage } from "pages/inspector/InspectorPage";
|
||||
import { ClusterStatsPage } from "pages/ClusterStatsPage";
|
||||
import { SupplyPage } from "pages/SupplyPage";
|
||||
import { TransactionDetailsPage } from "pages/TransactionDetailsPage";
|
||||
import { BlockDetailsPage } from "pages/BlockDetailsPage";
|
||||
import { EpochDetailsPage } from "pages/EpochDetailsPage";
|
||||
|
||||
const ADDRESS_ALIASES = ["account", "accounts", "addresses"];
|
||||
const TX_ALIASES = ["txs", "txn", "txns", "transaction", "transactions"];
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<ClusterModal />
|
||||
<div className="main-content pb-4">
|
||||
<Navbar />
|
||||
<MessageBanner />
|
||||
<ClusterStatusBanner />
|
||||
<SearchBar />
|
||||
<Switch>
|
||||
<Route exact path={["/supply", "/accounts", "accounts/top"]}>
|
||||
<SupplyPage />
|
||||
</Route>
|
||||
<Route
|
||||
exact
|
||||
path={TX_ALIASES.map((tx) => `/${tx}/:signature`)}
|
||||
render={({ match, location }) => {
|
||||
let pathname = `/tx/${match.params.signature}`;
|
||||
return <Redirect to={{ ...location, pathname }} />;
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={["/tx/inspector", "/tx/:signature/inspect"]}
|
||||
render={({ match }) => {
|
||||
const signature =
|
||||
"signature" in match.params
|
||||
? match.params.signature
|
||||
: undefined;
|
||||
return <TransactionInspectorPage signature={signature} />;
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={"/tx/:signature"}
|
||||
render={({ match }) => (
|
||||
<TransactionDetailsPage signature={match.params.signature} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={"/epoch/:id"}
|
||||
render={({ match }) => <EpochDetailsPage epoch={match.params.id} />}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={["/block/:id", "/block/:id/:tab"]}
|
||||
render={({ match }) => {
|
||||
const tab = "tab" in match.params ? match.params.tab : undefined;
|
||||
return <BlockDetailsPage slot={match.params.id} tab={tab} />;
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={[
|
||||
...ADDRESS_ALIASES.map((path) => `/${path}/:address`),
|
||||
...ADDRESS_ALIASES.map((path) => `/${path}/:address/:tab`),
|
||||
]}
|
||||
render={({ match, location }) => {
|
||||
let pathname = `/address/${match.params.address}`;
|
||||
if (match.params.tab) {
|
||||
pathname += `/${match.params.tab}`;
|
||||
}
|
||||
return <Redirect to={{ ...location, pathname }} />;
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={["/address/:address", "/address/:address/:tab"]}
|
||||
render={({ match }) => {
|
||||
const tab = "tab" in match.params ? match.params.tab : undefined;
|
||||
return (
|
||||
<AccountDetailsPage address={match.params.address} tab={tab} />
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route exact path="/">
|
||||
<ClusterStatsPage />
|
||||
</Route>
|
||||
<Route
|
||||
render={({ location }) => (
|
||||
<Redirect to={{ ...location, pathname: "/" }} />
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
|
@ -1,28 +0,0 @@
|
|||
import { expect } from "chai";
|
||||
import { lamportsToSol, LAMPORTS_PER_SOL } from "utils";
|
||||
|
||||
describe("lamportsToSol", () => {
|
||||
it("0 lamports", () => {
|
||||
expect(lamportsToSol(0)).to.eq(0.0);
|
||||
expect(lamportsToSol(BigInt(0))).to.eq(0.0);
|
||||
});
|
||||
|
||||
it("1 lamport", () => {
|
||||
expect(lamportsToSol(1)).to.eq(0.000000001);
|
||||
expect(lamportsToSol(BigInt(1))).to.eq(0.000000001);
|
||||
expect(lamportsToSol(-1)).to.eq(-0.000000001);
|
||||
expect(lamportsToSol(BigInt(-1))).to.eq(-0.000000001);
|
||||
});
|
||||
|
||||
it("1 SOL", () => {
|
||||
expect(lamportsToSol(LAMPORTS_PER_SOL)).to.eq(1.0);
|
||||
expect(lamportsToSol(BigInt(LAMPORTS_PER_SOL))).to.eq(1.0);
|
||||
expect(lamportsToSol(-LAMPORTS_PER_SOL)).to.eq(-1.0);
|
||||
expect(lamportsToSol(BigInt(-LAMPORTS_PER_SOL))).to.eq(-1.0);
|
||||
});
|
||||
|
||||
it("u64::MAX lamports", () => {
|
||||
expect(lamportsToSol(2n ** 64n)).to.eq(18446744073.709551615);
|
||||
expect(lamportsToSol(-(2n ** 64n))).to.eq(-18446744073.709551615);
|
||||
});
|
||||
});
|
|
@ -1,36 +0,0 @@
|
|||
import { PublicKey } from "@solana/web3.js";
|
||||
import { expect } from "chai";
|
||||
import { NFTOKEN_ADDRESS } from "../components/account/nftoken/nftoken";
|
||||
import { parseNFTokenNFTAccount } from "../components/account/nftoken/isNFTokenAccount";
|
||||
|
||||
describe("parseNFTokenAccounts", () => {
|
||||
it("parses an NFT", () => {
|
||||
const buffer = new Uint8Array([
|
||||
33, 180, 91, 53, 236, 15, 63, 97, 1, 13, 194, 212, 59, 127, 163, 1, 184,
|
||||
232, 229, 196, 221, 132, 114, 202, 93, 251, 147, 255, 156, 194, 45, 162,
|
||||
89, 138, 54, 129, 145, 16, 170, 225, 110, 171, 80, 175, 146, 42, 195, 197,
|
||||
124, 142, 197, 32, 198, 20, 137, 26, 33, 27, 67, 163, 173, 127, 113, 232,
|
||||
108, 17, 2, 184, 52, 59, 71, 87, 97, 1, 178, 138, 249, 251, 68, 1, 82,
|
||||
163, 86, 56, 204, 21, 192, 126, 64, 94, 187, 81, 78, 188, 73, 85, 189,
|
||||
140, 52, 199, 206, 30, 238, 117, 158, 114, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 67, 0, 0, 0, 104, 116, 116, 112, 115, 58, 47, 47, 99, 100, 110, 46,
|
||||
103, 108, 111, 119, 46, 97, 112, 112, 47, 110, 47, 56, 56, 47, 55, 56,
|
||||
101, 102, 49, 55, 99, 49, 45, 50, 98, 53, 97, 45, 52, 54, 56, 101, 45, 97,
|
||||
101, 56, 102, 45, 55, 52, 48, 51, 56, 53, 54, 101, 57, 102, 48, 48, 46,
|
||||
106, 115, 111, 110, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
]);
|
||||
const nftAccount = parseNFTokenNFTAccount({
|
||||
pubkey: new PublicKey("FagABcRBhZH27JDtu6A1Jo9woXyoznP28QujLkxkN9Hj"),
|
||||
space: buffer.length,
|
||||
lamports: 1,
|
||||
executable: false,
|
||||
data: { raw: buffer as Buffer },
|
||||
owner: new PublicKey(NFTOKEN_ADDRESS),
|
||||
});
|
||||
expect(nftAccount!.metadata_url).to.eq(
|
||||
"https://cdn.glow.app/n/88/78ef17c1-2b5a-468e-ae8f-7403856e9f00.json"
|
||||
);
|
||||
});
|
||||
});
|
|
@ -1,194 +0,0 @@
|
|||
import React, { ChangeEvent } from "react";
|
||||
import { Link, useHistory, useLocation } from "react-router-dom";
|
||||
import { useDebounceCallback } from "@react-hook/debounce";
|
||||
import { Location } from "history";
|
||||
import {
|
||||
useCluster,
|
||||
ClusterStatus,
|
||||
clusterName,
|
||||
clusterSlug,
|
||||
CLUSTERS,
|
||||
Cluster,
|
||||
useClusterModal,
|
||||
useUpdateCustomUrl,
|
||||
} from "providers/cluster";
|
||||
import { assertUnreachable, localStorageIsAvailable } from "../utils";
|
||||
import { Overlay } from "./common/Overlay";
|
||||
import { useQuery } from "utils/url";
|
||||
|
||||
export function ClusterModal() {
|
||||
const [show, setShow] = useClusterModal();
|
||||
const onClose = () => setShow(false);
|
||||
const showDeveloperSettings = localStorageIsAvailable();
|
||||
const enableCustomUrl =
|
||||
showDeveloperSettings && localStorage.getItem("enableCustomUrl") !== null;
|
||||
const onToggleCustomUrlFeature = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.checked) {
|
||||
localStorage.setItem("enableCustomUrl", "");
|
||||
} else {
|
||||
localStorage.removeItem("enableCustomUrl");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`offcanvas offcanvas-end${show ? " show" : ""}`}>
|
||||
<div className="modal-body" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="c-pointer" onClick={onClose}>
|
||||
×
|
||||
</span>
|
||||
|
||||
<h2 className="text-center mb-4 mt-4">Choose a Cluster</h2>
|
||||
<ClusterToggle />
|
||||
|
||||
{showDeveloperSettings && (
|
||||
<>
|
||||
<hr />
|
||||
|
||||
<h2 className="text-center mb-4 mt-4">Developer Settings</h2>
|
||||
<div className="d-flex justify-content-between">
|
||||
<span className="me-3">Enable custom url param</span>
|
||||
<div className="form-check form-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultChecked={enableCustomUrl}
|
||||
className="form-check-input"
|
||||
id="cardToggle"
|
||||
onChange={onToggleCustomUrlFeature}
|
||||
/>
|
||||
<label
|
||||
className="form-check-label"
|
||||
htmlFor="cardToggle"
|
||||
></label>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted font-size-sm mt-3">
|
||||
Enable this setting to easily connect to a custom cluster via
|
||||
the "customUrl" url param.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div onClick={onClose}>
|
||||
<Overlay show={show} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type InputProps = { activeSuffix: string; active: boolean };
|
||||
function CustomClusterInput({ activeSuffix, active }: InputProps) {
|
||||
const { customUrl } = useCluster();
|
||||
const updateCustomUrl = useUpdateCustomUrl();
|
||||
const [editing, setEditing] = React.useState(false);
|
||||
const query = useQuery();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
|
||||
const btnClass = active
|
||||
? `border-${activeSuffix} text-${activeSuffix}`
|
||||
: "btn-white";
|
||||
|
||||
const clusterLocation = (location: Location) => {
|
||||
query.set("cluster", "custom");
|
||||
if (customUrl.length > 0) {
|
||||
query.set("customUrl", customUrl);
|
||||
}
|
||||
return {
|
||||
...location,
|
||||
search: query.toString(),
|
||||
};
|
||||
};
|
||||
|
||||
const onUrlInput = useDebounceCallback((url: string) => {
|
||||
updateCustomUrl(url);
|
||||
if (url.length > 0) {
|
||||
query.set("customUrl", url);
|
||||
history.push({ ...location, search: query.toString() });
|
||||
}
|
||||
}, 500);
|
||||
|
||||
const inputTextClass = editing ? "" : "text-muted";
|
||||
return (
|
||||
<>
|
||||
<Link className={`btn col-12 mb-3 ${btnClass}`} to={clusterLocation}>
|
||||
Custom RPC URL
|
||||
</Link>
|
||||
{active && (
|
||||
<input
|
||||
type="url"
|
||||
defaultValue={customUrl}
|
||||
className={`form-control ${inputTextClass}`}
|
||||
onFocus={() => setEditing(true)}
|
||||
onBlur={() => setEditing(false)}
|
||||
onInput={(e) => onUrlInput(e.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ClusterToggle() {
|
||||
const { status, cluster } = useCluster();
|
||||
|
||||
let activeSuffix = "";
|
||||
switch (status) {
|
||||
case ClusterStatus.Connected:
|
||||
activeSuffix = "primary";
|
||||
break;
|
||||
case ClusterStatus.Connecting:
|
||||
activeSuffix = "warning";
|
||||
break;
|
||||
case ClusterStatus.Failure:
|
||||
activeSuffix = "danger";
|
||||
break;
|
||||
default:
|
||||
assertUnreachable(status);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="btn-group-toggle d-flex flex-wrap mb-4">
|
||||
{CLUSTERS.map((net, index) => {
|
||||
const active = net === cluster;
|
||||
if (net === Cluster.Custom)
|
||||
return (
|
||||
<CustomClusterInput
|
||||
key={index}
|
||||
activeSuffix={activeSuffix}
|
||||
active={active}
|
||||
/>
|
||||
);
|
||||
|
||||
const btnClass = active
|
||||
? `border-${activeSuffix} text-${activeSuffix}`
|
||||
: "btn-white";
|
||||
|
||||
const clusterLocation = (location: Location) => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const slug = clusterSlug(net);
|
||||
if (slug !== "mainnet-beta") {
|
||||
params.set("cluster", slug);
|
||||
} else {
|
||||
params.delete("cluster");
|
||||
}
|
||||
return {
|
||||
...location,
|
||||
search: params.toString(),
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={index}
|
||||
className={`btn col-12 mb-3 ${btnClass}`}
|
||||
to={clusterLocation}
|
||||
>
|
||||
{clusterName(net)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,70 +0,0 @@
|
|||
import React from "react";
|
||||
import {
|
||||
useCluster,
|
||||
ClusterStatus,
|
||||
Cluster,
|
||||
useClusterModal,
|
||||
} from "providers/cluster";
|
||||
|
||||
export function ClusterStatusBanner() {
|
||||
const [, setShow] = useClusterModal();
|
||||
|
||||
return (
|
||||
<div className="container d-md-none my-4">
|
||||
<div onClick={() => setShow(true)}>
|
||||
<Button />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClusterStatusButton() {
|
||||
const [, setShow] = useClusterModal();
|
||||
|
||||
return (
|
||||
<div onClick={() => setShow(true)}>
|
||||
<Button />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Button() {
|
||||
const { status, cluster, name, customUrl } = useCluster();
|
||||
const statusName = cluster !== Cluster.Custom ? `${name}` : `${customUrl}`;
|
||||
|
||||
const btnClasses = (variant: string) => {
|
||||
return `btn d-block btn-${variant}`;
|
||||
};
|
||||
|
||||
const spinnerClasses = "spinner-grow spinner-grow-sm me-2";
|
||||
|
||||
switch (status) {
|
||||
case ClusterStatus.Connected:
|
||||
return (
|
||||
<span className={btnClasses("primary")}>
|
||||
<span className="fe fe-check-circle me-2"></span>
|
||||
{statusName}
|
||||
</span>
|
||||
);
|
||||
|
||||
case ClusterStatus.Connecting:
|
||||
return (
|
||||
<span className={btnClasses("warning")}>
|
||||
<span
|
||||
className={spinnerClasses}
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{statusName}
|
||||
</span>
|
||||
);
|
||||
|
||||
case ClusterStatus.Failure:
|
||||
return (
|
||||
<span className={btnClasses("danger")}>
|
||||
<span className="fe fe-alert-circle me-2"></span>
|
||||
{statusName}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,531 +0,0 @@
|
|||
import React from "react";
|
||||
import { Bar } from "react-chartjs-2";
|
||||
import CountUp from "react-countup";
|
||||
import {
|
||||
usePerformanceInfo,
|
||||
PERF_UPDATE_SEC,
|
||||
ClusterStatsStatus,
|
||||
} from "providers/stats/solanaClusterStats";
|
||||
import classNames from "classnames";
|
||||
import { TableCardBody } from "components/common/TableCardBody";
|
||||
import { ChartOptions, ChartTooltipModel } from "chart.js";
|
||||
import { PerformanceInfo } from "providers/stats/solanaPerformanceInfo";
|
||||
import { StatsNotReady } from "pages/ClusterStatsPage";
|
||||
import {
|
||||
PingInfo,
|
||||
PingRollupInfo,
|
||||
PingStatus,
|
||||
useSolanaPingInfo,
|
||||
} from "providers/stats/SolanaPingProvider";
|
||||
|
||||
type Series = "short" | "medium" | "long";
|
||||
type SetSeries = (series: Series) => void;
|
||||
const SERIES: Series[] = ["short", "medium", "long"];
|
||||
const SERIES_INFO = {
|
||||
short: {
|
||||
label: (index: number) => index,
|
||||
interval: "30m",
|
||||
},
|
||||
medium: {
|
||||
label: (index: number) => index * 4,
|
||||
interval: "2h",
|
||||
},
|
||||
long: {
|
||||
label: (index: number) => index * 12,
|
||||
interval: "6h",
|
||||
},
|
||||
};
|
||||
|
||||
export function LiveTransactionStatsCard() {
|
||||
const [series, setSeries] = React.useState<Series>("short");
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h4 className="card-header-title">Live Transaction Stats</h4>
|
||||
</div>
|
||||
<TpsCardBody series={series} setSeries={setSeries} />
|
||||
<PingStatsCardBody series={series} setSeries={setSeries} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TpsCardBody({
|
||||
series,
|
||||
setSeries,
|
||||
}: {
|
||||
series: Series;
|
||||
setSeries: SetSeries;
|
||||
}) {
|
||||
const performanceInfo = usePerformanceInfo();
|
||||
|
||||
if (performanceInfo.status !== ClusterStatsStatus.Ready) {
|
||||
return (
|
||||
<StatsNotReady
|
||||
error={performanceInfo.status === ClusterStatsStatus.Error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TpsBarChart
|
||||
performanceInfo={performanceInfo}
|
||||
series={series}
|
||||
setSeries={setSeries}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const CUSTOM_TPS_TOOLTIP = function (
|
||||
this: any,
|
||||
tooltipModel: ChartTooltipModel
|
||||
) {
|
||||
// Tooltip Element
|
||||
let tooltipEl = document.getElementById("chartjs-tooltip");
|
||||
|
||||
// Create element on first render
|
||||
if (!tooltipEl) {
|
||||
tooltipEl = document.createElement("div");
|
||||
tooltipEl.id = "chartjs-tooltip";
|
||||
tooltipEl.innerHTML = `<div class="content"></div>`;
|
||||
document.body.appendChild(tooltipEl);
|
||||
}
|
||||
|
||||
// Hide if no tooltip
|
||||
if (tooltipModel.opacity === 0) {
|
||||
tooltipEl.style.opacity = "0";
|
||||
return;
|
||||
}
|
||||
|
||||
// Set Text
|
||||
if (tooltipModel.body) {
|
||||
const { label, value } = tooltipModel.dataPoints[0];
|
||||
const tooltipContent = tooltipEl.querySelector("div");
|
||||
if (tooltipContent) {
|
||||
let innerHtml = `<div class="value">${value} TPS</div>`;
|
||||
innerHtml += `<div class="label">${label}</div>`;
|
||||
tooltipContent.innerHTML = innerHtml;
|
||||
}
|
||||
}
|
||||
|
||||
// Enable tooltip and set position
|
||||
const canvas: Element = this._chart.canvas;
|
||||
const position = canvas.getBoundingClientRect();
|
||||
tooltipEl.style.opacity = "1";
|
||||
tooltipEl.style.left =
|
||||
position.left + window.pageXOffset + tooltipModel.caretX + "px";
|
||||
tooltipEl.style.top =
|
||||
position.top + window.pageYOffset + tooltipModel.caretY + "px";
|
||||
};
|
||||
|
||||
const TPS_CHART_OPTIONS = (historyMaxTps: number): ChartOptions => {
|
||||
return {
|
||||
tooltips: {
|
||||
intersect: false, // Show tooltip when cursor in between bars
|
||||
enabled: false, // Hide default tooltip
|
||||
custom: CUSTOM_TPS_TOOLTIP,
|
||||
},
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
scales: {
|
||||
xAxes: [
|
||||
{
|
||||
ticks: {
|
||||
display: false,
|
||||
},
|
||||
gridLines: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
yAxes: [
|
||||
{
|
||||
ticks: {
|
||||
stepSize: 100,
|
||||
fontSize: 10,
|
||||
fontColor: "#EEE",
|
||||
beginAtZero: true,
|
||||
display: true,
|
||||
suggestedMax: historyMaxTps,
|
||||
},
|
||||
gridLines: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
animation: {
|
||||
duration: 0, // general animation time
|
||||
},
|
||||
hover: {
|
||||
animationDuration: 0, // duration of animations when hovering an item
|
||||
},
|
||||
responsiveAnimationDuration: 0, // animation duration after a resize
|
||||
};
|
||||
};
|
||||
|
||||
type TpsBarChartProps = {
|
||||
performanceInfo: PerformanceInfo;
|
||||
series: Series;
|
||||
setSeries: SetSeries;
|
||||
};
|
||||
function TpsBarChart({ performanceInfo, series, setSeries }: TpsBarChartProps) {
|
||||
const { perfHistory, avgTps, historyMaxTps } = performanceInfo;
|
||||
const averageTps = Math.round(avgTps).toLocaleString("en-US");
|
||||
const transactionCount = <AnimatedTransactionCount info={performanceInfo} />;
|
||||
const seriesData = perfHistory[series];
|
||||
const chartOptions = React.useMemo(
|
||||
() => TPS_CHART_OPTIONS(historyMaxTps),
|
||||
[historyMaxTps]
|
||||
);
|
||||
|
||||
const seriesLength = seriesData.length;
|
||||
const chartData: Chart.ChartData = {
|
||||
labels: seriesData.map((val, i) => {
|
||||
return `${SERIES_INFO[series].label(seriesLength - i)}min ago`;
|
||||
}),
|
||||
datasets: [
|
||||
{
|
||||
backgroundColor: "#00D192",
|
||||
hoverBackgroundColor: "#00D192",
|
||||
borderWidth: 0,
|
||||
data: seriesData.map((val) => val || 0),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableCardBody>
|
||||
<tr>
|
||||
<td className="w-100">Transaction count</td>
|
||||
<td className="text-lg-end font-monospace">{transactionCount} </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="w-100">Transactions per second (TPS)</td>
|
||||
<td className="text-lg-end font-monospace">{averageTps} </td>
|
||||
</tr>
|
||||
</TableCardBody>
|
||||
|
||||
<hr className="my-0" />
|
||||
|
||||
<div className="card-body py-3">
|
||||
<div className="align-box-row align-items-start justify-content-between">
|
||||
<div className="d-flex justify-content-between w-100">
|
||||
<span className="mb-0 font-size-sm">TPS history</span>
|
||||
|
||||
<div className="font-size-sm">
|
||||
{SERIES.map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setSeries(key)}
|
||||
className={classNames("btn btn-sm btn-white ms-2", {
|
||||
active: series === key,
|
||||
})}
|
||||
>
|
||||
{SERIES_INFO[key].interval}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="perf-history"
|
||||
className="mt-3 d-flex justify-content-end flex-row w-100"
|
||||
>
|
||||
<div className="w-100">
|
||||
<Bar data={chartData} options={chartOptions} height={80} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedTransactionCount({ info }: { info: PerformanceInfo }) {
|
||||
const txCountRef = React.useRef(0);
|
||||
const countUpRef = React.useRef({ start: 0, period: 0, lastUpdate: 0 });
|
||||
const countUp = countUpRef.current;
|
||||
|
||||
const { transactionCount: txCount, avgTps } = info;
|
||||
|
||||
// Track last tx count to reset count up options
|
||||
if (txCount !== txCountRef.current) {
|
||||
if (countUp.lastUpdate > 0) {
|
||||
// Since we overshoot below, calculate the elapsed value
|
||||
// and start from there.
|
||||
const elapsed = Date.now() - countUp.lastUpdate;
|
||||
const elapsedPeriods = elapsed / (PERF_UPDATE_SEC * 1000);
|
||||
countUp.start = Math.floor(
|
||||
countUp.start + elapsedPeriods * countUp.period
|
||||
);
|
||||
|
||||
// if counter gets ahead of actual count, just hold for a bit
|
||||
// until txCount catches up (this will sometimes happen when a tab is
|
||||
// sent to the background and/or connection drops)
|
||||
countUp.period = Math.max(txCount - countUp.start, 1);
|
||||
} else {
|
||||
// Since this is the first tx count value, estimate the previous
|
||||
// tx count in order to have a starting point for our animation
|
||||
countUp.period = PERF_UPDATE_SEC * avgTps;
|
||||
countUp.start = txCount - countUp.period;
|
||||
}
|
||||
countUp.lastUpdate = Date.now();
|
||||
txCountRef.current = txCount;
|
||||
}
|
||||
|
||||
// Overshoot the target tx count in case the next update is delayed
|
||||
const COUNT_PERIODS = 3;
|
||||
const countUpEnd = countUp.start + COUNT_PERIODS * countUp.period;
|
||||
return (
|
||||
<CountUp
|
||||
start={countUp.start}
|
||||
end={countUpEnd}
|
||||
duration={PERF_UPDATE_SEC * COUNT_PERIODS}
|
||||
delay={0}
|
||||
useEasing={false}
|
||||
preserveValue={true}
|
||||
separator=","
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PingStatsCardBody({
|
||||
series,
|
||||
setSeries,
|
||||
}: {
|
||||
series: Series;
|
||||
setSeries: SetSeries;
|
||||
}) {
|
||||
const pingInfo = useSolanaPingInfo();
|
||||
|
||||
if (pingInfo.status !== PingStatus.Ready) {
|
||||
return (
|
||||
<PingStatsNotReady
|
||||
error={pingInfo.status === PingStatus.Error}
|
||||
retry={pingInfo.retry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PingBarChart pingInfo={pingInfo} series={series} setSeries={setSeries} />
|
||||
);
|
||||
}
|
||||
|
||||
type StatsNotReadyProps = { error: boolean; retry?: Function };
|
||||
function PingStatsNotReady({ error, retry }: StatsNotReadyProps) {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card-body text-center">
|
||||
There was a problem loading solana ping stats.{" "}
|
||||
{retry && (
|
||||
<button
|
||||
className="btn btn-white btn-sm"
|
||||
onClick={() => {
|
||||
retry();
|
||||
}}
|
||||
>
|
||||
<span className="fe fe-refresh-cw me-2"></span>
|
||||
Try Again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-body text-center">
|
||||
<span className="spinner-grow spinner-grow-sm me-2"></span>
|
||||
Loading
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CUSTOM_PING_TOOLTIP = function (
|
||||
this: any,
|
||||
tooltipModel: ChartTooltipModel
|
||||
) {
|
||||
// Tooltip Element
|
||||
let tooltipEl = document.getElementById("chartjs-tooltip");
|
||||
|
||||
// Create element on first render
|
||||
if (!tooltipEl) {
|
||||
tooltipEl = document.createElement("div");
|
||||
tooltipEl.id = "chartjs-tooltip";
|
||||
tooltipEl.innerHTML = `<div class="content"></div>`;
|
||||
document.body.appendChild(tooltipEl);
|
||||
}
|
||||
|
||||
// Hide if no tooltip
|
||||
if (tooltipModel.opacity === 0) {
|
||||
tooltipEl.style.opacity = "0";
|
||||
return;
|
||||
}
|
||||
|
||||
// Set Text
|
||||
if (tooltipModel.body) {
|
||||
const { label } = tooltipModel.dataPoints[0];
|
||||
const tooltipContent = tooltipEl.querySelector("div");
|
||||
if (tooltipContent) {
|
||||
tooltipContent.innerHTML = `${label}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Enable tooltip and set position
|
||||
const canvas: Element = this._chart.canvas;
|
||||
const position = canvas.getBoundingClientRect();
|
||||
tooltipEl.style.opacity = "1";
|
||||
tooltipEl.style.left =
|
||||
position.left + window.pageXOffset + tooltipModel.caretX + "px";
|
||||
tooltipEl.style.top =
|
||||
position.top + window.pageYOffset + tooltipModel.caretY + "px";
|
||||
};
|
||||
|
||||
const PING_CHART_OPTIONS: ChartOptions = {
|
||||
tooltips: {
|
||||
intersect: false, // Show tooltip when cursor in between bars
|
||||
enabled: false, // Hide default tooltip
|
||||
custom: CUSTOM_PING_TOOLTIP,
|
||||
},
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
scales: {
|
||||
xAxes: [
|
||||
{
|
||||
ticks: {
|
||||
display: false,
|
||||
},
|
||||
gridLines: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
yAxes: [
|
||||
{
|
||||
ticks: {
|
||||
stepSize: 100,
|
||||
fontSize: 10,
|
||||
fontColor: "#EEE",
|
||||
beginAtZero: true,
|
||||
display: true,
|
||||
},
|
||||
gridLines: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
animation: {
|
||||
duration: 0, // general animation time
|
||||
},
|
||||
hover: {
|
||||
animationDuration: 0, // duration of animations when hovering an item
|
||||
},
|
||||
responsiveAnimationDuration: 0, // animation duration after a resize
|
||||
};
|
||||
|
||||
function PingBarChart({
|
||||
pingInfo,
|
||||
series,
|
||||
setSeries,
|
||||
}: {
|
||||
pingInfo: PingRollupInfo;
|
||||
series: Series;
|
||||
setSeries: SetSeries;
|
||||
}) {
|
||||
const seriesData = pingInfo[series] || [];
|
||||
const maxMean = seriesData.reduce((a, b) => {
|
||||
return Math.max(a, b.mean);
|
||||
}, 0);
|
||||
const seriesLength = seriesData.length;
|
||||
const backgroundColor = (val: PingInfo) => {
|
||||
if (val.submitted === 0) {
|
||||
return "#08a274";
|
||||
}
|
||||
|
||||
if (val.loss >= 0.25 && val.loss <= 0.5) {
|
||||
return "#FFA500";
|
||||
}
|
||||
|
||||
return val.loss > 0.5 ? "#f00" : "#00D192";
|
||||
};
|
||||
const chartData: Chart.ChartData = {
|
||||
labels: seriesData.map((val, i) => {
|
||||
if (val.submitted === 0) {
|
||||
return `
|
||||
<div class="label">
|
||||
<p class="mb-0">Ping statistics unavailable</p>
|
||||
${SERIES_INFO[series].label(seriesLength - i)}min ago
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="value">${val.mean} ms</div>
|
||||
<div class="label">
|
||||
<p class="mb-0">${val.confirmed} of ${val.submitted} confirmed</p>
|
||||
${
|
||||
val.loss
|
||||
? `<p class="mb-0">${val.loss.toLocaleString(undefined, {
|
||||
style: "percent",
|
||||
minimumFractionDigits: 2,
|
||||
})} loss</p>`
|
||||
: ""
|
||||
}
|
||||
${SERIES_INFO[series].label(seriesLength - i)}min ago
|
||||
</div>
|
||||
`;
|
||||
}),
|
||||
datasets: [
|
||||
{
|
||||
minBarLength: 2,
|
||||
backgroundColor: seriesData.map(backgroundColor),
|
||||
hoverBackgroundColor: seriesData.map(backgroundColor),
|
||||
borderWidth: 0,
|
||||
data: seriesData.map((val) => {
|
||||
if (val.submitted === 0) {
|
||||
return maxMean * 0.5;
|
||||
}
|
||||
|
||||
return val.mean || 0;
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card-body py-3">
|
||||
<div className="align-box-row align-items-start justify-content-between">
|
||||
<div className="d-flex justify-content-between w-100">
|
||||
<span className="mb-0 font-size-sm">Average Ping Time</span>
|
||||
|
||||
<div className="font-size-sm">
|
||||
{SERIES.map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setSeries(key)}
|
||||
className={classNames("btn btn-sm btn-white ms-2", {
|
||||
active: series === key,
|
||||
})}
|
||||
>
|
||||
{SERIES_INFO[key].interval}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="perf-history"
|
||||
className="mt-3 d-flex justify-content-end flex-row w-100"
|
||||
>
|
||||
<div className="w-100">
|
||||
<Bar data={chartData} options={PING_CHART_OPTIONS} height={80} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,78 +0,0 @@
|
|||
import React from "react";
|
||||
import { useCluster, Cluster } from "providers/cluster";
|
||||
import { displayTimestamp } from "utils/date";
|
||||
|
||||
type Announcement = {
|
||||
message: string;
|
||||
estimate?: string;
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
};
|
||||
|
||||
const announcements = new Map<Cluster, Announcement>();
|
||||
// announcements.set(Cluster.Devnet, {
|
||||
// message: "Devnet API node is restarting",
|
||||
// start: new Date("July 25, 2020 18:00:00 GMT+8:00"),
|
||||
// estimate: "2 hours",
|
||||
// });
|
||||
// announcements.set(Cluster.MainnetBeta, {
|
||||
// message: "Mainnet Beta upgrade in progress. Transactions disabled until epoch 62",
|
||||
// start: new Date("August 2, 2020 00:00:00 GMT+0:00"),
|
||||
// end: new Date("August 4, 2020 00:00:00 GMT+0:00"),
|
||||
// });
|
||||
// announcements.set(Cluster.MainnetBeta, {
|
||||
// message:
|
||||
// "Mainnet Beta upgrade in progress. Transactions disabled until epoch 62",
|
||||
// });
|
||||
|
||||
export function MessageBanner() {
|
||||
const cluster = useCluster().cluster;
|
||||
const announcement = announcements.get(cluster);
|
||||
if (!announcement) return null;
|
||||
const { message, start, end, estimate } = announcement;
|
||||
|
||||
const now = new Date();
|
||||
if (end && now > end) return null;
|
||||
if (start && now < start) return null;
|
||||
|
||||
let timeframe;
|
||||
if (estimate || start || end) {
|
||||
timeframe = (
|
||||
<div>
|
||||
<hr className="text-gray-500 w-100 my-3 opacity-50" />
|
||||
{estimate && (
|
||||
<h5 className="font-sm text-gray-200">
|
||||
<span className="text-uppercase">Estimated Duration: </span>
|
||||
{estimate}
|
||||
</h5>
|
||||
)}
|
||||
{start && (
|
||||
<h5 className="font-sm text-gray-200">
|
||||
<span className="text-uppercase">Started at: </span>
|
||||
{displayTimestamp(start.getTime())}
|
||||
</h5>
|
||||
)}
|
||||
{end && (
|
||||
<h5 className="font-sm text-gray-200">
|
||||
<span className="text-uppercase">End: </span>
|
||||
{displayTimestamp(end.getTime())}
|
||||
</h5>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-info">
|
||||
<div className="container">
|
||||
<div className="d-flex flex-column align-items-center justify-content-center text-center py-3">
|
||||
<h3 className="mb-0 line-height-md">
|
||||
<span className="fe fe-alert-circle me-2"></span>
|
||||
{message}
|
||||
</h3>
|
||||
{timeframe}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,56 +0,0 @@
|
|||
import React from "react";
|
||||
import Logo from "img/logos-solana/dark-explorer-logo.svg";
|
||||
import { clusterPath } from "utils/url";
|
||||
import { Link, NavLink } from "react-router-dom";
|
||||
import { ClusterStatusButton } from "components/ClusterStatusButton";
|
||||
|
||||
export function Navbar() {
|
||||
// TODO: use `collapsing` to animate collapsible navbar
|
||||
const [collapse, setCollapse] = React.useState(false);
|
||||
|
||||
return (
|
||||
<nav className="navbar navbar-expand-md navbar-light">
|
||||
<div className="container">
|
||||
<Link to={clusterPath("/")}>
|
||||
<img src={Logo} width="250" alt="Solana Explorer" />
|
||||
</Link>
|
||||
|
||||
<button
|
||||
className="navbar-toggler"
|
||||
type="button"
|
||||
onClick={() => setCollapse((value) => !value)}
|
||||
>
|
||||
<span className="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`collapse navbar-collapse ms-auto me-4 ${
|
||||
collapse ? "show" : ""
|
||||
}`}
|
||||
>
|
||||
<ul className="navbar-nav me-auto">
|
||||
<li className="nav-item">
|
||||
<NavLink className="nav-link" to={clusterPath("/")} exact>
|
||||
Cluster Stats
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<NavLink className="nav-link" to={clusterPath("/supply")}>
|
||||
Supply
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<NavLink className="nav-link" to={clusterPath("/tx/inspector")}>
|
||||
Inspector
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="d-none d-md-block">
|
||||
<ClusterStatusButton />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
|
@ -1,102 +0,0 @@
|
|||
import { ParsedMessage, PublicKey, VersionedMessage } from "@solana/web3.js";
|
||||
import { Cluster } from "providers/cluster";
|
||||
import { TableCardBody } from "components/common/TableCardBody";
|
||||
import { InstructionLogs } from "utils/program-logs";
|
||||
import { ProgramName } from "utils/anchor";
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import getInstructionCardScrollAnchorId from "utils/get-instruction-card-scroll-anchor-id";
|
||||
|
||||
const NATIVE_PROGRAMS_MISSING_INVOKE_LOG: string[] = [
|
||||
"AddressLookupTab1e1111111111111111111111111",
|
||||
"ZkTokenProof1111111111111111111111111111111",
|
||||
"BPFLoader1111111111111111111111111111111111",
|
||||
"BPFLoader2111111111111111111111111111111111",
|
||||
"BPFLoaderUpgradeab1e11111111111111111111111",
|
||||
];
|
||||
|
||||
export function ProgramLogsCardBody({
|
||||
message,
|
||||
logs,
|
||||
cluster,
|
||||
url,
|
||||
}: {
|
||||
message: VersionedMessage | ParsedMessage;
|
||||
logs: InstructionLogs[];
|
||||
cluster: Cluster;
|
||||
url: string;
|
||||
}) {
|
||||
let logIndex = 0;
|
||||
let instructionProgramIds: PublicKey[];
|
||||
if ("compiledInstructions" in message) {
|
||||
instructionProgramIds = message.compiledInstructions.map((ix) => {
|
||||
return message.staticAccountKeys[ix.programIdIndex];
|
||||
});
|
||||
} else {
|
||||
instructionProgramIds = message.instructions.map((ix) => ix.programId);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCardBody>
|
||||
{instructionProgramIds.map((programId, index) => {
|
||||
const programAddress = programId.toBase58();
|
||||
let programLogs: InstructionLogs | undefined = logs[logIndex];
|
||||
if (programLogs?.invokedProgram === programAddress) {
|
||||
logIndex++;
|
||||
} else if (
|
||||
programLogs?.invokedProgram === null &&
|
||||
programLogs.logs.length > 0 &&
|
||||
NATIVE_PROGRAMS_MISSING_INVOKE_LOG.includes(programAddress)
|
||||
) {
|
||||
logIndex++;
|
||||
} else {
|
||||
programLogs = undefined;
|
||||
}
|
||||
|
||||
let badgeColor = "white";
|
||||
if (programLogs) {
|
||||
badgeColor = programLogs.failed ? "warning" : "success";
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
<Link
|
||||
className="d-flex align-items-center"
|
||||
to={(location) => ({
|
||||
...location,
|
||||
hash: `#${getInstructionCardScrollAnchorId([index + 1])}`,
|
||||
})}
|
||||
>
|
||||
<span className={`badge bg-${badgeColor}-soft me-2`}>
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className="program-log-instruction-name">
|
||||
<ProgramName
|
||||
programId={programId}
|
||||
cluster={cluster}
|
||||
url={url}
|
||||
/>{" "}
|
||||
Instruction
|
||||
</span>
|
||||
<span className="fe fe-chevrons-up c-pointer px-2" />
|
||||
</Link>
|
||||
{programLogs && (
|
||||
<div className="d-flex align-items-start flex-column font-monospace p-2 font-size-sm">
|
||||
{programLogs.logs.map((log, key) => {
|
||||
return (
|
||||
<span key={key}>
|
||||
<span className="text-muted">{log.prefix}</span>
|
||||
<span className={`text-${log.style}`}>{log.text}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableCardBody>
|
||||
);
|
||||
}
|
|
@ -1,393 +0,0 @@
|
|||
import React from "react";
|
||||
import bs58 from "bs58";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import Select, { InputActionMeta, ActionMeta, ValueType } from "react-select";
|
||||
import StateManager from "react-select";
|
||||
import {
|
||||
LOADER_IDS,
|
||||
PROGRAM_INFO_BY_ID,
|
||||
SPECIAL_IDS,
|
||||
SYSVAR_IDS,
|
||||
LoaderName,
|
||||
} from "utils/tx";
|
||||
import { Cluster, useCluster } from "providers/cluster";
|
||||
import { useTokenRegistry } from "providers/mints/token-registry";
|
||||
import { TokenInfoMap } from "@solana/spl-token-registry";
|
||||
import { Connection } from "@solana/web3.js";
|
||||
import { getDomainInfo, hasDomainSyntax } from "utils/name-service";
|
||||
|
||||
interface SearchOptions {
|
||||
label: string;
|
||||
options: {
|
||||
label: string;
|
||||
value: string[];
|
||||
pathname: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function SearchBar() {
|
||||
const [search, setSearch] = React.useState("");
|
||||
const searchRef = React.useRef("");
|
||||
const [searchOptions, setSearchOptions] = React.useState<SearchOptions[]>([]);
|
||||
const [loadingSearch, setLoadingSearch] = React.useState<boolean>(false);
|
||||
const [loadingSearchMessage, setLoadingSearchMessage] =
|
||||
React.useState<string>("loading...");
|
||||
const selectRef = React.useRef<StateManager<any> | null>(null);
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const { tokenRegistry } = useTokenRegistry();
|
||||
const { url, cluster, clusterInfo } = useCluster();
|
||||
|
||||
const onChange = (
|
||||
{ pathname }: ValueType<any, false>,
|
||||
meta: ActionMeta<any>
|
||||
) => {
|
||||
if (meta.action === "select-option") {
|
||||
history.push({ ...location, pathname });
|
||||
setSearch("");
|
||||
}
|
||||
};
|
||||
|
||||
const onInputChange = (value: string, { action }: InputActionMeta) => {
|
||||
if (action === "input-change") {
|
||||
setSearch(value);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
searchRef.current = search;
|
||||
setLoadingSearchMessage("Loading...");
|
||||
setLoadingSearch(true);
|
||||
|
||||
// builds and sets local search output
|
||||
const options = buildOptions(
|
||||
search,
|
||||
cluster,
|
||||
tokenRegistry,
|
||||
clusterInfo?.epochInfo.epoch
|
||||
);
|
||||
|
||||
setSearchOptions(options);
|
||||
|
||||
// checking for non local search output
|
||||
if (hasDomainSyntax(search)) {
|
||||
// if search input is a potential domain we continue the loading state
|
||||
domainSearch(options);
|
||||
} else {
|
||||
// if search input is not a potential domain we can conclude the search has finished
|
||||
setLoadingSearch(false);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search]);
|
||||
|
||||
// appends domain lookup results to the local search state
|
||||
const domainSearch = async (options: SearchOptions[]) => {
|
||||
setLoadingSearchMessage("Looking up domain...");
|
||||
const connection = new Connection(url);
|
||||
const searchTerm = search;
|
||||
const updatedOptions = await buildDomainOptions(
|
||||
connection,
|
||||
search,
|
||||
options
|
||||
);
|
||||
if (searchRef.current === searchTerm) {
|
||||
setSearchOptions(updatedOptions);
|
||||
// after attempting to fetch the domain name we can conclude the loading state
|
||||
setLoadingSearch(false);
|
||||
setLoadingSearchMessage("Loading...");
|
||||
}
|
||||
};
|
||||
|
||||
const resetValue = "" as any;
|
||||
return (
|
||||
<div className="container my-4">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<Select
|
||||
autoFocus
|
||||
ref={(ref) => (selectRef.current = ref)}
|
||||
options={searchOptions}
|
||||
noOptionsMessage={() => "No Results"}
|
||||
loadingMessage={() => loadingSearchMessage}
|
||||
placeholder="Search for blocks, accounts, transactions, programs, and tokens"
|
||||
value={resetValue}
|
||||
inputValue={search}
|
||||
blurInputOnSelect
|
||||
onMenuClose={() => selectRef.current?.blur()}
|
||||
onChange={onChange}
|
||||
styles={{
|
||||
/* work around for https://github.com/JedWatson/react-select/issues/3857 */
|
||||
placeholder: (style) => ({ ...style, pointerEvents: "none" }),
|
||||
input: (style) => ({ ...style, width: "100%" }),
|
||||
}}
|
||||
onInputChange={onInputChange}
|
||||
components={{ DropdownIndicator }}
|
||||
classNamePrefix="search-bar"
|
||||
isLoading={loadingSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildProgramOptions(search: string, cluster: Cluster) {
|
||||
const matchedPrograms = Object.entries(PROGRAM_INFO_BY_ID).filter(
|
||||
([address, { name, deployments }]) => {
|
||||
if (!deployments.includes(cluster)) return false;
|
||||
return (
|
||||
name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
address.includes(search)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if (matchedPrograms.length > 0) {
|
||||
return {
|
||||
label: "Programs",
|
||||
options: matchedPrograms.map(([address, { name }]) => ({
|
||||
label: name,
|
||||
value: [name, address],
|
||||
pathname: "/address/" + address,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const SEARCHABLE_LOADERS: LoaderName[] = [
|
||||
"BPF Loader",
|
||||
"BPF Loader 2",
|
||||
"BPF Upgradeable Loader",
|
||||
];
|
||||
|
||||
function buildLoaderOptions(search: string) {
|
||||
const matchedLoaders = Object.entries(LOADER_IDS).filter(
|
||||
([address, name]) => {
|
||||
return (
|
||||
SEARCHABLE_LOADERS.includes(name) &&
|
||||
(name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
address.includes(search))
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if (matchedLoaders.length > 0) {
|
||||
return {
|
||||
label: "Program Loaders",
|
||||
options: matchedLoaders.map(([id, name]) => ({
|
||||
label: name,
|
||||
value: [name, id],
|
||||
pathname: "/address/" + id,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildSysvarOptions(search: string) {
|
||||
const matchedSysvars = Object.entries(SYSVAR_IDS).filter(
|
||||
([address, name]) => {
|
||||
return (
|
||||
name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
address.includes(search)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if (matchedSysvars.length > 0) {
|
||||
return {
|
||||
label: "Sysvars",
|
||||
options: matchedSysvars.map(([id, name]) => ({
|
||||
label: name,
|
||||
value: [name, id],
|
||||
pathname: "/address/" + id,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildSpecialOptions(search: string) {
|
||||
const matchedSpecialIds = Object.entries(SPECIAL_IDS).filter(
|
||||
([address, name]) => {
|
||||
return (
|
||||
name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
address.includes(search)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if (matchedSpecialIds.length > 0) {
|
||||
return {
|
||||
label: "Accounts",
|
||||
options: matchedSpecialIds.map(([id, name]) => ({
|
||||
label: name,
|
||||
value: [name, id],
|
||||
pathname: "/address/" + id,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildTokenOptions(
|
||||
search: string,
|
||||
cluster: Cluster,
|
||||
tokenRegistry: TokenInfoMap
|
||||
) {
|
||||
const matchedTokens = Array.from(tokenRegistry.entries()).filter(
|
||||
([address, details]) => {
|
||||
const searchLower = search.toLowerCase();
|
||||
return (
|
||||
details.name.toLowerCase().includes(searchLower) ||
|
||||
details.symbol.toLowerCase().includes(searchLower) ||
|
||||
address.includes(search)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if (matchedTokens.length > 0) {
|
||||
return {
|
||||
label: "Tokens",
|
||||
options: matchedTokens.slice(0, 10).map(([id, details]) => ({
|
||||
label: details.name,
|
||||
value: [details.name, details.symbol, id],
|
||||
pathname: "/address/" + id,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function buildDomainOptions(
|
||||
connection: Connection,
|
||||
search: string,
|
||||
options: SearchOptions[]
|
||||
) {
|
||||
const domainInfo = await getDomainInfo(search, connection);
|
||||
const updatedOptions: SearchOptions[] = [...options];
|
||||
if (domainInfo && domainInfo.owner && domainInfo.address) {
|
||||
updatedOptions.push({
|
||||
label: "Domain Owner",
|
||||
options: [
|
||||
{
|
||||
label: domainInfo.owner,
|
||||
value: [search],
|
||||
pathname: "/address/" + domainInfo.owner,
|
||||
},
|
||||
],
|
||||
});
|
||||
updatedOptions.push({
|
||||
label: "Name Service Account",
|
||||
options: [
|
||||
{
|
||||
label: search,
|
||||
value: [search],
|
||||
pathname: "/address/" + domainInfo.address,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return updatedOptions;
|
||||
}
|
||||
|
||||
// builds local search options
|
||||
function buildOptions(
|
||||
rawSearch: string,
|
||||
cluster: Cluster,
|
||||
tokenRegistry: TokenInfoMap,
|
||||
currentEpoch?: number
|
||||
) {
|
||||
const search = rawSearch.trim();
|
||||
if (search.length === 0) return [];
|
||||
|
||||
const options = [];
|
||||
|
||||
const programOptions = buildProgramOptions(search, cluster);
|
||||
if (programOptions) {
|
||||
options.push(programOptions);
|
||||
}
|
||||
|
||||
const loaderOptions = buildLoaderOptions(search);
|
||||
if (loaderOptions) {
|
||||
options.push(loaderOptions);
|
||||
}
|
||||
|
||||
const sysvarOptions = buildSysvarOptions(search);
|
||||
if (sysvarOptions) {
|
||||
options.push(sysvarOptions);
|
||||
}
|
||||
|
||||
const specialOptions = buildSpecialOptions(search);
|
||||
if (specialOptions) {
|
||||
options.push(specialOptions);
|
||||
}
|
||||
|
||||
const tokenOptions = buildTokenOptions(search, cluster, tokenRegistry);
|
||||
if (tokenOptions) {
|
||||
options.push(tokenOptions);
|
||||
}
|
||||
|
||||
if (!isNaN(Number(search))) {
|
||||
options.push({
|
||||
label: "Block",
|
||||
options: [
|
||||
{
|
||||
label: `Slot #${search}`,
|
||||
value: [search],
|
||||
pathname: `/block/${search}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (currentEpoch !== undefined && Number(search) <= currentEpoch + 1) {
|
||||
options.push({
|
||||
label: "Epoch",
|
||||
options: [
|
||||
{
|
||||
label: `Epoch #${search}`,
|
||||
value: [search],
|
||||
pathname: `/epoch/${search}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer nice suggestions over raw suggestions
|
||||
if (options.length > 0) return options;
|
||||
|
||||
try {
|
||||
const decoded = bs58.decode(search);
|
||||
if (decoded.length === 32) {
|
||||
options.push({
|
||||
label: "Account",
|
||||
options: [
|
||||
{
|
||||
label: search,
|
||||
value: [search],
|
||||
pathname: "/address/" + search,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else if (decoded.length === 64) {
|
||||
options.push({
|
||||
label: "Transaction",
|
||||
options: [
|
||||
{
|
||||
label: search,
|
||||
value: [search],
|
||||
pathname: "/tx/" + search,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
} catch (err) {}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function DropdownIndicator() {
|
||||
return (
|
||||
<div className="search-indicator">
|
||||
<span className="fe fe-search"></span>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,74 +0,0 @@
|
|||
import React from "react";
|
||||
import { useSupply, useFetchSupply, Status } from "providers/supply";
|
||||
import { LoadingCard } from "./common/LoadingCard";
|
||||
import { ErrorCard } from "./common/ErrorCard";
|
||||
import { SolBalance } from "components/common/SolBalance";
|
||||
import { TableCardBody } from "./common/TableCardBody";
|
||||
|
||||
export function SupplyCard() {
|
||||
const supply = useSupply();
|
||||
const fetchSupply = useFetchSupply();
|
||||
|
||||
// Fetch supply on load
|
||||
React.useEffect(() => {
|
||||
if (supply === Status.Idle) fetchSupply();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (supply === Status.Disconnected) {
|
||||
return <ErrorCard text="Not connected to the cluster" />;
|
||||
}
|
||||
|
||||
if (supply === Status.Idle || supply === Status.Connecting)
|
||||
return <LoadingCard />;
|
||||
|
||||
if (typeof supply === "string") {
|
||||
return <ErrorCard text={supply} retry={fetchSupply} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
{renderHeader()}
|
||||
|
||||
<TableCardBody>
|
||||
<tr>
|
||||
<td className="w-100">Total Supply (SOL)</td>
|
||||
<td className="text-lg-end">
|
||||
<SolBalance lamports={supply.total} maximumFractionDigits={0} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td className="w-100">Circulating Supply (SOL)</td>
|
||||
<td className="text-lg-end">
|
||||
<SolBalance
|
||||
lamports={supply.circulating}
|
||||
maximumFractionDigits={0}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td className="w-100">Non-Circulating Supply (SOL)</td>
|
||||
<td className="text-lg-end">
|
||||
<SolBalance
|
||||
lamports={supply.nonCirculating}
|
||||
maximumFractionDigits={0}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderHeader = () => {
|
||||
return (
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h4 className="card-header-title">Supply Overview</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,221 +0,0 @@
|
|||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Location } from "history";
|
||||
import { AccountBalancePair } from "@solana/web3.js";
|
||||
import { useRichList, useFetchRichList, Status } from "providers/richList";
|
||||
import { LoadingCard } from "./common/LoadingCard";
|
||||
import { ErrorCard } from "./common/ErrorCard";
|
||||
import { SolBalance } from "components/common/SolBalance";
|
||||
import { useQuery } from "utils/url";
|
||||
import { useSupply } from "providers/supply";
|
||||
import { Address } from "./common/Address";
|
||||
|
||||
type Filter = "circulating" | "nonCirculating" | "all" | null;
|
||||
|
||||
export function TopAccountsCard() {
|
||||
const supply = useSupply();
|
||||
const richList = useRichList();
|
||||
const fetchRichList = useFetchRichList();
|
||||
const [showDropdown, setDropdown] = React.useState(false);
|
||||
const filter = useQueryFilter();
|
||||
|
||||
if (typeof supply !== "object") return null;
|
||||
|
||||
if (richList === Status.Disconnected) {
|
||||
return <ErrorCard text="Not connected to the cluster" />;
|
||||
}
|
||||
|
||||
if (richList === Status.Connecting) {
|
||||
return <LoadingCard />;
|
||||
}
|
||||
|
||||
if (typeof richList === "string") {
|
||||
return <ErrorCard text={richList} retry={fetchRichList} />;
|
||||
}
|
||||
|
||||
let supplyCount: number;
|
||||
let accounts, header;
|
||||
|
||||
if (richList !== Status.Idle) {
|
||||
switch (filter) {
|
||||
case "nonCirculating": {
|
||||
accounts = richList.nonCirculating;
|
||||
supplyCount = supply.nonCirculating;
|
||||
header = "Non-Circulating";
|
||||
break;
|
||||
}
|
||||
case "all": {
|
||||
accounts = richList.total;
|
||||
supplyCount = supply.total;
|
||||
header = "Total";
|
||||
break;
|
||||
}
|
||||
case "circulating":
|
||||
default: {
|
||||
accounts = richList.circulating;
|
||||
supplyCount = supply.circulating;
|
||||
header = "Circulating";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showDropdown && (
|
||||
<div className="dropdown-exit" onClick={() => setDropdown(false)} />
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h4 className="card-header-title">Largest Accounts</h4>
|
||||
</div>
|
||||
|
||||
<div className="col-auto">
|
||||
<FilterDropdown
|
||||
filter={filter}
|
||||
toggle={() => setDropdown((show) => !show)}
|
||||
show={showDropdown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{richList === Status.Idle && (
|
||||
<div className="card-body">
|
||||
<span
|
||||
className="btn btn-white ms-3 d-none d-md-inline"
|
||||
onClick={fetchRichList}
|
||||
>
|
||||
Load Largest Accounts
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{accounts && (
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-muted">Rank</th>
|
||||
<th className="text-muted">Address</th>
|
||||
<th className="text-muted text-end">Balance (SOL)</th>
|
||||
<th className="text-muted text-end">% of {header} Supply</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="list">
|
||||
{accounts.map((account, index) =>
|
||||
renderAccountRow(account, index, supplyCount)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const renderAccountRow = (
|
||||
account: AccountBalancePair,
|
||||
index: number,
|
||||
supply: number
|
||||
) => {
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
<span className="badge bg-gray-soft badge-pill">{index + 1}</span>
|
||||
</td>
|
||||
<td>
|
||||
<Address pubkey={account.address} link />
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<SolBalance lamports={account.lamports} maximumFractionDigits={0} />
|
||||
</td>
|
||||
<td className="text-end">{`${((100 * account.lamports) / supply).toFixed(
|
||||
3
|
||||
)}%`}</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
const useQueryFilter = (): Filter => {
|
||||
const query = useQuery();
|
||||
const filter = query.get("filter");
|
||||
if (
|
||||
filter === "circulating" ||
|
||||
filter === "nonCirculating" ||
|
||||
filter === "all"
|
||||
) {
|
||||
return filter;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const filterTitle = (filter: Filter): string => {
|
||||
switch (filter) {
|
||||
case "nonCirculating": {
|
||||
return "Non-Circulating";
|
||||
}
|
||||
case "all": {
|
||||
return "All";
|
||||
}
|
||||
case "circulating":
|
||||
default: {
|
||||
return "Circulating";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
type DropdownProps = {
|
||||
filter: Filter;
|
||||
toggle: () => void;
|
||||
show: boolean;
|
||||
};
|
||||
|
||||
const FilterDropdown = ({ filter, toggle, show }: DropdownProps) => {
|
||||
const buildLocation = (location: Location, filter: Filter) => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (filter === null) {
|
||||
params.delete("filter");
|
||||
} else {
|
||||
params.set("filter", filter);
|
||||
}
|
||||
return {
|
||||
...location,
|
||||
search: params.toString(),
|
||||
};
|
||||
};
|
||||
|
||||
const FILTERS: Filter[] = ["all", null, "nonCirculating"];
|
||||
return (
|
||||
<div className="dropdown">
|
||||
<button
|
||||
className="btn btn-white btn-sm dropdown-toggle"
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
>
|
||||
{filterTitle(filter)}
|
||||
</button>
|
||||
<div className={`dropdown-menu-end dropdown-menu${show ? " show" : ""}`}>
|
||||
{FILTERS.map((filterOption) => {
|
||||
return (
|
||||
<Link
|
||||
key={filterOption || "null"}
|
||||
to={(location) => buildLocation(location, filterOption)}
|
||||
className={`dropdown-item${
|
||||
filterOption === filter ? " active" : ""
|
||||
}`}
|
||||
onClick={toggle}
|
||||
>
|
||||
{filterTitle(filterOption)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,86 +0,0 @@
|
|||
import React, { useMemo } from "react";
|
||||
import { Account } from "providers/accounts";
|
||||
import { useCluster } from "providers/cluster";
|
||||
import { BorshAccountsCoder } from "@project-serum/anchor";
|
||||
import { IdlTypeDef } from "@project-serum/anchor/dist/cjs/idl";
|
||||
import { getAnchorProgramName, mapAccountToRows } from "utils/anchor";
|
||||
import { ErrorCard } from "components/common/ErrorCard";
|
||||
import { useAnchorProgram } from "providers/anchor";
|
||||
|
||||
export function AnchorAccountCard({ account }: { account: Account }) {
|
||||
const { lamports } = account;
|
||||
const { url } = useCluster();
|
||||
const anchorProgram = useAnchorProgram(account.owner.toString(), url);
|
||||
const rawData = account.data.raw;
|
||||
const programName = getAnchorProgramName(anchorProgram) || "Unknown Program";
|
||||
|
||||
const { decodedAccountData, accountDef } = useMemo(() => {
|
||||
let decodedAccountData: any | null = null;
|
||||
let accountDef: IdlTypeDef | undefined = undefined;
|
||||
if (anchorProgram && rawData) {
|
||||
const coder = new BorshAccountsCoder(anchorProgram.idl);
|
||||
const accountDefTmp = anchorProgram.idl.accounts?.find(
|
||||
(accountType: any) =>
|
||||
(rawData as Buffer)
|
||||
.slice(0, 8)
|
||||
.equals(BorshAccountsCoder.accountDiscriminator(accountType.name))
|
||||
);
|
||||
if (accountDefTmp) {
|
||||
accountDef = accountDefTmp;
|
||||
try {
|
||||
decodedAccountData = coder.decode(accountDef.name, rawData);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
decodedAccountData,
|
||||
accountDef,
|
||||
};
|
||||
}, [anchorProgram, rawData]);
|
||||
|
||||
if (lamports === undefined) return null;
|
||||
if (!anchorProgram) return <ErrorCard text="No Anchor IDL found" />;
|
||||
if (!decodedAccountData || !accountDef) {
|
||||
return (
|
||||
<ErrorCard text="Failed to decode account data according to the public Anchor interface" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h3 className="card-header-title">
|
||||
{programName}: {accountDef.name}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-1">Field</th>
|
||||
<th className="w-1">Type</th>
|
||||
<th className="w-1">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mapAccountToRows(
|
||||
decodedAccountData,
|
||||
accountDef as IdlTypeDef,
|
||||
anchorProgram.idl
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
import { PublicKey } from "@solana/web3.js";
|
||||
import { useAnchorProgram } from "providers/anchor";
|
||||
import { useCluster } from "providers/cluster";
|
||||
import ReactJson from "react-json-view";
|
||||
|
||||
export function AnchorProgramCard({ programId }: { programId: PublicKey }) {
|
||||
const { url } = useCluster();
|
||||
const program = useAnchorProgram(programId.toString(), url);
|
||||
|
||||
if (!program) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h3 className="card-header-title">Anchor IDL</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card metadata-json-viewer m-4">
|
||||
<ReactJson
|
||||
src={program.idl}
|
||||
theme={"solarized"}
|
||||
style={{ padding: 25 }}
|
||||
collapsed={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,61 +0,0 @@
|
|||
import React from "react";
|
||||
import {
|
||||
RecentBlockhashesInfo,
|
||||
RecentBlockhashesEntry,
|
||||
} from "validators/accounts/sysvar";
|
||||
|
||||
export function BlockhashesCard({
|
||||
blockhashes,
|
||||
}: {
|
||||
blockhashes: RecentBlockhashesInfo;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h3 className="card-header-title">Blockhashes</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-1 text-muted">Recency</th>
|
||||
<th className="w-1 text-muted">Blockhash</th>
|
||||
<th className="text-muted">Fee Calculator</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="list">
|
||||
{blockhashes.length > 0 &&
|
||||
blockhashes.map((entry: RecentBlockhashesEntry, index) => {
|
||||
return renderAccountRow(entry, index);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="card-footer">
|
||||
<div className="text-muted text-center">
|
||||
{blockhashes.length > 0 ? "" : "No blockhashes found"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const renderAccountRow = (entry: RecentBlockhashesEntry, index: number) => {
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td className="w-1">{index + 1}</td>
|
||||
<td className="w-1 font-monospace">{entry.blockhash}</td>
|
||||
<td className="">
|
||||
{entry.feeCalculator.lamportsPerSignature} lamports per signature
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
|
@ -1,158 +0,0 @@
|
|||
import React from "react";
|
||||
import { Account, useFetchAccountInfo } from "providers/accounts";
|
||||
import { TableCardBody } from "components/common/TableCardBody";
|
||||
import {
|
||||
ConfigAccount,
|
||||
StakeConfigInfoAccount,
|
||||
ValidatorInfoAccount,
|
||||
} from "validators/accounts/config";
|
||||
import {
|
||||
AccountAddressRow,
|
||||
AccountBalanceRow,
|
||||
AccountHeader,
|
||||
} from "components/common/Account";
|
||||
import { PublicKey } from "@solana/web3.js";
|
||||
import { Address } from "components/common/Address";
|
||||
|
||||
const MAX_SLASH_PENALTY = Math.pow(2, 8);
|
||||
|
||||
export function ConfigAccountSection({
|
||||
account,
|
||||
configAccount,
|
||||
}: {
|
||||
account: Account;
|
||||
configAccount: ConfigAccount;
|
||||
}) {
|
||||
switch (configAccount.type) {
|
||||
case "stakeConfig":
|
||||
return (
|
||||
<StakeConfigCard account={account} configAccount={configAccount} />
|
||||
);
|
||||
case "validatorInfo":
|
||||
return (
|
||||
<ValidatorInfoCard account={account} configAccount={configAccount} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function StakeConfigCard({
|
||||
account,
|
||||
configAccount,
|
||||
}: {
|
||||
account: Account;
|
||||
configAccount: StakeConfigInfoAccount;
|
||||
}) {
|
||||
const refresh = useFetchAccountInfo();
|
||||
|
||||
const warmupCooldownFormatted = new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 2,
|
||||
}).format(configAccount.info.warmupCooldownRate);
|
||||
|
||||
const slashPenaltyFormatted = new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 2,
|
||||
}).format(configAccount.info.slashPenalty / MAX_SLASH_PENALTY);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<AccountHeader
|
||||
title="Stake Config"
|
||||
refresh={() => refresh(account.pubkey, "parsed")}
|
||||
/>
|
||||
|
||||
<TableCardBody>
|
||||
<AccountAddressRow account={account} />
|
||||
<AccountBalanceRow account={account} />
|
||||
|
||||
<tr>
|
||||
<td>Warmup / Cooldown Rate</td>
|
||||
<td className="text-lg-end">{warmupCooldownFormatted}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Slash Penalty</td>
|
||||
<td className="text-lg-end">{slashPenaltyFormatted}</td>
|
||||
</tr>
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ValidatorInfoCard({
|
||||
account,
|
||||
configAccount,
|
||||
}: {
|
||||
account: Account;
|
||||
configAccount: ValidatorInfoAccount;
|
||||
}) {
|
||||
const refresh = useFetchAccountInfo();
|
||||
return (
|
||||
<div className="card">
|
||||
<AccountHeader
|
||||
title="Validator Info"
|
||||
refresh={() => refresh(account.pubkey, "parsed")}
|
||||
/>
|
||||
|
||||
<TableCardBody>
|
||||
<AccountAddressRow account={account} />
|
||||
<AccountBalanceRow account={account} />
|
||||
|
||||
{configAccount.info.configData.name && (
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td className="text-lg-end">
|
||||
{configAccount.info.configData.name}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{configAccount.info.configData.keybaseUsername && (
|
||||
<tr>
|
||||
<td>Keybase Username</td>
|
||||
<td className="text-lg-end">
|
||||
{configAccount.info.configData.keybaseUsername}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{configAccount.info.configData.website && (
|
||||
<tr>
|
||||
<td>Website</td>
|
||||
<td className="text-lg-end">
|
||||
<a
|
||||
href={configAccount.info.configData.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{configAccount.info.configData.website}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{configAccount.info.configData.details && (
|
||||
<tr>
|
||||
<td>Details</td>
|
||||
<td className="text-lg-end">
|
||||
{configAccount.info.configData.details}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{configAccount.info.keys && configAccount.info.keys.length > 1 && (
|
||||
<tr>
|
||||
<td>Signer</td>
|
||||
<td className="text-lg-end">
|
||||
<Address
|
||||
pubkey={new PublicKey(configAccount.info.keys[1].pubkey)}
|
||||
link
|
||||
alignRight
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
import React from "react";
|
||||
import { PublicKey } from "@solana/web3.js";
|
||||
import { useUserDomains, DomainInfo } from "../../utils/name-service";
|
||||
import { LoadingCard } from "components/common/LoadingCard";
|
||||
import { ErrorCard } from "components/common/ErrorCard";
|
||||
import { Address } from "components/common/Address";
|
||||
|
||||
export function DomainsCard({ pubkey }: { pubkey: PublicKey }) {
|
||||
const [domains, domainsLoading] = useUserDomains(pubkey);
|
||||
|
||||
if (domainsLoading && (!domains || domains.length === 0)) {
|
||||
return <LoadingCard message="Loading domains" />;
|
||||
} else if (!domains) {
|
||||
return <ErrorCard text="Failed to fetch domains" />;
|
||||
}
|
||||
|
||||
if (domains.length === 0) {
|
||||
return <ErrorCard text="No domain name found" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header align-items-center">
|
||||
<h3 className="card-header-title">Owned Domain Names</h3>
|
||||
</div>
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-muted">Domain Name</th>
|
||||
<th className="text-muted">Name Service Account</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="list">
|
||||
{domains.map((domain) => (
|
||||
<RenderDomainRow
|
||||
key={domain.address.toBase58()}
|
||||
domainInfo={domain}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderDomainRow({ domainInfo }: { domainInfo: DomainInfo }) {
|
||||
return (
|
||||
<tr>
|
||||
<td>{domainInfo.name}</td>
|
||||
<td>
|
||||
<Address pubkey={domainInfo.address} link />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
|
@ -1,116 +0,0 @@
|
|||
import React from "react";
|
||||
import { ConfirmedSignatureInfo, TransactionError } from "@solana/web3.js";
|
||||
|
||||
export type TransactionRow = {
|
||||
slot: number;
|
||||
signature: string;
|
||||
err: TransactionError | null;
|
||||
blockTime: number | null | undefined;
|
||||
statusClass: string;
|
||||
statusText: string;
|
||||
signatureInfo: ConfirmedSignatureInfo;
|
||||
};
|
||||
|
||||
export function HistoryCardHeader({
|
||||
title,
|
||||
refresh,
|
||||
fetching,
|
||||
}: {
|
||||
title: string;
|
||||
refresh: Function;
|
||||
fetching: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="card-header align-items-center">
|
||||
<h3 className="card-header-title">{title}</h3>
|
||||
<button
|
||||
className="btn btn-white btn-sm"
|
||||
disabled={fetching}
|
||||
onClick={() => refresh()}
|
||||
>
|
||||
{fetching ? (
|
||||
<>
|
||||
<span className="spinner-grow spinner-grow-sm me-2"></span>
|
||||
Loading
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="fe fe-refresh-cw me-2"></span>
|
||||
Refresh
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HistoryCardFooter({
|
||||
fetching,
|
||||
foundOldest,
|
||||
loadMore,
|
||||
}: {
|
||||
fetching: boolean;
|
||||
foundOldest: boolean;
|
||||
loadMore: Function;
|
||||
}) {
|
||||
return (
|
||||
<div className="card-footer">
|
||||
{foundOldest ? (
|
||||
<div className="text-muted text-center">Fetched full history</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-primary w-100"
|
||||
onClick={() => loadMore()}
|
||||
disabled={fetching}
|
||||
>
|
||||
{fetching ? (
|
||||
<>
|
||||
<span className="spinner-grow spinner-grow-sm me-2"></span>
|
||||
Loading
|
||||
</>
|
||||
) : (
|
||||
"Load More"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function getTransactionRows(
|
||||
transactions: ConfirmedSignatureInfo[]
|
||||
): TransactionRow[] {
|
||||
const transactionRows: TransactionRow[] = [];
|
||||
for (var i = 0; i < transactions.length; i++) {
|
||||
const slot = transactions[i].slot;
|
||||
const slotTransactions = [transactions[i]];
|
||||
while (i + 1 < transactions.length) {
|
||||
const nextSlot = transactions[i + 1].slot;
|
||||
if (nextSlot !== slot) break;
|
||||
slotTransactions.push(transactions[++i]);
|
||||
}
|
||||
|
||||
for (let slotTransaction of slotTransactions) {
|
||||
let statusText;
|
||||
let statusClass;
|
||||
if (slotTransaction.err) {
|
||||
statusClass = "warning";
|
||||
statusText = "Failed";
|
||||
} else {
|
||||
statusClass = "success";
|
||||
statusText = "Success";
|
||||
}
|
||||
transactionRows.push({
|
||||
slot,
|
||||
signature: slotTransaction.signature,
|
||||
err: slotTransaction.err,
|
||||
blockTime: slotTransaction.blockTime,
|
||||
statusClass,
|
||||
statusText,
|
||||
signatureInfo: slotTransaction,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return transactionRows;
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
import { NFTData } from "providers/accounts";
|
||||
import ReactJson from "react-json-view";
|
||||
|
||||
export function MetaplexMetadataCard({ nftData }: { nftData: NFTData }) {
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h3 className="card-header-title">Metaplex Metadata</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card metadata-json-viewer m-4">
|
||||
<ReactJson
|
||||
src={nftData.metadata}
|
||||
theme={"solarized"}
|
||||
style={{ padding: 25 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,87 +0,0 @@
|
|||
import React from "react";
|
||||
import { NFTData } from "providers/accounts";
|
||||
import { LoadingCard } from "components/common/LoadingCard";
|
||||
import { ErrorCard } from "components/common/ErrorCard";
|
||||
|
||||
interface Attribute {
|
||||
trait_type: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function MetaplexNFTAttributesCard({ nftData }: { nftData: NFTData }) {
|
||||
const [attributes, setAttributes] = React.useState<Attribute[]>([]);
|
||||
const [status, setStatus] = React.useState<"loading" | "success" | "error">(
|
||||
"loading"
|
||||
);
|
||||
|
||||
async function fetchMetadataAttributes() {
|
||||
try {
|
||||
const response = await fetch(nftData.metadata.data.uri);
|
||||
const metadata = await response.json();
|
||||
|
||||
// Verify if the attributes value is an array
|
||||
if (Array.isArray(metadata.attributes)) {
|
||||
// Filter attributes to keep objects matching schema
|
||||
const filteredAttributes = metadata.attributes.filter(
|
||||
(attribute: any) => {
|
||||
return (
|
||||
typeof attribute === "object" &&
|
||||
typeof attribute.trait_type === "string" &&
|
||||
(typeof attribute.value === "string" ||
|
||||
typeof attribute.value === "number")
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
setAttributes(filteredAttributes);
|
||||
setStatus("success");
|
||||
} else {
|
||||
throw new Error("Attributes is not an array");
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchMetadataAttributes();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (status === "loading") {
|
||||
return <LoadingCard />;
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return <ErrorCard text="Failed to fetch attributes" />;
|
||||
}
|
||||
|
||||
const attributesList: React.ReactNode[] = attributes.map(
|
||||
({ trait_type, value }) => {
|
||||
return (
|
||||
<tr key={`${trait_type}:${value}`}>
|
||||
<td>{trait_type}</td>
|
||||
<td>{value}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header align-items-center">
|
||||
<h3 className="card-header-title">Attributes</h3>
|
||||
</div>
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-muted w-1">Trait type</th>
|
||||
<th className="text-muted w-1">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="list">{attributesList}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,207 +0,0 @@
|
|||
import React from "react";
|
||||
import {
|
||||
NFTData,
|
||||
useFetchAccountInfo,
|
||||
useMintAccountInfo,
|
||||
} from "providers/accounts";
|
||||
import { programs } from "@metaplex/js";
|
||||
import { ArtContent } from "components/common/NFTArt";
|
||||
import { InfoTooltip } from "components/common/InfoTooltip";
|
||||
import { clusterPath } from "utils/url";
|
||||
import { Link } from "react-router-dom";
|
||||
import { EditionInfo } from "providers/accounts/utils/getEditionInfo";
|
||||
import { PublicKey } from "@solana/web3.js";
|
||||
|
||||
export function MetaplexNFTHeader({
|
||||
nftData,
|
||||
address,
|
||||
}: {
|
||||
nftData: NFTData;
|
||||
address: string;
|
||||
}) {
|
||||
const collectionAddress = nftData.metadata.collection?.key;
|
||||
const collectionMintInfo = useMintAccountInfo(collectionAddress);
|
||||
const fetchAccountInfo = useFetchAccountInfo();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (collectionAddress && !collectionMintInfo) {
|
||||
fetchAccountInfo(new PublicKey(collectionAddress), "parsed");
|
||||
}
|
||||
}, [fetchAccountInfo, collectionAddress]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const metadata = nftData.metadata;
|
||||
const data = nftData.json;
|
||||
const isVerifiedCollection =
|
||||
metadata.collection != null &&
|
||||
metadata.collection?.verified &&
|
||||
collectionMintInfo !== undefined;
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-auto ms-2 d-flex align-items-center">
|
||||
<ArtContent metadata={metadata} pubkey={address} data={data} />
|
||||
</div>
|
||||
<div className="col mb-3 ms-0.5 mt-3">
|
||||
{<h6 className="header-pretitle ms-1">Metaplex NFT</h6>}
|
||||
<div className="d-flex align-items-center">
|
||||
<h2 className="header-title ms-1 align-items-center no-overflow-with-ellipsis">
|
||||
{metadata.data.name !== ""
|
||||
? metadata.data.name
|
||||
: "No NFT name was found"}
|
||||
</h2>
|
||||
{getEditionPill(nftData.editionInfo)}
|
||||
{isVerifiedCollection ? getVerifiedCollectionPill() : null}
|
||||
</div>
|
||||
<h4 className="header-pretitle ms-1 mt-1 no-overflow-with-ellipsis">
|
||||
{metadata.data.symbol !== ""
|
||||
? metadata.data.symbol
|
||||
: "No Symbol was found"}
|
||||
</h4>
|
||||
<div className="mb-2 mt-2">
|
||||
{getSaleTypePill(metadata.primarySaleHappened)}
|
||||
</div>
|
||||
<div className="mb-3 mt-2">{getIsMutablePill(metadata.isMutable)}</div>
|
||||
<div className="btn-group">
|
||||
<button
|
||||
className="btn btn-dark btn-sm dropdown-toggle creators-dropdown-button-width"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Creators
|
||||
</button>
|
||||
<div className="dropdown-menu mt-2">
|
||||
{getCreatorDropdownItems(metadata.data.creators)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Creator = programs.metadata.Creator;
|
||||
function getCreatorDropdownItems(creators: Creator[] | null) {
|
||||
const CreatorHeader = () => {
|
||||
const creatorTooltip =
|
||||
"Verified creators signed the metadata associated with this NFT when it was created.";
|
||||
|
||||
const shareTooltip =
|
||||
"The percentage of the proceeds a creator receives when this NFT is sold.";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"d-flex align-items-center dropdown-header creator-dropdown-entry"
|
||||
}
|
||||
>
|
||||
<div className="d-flex font-monospace creator-dropdown-header">
|
||||
<span>Creator Address</span>
|
||||
<InfoTooltip bottom text={creatorTooltip} />
|
||||
</div>
|
||||
<div className="d-flex font-monospace">
|
||||
<span className="font-monospace">Royalty</span>
|
||||
<InfoTooltip bottom text={shareTooltip} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getVerifiedIcon = (isVerified: boolean) => {
|
||||
const className = isVerified ? "fe fe-check" : "fe fe-alert-octagon";
|
||||
return <i className={`ms-3 ${className}`}></i>;
|
||||
};
|
||||
|
||||
const CreatorEntry = (creator: Creator) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"d-flex align-items-center font-monospace creator-dropdown-entry ms-3 me-3"
|
||||
}
|
||||
>
|
||||
{getVerifiedIcon(creator.verified)}
|
||||
<Link
|
||||
className="dropdown-item font-monospace creator-dropdown-entry-address"
|
||||
to={clusterPath(`/address/${creator.address}`)}
|
||||
>
|
||||
{creator.address}
|
||||
</Link>
|
||||
<div className="me-3"> {`${creator.share}%`}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (creators && creators.length > 0) {
|
||||
let listOfCreators: JSX.Element[] = [];
|
||||
|
||||
listOfCreators.push(<CreatorHeader key={"header"} />);
|
||||
creators.forEach((creator) => {
|
||||
listOfCreators.push(<CreatorEntry key={creator.address} {...creator} />);
|
||||
});
|
||||
|
||||
return listOfCreators;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={"dropdown-item font-monospace"}>
|
||||
<div className="me-3">No creators are associated with this NFT.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getEditionPill(editionInfo: EditionInfo) {
|
||||
const masterEdition = editionInfo.masterEdition;
|
||||
const edition = editionInfo.edition;
|
||||
|
||||
return (
|
||||
<div className={"d-inline-flex ms-2"}>
|
||||
<span className="badge badge-pill bg-dark">{`${
|
||||
edition && masterEdition
|
||||
? `Edition ${edition.edition.toNumber()} / ${masterEdition.supply.toNumber()}`
|
||||
: masterEdition
|
||||
? "Master Edition"
|
||||
: "No Master Edition Information"
|
||||
}`}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getSaleTypePill(hasPrimarySaleHappened: boolean) {
|
||||
const primaryMarketTooltip =
|
||||
"Creator(s) split 100% of the proceeds when this NFT is sold.";
|
||||
|
||||
const secondaryMarketTooltip =
|
||||
"Creator(s) split the Seller Fee when this NFT is sold. The owner receives the remaining proceeds.";
|
||||
|
||||
return (
|
||||
<div className={"d-inline-flex align-items-center"}>
|
||||
<span className="badge badge-pill bg-dark">{`${
|
||||
hasPrimarySaleHappened ? "Secondary Market" : "Primary Market"
|
||||
}`}</span>
|
||||
<InfoTooltip
|
||||
bottom
|
||||
text={
|
||||
hasPrimarySaleHappened ? secondaryMarketTooltip : primaryMarketTooltip
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getIsMutablePill(isMutable: boolean) {
|
||||
return (
|
||||
<span className="badge badge-pill bg-dark">{`${
|
||||
isMutable ? "Mutable" : "Immutable"
|
||||
}`}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getVerifiedCollectionPill() {
|
||||
const onchainVerifiedToolTip =
|
||||
"This NFT has been verified as a member of an on-chain collection. This tag guarantees authenticity.";
|
||||
return (
|
||||
<div className={"d-inline-flex align-items-center ms-2"}>
|
||||
<span className="badge badge-pill bg-dark">{"Verified Collection"}</span>
|
||||
<InfoTooltip bottom text={onchainVerifiedToolTip} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
import React from "react";
|
||||
import { Account, useFetchAccountInfo } from "providers/accounts";
|
||||
import { TableCardBody } from "components/common/TableCardBody";
|
||||
import { Address } from "components/common/Address";
|
||||
import { NonceAccount } from "validators/accounts/nonce";
|
||||
import {
|
||||
AccountHeader,
|
||||
AccountAddressRow,
|
||||
AccountBalanceRow,
|
||||
} from "components/common/Account";
|
||||
|
||||
export function NonceAccountSection({
|
||||
account,
|
||||
nonceAccount,
|
||||
}: {
|
||||
account: Account;
|
||||
nonceAccount: NonceAccount;
|
||||
}) {
|
||||
const refresh = useFetchAccountInfo();
|
||||
return (
|
||||
<div className="card">
|
||||
<AccountHeader
|
||||
title="Nonce Account"
|
||||
refresh={() => refresh(account.pubkey, "parsed")}
|
||||
/>
|
||||
|
||||
<TableCardBody>
|
||||
<AccountAddressRow account={account} />
|
||||
<AccountBalanceRow account={account} />
|
||||
|
||||
<tr>
|
||||
<td>Authority</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={nonceAccount.info.authority} alignRight raw link />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Blockhash</td>
|
||||
<td className="text-lg-end">
|
||||
<code>{nonceAccount.info.blockhash}</code>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Fee</td>
|
||||
<td className="text-lg-end">
|
||||
{nonceAccount.info.feeCalculator.lamportsPerSignature} lamports per
|
||||
signature
|
||||
</td>
|
||||
</tr>
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,280 +0,0 @@
|
|||
import React from "react";
|
||||
import { PublicKey } from "@solana/web3.js";
|
||||
import { FetchStatus } from "providers/cache";
|
||||
import {
|
||||
useFetchAccountOwnedTokens,
|
||||
useAccountOwnedTokens,
|
||||
TokenInfoWithPubkey,
|
||||
} from "providers/accounts/tokens";
|
||||
import { ErrorCard } from "components/common/ErrorCard";
|
||||
import { LoadingCard } from "components/common/LoadingCard";
|
||||
import { Address } from "components/common/Address";
|
||||
import { useQuery } from "utils/url";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Location } from "history";
|
||||
import { useTokenRegistry } from "providers/mints/token-registry";
|
||||
import { BigNumber } from "bignumber.js";
|
||||
import { Identicon } from "components/common/Identicon";
|
||||
|
||||
type Display = "summary" | "detail" | null;
|
||||
|
||||
const SMALL_IDENTICON_WIDTH = 16;
|
||||
|
||||
const useQueryDisplay = (): Display => {
|
||||
const query = useQuery();
|
||||
const filter = query.get("display");
|
||||
if (filter === "summary" || filter === "detail") {
|
||||
return filter;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function OwnedTokensCard({ pubkey }: { pubkey: PublicKey }) {
|
||||
const address = pubkey.toBase58();
|
||||
const ownedTokens = useAccountOwnedTokens(address);
|
||||
const fetchAccountTokens = useFetchAccountOwnedTokens();
|
||||
const refresh = () => fetchAccountTokens(pubkey);
|
||||
const [showDropdown, setDropdown] = React.useState(false);
|
||||
const display = useQueryDisplay();
|
||||
|
||||
// Fetch owned tokens
|
||||
React.useEffect(() => {
|
||||
if (!ownedTokens) refresh();
|
||||
}, [address]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (ownedTokens === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { status } = ownedTokens;
|
||||
const tokens = ownedTokens.data?.tokens;
|
||||
const fetching = status === FetchStatus.Fetching;
|
||||
if (fetching && (tokens === undefined || tokens.length === 0)) {
|
||||
return <LoadingCard message="Loading token holdings" />;
|
||||
} else if (tokens === undefined) {
|
||||
return <ErrorCard retry={refresh} text="Failed to fetch token holdings" />;
|
||||
}
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return (
|
||||
<ErrorCard
|
||||
retry={refresh}
|
||||
retryText="Try Again"
|
||||
text={"No token holdings found"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (tokens.length > 100) {
|
||||
return (
|
||||
<ErrorCard text="Token holdings is not available for accounts with over 100 token accounts" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showDropdown && (
|
||||
<div className="dropdown-exit" onClick={() => setDropdown(false)} />
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header align-items-center">
|
||||
<h3 className="card-header-title">Token Holdings</h3>
|
||||
<DisplayDropdown
|
||||
display={display}
|
||||
toggle={() => setDropdown((show) => !show)}
|
||||
show={showDropdown}
|
||||
/>
|
||||
</div>
|
||||
{display === "detail" ? (
|
||||
<HoldingsDetailTable tokens={tokens} />
|
||||
) : (
|
||||
<HoldingsSummaryTable tokens={tokens} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HoldingsDetailTable({ tokens }: { tokens: TokenInfoWithPubkey[] }) {
|
||||
const detailsList: React.ReactNode[] = [];
|
||||
const { tokenRegistry } = useTokenRegistry();
|
||||
const showLogos = tokens.some(
|
||||
(t) => tokenRegistry.get(t.info.mint.toBase58())?.logoURI !== undefined
|
||||
);
|
||||
tokens.forEach((tokenAccount) => {
|
||||
const address = tokenAccount.pubkey.toBase58();
|
||||
const mintAddress = tokenAccount.info.mint.toBase58();
|
||||
const tokenDetails = tokenRegistry.get(mintAddress);
|
||||
detailsList.push(
|
||||
<tr key={address}>
|
||||
{showLogos && (
|
||||
<td className="w-1 p-0 text-center">
|
||||
{tokenDetails?.logoURI ? (
|
||||
<img
|
||||
src={tokenDetails.logoURI}
|
||||
alt="token icon"
|
||||
className="token-icon rounded-circle border border-4 border-gray-dark"
|
||||
/>
|
||||
) : (
|
||||
<Identicon
|
||||
address={address}
|
||||
className="avatar-img identicon-wrapper identicon-wrapper-small"
|
||||
style={{ width: SMALL_IDENTICON_WIDTH }}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<Address pubkey={tokenAccount.pubkey} link truncate />
|
||||
</td>
|
||||
<td>
|
||||
<Address pubkey={tokenAccount.info.mint} link truncate />
|
||||
</td>
|
||||
<td>
|
||||
{tokenAccount.info.tokenAmount.uiAmountString}{" "}
|
||||
{tokenDetails && tokenDetails.symbol}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{showLogos && (
|
||||
<th className="text-muted w-1 p-0 text-center">Logo</th>
|
||||
)}
|
||||
<th className="text-muted">Account Address</th>
|
||||
<th className="text-muted">Mint Address</th>
|
||||
<th className="text-muted">Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="list">{detailsList}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HoldingsSummaryTable({ tokens }: { tokens: TokenInfoWithPubkey[] }) {
|
||||
const { tokenRegistry } = useTokenRegistry();
|
||||
const mappedTokens = new Map<string, string>();
|
||||
for (const { info: token } of tokens) {
|
||||
const mintAddress = token.mint.toBase58();
|
||||
const totalByMint = mappedTokens.get(mintAddress);
|
||||
|
||||
let amount = token.tokenAmount.uiAmountString;
|
||||
if (totalByMint !== undefined) {
|
||||
amount = new BigNumber(totalByMint)
|
||||
.plus(token.tokenAmount.uiAmountString)
|
||||
.toString();
|
||||
}
|
||||
|
||||
mappedTokens.set(mintAddress, amount);
|
||||
}
|
||||
|
||||
const detailsList: React.ReactNode[] = [];
|
||||
const showLogos = tokens.some(
|
||||
(t) => tokenRegistry.get(t.info.mint.toBase58())?.logoURI !== undefined
|
||||
);
|
||||
mappedTokens.forEach((totalByMint, mintAddress) => {
|
||||
const tokenDetails = tokenRegistry.get(mintAddress);
|
||||
detailsList.push(
|
||||
<tr key={mintAddress}>
|
||||
{showLogos && (
|
||||
<td className="w-1 p-0 text-center">
|
||||
{tokenDetails?.logoURI ? (
|
||||
<img
|
||||
src={tokenDetails.logoURI}
|
||||
alt="token icon"
|
||||
className="token-icon rounded-circle border border-4 border-gray-dark"
|
||||
/>
|
||||
) : (
|
||||
<Identicon
|
||||
address={mintAddress}
|
||||
className="avatar-img identicon-wrapper identicon-wrapper-small"
|
||||
style={{ width: SMALL_IDENTICON_WIDTH }}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<Address pubkey={new PublicKey(mintAddress)} link useMetadata />
|
||||
</td>
|
||||
<td>
|
||||
{totalByMint} {tokenDetails && tokenDetails.symbol}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{showLogos && (
|
||||
<th className="text-muted w-1 p-0 text-center">Logo</th>
|
||||
)}
|
||||
<th className="text-muted">Mint Address</th>
|
||||
<th className="text-muted">Total Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="list">{detailsList}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownProps = {
|
||||
display: Display;
|
||||
toggle: () => void;
|
||||
show: boolean;
|
||||
};
|
||||
|
||||
const DisplayDropdown = ({ display, toggle, show }: DropdownProps) => {
|
||||
const buildLocation = (location: Location, display: Display) => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (display === null) {
|
||||
params.delete("display");
|
||||
} else {
|
||||
params.set("display", display);
|
||||
}
|
||||
return {
|
||||
...location,
|
||||
search: params.toString(),
|
||||
};
|
||||
};
|
||||
|
||||
const DISPLAY_OPTIONS: Display[] = [null, "detail"];
|
||||
return (
|
||||
<div className="dropdown">
|
||||
<button
|
||||
className="btn btn-white btn-sm dropdown-toggle"
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
>
|
||||
{display === "detail" ? "Detailed" : "Summary"}
|
||||
</button>
|
||||
<div className={`dropdown-menu-end dropdown-menu${show ? " show" : ""}`}>
|
||||
{DISPLAY_OPTIONS.map((displayOption) => {
|
||||
return (
|
||||
<Link
|
||||
key={displayOption || "null"}
|
||||
to={(location) => buildLocation(location, displayOption)}
|
||||
className={`dropdown-item${
|
||||
displayOption === display ? " active" : ""
|
||||
}`}
|
||||
onClick={toggle}
|
||||
>
|
||||
{displayOption === "detail" ? "Detailed" : "Summary"}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,133 +0,0 @@
|
|||
import React from "react";
|
||||
import { PublicKey } from "@solana/web3.js";
|
||||
import { useFetchRewards, useRewards } from "providers/accounts/rewards";
|
||||
import { LoadingCard } from "components/common/LoadingCard";
|
||||
import { FetchStatus } from "providers/cache";
|
||||
import { ErrorCard } from "components/common/ErrorCard";
|
||||
import { Slot } from "components/common/Slot";
|
||||
import { lamportsToSolString } from "utils";
|
||||
import { useAccountInfo } from "providers/accounts";
|
||||
import { Epoch } from "components/common/Epoch";
|
||||
|
||||
const U64_MAX = BigInt("0xffffffffffffffff");
|
||||
|
||||
export function RewardsCard({ pubkey }: { pubkey: PublicKey }) {
|
||||
const address = React.useMemo(() => pubkey.toBase58(), [pubkey]);
|
||||
const info = useAccountInfo(address);
|
||||
const account = info?.data;
|
||||
const parsedData = account?.data.parsed;
|
||||
|
||||
const highestEpoch = React.useMemo(() => {
|
||||
if (!parsedData) return;
|
||||
if (parsedData.program !== "stake") return;
|
||||
const stakeInfo = parsedData.parsed.info.stake;
|
||||
if (
|
||||
stakeInfo !== null &&
|
||||
stakeInfo.delegation.deactivationEpoch !== U64_MAX
|
||||
) {
|
||||
return Number(stakeInfo.delegation.deactivationEpoch);
|
||||
}
|
||||
}, [parsedData]);
|
||||
|
||||
const rewards = useRewards(address);
|
||||
const fetchRewards = useFetchRewards();
|
||||
const loadMore = () => fetchRewards(pubkey, highestEpoch);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!rewards) {
|
||||
fetchRewards(pubkey, highestEpoch);
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!rewards) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (rewards?.data === undefined) {
|
||||
if (rewards.status === FetchStatus.Fetching) {
|
||||
return <LoadingCard message="Loading rewards" />;
|
||||
}
|
||||
|
||||
return <ErrorCard retry={loadMore} text="Failed to fetch rewards" />;
|
||||
}
|
||||
|
||||
const rewardsList = rewards.data.rewards.map((reward) => {
|
||||
if (!reward) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={reward.epoch}>
|
||||
<td>
|
||||
<Epoch epoch={reward.epoch} link />
|
||||
</td>
|
||||
<td>
|
||||
<Slot slot={reward.effectiveSlot} link />
|
||||
</td>
|
||||
<td>{lamportsToSolString(reward.amount)}</td>
|
||||
<td>{lamportsToSolString(reward.postBalance)}</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
const rewardsFound = rewardsList.some((r) => r);
|
||||
const { foundOldest, lowestFetchedEpoch, highestFetchedEpoch } = rewards.data;
|
||||
const fetching = rewards.status === FetchStatus.Fetching;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h3 className="card-header-title">Rewards</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rewardsFound ? (
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-1 text-muted">Epoch</th>
|
||||
<th className="text-muted">Effective Slot</th>
|
||||
<th className="text-muted">Reward Amount</th>
|
||||
<th className="text-muted">Post Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="list">{rewardsList}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card-body">
|
||||
No rewards issued between epochs {lowestFetchedEpoch} and{" "}
|
||||
{highestFetchedEpoch}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-footer">
|
||||
{foundOldest ? (
|
||||
<div className="text-muted text-center">
|
||||
Fetched full reward history
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-primary w-100"
|
||||
onClick={() => loadMore()}
|
||||
disabled={fetching}
|
||||
>
|
||||
{fetching ? (
|
||||
<>
|
||||
<span className="spinner-grow spinner-grow-sm me-2"></span>
|
||||
Loading
|
||||
</>
|
||||
) : (
|
||||
"Load More"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,281 +0,0 @@
|
|||
import { ErrorCard } from "components/common/ErrorCard";
|
||||
import { TableCardBody } from "components/common/TableCardBody";
|
||||
import { UpgradeableLoaderAccountData } from "providers/accounts";
|
||||
import { fromProgramData, SecurityTXT } from "utils/security-txt";
|
||||
|
||||
export function SecurityCard({ data }: { data: UpgradeableLoaderAccountData }) {
|
||||
if (!data.programData) {
|
||||
return <ErrorCard text="Account has no data" />;
|
||||
}
|
||||
|
||||
const { securityTXT, error } = fromProgramData(data.programData);
|
||||
if (!securityTXT) {
|
||||
return <ErrorCard text={error!} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card security-txt">
|
||||
<div className="card-header">
|
||||
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
||||
Security.txt
|
||||
</h3>
|
||||
<small>
|
||||
Note that this is self-reported by the author of the program and might
|
||||
not be accurate.
|
||||
</small>
|
||||
</div>
|
||||
<TableCardBody>
|
||||
{ROWS.filter((x) => x.key in securityTXT).map((x, idx) => {
|
||||
return (
|
||||
<tr key={idx}>
|
||||
<td className="w-100">{x.display}</td>
|
||||
<RenderEntry value={securityTXT[x.key]} type={x.type} />
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
enum DisplayType {
|
||||
String,
|
||||
URL,
|
||||
Date,
|
||||
Contacts,
|
||||
PGP,
|
||||
Auditors,
|
||||
}
|
||||
type TableRow = {
|
||||
display: string;
|
||||
key: keyof SecurityTXT;
|
||||
type: DisplayType;
|
||||
};
|
||||
|
||||
const ROWS: TableRow[] = [
|
||||
{
|
||||
display: "Name",
|
||||
key: "name",
|
||||
type: DisplayType.String,
|
||||
},
|
||||
{
|
||||
display: "Project URL",
|
||||
key: "project_url",
|
||||
type: DisplayType.URL,
|
||||
},
|
||||
{
|
||||
display: "Contacts",
|
||||
key: "contacts",
|
||||
type: DisplayType.Contacts,
|
||||
},
|
||||
{
|
||||
display: "Policy",
|
||||
key: "policy",
|
||||
type: DisplayType.URL,
|
||||
},
|
||||
{
|
||||
display: "Preferred Languages",
|
||||
key: "preferred_languages",
|
||||
type: DisplayType.String,
|
||||
},
|
||||
{
|
||||
display: "Secure Contact Encryption",
|
||||
key: "encryption",
|
||||
type: DisplayType.PGP,
|
||||
},
|
||||
{
|
||||
display: "Source Code URL",
|
||||
key: "source_code",
|
||||
type: DisplayType.URL,
|
||||
},
|
||||
{
|
||||
display: "Source Code Release Version",
|
||||
key: "source_release",
|
||||
type: DisplayType.String,
|
||||
},
|
||||
{
|
||||
display: "Source Code Revision",
|
||||
key: "source_revision",
|
||||
type: DisplayType.String,
|
||||
},
|
||||
{
|
||||
display: "Auditors",
|
||||
key: "auditors",
|
||||
type: DisplayType.Auditors,
|
||||
},
|
||||
{
|
||||
display: "Acknowledgements",
|
||||
key: "acknowledgements",
|
||||
type: DisplayType.URL,
|
||||
},
|
||||
{
|
||||
display: "Expiry",
|
||||
key: "expiry",
|
||||
type: DisplayType.Date,
|
||||
},
|
||||
];
|
||||
|
||||
function RenderEntry({
|
||||
value,
|
||||
type,
|
||||
}: {
|
||||
value: SecurityTXT[keyof SecurityTXT];
|
||||
type: DisplayType;
|
||||
}) {
|
||||
if (!value) {
|
||||
return <></>;
|
||||
}
|
||||
switch (type) {
|
||||
case DisplayType.String:
|
||||
return <td className="text-lg-end font-monospace">{value}</td>;
|
||||
case DisplayType.Contacts:
|
||||
return (
|
||||
<td className="text-lg-end font-monospace">
|
||||
<ul>
|
||||
{value?.split(",").map((c, i) => {
|
||||
const idx = c.indexOf(":");
|
||||
if (idx < 0) {
|
||||
//invalid contact
|
||||
return <li key={i}>{c}</li>;
|
||||
}
|
||||
const [type, information] = [c.slice(0, idx), c.slice(idx + 1)];
|
||||
return (
|
||||
<li key={i}>
|
||||
<Contact type={type} information={information} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</td>
|
||||
);
|
||||
case DisplayType.URL:
|
||||
if (isValidLink(value)) {
|
||||
return (
|
||||
<td className="text-lg-end">
|
||||
<span className="font-monospace">
|
||||
<a rel="noopener noreferrer" target="_blank" href={value}>
|
||||
{value}
|
||||
<span className="fe fe-external-link ms-2"></span>
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<td className="text-lg-end">
|
||||
<pre>{value.trim()}</pre>
|
||||
</td>
|
||||
);
|
||||
case DisplayType.Date:
|
||||
return <td className="text-lg-end font-monospace">{value}</td>;
|
||||
case DisplayType.PGP:
|
||||
if (isValidLink(value)) {
|
||||
return (
|
||||
<td className="text-lg-end">
|
||||
<span className="font-monospace">
|
||||
<a rel="noopener noreferrer" target="_blank" href={value}>
|
||||
{value}
|
||||
<span className="fe fe-external-link ms-2"></span>
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<td>
|
||||
<code>{value.trim()}</code>
|
||||
</td>
|
||||
);
|
||||
case DisplayType.Auditors:
|
||||
if (isValidLink(value)) {
|
||||
return (
|
||||
<td className="text-lg-end">
|
||||
<span className="font-monospace">
|
||||
<a rel="noopener noreferrer" target="_blank" href={value}>
|
||||
{value}
|
||||
<span className="fe fe-external-link ms-2"></span>
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<td>
|
||||
<ul>
|
||||
{value?.split(",").map((c, idx) => {
|
||||
return <li key={idx}>{c}</li>;
|
||||
})}
|
||||
</ul>
|
||||
</td>
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return <></>;
|
||||
}
|
||||
|
||||
function isValidLink(value: string) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ["http:", "https:"].includes(url.protocol);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function Contact({ type, information }: { type: string; information: string }) {
|
||||
switch (type) {
|
||||
case "discord":
|
||||
return <>Discord: {information}</>;
|
||||
case "email":
|
||||
return (
|
||||
<a
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href={`mailto:${information}`}
|
||||
>
|
||||
{information}
|
||||
<span className="fe fe-external-link ms-2"></span>
|
||||
</a>
|
||||
);
|
||||
case "telegram":
|
||||
return (
|
||||
<a
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href={`https://t.me/${information}`}
|
||||
>
|
||||
Telegram: {information}
|
||||
<span className="fe fe-external-link ms-2"></span>
|
||||
</a>
|
||||
);
|
||||
case "twitter":
|
||||
return (
|
||||
<a
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href={`https://twitter.com/${information}`}
|
||||
>
|
||||
Twitter {information}
|
||||
<span className="fe fe-external-link ms-2"></span>
|
||||
</a>
|
||||
);
|
||||
case "link":
|
||||
if (isValidLink(information)) {
|
||||
return (
|
||||
<a rel="noopener noreferrer" target="_blank" href={`${information}`}>
|
||||
{information}
|
||||
<span className="fe fe-external-link ms-2"></span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <>{information}</>;
|
||||
case "other":
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
{type}: {information}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,60 +0,0 @@
|
|||
import { Slot } from "components/common/Slot";
|
||||
import React from "react";
|
||||
import {
|
||||
SysvarAccount,
|
||||
SlotHashesInfo,
|
||||
SlotHashEntry,
|
||||
} from "validators/accounts/sysvar";
|
||||
|
||||
export function SlotHashesCard({
|
||||
sysvarAccount,
|
||||
}: {
|
||||
sysvarAccount: SysvarAccount;
|
||||
}) {
|
||||
const slotHashes = sysvarAccount.info as SlotHashesInfo;
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h3 className="card-header-title">Slot Hashes</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-1 text-muted">Slot</th>
|
||||
<th className="text-muted">Hash</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="list">
|
||||
{slotHashes.length > 0 &&
|
||||
slotHashes.map((entry: SlotHashEntry, index) => {
|
||||
return renderAccountRow(entry, index);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="card-footer">
|
||||
<div className="text-muted text-center">
|
||||
{slotHashes.length > 0 ? "" : "No hashes found"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderAccountRow = (entry: SlotHashEntry, index: number) => {
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td className="w-1 font-monospace">
|
||||
<Slot slot={entry.slot} link />
|
||||
</td>
|
||||
<td className="font-monospace">{entry.hash}</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
|
@ -1,305 +0,0 @@
|
|||
import React from "react";
|
||||
import { TableCardBody } from "components/common/TableCardBody";
|
||||
import { SolBalance } from "components/common/SolBalance";
|
||||
import { displayTimestampUtc } from "utils/date";
|
||||
import { Account, useFetchAccountInfo } from "providers/accounts";
|
||||
import { Address } from "components/common/Address";
|
||||
import {
|
||||
StakeAccountInfo,
|
||||
StakeMeta,
|
||||
StakeAccountType,
|
||||
} from "validators/accounts/stake";
|
||||
import { StakeActivationData } from "@solana/web3.js";
|
||||
import { Epoch } from "components/common/Epoch";
|
||||
|
||||
const U64_MAX = BigInt("0xffffffffffffffff");
|
||||
|
||||
export function StakeAccountSection({
|
||||
account,
|
||||
stakeAccount,
|
||||
activation,
|
||||
stakeAccountType,
|
||||
}: {
|
||||
account: Account;
|
||||
stakeAccount: StakeAccountInfo;
|
||||
stakeAccountType: StakeAccountType;
|
||||
activation?: StakeActivationData;
|
||||
}) {
|
||||
const hideDelegation =
|
||||
stakeAccountType !== "delegated" ||
|
||||
isFullyInactivated(stakeAccount, activation);
|
||||
return (
|
||||
<>
|
||||
<LockupCard stakeAccount={stakeAccount} />
|
||||
<OverviewCard
|
||||
account={account}
|
||||
stakeAccount={stakeAccount}
|
||||
stakeAccountType={stakeAccountType}
|
||||
activation={activation}
|
||||
hideDelegation={hideDelegation}
|
||||
/>
|
||||
{!hideDelegation && (
|
||||
<DelegationCard
|
||||
stakeAccount={stakeAccount}
|
||||
activation={activation}
|
||||
stakeAccountType={stakeAccountType}
|
||||
/>
|
||||
)}
|
||||
<AuthoritiesCard meta={stakeAccount.meta} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LockupCard({ stakeAccount }: { stakeAccount: StakeAccountInfo }) {
|
||||
const unixTimestamp = 1000 * (stakeAccount.meta?.lockup.unixTimestamp || 0);
|
||||
if (Date.now() < unixTimestamp) {
|
||||
const prettyTimestamp = displayTimestampUtc(unixTimestamp);
|
||||
return (
|
||||
<div className="alert alert-warning text-center">
|
||||
<strong>Account is locked!</strong> Lockup expires on {prettyTimestamp}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const TYPE_NAMES = {
|
||||
uninitialized: "Uninitialized",
|
||||
initialized: "Initialized",
|
||||
delegated: "Delegated",
|
||||
rewardsPool: "RewardsPool",
|
||||
};
|
||||
|
||||
function displayStatus(
|
||||
stakeAccountType: StakeAccountType,
|
||||
activation?: StakeActivationData
|
||||
) {
|
||||
let status = TYPE_NAMES[stakeAccountType];
|
||||
let activationState = "";
|
||||
if (stakeAccountType !== "delegated") {
|
||||
status = "Not delegated";
|
||||
} else {
|
||||
activationState = activation ? `(${activation.state})` : "";
|
||||
}
|
||||
|
||||
return [status, activationState].join(" ");
|
||||
}
|
||||
|
||||
function OverviewCard({
|
||||
account,
|
||||
stakeAccount,
|
||||
stakeAccountType,
|
||||
activation,
|
||||
hideDelegation,
|
||||
}: {
|
||||
account: Account;
|
||||
stakeAccount: StakeAccountInfo;
|
||||
stakeAccountType: StakeAccountType;
|
||||
activation?: StakeActivationData;
|
||||
hideDelegation: boolean;
|
||||
}) {
|
||||
const refresh = useFetchAccountInfo();
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
||||
Stake Account
|
||||
</h3>
|
||||
<button
|
||||
className="btn btn-white btn-sm"
|
||||
onClick={() => refresh(account.pubkey, "parsed")}
|
||||
>
|
||||
<span className="fe fe-refresh-cw me-2"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TableCardBody>
|
||||
<tr>
|
||||
<td>Address</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={account.pubkey} alignRight raw />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Balance (SOL)</td>
|
||||
<td className="text-lg-end text-uppercase">
|
||||
<SolBalance lamports={account.lamports} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Rent Reserve (SOL)</td>
|
||||
<td className="text-lg-end">
|
||||
<SolBalance lamports={stakeAccount.meta.rentExemptReserve} />
|
||||
</td>
|
||||
</tr>
|
||||
{hideDelegation && (
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td className="text-lg-end">
|
||||
{isFullyInactivated(stakeAccount, activation)
|
||||
? "Not delegated"
|
||||
: displayStatus(stakeAccountType, activation)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DelegationCard({
|
||||
stakeAccount,
|
||||
stakeAccountType,
|
||||
activation,
|
||||
}: {
|
||||
stakeAccount: StakeAccountInfo;
|
||||
stakeAccountType: StakeAccountType;
|
||||
activation?: StakeActivationData;
|
||||
}) {
|
||||
let voterPubkey, activationEpoch, deactivationEpoch;
|
||||
const delegation = stakeAccount?.stake?.delegation;
|
||||
if (delegation) {
|
||||
voterPubkey = delegation.voter;
|
||||
if (delegation.activationEpoch !== U64_MAX) {
|
||||
activationEpoch = delegation.activationEpoch;
|
||||
}
|
||||
if (delegation.deactivationEpoch !== U64_MAX) {
|
||||
deactivationEpoch = delegation.deactivationEpoch;
|
||||
}
|
||||
}
|
||||
const { stake } = stakeAccount;
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
||||
Stake Delegation
|
||||
</h3>
|
||||
</div>
|
||||
<TableCardBody>
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td className="text-lg-end">
|
||||
{displayStatus(stakeAccountType, activation)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{stake && (
|
||||
<>
|
||||
<tr>
|
||||
<td>Delegated Stake (SOL)</td>
|
||||
<td className="text-lg-end">
|
||||
<SolBalance lamports={stake.delegation.stake} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{activation && (
|
||||
<>
|
||||
<tr>
|
||||
<td>Active Stake (SOL)</td>
|
||||
<td className="text-lg-end">
|
||||
<SolBalance lamports={activation.active} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Inactive Stake (SOL)</td>
|
||||
<td className="text-lg-end">
|
||||
<SolBalance lamports={activation.inactive} />
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
|
||||
{voterPubkey && (
|
||||
<tr>
|
||||
<td>Delegated Vote Address</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={voterPubkey} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
<tr>
|
||||
<td>Activation Epoch</td>
|
||||
<td className="text-lg-end">
|
||||
{activationEpoch !== undefined ? (
|
||||
<Epoch epoch={activationEpoch} link />
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Deactivation Epoch</td>
|
||||
<td className="text-lg-end">
|
||||
{deactivationEpoch !== undefined ? (
|
||||
<Epoch epoch={deactivationEpoch} link />
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthoritiesCard({ meta }: { meta: StakeMeta }) {
|
||||
const hasLockup = meta.lockup.unixTimestamp > 0;
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
||||
Authorities
|
||||
</h3>
|
||||
</div>
|
||||
<TableCardBody>
|
||||
<tr>
|
||||
<td>Stake Authority Address</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={meta.authorized.staker} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Withdraw Authority Address</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={meta.authorized.withdrawer} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{hasLockup && (
|
||||
<tr>
|
||||
<td>Lockup Authority Address</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={meta.lockup.custodian} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isFullyInactivated(
|
||||
stakeAccount: StakeAccountInfo,
|
||||
activation?: StakeActivationData
|
||||
): boolean {
|
||||
const { stake } = stakeAccount;
|
||||
|
||||
if (!stake || !activation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const delegatedStake = stake.delegation.stake;
|
||||
const inactiveStake = BigInt(activation.inactive);
|
||||
|
||||
return (
|
||||
stake.delegation.deactivationEpoch !== U64_MAX &&
|
||||
delegatedStake === inactiveStake
|
||||
);
|
||||
}
|
|
@ -1,73 +0,0 @@
|
|||
import React from "react";
|
||||
import { SolBalance } from "components/common/SolBalance";
|
||||
import { Epoch } from "components/common/Epoch";
|
||||
import {
|
||||
SysvarAccount,
|
||||
StakeHistoryInfo,
|
||||
StakeHistoryEntry,
|
||||
} from "validators/accounts/sysvar";
|
||||
|
||||
export function StakeHistoryCard({
|
||||
sysvarAccount,
|
||||
}: {
|
||||
sysvarAccount: SysvarAccount;
|
||||
}) {
|
||||
const stakeHistory = sysvarAccount.info as StakeHistoryInfo;
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h3 className="card-header-title">Stake History</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-1 text-muted">Epoch</th>
|
||||
<th className="text-muted">Effective (SOL)</th>
|
||||
<th className="text-muted">Activating (SOL)</th>
|
||||
<th className="text-muted">Deactivating (SOL)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="list">
|
||||
{stakeHistory.length > 0 &&
|
||||
stakeHistory.map((entry: StakeHistoryEntry, index) => {
|
||||
return renderAccountRow(entry, index);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="card-footer">
|
||||
<div className="text-muted text-center">
|
||||
{stakeHistory.length > 0 ? "" : "No stake history found"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const renderAccountRow = (entry: StakeHistoryEntry, index: number) => {
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td className="w-1 font-monospace">
|
||||
<Epoch epoch={entry.epoch} link />
|
||||
</td>
|
||||
<td className="font-monospace">
|
||||
<SolBalance lamports={entry.stakeHistory.effective} />
|
||||
</td>
|
||||
<td className="font-monospace">
|
||||
<SolBalance lamports={entry.stakeHistory.activating} />
|
||||
</td>
|
||||
<td className="font-monospace">
|
||||
<SolBalance lamports={entry.stakeHistory.deactivating} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
|
@ -1,420 +0,0 @@
|
|||
import React from "react";
|
||||
import { Account, useFetchAccountInfo } from "providers/accounts";
|
||||
import {
|
||||
SysvarAccount,
|
||||
SysvarClockAccount,
|
||||
SysvarEpochScheduleAccount,
|
||||
SysvarFeesAccount,
|
||||
SysvarRecentBlockhashesAccount,
|
||||
SysvarRentAccount,
|
||||
SysvarRewardsAccount,
|
||||
SysvarSlotHashesAccount,
|
||||
SysvarSlotHistoryAccount,
|
||||
SysvarStakeHistoryAccount,
|
||||
} from "validators/accounts/sysvar";
|
||||
import { TableCardBody } from "components/common/TableCardBody";
|
||||
import {
|
||||
AccountHeader,
|
||||
AccountAddressRow,
|
||||
AccountBalanceRow,
|
||||
} from "components/common/Account";
|
||||
import { displayTimestamp } from "utils/date";
|
||||
import { Slot } from "components/common/Slot";
|
||||
import { Epoch } from "components/common/Epoch";
|
||||
|
||||
export function SysvarAccountSection({
|
||||
account,
|
||||
sysvarAccount,
|
||||
}: {
|
||||
account: Account;
|
||||
sysvarAccount: SysvarAccount;
|
||||
}) {
|
||||
switch (sysvarAccount.type) {
|
||||
case "clock":
|
||||
return (
|
||||
<SysvarAccountClockCard
|
||||
account={account}
|
||||
sysvarAccount={sysvarAccount}
|
||||
/>
|
||||
);
|
||||
case "rent":
|
||||
return (
|
||||
<SysvarAccountRentCard
|
||||
account={account}
|
||||
sysvarAccount={sysvarAccount}
|
||||
/>
|
||||
);
|
||||
case "rewards":
|
||||
return (
|
||||
<SysvarAccountRewardsCard
|
||||
account={account}
|
||||
sysvarAccount={sysvarAccount}
|
||||
/>
|
||||
);
|
||||
case "epochSchedule":
|
||||
return (
|
||||
<SysvarAccountEpochScheduleCard
|
||||
account={account}
|
||||
sysvarAccount={sysvarAccount}
|
||||
/>
|
||||
);
|
||||
case "fees":
|
||||
return (
|
||||
<SysvarAccountFeesCard
|
||||
account={account}
|
||||
sysvarAccount={sysvarAccount}
|
||||
/>
|
||||
);
|
||||
case "recentBlockhashes":
|
||||
return (
|
||||
<SysvarAccountRecentBlockhashesCard
|
||||
account={account}
|
||||
sysvarAccount={sysvarAccount}
|
||||
/>
|
||||
);
|
||||
case "slotHashes":
|
||||
return (
|
||||
<SysvarAccountSlotHashes
|
||||
account={account}
|
||||
sysvarAccount={sysvarAccount}
|
||||
/>
|
||||
);
|
||||
case "slotHistory":
|
||||
return (
|
||||
<SysvarAccountSlotHistory
|
||||
account={account}
|
||||
sysvarAccount={sysvarAccount}
|
||||
/>
|
||||
);
|
||||
case "stakeHistory":
|
||||
return (
|
||||
<SysvarAccountStakeHistory
|
||||
account={account}
|
||||
sysvarAccount={sysvarAccount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function SysvarAccountRecentBlockhashesCard({
|
||||
account,
|
||||
}: {
|
||||
account: Account;
|
||||
sysvarAccount: SysvarRecentBlockhashesAccount;
|
||||
}) {
|
||||
const refresh = useFetchAccountInfo();
|
||||
return (
|
||||
<div className="card">
|
||||
<AccountHeader
|
||||
title="Sysvar: Recent Blockhashes"
|
||||
refresh={() => refresh(account.pubkey, "parsed")}
|
||||
/>
|
||||
|
||||
<TableCardBody>
|
||||
<AccountAddressRow account={account} />
|
||||
<AccountBalanceRow account={account} />
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SysvarAccountSlotHashes({
|
||||
account,
|
||||
}: {
|
||||
account: Account;
|
||||
sysvarAccount: SysvarSlotHashesAccount;
|
||||
}) {
|
||||
const refresh = useFetchAccountInfo();
|
||||
return (
|
||||
<div className="card">
|
||||
<AccountHeader
|
||||
title="Sysvar: Slot Hashes"
|
||||
refresh={() => refresh(account.pubkey, "parsed")}
|
||||
/>
|
||||
|
||||
<TableCardBody>
|
||||
<AccountAddressRow account={account} />
|
||||
<AccountBalanceRow account={account} />
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SysvarAccountSlotHistory({
|
||||
account,
|
||||
sysvarAccount,
|
||||
}: {
|
||||
account: Account;
|
||||
sysvarAccount: SysvarSlotHistoryAccount;
|
||||
}) {
|
||||
const refresh = useFetchAccountInfo();
|
||||
const history = Array.from(
|
||||
{
|
||||
length: 100,
|
||||
},
|
||||
(v, k) => sysvarAccount.info.nextSlot - k
|
||||
);
|
||||
return (
|
||||
<div className="card">
|
||||
<AccountHeader
|
||||
title="Sysvar: Slot History"
|
||||
refresh={() => refresh(account.pubkey, "parsed")}
|
||||
/>
|
||||
|
||||
<TableCardBody>
|
||||
<AccountAddressRow account={account} />
|
||||
<AccountBalanceRow account={account} />
|
||||
|
||||
<tr>
|
||||
<td className="align-top">
|
||||
Slot History{" "}
|
||||
<span className="text-muted">(previous 100 slots)</span>
|
||||
</td>
|
||||
<td className="text-lg-end font-monospace">
|
||||
{history.map((val) => (
|
||||
<p key={val} className="mb-0">
|
||||
<Slot slot={val} link />
|
||||
</p>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SysvarAccountStakeHistory({
|
||||
account,
|
||||
}: {
|
||||
account: Account;
|
||||
sysvarAccount: SysvarStakeHistoryAccount;
|
||||
}) {
|
||||
const refresh = useFetchAccountInfo();
|
||||
return (
|
||||
<div className="card">
|
||||
<AccountHeader
|
||||
title="Sysvar: Stake History"
|
||||
refresh={() => refresh(account.pubkey, "parsed")}
|
||||
/>
|
||||
|
||||
<TableCardBody>
|
||||
<AccountAddressRow account={account} />
|
||||
<AccountBalanceRow account={account} />
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SysvarAccountFeesCard({
|
||||
account,
|
||||
sysvarAccount,
|
||||
}: {
|
||||
account: Account;
|
||||
sysvarAccount: SysvarFeesAccount;
|
||||
}) {
|
||||
const refresh = useFetchAccountInfo();
|
||||
return (
|
||||
<div className="card">
|
||||
<AccountHeader
|
||||
title="Sysvar: Fees"
|
||||
refresh={() => refresh(account.pubkey, "parsed")}
|
||||
/>
|
||||
|
||||
<TableCardBody>
|
||||
<AccountAddressRow account={account} />
|
||||
<AccountBalanceRow account={account} />
|
||||
|
||||
<tr>
|
||||
<td>Lamports Per Signature</td>
|
||||
<td className="text-lg-end">
|
||||
{sysvarAccount.info.feeCalculator.lamportsPerSignature}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SysvarAccountEpochScheduleCard({
|
||||
account,
|
||||
sysvarAccount,
|
||||
}: {
|
||||
account: Account;
|
||||
sysvarAccount: SysvarEpochScheduleAccount;
|
||||
}) {
|
||||
const refresh = useFetchAccountInfo();
|
||||
return (
|
||||
<div className="card">
|
||||
<AccountHeader
|
||||
title="Sysvar: Epoch Schedule"
|
||||
refresh={() => refresh(account.pubkey, "parsed")}
|
||||
/>
|
||||
|
||||
<TableCardBody>
|
||||
<AccountAddressRow account={account} />
|
||||
<AccountBalanceRow account={account} />
|
||||
|
||||
<tr>
|
||||
<td>Slots Per Epoch</td>
|
||||
<td className="text-lg-end">{sysvarAccount.info.slotsPerEpoch}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Leader Schedule Slot Offset</td>
|
||||
<td className="text-lg-end">
|
||||
{sysvarAccount.info.leaderScheduleSlotOffset}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Epoch Warmup Enabled</td>
|
||||
<td className="text-lg-end">
|
||||
<code>{sysvarAccount.info.warmup ? "true" : "false"}</code>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>First Normal Epoch</td>
|
||||
<td className="text-lg-end">{sysvarAccount.info.firstNormalEpoch}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>First Normal Slot</td>
|
||||
<td className="text-lg-end">
|
||||
<Slot slot={sysvarAccount.info.firstNormalSlot} />
|
||||
</td>
|
||||
</tr>
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SysvarAccountClockCard({
|
||||
account,
|
||||
sysvarAccount,
|
||||
}: {
|
||||
account: Account;
|
||||
sysvarAccount: SysvarClockAccount;
|
||||
}) {
|
||||
const refresh = useFetchAccountInfo();
|
||||
return (
|
||||
<div className="card">
|
||||
<AccountHeader
|
||||
title="Sysvar: Clock"
|
||||
refresh={() => refresh(account.pubkey, "parsed")}
|
||||
/>
|
||||
|
||||
<TableCardBody>
|
||||
<AccountAddressRow account={account} />
|
||||
<AccountBalanceRow account={account} />
|
||||
|
||||
<tr>
|
||||
<td>Timestamp</td>
|
||||
<td className="text-lg-end font-monospace">
|
||||
{displayTimestamp(sysvarAccount.info.unixTimestamp * 1000)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Epoch</td>
|
||||
<td className="text-lg-end">
|
||||
<Epoch epoch={sysvarAccount.info.epoch} link />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Leader Schedule Epoch</td>
|
||||
<td className="text-lg-end">
|
||||
<Epoch epoch={sysvarAccount.info.leaderScheduleEpoch} link />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Slot</td>
|
||||
<td className="text-lg-end">
|
||||
<Slot slot={sysvarAccount.info.slot} link />
|
||||
</td>
|
||||
</tr>
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SysvarAccountRentCard({
|
||||
account,
|
||||
sysvarAccount,
|
||||
}: {
|
||||
account: Account;
|
||||
sysvarAccount: SysvarRentAccount;
|
||||
}) {
|
||||
const refresh = useFetchAccountInfo();
|
||||
return (
|
||||
<div className="card">
|
||||
<AccountHeader
|
||||
title="Sysvar: Rent"
|
||||
refresh={() => refresh(account.pubkey, "parsed")}
|
||||
/>
|
||||
|
||||
<TableCardBody>
|
||||
<AccountAddressRow account={account} />
|
||||
<AccountBalanceRow account={account} />
|
||||
|
||||
<tr>
|
||||
<td>Burn Percent</td>
|
||||
<td className="text-lg-end">
|
||||
{sysvarAccount.info.burnPercent + "%"}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Exemption Threshold</td>
|
||||
<td className="text-lg-end">
|
||||
{sysvarAccount.info.exemptionThreshold} years
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Lamports Per Byte Year</td>
|
||||
<td className="text-lg-end">
|
||||
{sysvarAccount.info.lamportsPerByteYear}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SysvarAccountRewardsCard({
|
||||
account,
|
||||
sysvarAccount,
|
||||
}: {
|
||||
account: Account;
|
||||
sysvarAccount: SysvarRewardsAccount;
|
||||
}) {
|
||||
const refresh = useFetchAccountInfo();
|
||||
|
||||
const validatorPointValueFormatted = new Intl.NumberFormat("en-US", {
|
||||
maximumSignificantDigits: 20,
|
||||
}).format(sysvarAccount.info.validatorPointValue);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<AccountHeader
|
||||
title="Sysvar: Rewards"
|
||||
refresh={() => refresh(account.pubkey, "parsed")}
|
||||
/>
|
||||
|
||||
<TableCardBody>
|
||||
<AccountAddressRow account={account} />
|
||||
<AccountBalanceRow account={account} />
|
||||
|
||||
<tr>
|
||||
<td>Validator Point Value</td>
|
||||
<td className="text-lg-end font-monospace">
|
||||
{validatorPointValueFormatted} lamports
|
||||
</td>
|
||||
</tr>
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,566 +0,0 @@
|
|||
import {
|
||||
Account,
|
||||
NFTData,
|
||||
TokenProgramData,
|
||||
useFetchAccountInfo,
|
||||
} from "providers/accounts";
|
||||
import {
|
||||
TokenAccount,
|
||||
MintAccountInfo,
|
||||
TokenAccountInfo,
|
||||
MultisigAccountInfo,
|
||||
} from "validators/accounts/token";
|
||||
import { create } from "superstruct";
|
||||
import { TableCardBody } from "components/common/TableCardBody";
|
||||
import { Address } from "components/common/Address";
|
||||
import { UnknownAccountCard } from "./UnknownAccountCard";
|
||||
import { Cluster, useCluster } from "providers/cluster";
|
||||
import { abbreviatedNumber, normalizeTokenAmount } from "utils";
|
||||
import { addressLabel } from "utils/tx";
|
||||
import { reportError } from "utils/sentry";
|
||||
import { useTokenRegistry } from "providers/mints/token-registry";
|
||||
import { BigNumber } from "bignumber.js";
|
||||
import { Copyable } from "components/common/Copyable";
|
||||
import { CoingeckoStatus, useCoinGecko } from "utils/coingecko";
|
||||
import { displayTimestampWithoutDate } from "utils/date";
|
||||
import { LoadingCard } from "components/common/LoadingCard";
|
||||
import { PublicKey } from "@solana/web3.js";
|
||||
import isMetaplexNFT from "providers/accounts/utils/isMetaplexNFT";
|
||||
|
||||
const getEthAddress = (link?: string) => {
|
||||
let address = "";
|
||||
if (link) {
|
||||
const extractEth = link.match(/0x[a-fA-F0-9]{40,64}/);
|
||||
|
||||
if (extractEth) {
|
||||
address = extractEth[0];
|
||||
}
|
||||
}
|
||||
|
||||
return address;
|
||||
};
|
||||
|
||||
export function TokenAccountSection({
|
||||
account,
|
||||
tokenAccount,
|
||||
}: {
|
||||
account: Account;
|
||||
tokenAccount: TokenAccount;
|
||||
}) {
|
||||
const { cluster } = useCluster();
|
||||
|
||||
try {
|
||||
switch (tokenAccount.type) {
|
||||
case "mint": {
|
||||
const info = create(tokenAccount.info, MintAccountInfo);
|
||||
|
||||
if (isMetaplexNFT(account.data.parsed, info)) {
|
||||
return (
|
||||
<NonFungibleTokenMintAccountCard
|
||||
account={account}
|
||||
nftData={(account.data.parsed as TokenProgramData).nftData!}
|
||||
mintInfo={info}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <FungibleTokenMintAccountCard account={account} info={info} />;
|
||||
}
|
||||
case "account": {
|
||||
const info = create(tokenAccount.info, TokenAccountInfo);
|
||||
return <TokenAccountCard account={account} info={info} />;
|
||||
}
|
||||
case "multisig": {
|
||||
const info = create(tokenAccount.info, MultisigAccountInfo);
|
||||
return <MultisigAccountCard account={account} info={info} />;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (cluster !== Cluster.Custom) {
|
||||
reportError(err, {
|
||||
address: account.pubkey.toBase58(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return <UnknownAccountCard account={account} />;
|
||||
}
|
||||
|
||||
function FungibleTokenMintAccountCard({
|
||||
account,
|
||||
info,
|
||||
}: {
|
||||
account: Account;
|
||||
info: MintAccountInfo;
|
||||
}) {
|
||||
const { tokenRegistry } = useTokenRegistry();
|
||||
const mintAddress = account.pubkey.toBase58();
|
||||
const fetchInfo = useFetchAccountInfo();
|
||||
const refresh = () => fetchInfo(account.pubkey, "parsed");
|
||||
const tokenInfo = tokenRegistry.get(mintAddress);
|
||||
|
||||
const bridgeContractAddress = getEthAddress(
|
||||
tokenInfo?.extensions?.bridgeContract
|
||||
);
|
||||
const assetContractAddress = getEthAddress(
|
||||
tokenInfo?.extensions?.assetContract
|
||||
);
|
||||
|
||||
const coinInfo = useCoinGecko(tokenInfo?.extensions?.coingeckoId);
|
||||
|
||||
let tokenPriceInfo;
|
||||
let tokenPriceDecimals = 2;
|
||||
if (coinInfo?.status === CoingeckoStatus.Success) {
|
||||
tokenPriceInfo = coinInfo.coinInfo;
|
||||
if (tokenPriceInfo && tokenPriceInfo.price < 1) {
|
||||
tokenPriceDecimals = 6;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{tokenInfo?.extensions?.coingeckoId &&
|
||||
coinInfo?.status === CoingeckoStatus.Loading && (
|
||||
<LoadingCard message="Loading token price data" />
|
||||
)}
|
||||
{tokenPriceInfo && tokenPriceInfo.price && (
|
||||
<div className="row">
|
||||
<div className="col-12 col-lg-4 col-xl">
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<h4>
|
||||
Price{" "}
|
||||
{tokenPriceInfo.market_cap_rank && (
|
||||
<span className="ms-2 badge bg-primary rank">
|
||||
Rank #{tokenPriceInfo.market_cap_rank}
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<h1 className="mb-0">
|
||||
${tokenPriceInfo.price.toFixed(tokenPriceDecimals)}{" "}
|
||||
{tokenPriceInfo.price_change_percentage_24h > 0 && (
|
||||
<small className="change-positive">
|
||||
↑{" "}
|
||||
{tokenPriceInfo.price_change_percentage_24h.toFixed(2)}%
|
||||
</small>
|
||||
)}
|
||||
{tokenPriceInfo.price_change_percentage_24h < 0 && (
|
||||
<small className="change-negative">
|
||||
↓{" "}
|
||||
{tokenPriceInfo.price_change_percentage_24h.toFixed(2)}%
|
||||
</small>
|
||||
)}
|
||||
{tokenPriceInfo.price_change_percentage_24h === 0 && (
|
||||
<small>0%</small>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-lg-4 col-xl">
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<h4>24 Hour Volume</h4>
|
||||
<h1 className="mb-0">
|
||||
${abbreviatedNumber(tokenPriceInfo.volume_24)}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-lg-4 col-xl">
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<h4>Market Cap</h4>
|
||||
<h1 className="mb-0">
|
||||
${abbreviatedNumber(tokenPriceInfo.market_cap)}
|
||||
</h1>
|
||||
<p className="updated-time text-muted">
|
||||
Updated at{" "}
|
||||
{displayTimestampWithoutDate(
|
||||
tokenPriceInfo.last_updated.getTime()
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
||||
{tokenInfo ? "Overview" : "Token Mint"}
|
||||
</h3>
|
||||
<button className="btn btn-white btn-sm" onClick={refresh}>
|
||||
<span className="fe fe-refresh-cw me-2"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<TableCardBody>
|
||||
<tr>
|
||||
<td>Address</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={account.pubkey} alignRight raw />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{info.mintAuthority === null ? "Fixed Supply" : "Current Supply"}
|
||||
</td>
|
||||
<td className="text-lg-end">
|
||||
{normalizeTokenAmount(info.supply, info.decimals).toLocaleString(
|
||||
"en-US",
|
||||
{
|
||||
maximumFractionDigits: 20,
|
||||
}
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{tokenInfo?.extensions?.website && (
|
||||
<tr>
|
||||
<td>Website</td>
|
||||
<td className="text-lg-end">
|
||||
<a
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href={tokenInfo.extensions.website}
|
||||
>
|
||||
{tokenInfo.extensions.website}
|
||||
<span className="fe fe-external-link ms-2"></span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{info.mintAuthority && (
|
||||
<tr>
|
||||
<td>Mint Authority</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={info.mintAuthority} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{info.freezeAuthority && (
|
||||
<tr>
|
||||
<td>Freeze Authority</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={info.freezeAuthority} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>Decimals</td>
|
||||
<td className="text-lg-end">{info.decimals}</td>
|
||||
</tr>
|
||||
{!info.isInitialized && (
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td className="text-lg-end">Uninitialized</td>
|
||||
</tr>
|
||||
)}
|
||||
{tokenInfo?.extensions?.bridgeContract && bridgeContractAddress && (
|
||||
<tr>
|
||||
<td>Bridge Contract</td>
|
||||
<td className="text-lg-end">
|
||||
<Copyable text={bridgeContractAddress}>
|
||||
<a
|
||||
href={tokenInfo.extensions.bridgeContract}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{bridgeContractAddress}
|
||||
</a>
|
||||
</Copyable>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{tokenInfo?.extensions?.assetContract && assetContractAddress && (
|
||||
<tr>
|
||||
<td>Bridged Asset Contract</td>
|
||||
<td className="text-lg-end">
|
||||
<Copyable text={assetContractAddress}>
|
||||
<a
|
||||
href={tokenInfo.extensions.bridgeContract}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{assetContractAddress}
|
||||
</a>
|
||||
</Copyable>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</TableCardBody>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NonFungibleTokenMintAccountCard({
|
||||
account,
|
||||
nftData,
|
||||
mintInfo,
|
||||
}: {
|
||||
account: Account;
|
||||
nftData: NFTData;
|
||||
mintInfo: MintAccountInfo;
|
||||
}) {
|
||||
const fetchInfo = useFetchAccountInfo();
|
||||
const refresh = () => fetchInfo(account.pubkey, "parsed");
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
||||
Overview
|
||||
</h3>
|
||||
<button className="btn btn-white btn-sm" onClick={refresh}>
|
||||
<span className="fe fe-refresh-cw me-2"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<TableCardBody>
|
||||
<tr>
|
||||
<td>Address</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={account.pubkey} alignRight raw />
|
||||
</td>
|
||||
</tr>
|
||||
{nftData.editionInfo.masterEdition?.maxSupply && (
|
||||
<tr>
|
||||
<td>Max Total Supply</td>
|
||||
<td className="text-lg-end">
|
||||
{nftData.editionInfo.masterEdition.maxSupply.toNumber() === 0
|
||||
? 1
|
||||
: nftData.editionInfo.masterEdition.maxSupply.toNumber()}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{nftData?.editionInfo.masterEdition?.supply && (
|
||||
<tr>
|
||||
<td>Current Supply</td>
|
||||
<td className="text-lg-end">
|
||||
{nftData.editionInfo.masterEdition.supply.toNumber() === 0
|
||||
? 1
|
||||
: nftData.editionInfo.masterEdition.supply.toNumber()}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!!nftData?.metadata.collection?.verified && (
|
||||
<tr>
|
||||
<td>Verified Collection Address</td>
|
||||
<td className="text-lg-end">
|
||||
<Address
|
||||
pubkey={new PublicKey(nftData.metadata.collection.key)}
|
||||
alignRight
|
||||
link
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{mintInfo.mintAuthority && (
|
||||
<tr>
|
||||
<td>Mint Authority</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={mintInfo.mintAuthority} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{mintInfo.freezeAuthority && (
|
||||
<tr>
|
||||
<td>Freeze Authority</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={mintInfo.freezeAuthority} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>Update Authority</td>
|
||||
<td className="text-lg-end">
|
||||
<Address
|
||||
pubkey={new PublicKey(nftData.metadata.updateAuthority)}
|
||||
alignRight
|
||||
link
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{nftData?.json && nftData.json.external_url && (
|
||||
<tr>
|
||||
<td>Website</td>
|
||||
<td className="text-lg-end">
|
||||
<a
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href={nftData.json.external_url}
|
||||
>
|
||||
{nftData.json.external_url}
|
||||
<span className="fe fe-external-link ms-2"></span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{nftData?.metadata.data && (
|
||||
<tr>
|
||||
<td>Seller Fee</td>
|
||||
<td className="text-lg-end">
|
||||
{`${nftData?.metadata.data.sellerFeeBasisPoints / 100}%`}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenAccountCard({
|
||||
account,
|
||||
info,
|
||||
}: {
|
||||
account: Account;
|
||||
info: TokenAccountInfo;
|
||||
}) {
|
||||
const refresh = useFetchAccountInfo();
|
||||
const { cluster } = useCluster();
|
||||
const { tokenRegistry } = useTokenRegistry();
|
||||
const label = addressLabel(account.pubkey.toBase58(), cluster, tokenRegistry);
|
||||
|
||||
let unit, balance;
|
||||
if (info.isNative) {
|
||||
unit = "SOL";
|
||||
balance = (
|
||||
<>
|
||||
◎
|
||||
<span className="font-monospace">
|
||||
{new BigNumber(info.tokenAmount.uiAmountString).toFormat(9)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
balance = <>{info.tokenAmount.uiAmountString}</>;
|
||||
unit = tokenRegistry.get(info.mint.toBase58())?.symbol || "tokens";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
||||
Token Account
|
||||
</h3>
|
||||
<button
|
||||
className="btn btn-white btn-sm"
|
||||
onClick={() => refresh(account.pubkey, "parsed")}
|
||||
>
|
||||
<span className="fe fe-refresh-cw me-2"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TableCardBody>
|
||||
<tr>
|
||||
<td>Address</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={account.pubkey} alignRight raw />
|
||||
</td>
|
||||
</tr>
|
||||
{label && (
|
||||
<tr>
|
||||
<td>Address Label</td>
|
||||
<td className="text-lg-end">{label}</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>Mint</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={info.mint} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Owner</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={info.owner} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Token balance ({unit})</td>
|
||||
<td className="text-lg-end">{balance}</td>
|
||||
</tr>
|
||||
{info.state === "uninitialized" && (
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td className="text-lg-end">Uninitialized</td>
|
||||
</tr>
|
||||
)}
|
||||
{info.rentExemptReserve && (
|
||||
<tr>
|
||||
<td>Rent-exempt reserve (SOL)</td>
|
||||
<td className="text-lg-end">
|
||||
<>
|
||||
◎
|
||||
<span className="font-monospace">
|
||||
{new BigNumber(
|
||||
info.rentExemptReserve.uiAmountString
|
||||
).toFormat(9)}
|
||||
</span>
|
||||
</>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MultisigAccountCard({
|
||||
account,
|
||||
info,
|
||||
}: {
|
||||
account: Account;
|
||||
info: MultisigAccountInfo;
|
||||
}) {
|
||||
const refresh = useFetchAccountInfo();
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
||||
Multisig Account
|
||||
</h3>
|
||||
<button
|
||||
className="btn btn-white btn-sm"
|
||||
onClick={() => refresh(account.pubkey, "parsed")}
|
||||
>
|
||||
<span className="fe fe-refresh-cw me-2"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TableCardBody>
|
||||
<tr>
|
||||
<td>Address</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={account.pubkey} alignRight raw />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Required Signers</td>
|
||||
<td className="text-lg-end">{info.numRequiredSigners}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Valid Signers</td>
|
||||
<td className="text-lg-end">{info.numValidSigners}</td>
|
||||
</tr>
|
||||
{info.signers.map((signer) => (
|
||||
<tr key={signer.toString()}>
|
||||
<td>Signer</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={signer} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!info.isInitialized && (
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td className="text-lg-end">Uninitialized</td>
|
||||
</tr>
|
||||
)}
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,612 +0,0 @@
|
|||
import React from "react";
|
||||
import {
|
||||
PublicKey,
|
||||
ConfirmedSignatureInfo,
|
||||
ParsedInstruction,
|
||||
PartiallyDecodedInstruction,
|
||||
} from "@solana/web3.js";
|
||||
import { CacheEntry, FetchStatus } from "providers/cache";
|
||||
import {
|
||||
useAccountHistories,
|
||||
useFetchAccountHistory,
|
||||
} from "providers/accounts/history";
|
||||
import {
|
||||
useAccountOwnedTokens,
|
||||
TokenInfoWithPubkey,
|
||||
TOKEN_PROGRAM_ID,
|
||||
} from "providers/accounts/tokens";
|
||||
import { ErrorCard } from "components/common/ErrorCard";
|
||||
import { LoadingCard } from "components/common/LoadingCard";
|
||||
import { Signature } from "components/common/Signature";
|
||||
import { Address } from "components/common/Address";
|
||||
import { Slot } from "components/common/Slot";
|
||||
import {
|
||||
Details,
|
||||
useFetchTransactionDetails,
|
||||
useTransactionDetailsCache,
|
||||
} from "providers/transactions/parsed";
|
||||
import { reportError } from "utils/sentry";
|
||||
import { intoTransactionInstruction, displayAddress } from "utils/tx";
|
||||
import {
|
||||
isTokenSwapInstruction,
|
||||
parseTokenSwapInstructionTitle,
|
||||
} from "components/instruction/token-swap/types";
|
||||
import {
|
||||
isTokenLendingInstruction,
|
||||
parseTokenLendingInstructionTitle,
|
||||
} from "components/instruction/token-lending/types";
|
||||
import {
|
||||
isSerumInstruction,
|
||||
parseSerumInstructionTitle,
|
||||
} from "components/instruction/serum/types";
|
||||
import { INNER_INSTRUCTIONS_START_SLOT } from "pages/TransactionDetailsPage";
|
||||
import { useCluster, Cluster } from "providers/cluster";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Location } from "history";
|
||||
import { useQuery } from "utils/url";
|
||||
import { TokenInfoMap } from "@solana/spl-token-registry";
|
||||
import { useTokenRegistry } from "providers/mints/token-registry";
|
||||
import { getTokenProgramInstructionName } from "utils/instruction";
|
||||
import {
|
||||
isMangoInstruction,
|
||||
parseMangoInstructionTitle,
|
||||
} from "components/instruction/mango/types";
|
||||
|
||||
const TRUNCATE_TOKEN_LENGTH = 10;
|
||||
const ALL_TOKENS = "";
|
||||
|
||||
type InstructionType = {
|
||||
name: string;
|
||||
innerInstructions: (ParsedInstruction | PartiallyDecodedInstruction)[];
|
||||
};
|
||||
|
||||
export function TokenHistoryCard({ pubkey }: { pubkey: PublicKey }) {
|
||||
const address = pubkey.toBase58();
|
||||
const ownedTokens = useAccountOwnedTokens(address);
|
||||
|
||||
if (ownedTokens === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = ownedTokens.data?.tokens;
|
||||
if (tokens === undefined || tokens.length === 0) return null;
|
||||
|
||||
if (tokens.length > 25) {
|
||||
return (
|
||||
<ErrorCard text="Token transaction history is not available for accounts with over 25 token accounts" />
|
||||
);
|
||||
}
|
||||
|
||||
return <TokenHistoryTable tokens={tokens} />;
|
||||
}
|
||||
|
||||
const useQueryFilter = (): string => {
|
||||
const query = useQuery();
|
||||
const filter = query.get("filter");
|
||||
return filter || "";
|
||||
};
|
||||
|
||||
type FilterProps = {
|
||||
filter: string;
|
||||
toggle: () => void;
|
||||
show: boolean;
|
||||
tokens: TokenInfoWithPubkey[];
|
||||
};
|
||||
|
||||
function TokenHistoryTable({ tokens }: { tokens: TokenInfoWithPubkey[] }) {
|
||||
const accountHistories = useAccountHistories();
|
||||
const fetchAccountHistory = useFetchAccountHistory();
|
||||
const transactionDetailsCache = useTransactionDetailsCache();
|
||||
const [showDropdown, setDropdown] = React.useState(false);
|
||||
const filter = useQueryFilter();
|
||||
|
||||
const filteredTokens = React.useMemo(
|
||||
() =>
|
||||
tokens.filter((token) => {
|
||||
if (filter === ALL_TOKENS) {
|
||||
return true;
|
||||
}
|
||||
return token.info.mint.toBase58() === filter;
|
||||
}),
|
||||
[tokens, filter]
|
||||
);
|
||||
|
||||
const fetchHistories = React.useCallback(
|
||||
(refresh?: boolean) => {
|
||||
filteredTokens.forEach((token) => {
|
||||
fetchAccountHistory(token.pubkey, refresh);
|
||||
});
|
||||
},
|
||||
[filteredTokens, fetchAccountHistory]
|
||||
);
|
||||
|
||||
// Fetch histories on load
|
||||
React.useEffect(() => {
|
||||
filteredTokens.forEach((token) => {
|
||||
const address = token.pubkey.toBase58();
|
||||
if (!accountHistories[address]) {
|
||||
fetchAccountHistory(token.pubkey, true);
|
||||
}
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const allFoundOldest = filteredTokens.every((token) => {
|
||||
const history = accountHistories[token.pubkey.toBase58()];
|
||||
return history?.data?.foundOldest === true;
|
||||
});
|
||||
|
||||
const allFetchedSome = filteredTokens.every((token) => {
|
||||
const history = accountHistories[token.pubkey.toBase58()];
|
||||
return history?.data !== undefined;
|
||||
});
|
||||
|
||||
// Find the oldest slot which we know we have the full history for
|
||||
let oldestSlot: number | undefined = allFoundOldest ? 0 : undefined;
|
||||
|
||||
if (!allFoundOldest && allFetchedSome) {
|
||||
filteredTokens.forEach((token) => {
|
||||
const history = accountHistories[token.pubkey.toBase58()];
|
||||
if (history?.data?.foundOldest === false) {
|
||||
const earliest =
|
||||
history.data.fetched[history.data.fetched.length - 1].slot;
|
||||
if (!oldestSlot) oldestSlot = earliest;
|
||||
oldestSlot = Math.max(oldestSlot, earliest);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const fetching = filteredTokens.some((token) => {
|
||||
const history = accountHistories[token.pubkey.toBase58()];
|
||||
return history?.status === FetchStatus.Fetching;
|
||||
});
|
||||
|
||||
const failed = filteredTokens.some((token) => {
|
||||
const history = accountHistories[token.pubkey.toBase58()];
|
||||
return history?.status === FetchStatus.FetchFailed;
|
||||
});
|
||||
|
||||
const sigSet = new Set();
|
||||
const mintAndTxs = filteredTokens
|
||||
.map((token) => ({
|
||||
mint: token.info.mint,
|
||||
history: accountHistories[token.pubkey.toBase58()],
|
||||
}))
|
||||
.filter(({ history }) => {
|
||||
return history?.data?.fetched && history.data.fetched.length > 0;
|
||||
})
|
||||
.flatMap(({ mint, history }) =>
|
||||
(history?.data?.fetched as ConfirmedSignatureInfo[]).map((tx) => ({
|
||||
mint,
|
||||
tx,
|
||||
}))
|
||||
)
|
||||
.filter(({ tx }) => {
|
||||
if (sigSet.has(tx.signature)) return false;
|
||||
sigSet.add(tx.signature);
|
||||
return true;
|
||||
})
|
||||
.filter(({ tx }) => {
|
||||
return oldestSlot !== undefined && tx.slot >= oldestSlot;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!fetching && mintAndTxs.length < 1 && !allFoundOldest) {
|
||||
fetchHistories();
|
||||
}
|
||||
}, [fetching, mintAndTxs, allFoundOldest, fetchHistories]);
|
||||
|
||||
if (mintAndTxs.length === 0) {
|
||||
if (fetching) {
|
||||
return <LoadingCard message="Loading history" />;
|
||||
} else if (failed) {
|
||||
return (
|
||||
<ErrorCard
|
||||
retry={() => fetchHistories(true)}
|
||||
text="Failed to fetch transaction history"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ErrorCard
|
||||
retry={() => fetchHistories(true)}
|
||||
retryText="Try again"
|
||||
text="No transaction history found"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
mintAndTxs.sort((a, b) => {
|
||||
if (a.tx.slot > b.tx.slot) return -1;
|
||||
if (a.tx.slot < b.tx.slot) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header align-items-center">
|
||||
<h3 className="card-header-title">Token History</h3>
|
||||
<FilterDropdown
|
||||
filter={filter}
|
||||
toggle={() => setDropdown((show) => !show)}
|
||||
show={showDropdown}
|
||||
tokens={tokens}
|
||||
></FilterDropdown>
|
||||
<button
|
||||
className="btn btn-white btn-sm"
|
||||
disabled={fetching}
|
||||
onClick={() => fetchHistories(true)}
|
||||
>
|
||||
{fetching ? (
|
||||
<>
|
||||
<span className="spinner-grow spinner-grow-sm me-2"></span>
|
||||
Loading
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="fe fe-refresh-cw me-2"></span>
|
||||
Refresh
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-muted w-1">Slot</th>
|
||||
<th className="text-muted">Result</th>
|
||||
<th className="text-muted">Token</th>
|
||||
<th className="text-muted">Instruction Type</th>
|
||||
<th className="text-muted">Transaction Signature</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="list">
|
||||
{mintAndTxs.map(({ mint, tx }) => (
|
||||
<TokenTransactionRow
|
||||
key={tx.signature}
|
||||
mint={mint}
|
||||
tx={tx}
|
||||
details={transactionDetailsCache[tx.signature]}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="card-footer">
|
||||
{allFoundOldest ? (
|
||||
<div className="text-muted text-center">Fetched full history</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-primary w-100"
|
||||
onClick={() => fetchHistories()}
|
||||
disabled={fetching}
|
||||
>
|
||||
{fetching ? (
|
||||
<>
|
||||
<span className="spinner-grow spinner-grow-sm me-2"></span>
|
||||
Loading
|
||||
</>
|
||||
) : (
|
||||
"Load More"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FilterDropdown = ({ filter, toggle, show, tokens }: FilterProps) => {
|
||||
const { cluster } = useCluster();
|
||||
const { tokenRegistry } = useTokenRegistry();
|
||||
|
||||
const buildLocation = (location: Location, filter: string) => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (filter === ALL_TOKENS) {
|
||||
params.delete("filter");
|
||||
} else {
|
||||
params.set("filter", filter);
|
||||
}
|
||||
return {
|
||||
...location,
|
||||
search: params.toString(),
|
||||
};
|
||||
};
|
||||
|
||||
const filterOptions: string[] = [ALL_TOKENS];
|
||||
const nameLookup: Map<string, string> = new Map();
|
||||
|
||||
tokens.forEach((token) => {
|
||||
const address = token.info.mint.toBase58();
|
||||
if (!nameLookup.has(address)) {
|
||||
filterOptions.push(address);
|
||||
nameLookup.set(address, formatTokenName(address, cluster, tokenRegistry));
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="dropdown me-2">
|
||||
<small className="me-2">Filter:</small>
|
||||
<button
|
||||
className="btn btn-white btn-sm dropdown-toggle"
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
>
|
||||
{filter === ALL_TOKENS ? "All Tokens" : nameLookup.get(filter)}
|
||||
</button>
|
||||
<div
|
||||
className={`token-filter dropdown-menu-end dropdown-menu${
|
||||
show ? " show" : ""
|
||||
}`}
|
||||
>
|
||||
{filterOptions.map((filterOption) => {
|
||||
return (
|
||||
<Link
|
||||
key={filterOption}
|
||||
to={(location: Location) => buildLocation(location, filterOption)}
|
||||
className={`dropdown-item${
|
||||
filterOption === filter ? " active" : ""
|
||||
}`}
|
||||
onClick={toggle}
|
||||
>
|
||||
{filterOption === ALL_TOKENS
|
||||
? "All Tokens"
|
||||
: formatTokenName(filterOption, cluster, tokenRegistry)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TokenTransactionRow = React.memo(
|
||||
({
|
||||
mint,
|
||||
tx,
|
||||
details,
|
||||
}: {
|
||||
mint: PublicKey;
|
||||
tx: ConfirmedSignatureInfo;
|
||||
details: CacheEntry<Details> | undefined;
|
||||
}) => {
|
||||
const fetchDetails = useFetchTransactionDetails();
|
||||
const { cluster } = useCluster();
|
||||
|
||||
// Fetch details on load
|
||||
React.useEffect(() => {
|
||||
if (!details) fetchDetails(tx.signature);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
let statusText: string;
|
||||
let statusClass: string;
|
||||
if (tx.err) {
|
||||
statusClass = "warning";
|
||||
statusText = "Failed";
|
||||
} else {
|
||||
statusClass = "success";
|
||||
statusText = "Success";
|
||||
}
|
||||
|
||||
const transactionWithMeta = details?.data?.transactionWithMeta;
|
||||
const instructions = transactionWithMeta?.transaction.message.instructions;
|
||||
if (!instructions)
|
||||
return (
|
||||
<tr key={tx.signature}>
|
||||
<td className="w-1">
|
||||
<Slot slot={tx.slot} link />
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span className={`badge bg-${statusClass}-soft`}>{statusText}</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<Address pubkey={mint} link truncate />
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span className="spinner-grow spinner-grow-sm me-2"></span>
|
||||
Loading
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<Signature signature={tx.signature} link />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
let tokenInstructionNames: InstructionType[] = [];
|
||||
|
||||
if (transactionWithMeta) {
|
||||
tokenInstructionNames = instructions
|
||||
.map((ix, index): InstructionType | undefined => {
|
||||
let name = "Unknown";
|
||||
|
||||
const innerInstructions: (
|
||||
| ParsedInstruction
|
||||
| PartiallyDecodedInstruction
|
||||
)[] = [];
|
||||
|
||||
if (
|
||||
transactionWithMeta.meta?.innerInstructions &&
|
||||
(cluster !== Cluster.MainnetBeta ||
|
||||
transactionWithMeta.slot >= INNER_INSTRUCTIONS_START_SLOT)
|
||||
) {
|
||||
transactionWithMeta.meta.innerInstructions.forEach((ix) => {
|
||||
if (ix.index === index) {
|
||||
ix.instructions.forEach((inner) => {
|
||||
innerInstructions.push(inner);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let transactionInstruction;
|
||||
if (transactionWithMeta?.transaction) {
|
||||
transactionInstruction = intoTransactionInstruction(
|
||||
transactionWithMeta.transaction,
|
||||
ix
|
||||
);
|
||||
}
|
||||
|
||||
if ("parsed" in ix) {
|
||||
if (ix.program === "spl-token") {
|
||||
name = getTokenProgramInstructionName(ix, tx);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
} else if (
|
||||
transactionInstruction &&
|
||||
isSerumInstruction(transactionInstruction)
|
||||
) {
|
||||
try {
|
||||
name = parseSerumInstructionTitle(transactionInstruction);
|
||||
} catch (error) {
|
||||
reportError(error, { signature: tx.signature });
|
||||
return undefined;
|
||||
}
|
||||
} else if (
|
||||
transactionInstruction &&
|
||||
isTokenSwapInstruction(transactionInstruction)
|
||||
) {
|
||||
try {
|
||||
name = parseTokenSwapInstructionTitle(transactionInstruction);
|
||||
} catch (error) {
|
||||
reportError(error, { signature: tx.signature });
|
||||
return undefined;
|
||||
}
|
||||
} else if (
|
||||
transactionInstruction &&
|
||||
isTokenLendingInstruction(transactionInstruction)
|
||||
) {
|
||||
try {
|
||||
name = parseTokenLendingInstructionTitle(transactionInstruction);
|
||||
} catch (error) {
|
||||
reportError(error, { signature: tx.signature });
|
||||
return undefined;
|
||||
}
|
||||
} else if (
|
||||
transactionInstruction &&
|
||||
isMangoInstruction(transactionInstruction)
|
||||
) {
|
||||
try {
|
||||
name = parseMangoInstructionTitle(transactionInstruction);
|
||||
} catch (error) {
|
||||
reportError(error, { signature: tx.signature });
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
ix.accounts.findIndex((account) =>
|
||||
account.equals(TOKEN_PROGRAM_ID)
|
||||
) >= 0
|
||||
) {
|
||||
name = "Unknown (Inner)";
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
innerInstructions,
|
||||
};
|
||||
})
|
||||
.filter((name) => name !== undefined) as InstructionType[];
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{tokenInstructionNames.map((instructionType, index) => {
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td className="w-1">
|
||||
<Slot slot={tx.slot} link />
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span className={`badge bg-${statusClass}-soft`}>
|
||||
{statusText}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="forced-truncate">
|
||||
<Address pubkey={mint} link truncateUnknown />
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<InstructionDetails instructionType={instructionType} tx={tx} />
|
||||
</td>
|
||||
|
||||
<td className="forced-truncate">
|
||||
<Signature signature={tx.signature} link truncate />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
function InstructionDetails({
|
||||
instructionType,
|
||||
tx,
|
||||
}: {
|
||||
instructionType: InstructionType;
|
||||
tx: ConfirmedSignatureInfo;
|
||||
}) {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
let instructionTypes = instructionType.innerInstructions
|
||||
.map((ix) => {
|
||||
if ("parsed" in ix && ix.program === "spl-token") {
|
||||
return getTokenProgramInstructionName(ix, tx);
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.filter((type) => type !== undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="tree">
|
||||
{instructionTypes.length > 0 && (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
className={`c-pointer fe me-2 ${
|
||||
expanded ? "fe-minus-square" : "fe-plus-square"
|
||||
}`}
|
||||
></span>
|
||||
)}
|
||||
{instructionType.name}
|
||||
</p>
|
||||
{expanded && (
|
||||
<ul className="tree">
|
||||
{instructionTypes.map((type, index) => {
|
||||
return <li key={index}>{type}</li>;
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTokenName(
|
||||
pubkey: string,
|
||||
cluster: Cluster,
|
||||
tokenRegistry: TokenInfoMap
|
||||
): string {
|
||||
let display = displayAddress(pubkey, cluster, tokenRegistry);
|
||||
|
||||
if (display === pubkey) {
|
||||
display = display.slice(0, TRUNCATE_TOKEN_LENGTH) + "\u2026";
|
||||
}
|
||||
|
||||
return display;
|
||||
}
|
|
@ -1,149 +0,0 @@
|
|||
import React from "react";
|
||||
import { PublicKey } from "@solana/web3.js";
|
||||
import { LoadingCard } from "components/common/LoadingCard";
|
||||
import { ErrorCard } from "components/common/ErrorCard";
|
||||
import { Address } from "components/common/Address";
|
||||
import {
|
||||
useTokenLargestTokens,
|
||||
useFetchTokenLargestAccounts,
|
||||
TokenAccountBalancePairWithOwner,
|
||||
} from "providers/mints/largest";
|
||||
import { FetchStatus } from "providers/cache";
|
||||
import { useMintAccountInfo } from "providers/accounts";
|
||||
import { normalizeTokenAmount } from "utils";
|
||||
import { useTokenRegistry } from "providers/mints/token-registry";
|
||||
import BigNumber from "bignumber.js";
|
||||
|
||||
export function TokenLargestAccountsCard({ pubkey }: { pubkey: PublicKey }) {
|
||||
const mintAddress = pubkey.toBase58();
|
||||
const mintInfo = useMintAccountInfo(mintAddress);
|
||||
const largestAccounts = useTokenLargestTokens(mintAddress);
|
||||
const fetchLargestAccounts = useFetchTokenLargestAccounts();
|
||||
const refreshLargest = React.useCallback(
|
||||
() => fetchLargestAccounts(pubkey),
|
||||
[pubkey, fetchLargestAccounts]
|
||||
);
|
||||
const { tokenRegistry } = useTokenRegistry();
|
||||
const unit = tokenRegistry.get(mintAddress)?.symbol;
|
||||
const unitLabel = unit ? `(${unit})` : "";
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mintInfo) refreshLargest();
|
||||
}, [mintInfo, refreshLargest]);
|
||||
|
||||
// Largest accounts hasn't started fetching
|
||||
if (largestAccounts === undefined) return null;
|
||||
|
||||
// This is not a mint account
|
||||
if (mintInfo === undefined) return null;
|
||||
|
||||
if (largestAccounts?.data === undefined) {
|
||||
if (largestAccounts.status === FetchStatus.Fetching) {
|
||||
return <LoadingCard message="Loading largest accounts" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorCard
|
||||
retry={refreshLargest}
|
||||
text="Failed to fetch largest accounts"
|
||||
/>
|
||||
);
|
||||
} else if (largestAccounts.status === FetchStatus.Fetching) {
|
||||
return <LoadingCard message="Refreshing largest accounts" />;
|
||||
}
|
||||
|
||||
const accounts = largestAccounts.data.largest;
|
||||
if (accounts.length === 0) {
|
||||
return <ErrorCard text="No holders found" />;
|
||||
}
|
||||
|
||||
// Find largest fixed point in accounts array
|
||||
const balanceFixedPoint = accounts.reduce(
|
||||
(prev: number, current: TokenAccountBalancePairWithOwner) => {
|
||||
const amount = `${current.uiAmountString}`;
|
||||
const length = amount.length;
|
||||
const decimalIndex = amount.indexOf(".");
|
||||
if (decimalIndex >= 0 && length - decimalIndex - 1 > prev) {
|
||||
return length - decimalIndex - 1;
|
||||
}
|
||||
return prev;
|
||||
},
|
||||
0
|
||||
);
|
||||
|
||||
const supplyTotal = normalizeTokenAmount(mintInfo.supply, mintInfo.decimals);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h4 className="card-header-title">Largest Accounts</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-muted">Rank</th>
|
||||
<th className="text-muted">Address</th>
|
||||
<th className="text-muted">Owner</th>
|
||||
<th className="text-muted text-end">Balance {unitLabel}</th>
|
||||
<th className="text-muted text-end">% of Total Supply</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="list">
|
||||
{accounts.map((account, index) =>
|
||||
renderAccountRow(account, index, balanceFixedPoint, supplyTotal)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const renderAccountRow = (
|
||||
account: TokenAccountBalancePairWithOwner,
|
||||
index: number,
|
||||
balanceFixedPoint: number,
|
||||
supply: number
|
||||
) => {
|
||||
let percent = "-";
|
||||
if (supply > 0 && account.uiAmountString) {
|
||||
let uiAmountPercent = new BigNumber(account.uiAmountString)
|
||||
.times(100)
|
||||
.dividedBy(supply);
|
||||
|
||||
percent = `${uiAmountPercent.toFormat(3)}%`;
|
||||
|
||||
if (
|
||||
parseFloat(percent) === 0 &&
|
||||
new BigNumber(account.uiAmountString).gt(0)
|
||||
) {
|
||||
percent = `~${percent}`;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
<span className="badge bg-gray-soft badge-pill">{index + 1}</span>
|
||||
</td>
|
||||
<td className="td">
|
||||
<Address pubkey={account.address} link truncate />
|
||||
</td>
|
||||
<td>
|
||||
{account.owner && <Address pubkey={account.owner} link truncate />}
|
||||
</td>
|
||||
<td className="text-end font-monospace">
|
||||
{account.uiAmountString &&
|
||||
new BigNumber(account.uiAmountString).toFormat(balanceFixedPoint)}
|
||||
</td>
|
||||
<td className="text-end font-monospace">{percent}</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
|
@ -1,66 +0,0 @@
|
|||
import React from "react";
|
||||
import { Account } from "providers/accounts";
|
||||
import { SolBalance } from "components/common/SolBalance";
|
||||
import { TableCardBody } from "components/common/TableCardBody";
|
||||
import { Address } from "components/common/Address";
|
||||
import { addressLabel } from "utils/tx";
|
||||
import { useCluster } from "providers/cluster";
|
||||
import { useTokenRegistry } from "providers/mints/token-registry";
|
||||
|
||||
export function UnknownAccountCard({ account }: { account: Account }) {
|
||||
const { cluster } = useCluster();
|
||||
const { tokenRegistry } = useTokenRegistry();
|
||||
|
||||
const label = addressLabel(account.pubkey.toBase58(), cluster, tokenRegistry);
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header align-items-center">
|
||||
<h3 className="card-header-title">Overview</h3>
|
||||
</div>
|
||||
|
||||
<TableCardBody>
|
||||
<tr>
|
||||
<td>Address</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={account.pubkey} alignRight raw />
|
||||
</td>
|
||||
</tr>
|
||||
{label && (
|
||||
<tr>
|
||||
<td>Address Label</td>
|
||||
<td className="text-lg-end">{label}</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>Balance (SOL)</td>
|
||||
<td className="text-lg-end">
|
||||
{account.lamports === 0 ? (
|
||||
"Account does not exist"
|
||||
) : (
|
||||
<SolBalance lamports={account.lamports} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{account.space !== undefined && (
|
||||
<tr>
|
||||
<td>Allocated Data Size</td>
|
||||
<td className="text-lg-end">{account.space} byte(s)</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
<tr>
|
||||
<td>Assigned Program Id</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={account.owner} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Executable</td>
|
||||
<td className="text-lg-end">{account.executable ? "Yes" : "No"}</td>
|
||||
</tr>
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,338 +0,0 @@
|
|||
import React from "react";
|
||||
import { TableCardBody } from "components/common/TableCardBody";
|
||||
import { SolBalance } from "components/common/SolBalance";
|
||||
import { Account, useFetchAccountInfo } from "providers/accounts";
|
||||
import { Address } from "components/common/Address";
|
||||
import {
|
||||
ProgramAccountInfo,
|
||||
ProgramBufferAccountInfo,
|
||||
ProgramDataAccountInfo,
|
||||
UpgradeableLoaderAccount,
|
||||
} from "validators/accounts/upgradeable-program";
|
||||
import { Slot } from "components/common/Slot";
|
||||
import { addressLabel } from "utils/tx";
|
||||
import { useCluster } from "providers/cluster";
|
||||
import { UnknownAccountCard } from "components/account/UnknownAccountCard";
|
||||
import { Downloadable } from "components/common/Downloadable";
|
||||
import { CheckingBadge, VerifiedBadge } from "components/common/VerifiedBadge";
|
||||
import { InfoTooltip } from "components/common/InfoTooltip";
|
||||
import { useVerifiableBuilds } from "utils/program-verification";
|
||||
import { SecurityTXTBadge } from "components/common/SecurityTXTBadge";
|
||||
|
||||
export function UpgradeableLoaderAccountSection({
|
||||
account,
|
||||
parsedData,
|
||||
programData,
|
||||
}: {
|
||||
account: Account;
|
||||
parsedData: UpgradeableLoaderAccount;
|
||||
programData: ProgramDataAccountInfo | undefined;
|
||||
}) {
|
||||
switch (parsedData.type) {
|
||||
case "program": {
|
||||
return (
|
||||
<UpgradeableProgramSection
|
||||
account={account}
|
||||
programAccount={parsedData.info}
|
||||
programData={programData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "programData": {
|
||||
return (
|
||||
<UpgradeableProgramDataSection
|
||||
account={account}
|
||||
programData={parsedData.info}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "buffer": {
|
||||
return (
|
||||
<UpgradeableProgramBufferSection
|
||||
account={account}
|
||||
programBuffer={parsedData.info}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "uninitialized": {
|
||||
return <UnknownAccountCard account={account} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function UpgradeableProgramSection({
|
||||
account,
|
||||
programAccount,
|
||||
programData,
|
||||
}: {
|
||||
account: Account;
|
||||
programAccount: ProgramAccountInfo;
|
||||
programData: ProgramDataAccountInfo | undefined;
|
||||
}) {
|
||||
const refresh = useFetchAccountInfo();
|
||||
const { cluster } = useCluster();
|
||||
const label = addressLabel(account.pubkey.toBase58(), cluster);
|
||||
const { loading, verifiableBuilds } = useVerifiableBuilds(account.pubkey);
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
||||
{programData === undefined && "Closed "}Program Account
|
||||
</h3>
|
||||
<button
|
||||
className="btn btn-white btn-sm"
|
||||
onClick={() => refresh(account.pubkey, "parsed")}
|
||||
>
|
||||
<span className="fe fe-refresh-cw me-2"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TableCardBody>
|
||||
<tr>
|
||||
<td>Address</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={account.pubkey} alignRight raw />
|
||||
</td>
|
||||
</tr>
|
||||
{label && (
|
||||
<tr>
|
||||
<td>Address Label</td>
|
||||
<td className="text-lg-end">{label}</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>Balance (SOL)</td>
|
||||
<td className="text-lg-end text-uppercase">
|
||||
<SolBalance lamports={account.lamports} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Executable</td>
|
||||
<td className="text-lg-end">
|
||||
{programData !== undefined ? "Yes" : "No"}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Executable Data{programData === undefined && " (Closed)"}</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={programAccount.programData} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
{programData !== undefined && (
|
||||
<>
|
||||
<tr>
|
||||
<td>Upgradeable</td>
|
||||
<td className="text-lg-end">
|
||||
{programData.authority !== null ? "Yes" : "No"}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<LastVerifiedBuildLabel />
|
||||
</td>
|
||||
<td className="text-lg-end">
|
||||
{loading ? (
|
||||
<CheckingBadge />
|
||||
) : (
|
||||
<>
|
||||
{verifiableBuilds.map((b, i) => (
|
||||
<VerifiedBadge
|
||||
key={i}
|
||||
verifiableBuild={b}
|
||||
deploySlot={programData.slot}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<SecurityLabel />
|
||||
</td>
|
||||
<td className="text-lg-end">
|
||||
<SecurityTXTBadge
|
||||
programData={programData}
|
||||
pubkey={account.pubkey}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last Deployed Slot</td>
|
||||
<td className="text-lg-end">
|
||||
<Slot slot={programData.slot} link />
|
||||
</td>
|
||||
</tr>
|
||||
{programData.authority !== null && (
|
||||
<tr>
|
||||
<td>Upgrade Authority</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={programData.authority} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SecurityLabel() {
|
||||
return (
|
||||
<InfoTooltip text="Security.txt helps security researchers to contact developers if they find security bugs.">
|
||||
<a
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href="https://github.com/neodyme-labs/solana-security-txt"
|
||||
>
|
||||
<span className="security-txt-link-color-hack-reee">Security.txt</span>
|
||||
<span className="fe fe-external-link ms-2"></span>
|
||||
</a>
|
||||
</InfoTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function LastVerifiedBuildLabel() {
|
||||
return (
|
||||
<InfoTooltip text="Indicates whether the program currently deployed on-chain is verified to match the associated published source code, when it is available.">
|
||||
Verifiable Build Status (experimental)
|
||||
</InfoTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpgradeableProgramDataSection({
|
||||
account,
|
||||
programData,
|
||||
}: {
|
||||
account: Account;
|
||||
programData: ProgramDataAccountInfo;
|
||||
}) {
|
||||
const refresh = useFetchAccountInfo();
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
||||
Program Executable Data Account
|
||||
</h3>
|
||||
<button
|
||||
className="btn btn-white btn-sm"
|
||||
onClick={() => refresh(account.pubkey, "parsed")}
|
||||
>
|
||||
<span className="fe fe-refresh-cw me-2"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TableCardBody>
|
||||
<tr>
|
||||
<td>Address</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={account.pubkey} alignRight raw />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Balance (SOL)</td>
|
||||
<td className="text-lg-end text-uppercase">
|
||||
<SolBalance lamports={account.lamports} />
|
||||
</td>
|
||||
</tr>
|
||||
{account.space !== undefined && (
|
||||
<tr>
|
||||
<td>Data Size (Bytes)</td>
|
||||
<td className="text-lg-end">
|
||||
<Downloadable
|
||||
data={programData.data[0]}
|
||||
filename={`${account.pubkey.toString()}.bin`}
|
||||
>
|
||||
<span className="me-2">{account.space}</span>
|
||||
</Downloadable>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>Upgradeable</td>
|
||||
<td className="text-lg-end">
|
||||
{programData.authority !== null ? "Yes" : "No"}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last Deployed Slot</td>
|
||||
<td className="text-lg-end">
|
||||
<Slot slot={programData.slot} link />
|
||||
</td>
|
||||
</tr>
|
||||
{programData.authority !== null && (
|
||||
<tr>
|
||||
<td>Upgrade Authority</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={programData.authority} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpgradeableProgramBufferSection({
|
||||
account,
|
||||
programBuffer,
|
||||
}: {
|
||||
account: Account;
|
||||
programBuffer: ProgramBufferAccountInfo;
|
||||
}) {
|
||||
const refresh = useFetchAccountInfo();
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
||||
Program Deploy Buffer Account
|
||||
</h3>
|
||||
<button
|
||||
className="btn btn-white btn-sm"
|
||||
onClick={() => refresh(account.pubkey, "parsed")}
|
||||
>
|
||||
<span className="fe fe-refresh-cw me-2"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TableCardBody>
|
||||
<tr>
|
||||
<td>Address</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={account.pubkey} alignRight raw />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Balance (SOL)</td>
|
||||
<td className="text-lg-end text-uppercase">
|
||||
<SolBalance lamports={account.lamports} />
|
||||
</td>
|
||||
</tr>
|
||||
{account.space !== undefined && (
|
||||
<tr>
|
||||
<td>Data Size (Bytes)</td>
|
||||
<td className="text-lg-end">{account.space}</td>
|
||||
</tr>
|
||||
)}
|
||||
{programBuffer.authority !== null && (
|
||||
<tr>
|
||||
<td>Deploy Authority</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={programBuffer.authority} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td>Owner</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={account.owner} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,87 +0,0 @@
|
|||
import React from "react";
|
||||
import { Account, useFetchAccountInfo } from "providers/accounts";
|
||||
import { TableCardBody } from "components/common/TableCardBody";
|
||||
import { Address } from "components/common/Address";
|
||||
import { VoteAccount } from "validators/accounts/vote";
|
||||
import { displayTimestamp } from "utils/date";
|
||||
import {
|
||||
AccountHeader,
|
||||
AccountAddressRow,
|
||||
AccountBalanceRow,
|
||||
} from "components/common/Account";
|
||||
import { Slot } from "components/common/Slot";
|
||||
|
||||
export function VoteAccountSection({
|
||||
account,
|
||||
voteAccount,
|
||||
}: {
|
||||
account: Account;
|
||||
voteAccount: VoteAccount;
|
||||
}) {
|
||||
const refresh = useFetchAccountInfo();
|
||||
const rootSlot = voteAccount.info.rootSlot;
|
||||
return (
|
||||
<div className="card">
|
||||
<AccountHeader
|
||||
title="Vote Account"
|
||||
refresh={() => refresh(account.pubkey, "parsed")}
|
||||
/>
|
||||
|
||||
<TableCardBody>
|
||||
<AccountAddressRow account={account} />
|
||||
<AccountBalanceRow account={account} />
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
Authorized Voter
|
||||
{voteAccount.info.authorizedVoters.length > 1 ? "s" : ""}
|
||||
</td>
|
||||
<td className="text-lg-end">
|
||||
{voteAccount.info.authorizedVoters.map((voter) => {
|
||||
return (
|
||||
<Address
|
||||
pubkey={voter.authorizedVoter}
|
||||
key={voter.authorizedVoter.toString()}
|
||||
alignRight
|
||||
raw
|
||||
link
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Authorized Withdrawer</td>
|
||||
<td className="text-lg-end">
|
||||
<Address
|
||||
pubkey={voteAccount.info.authorizedWithdrawer}
|
||||
alignRight
|
||||
raw
|
||||
link
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Last Timestamp</td>
|
||||
<td className="text-lg-end font-monospace">
|
||||
{displayTimestamp(voteAccount.info.lastTimestamp.timestamp * 1000)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Commission</td>
|
||||
<td className="text-lg-end">{voteAccount.info.commission + "%"}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Root Slot</td>
|
||||
<td className="text-lg-end">
|
||||
{rootSlot !== null ? <Slot slot={rootSlot} link /> : "N/A"}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
import { Slot } from "components/common/Slot";
|
||||
import React from "react";
|
||||
import { VoteAccount, Vote } from "validators/accounts/vote";
|
||||
|
||||
export function VotesCard({ voteAccount }: { voteAccount: VoteAccount }) {
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h3 className="card-header-title">Vote History</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-1 text-muted">Slot</th>
|
||||
<th className="text-muted">Confirmation Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="list">
|
||||
{voteAccount.info.votes.length > 0 &&
|
||||
voteAccount.info.votes
|
||||
.reverse()
|
||||
.map((vote: Vote, index) => renderAccountRow(vote, index))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="card-footer">
|
||||
<div className="text-muted text-center">
|
||||
{voteAccount.info.votes.length > 0 ? "" : "No votes found"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const renderAccountRow = (vote: Vote, index: number) => {
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td className="w-1 font-monospace">
|
||||
<Slot slot={vote.slot} link />
|
||||
</td>
|
||||
<td className="font-monospace">{vote.confirmationCount}</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
|
@ -1,95 +0,0 @@
|
|||
import React from "react";
|
||||
import { TableCardBody } from "components/common/TableCardBody";
|
||||
import { SolBalance } from "components/common/SolBalance";
|
||||
import { Account, useFetchAccountInfo } from "providers/accounts";
|
||||
import { Address } from "components/common/Address";
|
||||
import { AddressLookupTableAccount } from "@solana/web3.js";
|
||||
import { Slot } from "components/common/Slot";
|
||||
import { AddressLookupTableAccountInfo } from "validators/accounts/address-lookup-table";
|
||||
|
||||
export function AddressLookupTableAccountSection(
|
||||
params:
|
||||
| {
|
||||
account: Account;
|
||||
data: Uint8Array;
|
||||
}
|
||||
| {
|
||||
account: Account;
|
||||
lookupTableAccount: AddressLookupTableAccountInfo;
|
||||
}
|
||||
) {
|
||||
const account = params.account;
|
||||
const lookupTableState = React.useMemo(() => {
|
||||
if ("data" in params) {
|
||||
return AddressLookupTableAccount.deserialize(params.data);
|
||||
} else {
|
||||
return params.lookupTableAccount;
|
||||
}
|
||||
}, [params]);
|
||||
const lookupTableAccount = new AddressLookupTableAccount({
|
||||
key: account.pubkey,
|
||||
state: lookupTableState,
|
||||
});
|
||||
const refresh = useFetchAccountInfo();
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
||||
Address Lookup Table Account
|
||||
</h3>
|
||||
<button
|
||||
className="btn btn-white btn-sm"
|
||||
onClick={() => refresh(account.pubkey, "parsed")}
|
||||
>
|
||||
<span className="fe fe-refresh-cw me-2"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TableCardBody>
|
||||
<tr>
|
||||
<td>Address</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={account.pubkey} alignRight raw />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Balance (SOL)</td>
|
||||
<td className="text-lg-end text-uppercase">
|
||||
<SolBalance lamports={account.lamports} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Activation Status</td>
|
||||
<td className="text-lg-end text-uppercase">
|
||||
{lookupTableAccount.isActive() ? "Active" : "Deactivated"}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last Extended Slot</td>
|
||||
<td className="text-lg-end">
|
||||
{lookupTableAccount.state.lastExtendedSlot === 0 ? (
|
||||
"None (Empty)"
|
||||
) : (
|
||||
<Slot slot={lookupTableAccount.state.lastExtendedSlot} link />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Authority</td>
|
||||
<td className="text-lg-end">
|
||||
{lookupTableAccount.state.authority === undefined ? (
|
||||
"None (Frozen)"
|
||||
) : (
|
||||
<Address
|
||||
pubkey={lookupTableAccount.state.authority}
|
||||
alignRight
|
||||
link
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,71 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
import { AddressLookupTableAccount, PublicKey } from "@solana/web3.js";
|
||||
import { Address } from "components/common/Address";
|
||||
import { AddressLookupTableAccountInfo } from "validators/accounts/address-lookup-table";
|
||||
|
||||
export function LookupTableEntriesCard(
|
||||
params:
|
||||
| {
|
||||
parsedLookupTable: AddressLookupTableAccountInfo;
|
||||
}
|
||||
| {
|
||||
lookupTableAccountData: Uint8Array;
|
||||
}
|
||||
) {
|
||||
const lookupTableState = React.useMemo(() => {
|
||||
if ("lookupTableAccountData" in params) {
|
||||
return AddressLookupTableAccount.deserialize(
|
||||
params.lookupTableAccountData
|
||||
);
|
||||
} else {
|
||||
return params.parsedLookupTable;
|
||||
}
|
||||
}, [params]);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="row align-items-center">
|
||||
<div className="col">
|
||||
<h3 className="card-header-title">Lookup Table Entries</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-1 text-muted">Index</th>
|
||||
<th className="text-muted">Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="list">
|
||||
{lookupTableState.addresses.length > 0 &&
|
||||
lookupTableState.addresses.map((entry: PublicKey, index) => {
|
||||
return renderRow(entry, index);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{lookupTableState.addresses.length === 0 && (
|
||||
<div className="card-footer">
|
||||
<div className="text-muted text-center">No entries found</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderRow = (entry: PublicKey, index: number) => {
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td className="w-1 font-monospace">{index}</td>
|
||||
<td className="font-monospace">
|
||||
<Address pubkey={entry} link />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
|
@ -1,13 +0,0 @@
|
|||
import { PublicKey } from "@solana/web3.js";
|
||||
|
||||
const PROGRAM_ID: string = "AddressLookupTab1e1111111111111111111111111";
|
||||
|
||||
export function isAddressLookupTableAccount(
|
||||
accountOwner: PublicKey,
|
||||
accountData: Uint8Array
|
||||
): boolean {
|
||||
if (accountOwner.toBase58() !== PROGRAM_ID) return false;
|
||||
if (!accountData || accountData.length === 0) return false;
|
||||
const LOOKUP_TABLE_ACCOUNT_TYPE = 1;
|
||||
return accountData[0] === LOOKUP_TABLE_ACCOUNT_TYPE;
|
||||
}
|
|
@ -1,197 +0,0 @@
|
|||
import React from "react";
|
||||
import {
|
||||
ParsedTransactionWithMeta,
|
||||
ParsedInstruction,
|
||||
PartiallyDecodedInstruction,
|
||||
PublicKey,
|
||||
} from "@solana/web3.js";
|
||||
import { useAccountHistory } from "providers/accounts";
|
||||
import { Signature } from "components/common/Signature";
|
||||
import {
|
||||
getTokenInstructionName,
|
||||
InstructionContainer,
|
||||
} from "utils/instruction";
|
||||
import { Address } from "components/common/Address";
|
||||
import { LoadingCard } from "components/common/LoadingCard";
|
||||
import { ErrorCard } from "components/common/ErrorCard";
|
||||
import { FetchStatus } from "providers/cache";
|
||||
import { useFetchAccountHistory } from "providers/accounts/history";
|
||||
import {
|
||||
getTransactionRows,
|
||||
HistoryCardFooter,
|
||||
HistoryCardHeader,
|
||||
} from "../HistoryCardComponents";
|
||||
import { extractMintDetails, MintDetails } from "./common";
|
||||
import Moment from "react-moment";
|
||||
|
||||
export function TokenInstructionsCard({ pubkey }: { pubkey: PublicKey }) {
|
||||
const address = pubkey.toBase58();
|
||||
const history = useAccountHistory(address);
|
||||
const fetchAccountHistory = useFetchAccountHistory();
|
||||
const refresh = () => fetchAccountHistory(pubkey, true, true);
|
||||
const loadMore = () => fetchAccountHistory(pubkey, true);
|
||||
|
||||
const transactionRows = React.useMemo(() => {
|
||||
if (history?.data?.fetched) {
|
||||
return getTransactionRows(history.data.fetched);
|
||||
}
|
||||
return [];
|
||||
}, [history]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!history || !history.data?.transactionMap?.size) {
|
||||
refresh();
|
||||
}
|
||||
}, [address]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const { hasTimestamps, detailsList } = React.useMemo(() => {
|
||||
const detailedHistoryMap =
|
||||
history?.data?.transactionMap ||
|
||||
new Map<string, ParsedTransactionWithMeta>();
|
||||
const hasTimestamps = transactionRows.some((element) => element.blockTime);
|
||||
const detailsList: React.ReactNode[] = [];
|
||||
const mintMap = new Map<string, MintDetails>();
|
||||
|
||||
transactionRows.forEach(
|
||||
({ signatureInfo, signature, blockTime, statusClass, statusText }) => {
|
||||
const transactionWithMeta = detailedHistoryMap.get(signature);
|
||||
if (!transactionWithMeta) return;
|
||||
|
||||
extractMintDetails(transactionWithMeta, mintMap);
|
||||
|
||||
let instructions: (ParsedInstruction | PartiallyDecodedInstruction)[] =
|
||||
[];
|
||||
|
||||
InstructionContainer.create(transactionWithMeta).instructions.forEach(
|
||||
({ instruction, inner }) => {
|
||||
if (isRelevantInstruction(pubkey, address, mintMap, instruction)) {
|
||||
instructions.push(instruction);
|
||||
}
|
||||
instructions.push(
|
||||
...inner.filter((instruction) =>
|
||||
isRelevantInstruction(pubkey, address, mintMap, instruction)
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
instructions.forEach((ix, index) => {
|
||||
const programId = ix.programId;
|
||||
|
||||
const instructionName = getTokenInstructionName(
|
||||
transactionWithMeta,
|
||||
ix,
|
||||
signatureInfo
|
||||
);
|
||||
|
||||
if (instructionName) {
|
||||
detailsList.push(
|
||||
<tr key={signature + index}>
|
||||
<td>
|
||||
<Signature signature={signature} link truncateChars={48} />
|
||||
</td>
|
||||
|
||||
{hasTimestamps && (
|
||||
<td className="text-muted">
|
||||
{blockTime && <Moment date={blockTime * 1000} fromNow />}
|
||||
</td>
|
||||
)}
|
||||
|
||||
<td>{instructionName}</td>
|
||||
|
||||
<td>
|
||||
<Address
|
||||
pubkey={programId}
|
||||
link
|
||||
truncate
|
||||
truncateChars={16}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span className={`badge bg-${statusClass}-soft`}>
|
||||
{statusText}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
hasTimestamps,
|
||||
detailsList,
|
||||
};
|
||||
}, [history, transactionRows, address, pubkey]);
|
||||
|
||||
if (!history) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (history?.data === undefined) {
|
||||
if (history.status === FetchStatus.Fetching) {
|
||||
return <LoadingCard message="Loading token instructions" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorCard retry={refresh} text="Failed to fetch token instructions" />
|
||||
);
|
||||
}
|
||||
|
||||
const fetching = history.status === FetchStatus.Fetching;
|
||||
return (
|
||||
<div className="card">
|
||||
<HistoryCardHeader
|
||||
fetching={fetching}
|
||||
refresh={() => refresh()}
|
||||
title="Token Instructions"
|
||||
/>
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-muted w-1">Transaction Signature</th>
|
||||
{hasTimestamps && <th className="text-muted">Age</th>}
|
||||
<th className="text-muted">Instruction</th>
|
||||
<th className="text-muted">Program</th>
|
||||
<th className="text-muted">Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="list">{detailsList}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<HistoryCardFooter
|
||||
fetching={fetching}
|
||||
foundOldest={history.data.foundOldest}
|
||||
loadMore={() => loadMore()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isRelevantInstruction(
|
||||
pubkey: PublicKey,
|
||||
address: string,
|
||||
mintMap: Map<string, MintDetails>,
|
||||
instruction: ParsedInstruction | PartiallyDecodedInstruction
|
||||
) {
|
||||
if ("accounts" in instruction) {
|
||||
return instruction.accounts.some(
|
||||
(account) =>
|
||||
account.equals(pubkey) ||
|
||||
mintMap.get(account.toBase58())?.mint === address
|
||||
);
|
||||
} else if (
|
||||
typeof instruction.parsed === "object" &&
|
||||
"info" in instruction.parsed
|
||||
) {
|
||||
return Object.entries(instruction.parsed.info).some(
|
||||
([key, value]) =>
|
||||
value === address ||
|
||||
(typeof value === "string" && mintMap.get(value)?.mint === address)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
|
@ -1,272 +0,0 @@
|
|||
import React from "react";
|
||||
import {
|
||||
ParsedTransactionWithMeta,
|
||||
ParsedInstruction,
|
||||
PartiallyDecodedInstruction,
|
||||
PublicKey,
|
||||
} from "@solana/web3.js";
|
||||
import { useAccountHistory } from "providers/accounts";
|
||||
import { useTokenRegistry } from "providers/mints/token-registry";
|
||||
import { create } from "superstruct";
|
||||
import {
|
||||
TokenInstructionType,
|
||||
Transfer,
|
||||
TransferChecked,
|
||||
} from "components/instruction/token/types";
|
||||
import { InstructionContainer } from "utils/instruction";
|
||||
import { Signature } from "components/common/Signature";
|
||||
import { Address } from "components/common/Address";
|
||||
import { normalizeTokenAmount } from "utils";
|
||||
import {
|
||||
getTransactionRows,
|
||||
HistoryCardFooter,
|
||||
HistoryCardHeader,
|
||||
} from "../HistoryCardComponents";
|
||||
import { LoadingCard } from "components/common/LoadingCard";
|
||||
import { useFetchAccountHistory } from "providers/accounts/history";
|
||||
import { ErrorCard } from "components/common/ErrorCard";
|
||||
import { FetchStatus } from "providers/cache";
|
||||
import Moment from "react-moment";
|
||||
import { extractMintDetails, MintDetails } from "./common";
|
||||
import { Cluster, useCluster } from "providers/cluster";
|
||||
import { reportError } from "utils/sentry";
|
||||
|
||||
type IndexedTransfer = {
|
||||
index: number;
|
||||
childIndex?: number;
|
||||
transfer: Transfer | TransferChecked;
|
||||
};
|
||||
|
||||
export function TokenTransfersCard({ pubkey }: { pubkey: PublicKey }) {
|
||||
const { cluster } = useCluster();
|
||||
const address = pubkey.toBase58();
|
||||
const history = useAccountHistory(address);
|
||||
const fetchAccountHistory = useFetchAccountHistory();
|
||||
const refresh = () => fetchAccountHistory(pubkey, true, true);
|
||||
const loadMore = () => fetchAccountHistory(pubkey, true);
|
||||
|
||||
const { tokenRegistry } = useTokenRegistry();
|
||||
|
||||
const mintDetails = React.useMemo(
|
||||
() => tokenRegistry.get(address),
|
||||
[address, tokenRegistry]
|
||||
);
|
||||
|
||||
const transactionRows = React.useMemo(() => {
|
||||
if (history?.data?.fetched) {
|
||||
return getTransactionRows(history.data.fetched);
|
||||
}
|
||||
return [];
|
||||
}, [history]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!history || !history.data?.transactionMap?.size) {
|
||||
refresh();
|
||||
}
|
||||
}, [address]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const { hasTimestamps, detailsList } = React.useMemo(() => {
|
||||
const detailedHistoryMap =
|
||||
history?.data?.transactionMap ||
|
||||
new Map<string, ParsedTransactionWithMeta>();
|
||||
const hasTimestamps = transactionRows.some((element) => element.blockTime);
|
||||
const detailsList: React.ReactNode[] = [];
|
||||
const mintMap = new Map<string, MintDetails>();
|
||||
|
||||
transactionRows.forEach(
|
||||
({ signature, blockTime, statusText, statusClass }) => {
|
||||
const transactionWithMeta = detailedHistoryMap.get(signature);
|
||||
if (!transactionWithMeta) return;
|
||||
|
||||
// Extract mint information from token deltas
|
||||
// (used to filter out non-checked tokens transfers not belonging to this mint)
|
||||
extractMintDetails(transactionWithMeta, mintMap);
|
||||
|
||||
// Extract all transfers from transaction
|
||||
let transfers: IndexedTransfer[] = [];
|
||||
InstructionContainer.create(transactionWithMeta).instructions.forEach(
|
||||
({ instruction, inner }, index) => {
|
||||
const transfer = getTransfer(instruction, cluster, signature);
|
||||
if (transfer) {
|
||||
transfers.push({
|
||||
transfer,
|
||||
index,
|
||||
});
|
||||
}
|
||||
inner.forEach((instruction, childIndex) => {
|
||||
const transfer = getTransfer(instruction, cluster, signature);
|
||||
if (transfer) {
|
||||
transfers.push({
|
||||
transfer,
|
||||
index,
|
||||
childIndex,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Filter out transfers not belonging to this mint
|
||||
transfers = transfers.filter(({ transfer }) => {
|
||||
const sourceKey = transfer.source.toBase58();
|
||||
const destinationKey = transfer.destination.toBase58();
|
||||
|
||||
if ("tokenAmount" in transfer && transfer.mint.equals(pubkey)) {
|
||||
return true;
|
||||
} else if (
|
||||
mintMap.has(sourceKey) &&
|
||||
mintMap.get(sourceKey)?.mint === address
|
||||
) {
|
||||
return true;
|
||||
} else if (
|
||||
mintMap.has(destinationKey) &&
|
||||
mintMap.get(destinationKey)?.mint === address
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
transfers.forEach(({ transfer, index, childIndex }) => {
|
||||
let units = "Tokens";
|
||||
let amountString = "";
|
||||
|
||||
if (mintDetails?.symbol) {
|
||||
units = mintDetails.symbol;
|
||||
}
|
||||
|
||||
if ("tokenAmount" in transfer) {
|
||||
amountString = transfer.tokenAmount.uiAmountString;
|
||||
} else {
|
||||
let decimals = 0;
|
||||
|
||||
if (mintDetails?.decimals) {
|
||||
decimals = mintDetails.decimals;
|
||||
} else if (mintMap.has(transfer.source.toBase58())) {
|
||||
decimals = mintMap.get(transfer.source.toBase58())?.decimals || 0;
|
||||
} else if (mintMap.has(transfer.destination.toBase58())) {
|
||||
decimals =
|
||||
mintMap.get(transfer.destination.toBase58())?.decimals || 0;
|
||||
}
|
||||
|
||||
amountString = new Intl.NumberFormat("en-US", {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(normalizeTokenAmount(transfer.amount, decimals));
|
||||
}
|
||||
|
||||
detailsList.push(
|
||||
<tr key={signature + index + (childIndex || "")}>
|
||||
<td>
|
||||
<Signature signature={signature} link truncateChars={24} />
|
||||
</td>
|
||||
|
||||
{hasTimestamps && (
|
||||
<td className="text-muted">
|
||||
{blockTime && <Moment date={blockTime * 1000} fromNow />}
|
||||
</td>
|
||||
)}
|
||||
|
||||
<td>
|
||||
<Address pubkey={transfer.source} link truncateChars={16} />
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<Address
|
||||
pubkey={transfer.destination}
|
||||
link
|
||||
truncateChars={16}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{amountString} {units}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span className={`badge bg-${statusClass}-soft`}>
|
||||
{statusText}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
hasTimestamps,
|
||||
detailsList,
|
||||
};
|
||||
}, [history, transactionRows, mintDetails, pubkey, address, cluster]);
|
||||
|
||||
if (!history) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (history?.data === undefined) {
|
||||
if (history.status === FetchStatus.Fetching) {
|
||||
return <LoadingCard message="Loading token transfers" />;
|
||||
}
|
||||
|
||||
return <ErrorCard retry={refresh} text="Failed to fetch token transfers" />;
|
||||
}
|
||||
|
||||
const fetching = history.status === FetchStatus.Fetching;
|
||||
return (
|
||||
<div className="card">
|
||||
<HistoryCardHeader
|
||||
fetching={fetching}
|
||||
refresh={() => refresh()}
|
||||
title="Token Transfers"
|
||||
/>
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-muted">Transaction Signature</th>
|
||||
{hasTimestamps && <th className="text-muted">Age</th>}
|
||||
<th className="text-muted">Source</th>
|
||||
<th className="text-muted">Destination</th>
|
||||
<th className="text-muted">Amount</th>
|
||||
<th className="text-muted">Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="list">{detailsList}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<HistoryCardFooter
|
||||
fetching={fetching}
|
||||
foundOldest={history.data.foundOldest}
|
||||
loadMore={() => loadMore()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTransfer(
|
||||
instruction: ParsedInstruction | PartiallyDecodedInstruction,
|
||||
cluster: Cluster,
|
||||
signature: string
|
||||
): Transfer | TransferChecked | undefined {
|
||||
if ("parsed" in instruction && instruction.program === "spl-token") {
|
||||
try {
|
||||
const { type: rawType } = instruction.parsed;
|
||||
const type = create(rawType, TokenInstructionType);
|
||||
|
||||
if (type === "transferChecked") {
|
||||
return create(instruction.parsed.info, TransferChecked);
|
||||
} else if (type === "transfer") {
|
||||
return create(instruction.parsed.info, Transfer);
|
||||
}
|
||||
} catch (error) {
|
||||
if (cluster === Cluster.MainnetBeta) {
|
||||
reportError(error, {
|
||||
signature,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
|
@ -1,121 +0,0 @@
|
|||
import React from "react";
|
||||
import { Signature } from "components/common/Signature";
|
||||
import { Slot } from "components/common/Slot";
|
||||
import Moment from "react-moment";
|
||||
import { PublicKey } from "@solana/web3.js";
|
||||
import {
|
||||
useAccountHistory,
|
||||
useFetchAccountHistory,
|
||||
} from "providers/accounts/history";
|
||||
import {
|
||||
getTransactionRows,
|
||||
HistoryCardFooter,
|
||||
HistoryCardHeader,
|
||||
} from "../HistoryCardComponents";
|
||||
import { FetchStatus } from "providers/cache";
|
||||
import { LoadingCard } from "components/common/LoadingCard";
|
||||
import { ErrorCard } from "components/common/ErrorCard";
|
||||
import { displayTimestampUtc } from "utils/date";
|
||||
|
||||
export function TransactionHistoryCard({ pubkey }: { pubkey: PublicKey }) {
|
||||
const address = pubkey.toBase58();
|
||||
const history = useAccountHistory(address);
|
||||
const fetchAccountHistory = useFetchAccountHistory();
|
||||
const refresh = () => fetchAccountHistory(pubkey, false, true);
|
||||
const loadMore = () => fetchAccountHistory(pubkey, false);
|
||||
|
||||
const transactionRows = React.useMemo(() => {
|
||||
if (history?.data?.fetched) {
|
||||
return getTransactionRows(history.data.fetched);
|
||||
}
|
||||
return [];
|
||||
}, [history]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!history) {
|
||||
refresh();
|
||||
}
|
||||
}, [address]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!history) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (history?.data === undefined) {
|
||||
if (history.status === FetchStatus.Fetching) {
|
||||
return <LoadingCard message="Loading history" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorCard retry={refresh} text="Failed to fetch transaction history" />
|
||||
);
|
||||
}
|
||||
|
||||
const hasTimestamps = transactionRows.some((element) => element.blockTime);
|
||||
const detailsList: React.ReactNode[] = transactionRows.map(
|
||||
({ slot, signature, blockTime, statusClass, statusText }) => {
|
||||
return (
|
||||
<tr key={signature}>
|
||||
<td>
|
||||
<Signature signature={signature} link truncateChars={60} />
|
||||
</td>
|
||||
|
||||
<td className="w-1">
|
||||
<Slot slot={slot} link />
|
||||
</td>
|
||||
|
||||
{hasTimestamps && (
|
||||
<>
|
||||
<td className="text-muted">
|
||||
{blockTime ? <Moment date={blockTime * 1000} fromNow /> : "---"}
|
||||
</td>
|
||||
<td className="text-muted">
|
||||
{blockTime
|
||||
? displayTimestampUtc(blockTime * 1000, true)
|
||||
: "---"}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
|
||||
<td>
|
||||
<span className={`badge bg-${statusClass}-soft`}>{statusText}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const fetching = history.status === FetchStatus.Fetching;
|
||||
return (
|
||||
<div className="card">
|
||||
<HistoryCardHeader
|
||||
fetching={fetching}
|
||||
refresh={() => refresh()}
|
||||
title="Transaction History"
|
||||
/>
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-muted w-1">Transaction Signature</th>
|
||||
<th className="text-muted w-1">Block</th>
|
||||
{hasTimestamps && (
|
||||
<>
|
||||
<th className="text-muted w-1">Age</th>
|
||||
<th className="text-muted w-1">Timestamp</th>
|
||||
</>
|
||||
)}
|
||||
<th className="text-muted">Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="list">{detailsList}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<HistoryCardFooter
|
||||
fetching={fetching}
|
||||
foundOldest={history.data.foundOldest}
|
||||
loadMore={() => loadMore()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
import { ParsedTransactionWithMeta } from "@solana/web3.js";
|
||||
|
||||
export type MintDetails = {
|
||||
decimals: number;
|
||||
mint: string;
|
||||
};
|
||||
|
||||
export function extractMintDetails(
|
||||
transactionWithMeta: ParsedTransactionWithMeta,
|
||||
mintMap: Map<string, MintDetails>
|
||||
) {
|
||||
if (transactionWithMeta.meta?.preTokenBalances) {
|
||||
transactionWithMeta.meta.preTokenBalances.forEach((balance) => {
|
||||
const account =
|
||||
transactionWithMeta.transaction.message.accountKeys[
|
||||
balance.accountIndex
|
||||
];
|
||||
mintMap.set(account.pubkey.toBase58(), {
|
||||
decimals: balance.uiTokenAmount.decimals,
|
||||
mint: balance.mint,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (transactionWithMeta.meta?.postTokenBalances) {
|
||||
transactionWithMeta.meta.postTokenBalances.forEach((balance) => {
|
||||
const account =
|
||||
transactionWithMeta.transaction.message.accountKeys[
|
||||
balance.accountIndex
|
||||
];
|
||||
mintMap.set(account.pubkey.toBase58(), {
|
||||
decimals: balance.uiTokenAmount.decimals,
|
||||
mint: balance.mint,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,120 +0,0 @@
|
|||
import React, { Suspense } from "react";
|
||||
import { Account } from "../../../providers/accounts";
|
||||
import {
|
||||
parseNFTokenCollectionAccount,
|
||||
parseNFTokenNFTAccount,
|
||||
} from "./isNFTokenAccount";
|
||||
import { NftokenTypes } from "./nftoken-types";
|
||||
import { InfoTooltip } from "../../common/InfoTooltip";
|
||||
import { CachedImageContent } from "../../common/NFTArt";
|
||||
import { useNftokenMetadata } from "./nftoken-hooks";
|
||||
|
||||
export function NFTokenAccountHeader({ account }: { account: Account }) {
|
||||
const nft = parseNFTokenNFTAccount(account);
|
||||
|
||||
if (nft) {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<NFTokenNFTHeader nft={nft} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const collection = parseNFTokenCollectionAccount(account);
|
||||
if (collection) {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<NFTokenCollectionHeader collection={collection} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h6 className="header-pretitle">Details</h6>
|
||||
<h2 className="header-title">Account</h2>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function NFTokenNFTHeader({ nft }: { nft: NftokenTypes.NftAccount }) {
|
||||
const { data: metadata } = useNftokenMetadata(nft.metadata_url);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-auto ms-2 d-flex align-items-center">
|
||||
<CachedImageContent uri={metadata?.image} />
|
||||
</div>
|
||||
|
||||
<div className="col mb-3 ms-0.5 mt-3">
|
||||
{<h6 className="header-pretitle ms-1">NFToken NFT</h6>}
|
||||
<div className="d-flex align-items-center">
|
||||
<h2 className="header-title ms-1 align-items-center no-overflow-with-ellipsis">
|
||||
{metadata ? metadata.name || "No NFT name was found" : "Loading..."}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={"d-inline-flex align-items-center mt-2"}>
|
||||
<span className="badge badge-pill bg-dark">{`${
|
||||
nft.authority_can_update ? "Mutable" : "Immutable"
|
||||
}`}</span>
|
||||
|
||||
<InfoTooltip
|
||||
bottom
|
||||
text={
|
||||
nft.authority_can_update
|
||||
? "The authority of this NFT can update the Metadata."
|
||||
: "The Metadata cannot be updated by anyone."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NFTokenCollectionHeader({
|
||||
collection,
|
||||
}: {
|
||||
collection: NftokenTypes.CollectionAccount;
|
||||
}) {
|
||||
const { data: metadata } = useNftokenMetadata(collection.metadata_url);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-auto ms-2 d-flex align-items-center">
|
||||
<CachedImageContent uri={metadata?.image} />
|
||||
</div>
|
||||
|
||||
<div className="col mb-3 ms-0.5 mt-3">
|
||||
{<h6 className="header-pretitle ms-1">NFToken Collection</h6>}
|
||||
<div className="d-flex align-items-center">
|
||||
<h2 className="header-title ms-1 align-items-center no-overflow-with-ellipsis">
|
||||
{metadata
|
||||
? metadata.name || "No collection name was found"
|
||||
: "Loading..."}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={"d-inline-flex align-items-center mt-2"}>
|
||||
<span className="badge badge-pill bg-dark">{`${
|
||||
collection.authority_can_update ? "Mutable" : "Immutable"
|
||||
}`}</span>
|
||||
|
||||
<InfoTooltip
|
||||
bottom
|
||||
text={
|
||||
collection.authority_can_update
|
||||
? "The authority of this Collection can update the Metadata and add NFTs."
|
||||
: "The Metadata cannot be updated by anyone."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,217 +0,0 @@
|
|||
import { PublicKey } from "@solana/web3.js";
|
||||
import { Address } from "components/common/Address";
|
||||
import { TableCardBody } from "components/common/TableCardBody";
|
||||
import { Account, useFetchAccountInfo } from "providers/accounts";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import {
|
||||
parseNFTokenCollectionAccount,
|
||||
parseNFTokenNFTAccount,
|
||||
} from "./isNFTokenAccount";
|
||||
import { NftokenTypes } from "./nftoken-types";
|
||||
import { MAX_TIME_LOADING_IMAGE, useCachedImage } from "../../common/NFTArt";
|
||||
import { useCollectionNfts } from "./nftoken-hooks";
|
||||
import { UnknownAccountCard } from "../UnknownAccountCard";
|
||||
|
||||
export function NFTokenAccountSection({ account }: { account: Account }) {
|
||||
const nft = parseNFTokenNFTAccount(account);
|
||||
if (nft) {
|
||||
return <NFTCard nft={nft} />;
|
||||
}
|
||||
|
||||
const collection = parseNFTokenCollectionAccount(account);
|
||||
if (collection) {
|
||||
return <CollectionCard collection={collection} />;
|
||||
}
|
||||
|
||||
return <UnknownAccountCard account={account} />;
|
||||
}
|
||||
|
||||
const NFTCard = ({ nft }: { nft: NftokenTypes.NftAccount }) => {
|
||||
const fetchInfo = useFetchAccountInfo();
|
||||
const refresh = () => fetchInfo(new PublicKey(nft.address), "parsed");
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
||||
Overview
|
||||
</h3>
|
||||
<button className="btn btn-white btn-sm" onClick={refresh}>
|
||||
<span className="fe fe-refresh-cw me-2"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TableCardBody>
|
||||
<tr>
|
||||
<td>Address</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={new PublicKey(nft.address)} alignRight raw />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Authority</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={new PublicKey(nft.authority)} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Holder</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={new PublicKey(nft.holder)} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Delegate</td>
|
||||
<td className="text-lg-end">
|
||||
{nft.delegate ? (
|
||||
<Address pubkey={new PublicKey(nft.delegate)} alignRight link />
|
||||
) : (
|
||||
"Not Delegated"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Collection</td>
|
||||
<td className="text-lg-end">
|
||||
{nft.collection ? (
|
||||
<Address pubkey={new PublicKey(nft.collection)} alignRight link />
|
||||
) : (
|
||||
"No Collection"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const NftokenImage = ({
|
||||
url,
|
||||
size,
|
||||
}: {
|
||||
url: string | undefined;
|
||||
size: number;
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [showError, setShowError] = useState<boolean>(false);
|
||||
const [timeout, setTimeout] = useState<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
// Set the timeout if we don't have a valid uri
|
||||
if (!url && !timeout) {
|
||||
setTimeout(setInterval(() => setShowError(true), MAX_TIME_LOADING_IMAGE));
|
||||
}
|
||||
|
||||
// We have a uri - clear the timeout
|
||||
if (url && timeout) {
|
||||
clearInterval(timeout);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeout) {
|
||||
clearInterval(timeout);
|
||||
}
|
||||
};
|
||||
}, [url, setShowError, timeout, setTimeout]);
|
||||
|
||||
const { cachedBlob } = useCachedImage(url || "");
|
||||
|
||||
return (
|
||||
<>
|
||||
{showError ? (
|
||||
<div
|
||||
style={{ width: size, height: size, backgroundColor: "lightgrey" }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{isLoading && (
|
||||
<div
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: "lightgrey",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className={`${isLoading ? "d-none" : "d-block"}`}>
|
||||
<img
|
||||
className={`rounded mx-auto ${isLoading ? "d-none" : "d-block"}`}
|
||||
src={cachedBlob}
|
||||
alt={"nft"}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
onLoad={() => {
|
||||
setIsLoading(false);
|
||||
}}
|
||||
onError={() => {
|
||||
setShowError(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CollectionCard = ({
|
||||
collection,
|
||||
}: {
|
||||
collection: NftokenTypes.CollectionAccount;
|
||||
}) => {
|
||||
const fetchInfo = useFetchAccountInfo();
|
||||
const refresh = () => fetchInfo(new PublicKey(collection.address), "parsed");
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
||||
Overview
|
||||
</h3>
|
||||
<button className="btn btn-white btn-sm" onClick={refresh}>
|
||||
<span className="fe fe-refresh-cw me-2"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TableCardBody>
|
||||
<tr>
|
||||
<td>Address</td>
|
||||
<td className="text-lg-end">
|
||||
<Address
|
||||
pubkey={new PublicKey(collection.address)}
|
||||
alignRight
|
||||
raw
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Authority</td>
|
||||
<td className="text-lg-end">
|
||||
<Address
|
||||
pubkey={new PublicKey(collection.authority)}
|
||||
alignRight
|
||||
link
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Number of NFTs</td>
|
||||
<td className="text-lg-end">
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<NumNfts collection={collection.address} />
|
||||
</Suspense>
|
||||
</td>
|
||||
</tr>
|
||||
</TableCardBody>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NumNfts = ({ collection }: { collection: string }) => {
|
||||
const { data: nfts } = useCollectionNfts({ collectionAddress: collection });
|
||||
return <div>{nfts.length}</div>;
|
||||
};
|
|
@ -1,64 +0,0 @@
|
|||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { clusterPath } from "../../../utils/url";
|
||||
import { useCollectionNfts } from "./nftoken-hooks";
|
||||
import { NftokenImage } from "./NFTokenAccountSection";
|
||||
|
||||
export function NFTokenCollectionNFTGrid({
|
||||
collection,
|
||||
}: {
|
||||
collection: string;
|
||||
}) {
|
||||
const { data: nfts, mutate } = useCollectionNfts({
|
||||
collectionAddress: collection,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header align-items-center">
|
||||
<h3 className="card-header-title">NFTs</h3>
|
||||
|
||||
<button className="btn btn-white btn-sm" onClick={() => mutate()}>
|
||||
<span className="fe fe-refresh-cw me-2"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="py-4">
|
||||
{nfts.length === 0 && <div className={"px-4"}>No NFTs Found</div>}
|
||||
|
||||
{nfts.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
/* Creates as many columns as possible that are at least 10rem wide. */
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(10rem, 1fr))",
|
||||
gridGap: "1.5rem",
|
||||
}}
|
||||
>
|
||||
{nfts.map((nft) => (
|
||||
<div
|
||||
key={nft.address}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
<NftokenImage url={nft.image} size={80} />
|
||||
|
||||
<div>
|
||||
<Link to={clusterPath(`/address/${nft.address}`)}>
|
||||
<div>{nft.name ?? "No Name"}</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
# NFToken
|
||||
|
||||
NFToken is a cheap, simple, secure NFT standard on Solana.
|
||||
|
||||
You can find more information and support here:
|
||||
|
||||
- [Website](https://nftoken.so)
|
||||
- [Twitter](https://twitter.com/nftoken_so)
|
||||
- [Lead Maintainer](https://twitter.com/VictorPontis)
|
|
@ -1,85 +0,0 @@
|
|||
import { PublicKey } from "@solana/web3.js";
|
||||
import { NFTOKEN_ADDRESS } from "./nftoken";
|
||||
import { Account } from "../../../providers/accounts";
|
||||
import { NftokenTypes } from "./nftoken-types";
|
||||
|
||||
export function isNFTokenAccount(account: Account): boolean {
|
||||
return Boolean(
|
||||
account.owner.toBase58() === NFTOKEN_ADDRESS && account.data.raw
|
||||
);
|
||||
}
|
||||
|
||||
const nftokenAccountDisc = "IbRbNewPP2E=";
|
||||
|
||||
export const parseNFTokenNFTAccount = (
|
||||
account: Account
|
||||
): NftokenTypes.NftAccount | null => {
|
||||
if (!isNFTokenAccount(account)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = NftokenTypes.nftAccountLayout.decode(account.data.raw!);
|
||||
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
Buffer.from(parsed!.discriminator).toString("base64") !==
|
||||
nftokenAccountDisc
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
address: account.pubkey.toBase58(),
|
||||
holder: new PublicKey(parsed.holder).toBase58(),
|
||||
authority: new PublicKey(parsed.authority).toBase58(),
|
||||
authority_can_update: Boolean(parsed.authority_can_update),
|
||||
|
||||
collection: new PublicKey(parsed.collection).toBase58(),
|
||||
delegate: new PublicKey(parsed.delegate).toBase58(),
|
||||
|
||||
metadata_url: parsed.metadata_url?.replace(/\0/g, "") ?? null,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Problem parsing NFToken NFT...", e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const collectionAccountDisc = "RQLwA3YS2fI=";
|
||||
export const parseNFTokenCollectionAccount = (
|
||||
account: Account
|
||||
): NftokenTypes.CollectionAccount | null => {
|
||||
if (!isNFTokenAccount(account)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = NftokenTypes.collectionAccountLayout.decode(
|
||||
account.data.raw!
|
||||
);
|
||||
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
Buffer.from(parsed.discriminator).toString("base64") !==
|
||||
collectionAccountDisc
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
address: account.pubkey.toBase58(),
|
||||
authority: parsed.authority,
|
||||
authority_can_update: Boolean(parsed.authority_can_update),
|
||||
metadata_url: parsed.metadata_url?.replace(/\0/g, "") ?? null,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Problem parsing NFToken Collection...", e);
|
||||
return null;
|
||||
}
|
||||
};
|
|
@ -1,55 +0,0 @@
|
|||
import useSWR, { SWRResponse } from "swr";
|
||||
import { useCluster } from "../../../providers/cluster";
|
||||
import { NftokenFetcher } from "./nftoken";
|
||||
import { NftokenTypes } from "./nftoken-types";
|
||||
|
||||
const getCollectionNftsFetcher = async (
|
||||
_method: string,
|
||||
collectionAddress: string,
|
||||
url: string
|
||||
) => {
|
||||
return await NftokenFetcher.getNftsInCollection({
|
||||
collection: collectionAddress,
|
||||
rpcUrl: url,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCollectionNfts = ({
|
||||
collectionAddress,
|
||||
}: {
|
||||
collectionAddress: string;
|
||||
}): {
|
||||
// We can be confident that data will be nonnull even if the request fails,
|
||||
// if we defined fallbackData in the config.
|
||||
data: NftokenTypes.NftInfo[];
|
||||
error: any;
|
||||
mutate: SWRResponse<NftokenTypes.NftInfo[], never>["mutate"];
|
||||
} => {
|
||||
const { url } = useCluster();
|
||||
|
||||
const swrKey = ["getNftsInCollection", collectionAddress, url];
|
||||
const { data, error, mutate } = useSWR(swrKey, getCollectionNftsFetcher, {
|
||||
suspense: true,
|
||||
});
|
||||
// Not nullable since we use suspense
|
||||
return { data: data!, error, mutate };
|
||||
};
|
||||
|
||||
const getMetadataFetcher = async (metadataUrl: string) => {
|
||||
return await NftokenFetcher.getMetadata({ url: metadataUrl });
|
||||
};
|
||||
|
||||
export const useNftokenMetadata = (
|
||||
metadataUrl: string | null | undefined
|
||||
): {
|
||||
data: NftokenTypes.Metadata | null;
|
||||
error: any;
|
||||
mutate: SWRResponse<NftokenTypes.Metadata | null, never>["mutate"];
|
||||
} => {
|
||||
const swrKey = [metadataUrl];
|
||||
const { data, error, mutate } = useSWR(swrKey, getMetadataFetcher, {
|
||||
suspense: true,
|
||||
});
|
||||
// Not nullable since we use suspense
|
||||
return { data: data!, error, mutate };
|
||||
};
|
|
@ -1,69 +0,0 @@
|
|||
import * as BufferLayout from "@solana/buffer-layout";
|
||||
|
||||
const publicKey = (property: string) => {
|
||||
return BufferLayout.blob(32, property);
|
||||
};
|
||||
|
||||
export namespace NftokenTypes {
|
||||
export type Metadata = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
|
||||
image: string;
|
||||
traits: any;
|
||||
|
||||
animation_url: string | null;
|
||||
external_url: string | null;
|
||||
};
|
||||
|
||||
export type CollectionAccount = {
|
||||
address: string;
|
||||
authority: string;
|
||||
authority_can_update: boolean;
|
||||
|
||||
metadata_url: string | null;
|
||||
};
|
||||
|
||||
export type NftAccount = {
|
||||
address: string;
|
||||
holder: string;
|
||||
authority: string;
|
||||
authority_can_update: boolean;
|
||||
|
||||
collection: string | null;
|
||||
delegate: string | null;
|
||||
|
||||
metadata_url: string;
|
||||
};
|
||||
|
||||
export type NftInfo = NftAccount & Partial<Metadata>;
|
||||
|
||||
export const nftAccountLayout = BufferLayout.struct([
|
||||
BufferLayout.blob(8, "discriminator"),
|
||||
BufferLayout.u8("version"),
|
||||
publicKey("holder"),
|
||||
publicKey("authority"),
|
||||
BufferLayout.u8("authority_can_update"),
|
||||
publicKey("collection"),
|
||||
publicKey("delegate"),
|
||||
BufferLayout.u8("is_frozen"),
|
||||
BufferLayout.u8("unused_1"),
|
||||
BufferLayout.u8("unused_2"),
|
||||
BufferLayout.u8("unused_3"),
|
||||
BufferLayout.u32("metadata_url_length"),
|
||||
BufferLayout.utf8(400, "metadata_url"),
|
||||
]);
|
||||
|
||||
export const collectionAccountLayout = BufferLayout.struct([
|
||||
BufferLayout.blob(8, "discriminator"),
|
||||
BufferLayout.u8("version"),
|
||||
publicKey("authority"),
|
||||
BufferLayout.u8("authority_can_update"),
|
||||
BufferLayout.u8("unused_1"),
|
||||
BufferLayout.u8("unused_2"),
|
||||
BufferLayout.u8("unused_3"),
|
||||
BufferLayout.u8("unused_4"),
|
||||
BufferLayout.u32("metadata_url_length"),
|
||||
BufferLayout.utf8(400, "metadata_url"),
|
||||
]);
|
||||
}
|
|
@ -1,143 +0,0 @@
|
|||
import axios from "axios";
|
||||
import pLimit from "p-limit";
|
||||
import { Connection, PublicKey } from "@solana/web3.js";
|
||||
import bs58 from "bs58";
|
||||
import { NftokenTypes } from "./nftoken-types";
|
||||
|
||||
export const NFTOKEN_ADDRESS = "nftokf9qcHSYkVSP3P2gUMmV6d4AwjMueXgUu43HyLL";
|
||||
|
||||
const nftokenAccountDiscInHex = "21b45b35ec0f3f61";
|
||||
|
||||
export namespace NftokenFetcher {
|
||||
export const getNftsInCollection = async ({
|
||||
collection,
|
||||
rpcUrl,
|
||||
}: {
|
||||
collection: string;
|
||||
rpcUrl: string;
|
||||
}): Promise<NftokenTypes.NftInfo[]> => {
|
||||
const connection = new Connection(rpcUrl);
|
||||
const accounts = await connection.getProgramAccounts(
|
||||
new PublicKey(NFTOKEN_ADDRESS),
|
||||
{
|
||||
filters: [
|
||||
{
|
||||
memcmp: {
|
||||
offset: 0,
|
||||
bytes: bs58.encode(Buffer.from(nftokenAccountDiscInHex, "hex")),
|
||||
},
|
||||
},
|
||||
{
|
||||
memcmp: {
|
||||
offset:
|
||||
8 + // discriminator
|
||||
1 + // version
|
||||
32 + // holder
|
||||
32 + // authority
|
||||
1, // authority_can_update
|
||||
bytes: collection,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
const parsed_accounts: NftokenTypes.NftAccount[] = accounts.flatMap(
|
||||
(account) => {
|
||||
const parsed = NftokenTypes.nftAccountLayout.decode(
|
||||
account.account.data
|
||||
);
|
||||
|
||||
if (!parsed) {
|
||||
return [];
|
||||
}
|
||||
return {
|
||||
address: account.pubkey.toBase58(),
|
||||
holder: parsed.holder,
|
||||
authority: parsed.authority,
|
||||
authority_can_update: Boolean(parsed.authority_can_update),
|
||||
|
||||
collection: parsed.collection,
|
||||
delegate: parsed.delegate,
|
||||
|
||||
metadata_url: parsed.metadata_url,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const metadata_urls = parsed_accounts.map((a) => a.metadata_url);
|
||||
const metadataMap = await getMetadataMap({ urls: metadata_urls });
|
||||
|
||||
const nfts = parsed_accounts.map((account) => ({
|
||||
...account,
|
||||
...metadataMap.get(account.metadata_url),
|
||||
}));
|
||||
nfts.sort();
|
||||
return nfts.sort((a, b) => {
|
||||
if (a.name && b.name) {
|
||||
return a.name < b.name ? -1 : 1;
|
||||
}
|
||||
|
||||
if (a.name) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (b.name) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return a.address < b.address ? 1 : -1;
|
||||
});
|
||||
};
|
||||
|
||||
export const getMetadata = async ({
|
||||
url,
|
||||
}: {
|
||||
url: string | null | undefined;
|
||||
}): Promise<NftokenTypes.Metadata | null> => {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadataMap = await getMetadataMap({
|
||||
urls: [url],
|
||||
});
|
||||
return metadataMap.get(url) ?? null;
|
||||
};
|
||||
|
||||
export const getMetadataMap = async ({
|
||||
urls: _urls,
|
||||
}: {
|
||||
urls: Array<string | null | undefined>;
|
||||
}): Promise<Map<string, NftokenTypes.Metadata | null>> => {
|
||||
const urls = Array.from(
|
||||
new Set(_urls.filter((url): url is string => Boolean(url)))
|
||||
);
|
||||
|
||||
const metadataMap = new Map<string, NftokenTypes.Metadata | null>();
|
||||
|
||||
const limit = pLimit(5);
|
||||
const promises = urls.map((url) =>
|
||||
limit(async () => {
|
||||
try {
|
||||
const { data } = await axios.get(url, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
metadataMap.set(url, {
|
||||
name: data.name ?? "",
|
||||
description: data.description ?? null,
|
||||
image: data.image ?? "",
|
||||
traits: data.traits ?? [],
|
||||
animation_url: data.animation_url ?? null,
|
||||
external_url: data.external_url ?? null,
|
||||
});
|
||||
} catch {
|
||||
metadataMap.set(url, null);
|
||||
}
|
||||
})
|
||||
);
|
||||
await Promise.all(promises);
|
||||
|
||||
return metadataMap;
|
||||
};
|
||||
}
|
|
@ -1,131 +0,0 @@
|
|||
import React from "react";
|
||||
import { PublicKey, VersionedBlockResponse } from "@solana/web3.js";
|
||||
import { Address } from "components/common/Address";
|
||||
import { Link } from "react-router-dom";
|
||||
import { clusterPath } from "utils/url";
|
||||
|
||||
type AccountStats = {
|
||||
reads: number;
|
||||
writes: number;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export function BlockAccountsCard({
|
||||
block,
|
||||
blockSlot,
|
||||
}: {
|
||||
block: VersionedBlockResponse;
|
||||
blockSlot: number;
|
||||
}) {
|
||||
const [numDisplayed, setNumDisplayed] = React.useState(10);
|
||||
const totalTransactions = block.transactions.length;
|
||||
|
||||
const accountStats = React.useMemo(() => {
|
||||
const statsMap = new Map<string, AccountStats>();
|
||||
block.transactions.forEach((tx) => {
|
||||
const message = tx.transaction.message;
|
||||
const txSet = new Map<string, boolean>();
|
||||
const accountKeys = message.getAccountKeys({
|
||||
accountKeysFromLookups: tx.meta?.loadedAddresses,
|
||||
});
|
||||
message.compiledInstructions.forEach((ix) => {
|
||||
ix.accountKeyIndexes.forEach((index) => {
|
||||
const address = accountKeys.get(index)!.toBase58();
|
||||
txSet.set(address, message.isAccountWritable(index));
|
||||
});
|
||||
});
|
||||
|
||||
txSet.forEach((isWritable, address) => {
|
||||
const stats = statsMap.get(address) || { reads: 0, writes: 0 };
|
||||
if (isWritable) {
|
||||
stats.writes++;
|
||||
} else {
|
||||
stats.reads++;
|
||||
}
|
||||
statsMap.set(address, stats);
|
||||
});
|
||||
});
|
||||
|
||||
const accountEntries = [];
|
||||
for (let entry of statsMap) {
|
||||
accountEntries.push(entry);
|
||||
}
|
||||
|
||||
accountEntries.sort((a, b) => {
|
||||
const aCount = a[1].reads + a[1].writes;
|
||||
const bCount = b[1].reads + b[1].writes;
|
||||
if (aCount < bCount) return 1;
|
||||
if (aCount > bCount) return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return accountEntries;
|
||||
}, [block]);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header align-items-center">
|
||||
<h3 className="card-header-title">Block Account Usage</h3>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-muted">Account</th>
|
||||
<th className="text-muted">Read-Write Count</th>
|
||||
<th className="text-muted">Read-Only Count</th>
|
||||
<th className="text-muted">Total Count</th>
|
||||
<th className="text-muted">% of Transactions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{accountStats
|
||||
.slice(0, numDisplayed)
|
||||
.map(([address, { writes, reads }]) => {
|
||||
return (
|
||||
<tr key={address}>
|
||||
<td>
|
||||
<Link
|
||||
to={clusterPath(
|
||||
`/block/${blockSlot}`,
|
||||
new URLSearchParams(
|
||||
`accountFilter=${address}&filter=all`
|
||||
)
|
||||
)}
|
||||
>
|
||||
<Address pubkey={new PublicKey(address)} />
|
||||
</Link>
|
||||
</td>
|
||||
<td>{writes}</td>
|
||||
<td>{reads}</td>
|
||||
<td>{writes + reads}</td>
|
||||
<td>
|
||||
{((100 * (writes + reads)) / totalTransactions).toFixed(
|
||||
2
|
||||
)}
|
||||
%
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{accountStats.length > numDisplayed && (
|
||||
<div className="card-footer">
|
||||
<button
|
||||
className="btn btn-primary w-100"
|
||||
onClick={() =>
|
||||
setNumDisplayed((displayed) => displayed + PAGE_SIZE)
|
||||
}
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,466 +0,0 @@
|
|||
import React from "react";
|
||||
import { Link, useHistory, useLocation } from "react-router-dom";
|
||||
import { Location } from "history";
|
||||
import {
|
||||
ConfirmedTransactionMeta,
|
||||
TransactionSignature,
|
||||
PublicKey,
|
||||
VOTE_PROGRAM_ID,
|
||||
VersionedBlockResponse,
|
||||
} from "@solana/web3.js";
|
||||
import { ErrorCard } from "components/common/ErrorCard";
|
||||
import { Signature } from "components/common/Signature";
|
||||
import { Address } from "components/common/Address";
|
||||
import { pickClusterParams, useQuery } from "utils/url";
|
||||
import { useCluster } from "providers/cluster";
|
||||
import { displayAddress } from "utils/tx";
|
||||
import { parseProgramLogs } from "utils/program-logs";
|
||||
import { SolBalance } from "components/common/SolBalance";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const useQueryProgramFilter = (query: URLSearchParams): string => {
|
||||
const filter = query.get("filter");
|
||||
return filter || "";
|
||||
};
|
||||
|
||||
const useQueryAccountFilter = (query: URLSearchParams): PublicKey | null => {
|
||||
const filter = query.get("accountFilter");
|
||||
if (filter !== null) {
|
||||
try {
|
||||
return new PublicKey(filter);
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
type SortMode = "index" | "compute" | "fee";
|
||||
const useQuerySort = (query: URLSearchParams): SortMode => {
|
||||
const sort = query.get("sort");
|
||||
if (sort === "compute") return "compute";
|
||||
if (sort === "fee") return "fee";
|
||||
return "index";
|
||||
};
|
||||
|
||||
type TransactionWithInvocations = {
|
||||
index: number;
|
||||
signature?: TransactionSignature;
|
||||
meta: ConfirmedTransactionMeta | null;
|
||||
invocations: Map<string, number>;
|
||||
computeUnits?: number;
|
||||
logTruncated: boolean;
|
||||
};
|
||||
|
||||
export function BlockHistoryCard({ block }: { block: VersionedBlockResponse }) {
|
||||
const [numDisplayed, setNumDisplayed] = React.useState(PAGE_SIZE);
|
||||
const [showDropdown, setDropdown] = React.useState(false);
|
||||
const query = useQuery();
|
||||
const programFilter = useQueryProgramFilter(query);
|
||||
const accountFilter = useQueryAccountFilter(query);
|
||||
const sortMode = useQuerySort(query);
|
||||
const { cluster } = useCluster();
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
const { transactions, invokedPrograms } = React.useMemo(() => {
|
||||
const invokedPrograms = new Map<string, number>();
|
||||
|
||||
const transactions: TransactionWithInvocations[] = block.transactions.map(
|
||||
(tx, index) => {
|
||||
let signature: TransactionSignature | undefined;
|
||||
if (tx.transaction.signatures.length > 0) {
|
||||
signature = tx.transaction.signatures[0];
|
||||
}
|
||||
|
||||
let programIndexes = tx.transaction.message.compiledInstructions
|
||||
.map((ix) => ix.programIdIndex)
|
||||
.concat(
|
||||
tx.meta?.innerInstructions?.flatMap((ix) => {
|
||||
return ix.instructions.map((ix) => ix.programIdIndex);
|
||||
}) || []
|
||||
);
|
||||
|
||||
const indexMap = new Map<number, number>();
|
||||
programIndexes.forEach((programIndex) => {
|
||||
const count = indexMap.get(programIndex) || 0;
|
||||
indexMap.set(programIndex, count + 1);
|
||||
});
|
||||
|
||||
const invocations = new Map<string, number>();
|
||||
const accountKeys = tx.transaction.message.getAccountKeys({
|
||||
accountKeysFromLookups: tx.meta?.loadedAddresses,
|
||||
});
|
||||
for (const [i, count] of indexMap.entries()) {
|
||||
const programId = accountKeys.get(i)!.toBase58();
|
||||
invocations.set(programId, count);
|
||||
const programTransactionCount = invokedPrograms.get(programId) || 0;
|
||||
invokedPrograms.set(programId, programTransactionCount + 1);
|
||||
}
|
||||
|
||||
let logTruncated = false;
|
||||
let computeUnits: number | undefined = undefined;
|
||||
try {
|
||||
const parsedLogs = parseProgramLogs(
|
||||
tx.meta?.logMessages ?? [],
|
||||
tx.meta?.err ?? null,
|
||||
cluster
|
||||
);
|
||||
|
||||
logTruncated = parsedLogs[parsedLogs.length - 1].truncated;
|
||||
computeUnits = parsedLogs
|
||||
.map(({ computeUnits }) => computeUnits)
|
||||
.reduce((sum, next) => sum + next);
|
||||
} catch (err) {
|
||||
// ignore parsing errors because some old logs aren't parsable
|
||||
}
|
||||
|
||||
return {
|
||||
index,
|
||||
signature,
|
||||
meta: tx.meta,
|
||||
invocations,
|
||||
computeUnits,
|
||||
logTruncated,
|
||||
};
|
||||
}
|
||||
);
|
||||
return { transactions, invokedPrograms };
|
||||
}, [block, cluster]);
|
||||
|
||||
const [filteredTransactions, showComputeUnits] = React.useMemo((): [
|
||||
TransactionWithInvocations[],
|
||||
boolean
|
||||
] => {
|
||||
const voteFilter = VOTE_PROGRAM_ID.toBase58();
|
||||
const filteredTxs: TransactionWithInvocations[] = transactions
|
||||
.filter(({ invocations }) => {
|
||||
if (programFilter === ALL_TRANSACTIONS) {
|
||||
return true;
|
||||
} else if (programFilter === HIDE_VOTES) {
|
||||
// hide vote txs that don't invoke any other programs
|
||||
return !(invocations.has(voteFilter) && invocations.size === 1);
|
||||
}
|
||||
return invocations.has(programFilter);
|
||||
})
|
||||
.filter(({ index }) => {
|
||||
if (accountFilter === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const tx = block.transactions[index];
|
||||
const accountKeys = tx.transaction.message.getAccountKeys({
|
||||
accountKeysFromLookups: tx.meta?.loadedAddresses,
|
||||
});
|
||||
return accountKeys
|
||||
.keySegments()
|
||||
.flat()
|
||||
.find((key) => key.equals(accountFilter));
|
||||
});
|
||||
|
||||
const showComputeUnits = filteredTxs.every(
|
||||
(tx) => tx.computeUnits !== undefined
|
||||
);
|
||||
|
||||
if (sortMode === "compute" && showComputeUnits) {
|
||||
filteredTxs.sort((a, b) => b.computeUnits! - a.computeUnits!);
|
||||
} else if (sortMode === "fee") {
|
||||
filteredTxs.sort((a, b) => (b.meta?.fee || 0) - (a.meta?.fee || 0));
|
||||
}
|
||||
|
||||
return [filteredTxs, showComputeUnits];
|
||||
}, [
|
||||
block.transactions,
|
||||
transactions,
|
||||
programFilter,
|
||||
accountFilter,
|
||||
sortMode,
|
||||
]);
|
||||
|
||||
if (transactions.length === 0) {
|
||||
return <ErrorCard text="This block has no transactions" />;
|
||||
}
|
||||
|
||||
let title: string;
|
||||
if (filteredTransactions.length === transactions.length) {
|
||||
title = `Block Transactions (${filteredTransactions.length})`;
|
||||
} else {
|
||||
title = `Filtered Block Transactions (${filteredTransactions.length}/${transactions.length})`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header align-items-center">
|
||||
<h3 className="card-header-title">{title}</h3>
|
||||
<FilterDropdown
|
||||
filter={programFilter}
|
||||
toggle={() => setDropdown((show) => !show)}
|
||||
show={showDropdown}
|
||||
invokedPrograms={invokedPrograms}
|
||||
totalTransactionCount={transactions.length}
|
||||
></FilterDropdown>
|
||||
</div>
|
||||
|
||||
{accountFilter !== null && (
|
||||
<div className="card-body">
|
||||
Showing transactions which load account:
|
||||
<div className="d-inline-block ms-2">
|
||||
<Address pubkey={accountFilter} link />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredTransactions.length === 0 ? (
|
||||
<div className="card-body">
|
||||
{accountFilter === null && programFilter === HIDE_VOTES
|
||||
? "This block doesn't contain any non-vote transactions"
|
||||
: "No transactions found with this filter"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
className="text-muted c-pointer"
|
||||
onClick={() => {
|
||||
query.delete("sort");
|
||||
history.push(pickClusterParams(location, query));
|
||||
}}
|
||||
>
|
||||
#
|
||||
</th>
|
||||
<th className="text-muted">Result</th>
|
||||
<th className="text-muted">Transaction Signature</th>
|
||||
<th
|
||||
className="text-muted text-end c-pointer"
|
||||
onClick={() => {
|
||||
query.set("sort", "fee");
|
||||
history.push(pickClusterParams(location, query));
|
||||
}}
|
||||
>
|
||||
Fee
|
||||
</th>
|
||||
{showComputeUnits && (
|
||||
<th
|
||||
className="text-muted text-end c-pointer"
|
||||
onClick={() => {
|
||||
query.set("sort", "compute");
|
||||
history.push(pickClusterParams(location, query));
|
||||
}}
|
||||
>
|
||||
Compute
|
||||
</th>
|
||||
)}
|
||||
<th className="text-muted">Invoked Programs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="list">
|
||||
{filteredTransactions.slice(0, numDisplayed).map((tx, i) => {
|
||||
let statusText;
|
||||
let statusClass;
|
||||
let signature: React.ReactNode;
|
||||
if (tx.meta?.err || !tx.signature) {
|
||||
statusClass = "warning";
|
||||
statusText = "Failed";
|
||||
} else {
|
||||
statusClass = "success";
|
||||
statusText = "Success";
|
||||
}
|
||||
|
||||
if (tx.signature) {
|
||||
signature = (
|
||||
<Signature
|
||||
signature={tx.signature}
|
||||
link
|
||||
truncateChars={48}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = [...tx.invocations.entries()];
|
||||
entries.sort();
|
||||
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td>{tx.index + 1}</td>
|
||||
<td>
|
||||
<span className={`badge bg-${statusClass}-soft`}>
|
||||
{statusText}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td>{signature}</td>
|
||||
|
||||
<td className="text-end">
|
||||
{tx.meta !== null ? (
|
||||
<SolBalance lamports={tx.meta.fee} />
|
||||
) : (
|
||||
"Unknown"
|
||||
)}
|
||||
</td>
|
||||
|
||||
{showComputeUnits && (
|
||||
<td className="text-end">
|
||||
{tx.logTruncated && ">"}
|
||||
{tx.computeUnits !== undefined
|
||||
? new Intl.NumberFormat("en-US").format(
|
||||
tx.computeUnits
|
||||
)
|
||||
: "Unknown"}
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
{tx.invocations.size === 0
|
||||
? "NA"
|
||||
: entries.map(([programId, count], i) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="d-flex align-items-center"
|
||||
>
|
||||
<Address
|
||||
pubkey={new PublicKey(programId)}
|
||||
link
|
||||
/>
|
||||
<span className="ms-2 text-muted">{`(${count})`}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredTransactions.length > numDisplayed && (
|
||||
<div className="card-footer">
|
||||
<button
|
||||
className="btn btn-primary w-100"
|
||||
onClick={() =>
|
||||
setNumDisplayed((displayed) => displayed + PAGE_SIZE)
|
||||
}
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FilterProps = {
|
||||
filter: string;
|
||||
toggle: () => void;
|
||||
show: boolean;
|
||||
invokedPrograms: Map<string, number>;
|
||||
totalTransactionCount: number;
|
||||
};
|
||||
|
||||
const ALL_TRANSACTIONS = "all";
|
||||
const HIDE_VOTES = "";
|
||||
|
||||
type FilterOption = {
|
||||
name: string;
|
||||
programId: string;
|
||||
transactionCount: number;
|
||||
};
|
||||
|
||||
const FilterDropdown = ({
|
||||
filter,
|
||||
toggle,
|
||||
show,
|
||||
invokedPrograms,
|
||||
totalTransactionCount,
|
||||
}: FilterProps) => {
|
||||
const { cluster } = useCluster();
|
||||
const buildLocation = (location: Location, filter: string) => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (filter === HIDE_VOTES) {
|
||||
params.delete("filter");
|
||||
} else {
|
||||
params.set("filter", filter);
|
||||
}
|
||||
return {
|
||||
...location,
|
||||
search: params.toString(),
|
||||
};
|
||||
};
|
||||
|
||||
let defaultFilterOption: FilterOption = {
|
||||
name: "All Except Votes",
|
||||
programId: HIDE_VOTES,
|
||||
transactionCount:
|
||||
totalTransactionCount -
|
||||
(invokedPrograms.get(VOTE_PROGRAM_ID.toBase58()) || 0),
|
||||
};
|
||||
|
||||
let allTransactionsOption: FilterOption = {
|
||||
name: "All Transactions",
|
||||
programId: ALL_TRANSACTIONS,
|
||||
transactionCount: totalTransactionCount,
|
||||
};
|
||||
|
||||
let currentFilterOption =
|
||||
filter !== ALL_TRANSACTIONS ? defaultFilterOption : allTransactionsOption;
|
||||
|
||||
const filterOptions: FilterOption[] = [
|
||||
defaultFilterOption,
|
||||
allTransactionsOption,
|
||||
];
|
||||
const placeholderRegistry = new Map();
|
||||
|
||||
[...invokedPrograms.entries()].forEach(([programId, transactionCount]) => {
|
||||
const name = displayAddress(programId, cluster, placeholderRegistry);
|
||||
if (filter === programId) {
|
||||
currentFilterOption = {
|
||||
programId,
|
||||
name: `${name} Transactions (${transactionCount})`,
|
||||
transactionCount,
|
||||
};
|
||||
}
|
||||
filterOptions.push({ name, programId, transactionCount });
|
||||
});
|
||||
|
||||
filterOptions.sort((a, b) => {
|
||||
if (a.transactionCount !== b.transactionCount) {
|
||||
return b.transactionCount - a.transactionCount;
|
||||
} else {
|
||||
return b.name > a.name ? -1 : 1;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="dropdown me-2">
|
||||
<button
|
||||
className="btn btn-white btn-sm dropdown-toggle"
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
>
|
||||
{currentFilterOption.name}
|
||||
</button>
|
||||
<div
|
||||
className={`token-filter dropdown-menu-end dropdown-menu${
|
||||
show ? " show" : ""
|
||||
}`}
|
||||
>
|
||||
{filterOptions.map(({ name, programId, transactionCount }) => {
|
||||
return (
|
||||
<Link
|
||||
key={programId}
|
||||
to={(location: Location) => buildLocation(location, programId)}
|
||||
className={`dropdown-item${
|
||||
programId === filter ? " active" : ""
|
||||
}`}
|
||||
onClick={toggle}
|
||||
>
|
||||
{`${name} (${transactionCount})`}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,246 +0,0 @@
|
|||
import React from "react";
|
||||
import { TableCardBody } from "components/common/TableCardBody";
|
||||
import { useBlock, useFetchBlock, FetchStatus } from "providers/block";
|
||||
import { ErrorCard } from "components/common/ErrorCard";
|
||||
import { LoadingCard } from "components/common/LoadingCard";
|
||||
import { Slot } from "components/common/Slot";
|
||||
import { ClusterStatus, useCluster } from "providers/cluster";
|
||||
import { BlockHistoryCard } from "./BlockHistoryCard";
|
||||
import { BlockRewardsCard } from "./BlockRewardsCard";
|
||||
import { VersionedBlockResponse } from "@solana/web3.js";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { clusterPath } from "utils/url";
|
||||
import { BlockProgramsCard } from "./BlockProgramsCard";
|
||||
import { BlockAccountsCard } from "./BlockAccountsCard";
|
||||
import { displayTimestamp, displayTimestampUtc } from "utils/date";
|
||||
import { Epoch } from "components/common/Epoch";
|
||||
import { Address } from "components/common/Address";
|
||||
|
||||
export function BlockOverviewCard({
|
||||
slot,
|
||||
tab,
|
||||
}: {
|
||||
slot: number;
|
||||
tab?: string;
|
||||
}) {
|
||||
const confirmedBlock = useBlock(slot);
|
||||
const fetchBlock = useFetchBlock();
|
||||
const { clusterInfo, status } = useCluster();
|
||||
const refresh = () => fetchBlock(slot);
|
||||
|
||||
// Fetch block on load
|
||||
React.useEffect(() => {
|
||||
if (!confirmedBlock && status === ClusterStatus.Connected) refresh();
|
||||
}, [slot, status]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!confirmedBlock || confirmedBlock.status === FetchStatus.Fetching) {
|
||||
return <LoadingCard message="Loading block" />;
|
||||
} else if (
|
||||
confirmedBlock.data === undefined ||
|
||||
confirmedBlock.status === FetchStatus.FetchFailed
|
||||
) {
|
||||
return <ErrorCard retry={refresh} text="Failed to fetch block" />;
|
||||
} else if (confirmedBlock.data.block === undefined) {
|
||||
return <ErrorCard retry={refresh} text={`Block ${slot} was not found`} />;
|
||||
}
|
||||
|
||||
const { block, blockLeader, childSlot, childLeader, parentLeader } =
|
||||
confirmedBlock.data;
|
||||
const showSuccessfulCount = block.transactions.every(
|
||||
(tx) => tx.meta !== null
|
||||
);
|
||||
const successfulTxs = block.transactions.filter(
|
||||
(tx) => tx.meta?.err === null
|
||||
);
|
||||
const epoch = clusterInfo?.epochSchedule.getEpoch(slot);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
||||
Overview
|
||||
</h3>
|
||||
</div>
|
||||
<TableCardBody>
|
||||
<tr>
|
||||
<td className="w-100">Blockhash</td>
|
||||
<td className="text-lg-end font-monospace">
|
||||
<span>{block.blockhash}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="w-100">Slot</td>
|
||||
<td className="text-lg-end font-monospace">
|
||||
<Slot slot={slot} />
|
||||
</td>
|
||||
</tr>
|
||||
{blockLeader !== undefined && (
|
||||
<tr>
|
||||
<td className="w-100">Slot Leader</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={blockLeader} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{block.blockTime ? (
|
||||
<>
|
||||
<tr>
|
||||
<td>Timestamp (Local)</td>
|
||||
<td className="text-lg-end">
|
||||
<span className="font-monospace">
|
||||
{displayTimestamp(block.blockTime * 1000, true)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Timestamp (UTC)</td>
|
||||
<td className="text-lg-end">
|
||||
<span className="font-monospace">
|
||||
{displayTimestampUtc(block.blockTime * 1000, true)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
) : (
|
||||
<tr>
|
||||
<td className="w-100">Timestamp</td>
|
||||
<td className="text-lg-end">Unavailable</td>
|
||||
</tr>
|
||||
)}
|
||||
{epoch !== undefined && (
|
||||
<tr>
|
||||
<td className="w-100">Epoch</td>
|
||||
<td className="text-lg-end font-monospace">
|
||||
<Epoch epoch={epoch} link />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td className="w-100">Parent Blockhash</td>
|
||||
<td className="text-lg-end font-monospace">
|
||||
<span>{block.previousBlockhash}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="w-100">Parent Slot</td>
|
||||
<td className="text-lg-end font-monospace">
|
||||
<Slot slot={block.parentSlot} link />
|
||||
</td>
|
||||
</tr>
|
||||
{parentLeader !== undefined && (
|
||||
<tr>
|
||||
<td className="w-100">Parent Slot Leader</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={parentLeader} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{childSlot !== undefined && (
|
||||
<tr>
|
||||
<td className="w-100">Child Slot</td>
|
||||
<td className="text-lg-end font-monospace">
|
||||
<Slot slot={childSlot} link />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{childLeader !== undefined && (
|
||||
<tr>
|
||||
<td className="w-100">Child Slot Leader</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={childLeader} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td className="w-100">Processed Transactions</td>
|
||||
<td className="text-lg-end font-monospace">
|
||||
<span>{block.transactions.length}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{showSuccessfulCount && (
|
||||
<tr>
|
||||
<td className="w-100">Successful Transactions</td>
|
||||
<td className="text-lg-end font-monospace">
|
||||
<span>{successfulTxs.length}</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</TableCardBody>
|
||||
</div>
|
||||
|
||||
<MoreSection block={block} slot={slot} tab={tab} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const TABS: Tab[] = [
|
||||
{
|
||||
slug: "history",
|
||||
title: "Transactions",
|
||||
path: "",
|
||||
},
|
||||
{
|
||||
slug: "rewards",
|
||||
title: "Rewards",
|
||||
path: "/rewards",
|
||||
},
|
||||
{
|
||||
slug: "programs",
|
||||
title: "Programs",
|
||||
path: "/programs",
|
||||
},
|
||||
{
|
||||
slug: "accounts",
|
||||
title: "Accounts",
|
||||
path: "/accounts",
|
||||
},
|
||||
];
|
||||
|
||||
type MoreTabs = "history" | "rewards" | "programs" | "accounts";
|
||||
|
||||
type Tab = {
|
||||
slug: MoreTabs;
|
||||
title: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
function MoreSection({
|
||||
slot,
|
||||
block,
|
||||
tab,
|
||||
}: {
|
||||
slot: number;
|
||||
block: VersionedBlockResponse;
|
||||
tab?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="container">
|
||||
<div className="header">
|
||||
<div className="header-body pt-0">
|
||||
<ul className="nav nav-tabs nav-overflow header-tabs">
|
||||
{TABS.map(({ title, slug, path }) => (
|
||||
<li key={slug} className="nav-item">
|
||||
<NavLink
|
||||
className="nav-link"
|
||||
to={clusterPath(`/block/${slot}${path}`)}
|
||||
exact
|
||||
>
|
||||
{title}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{tab === undefined && <BlockHistoryCard block={block} />}
|
||||
{tab === "rewards" && <BlockRewardsCard block={block} />}
|
||||
{tab === "accounts" && (
|
||||
<BlockAccountsCard block={block} blockSlot={slot} />
|
||||
)}
|
||||
{tab === "programs" && <BlockProgramsCard block={block} />}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,129 +0,0 @@
|
|||
import React from "react";
|
||||
import { PublicKey, VersionedBlockResponse } from "@solana/web3.js";
|
||||
import { Address } from "components/common/Address";
|
||||
import { TableCardBody } from "components/common/TableCardBody";
|
||||
|
||||
export function BlockProgramsCard({
|
||||
block,
|
||||
}: {
|
||||
block: VersionedBlockResponse;
|
||||
}) {
|
||||
const totalTransactions = block.transactions.length;
|
||||
const txSuccesses = new Map<string, number>();
|
||||
const txFrequency = new Map<string, number>();
|
||||
const ixFrequency = new Map<string, number>();
|
||||
|
||||
let totalInstructions = 0;
|
||||
block.transactions.forEach((tx) => {
|
||||
const message = tx.transaction.message;
|
||||
totalInstructions += message.compiledInstructions.length;
|
||||
const programUsed = new Set<string>();
|
||||
const accountKeys = tx.transaction.message.getAccountKeys({
|
||||
accountKeysFromLookups: tx.meta?.loadedAddresses,
|
||||
});
|
||||
const trackProgram = (index: number) => {
|
||||
if (index >= accountKeys.length) return;
|
||||
const programId = accountKeys.get(index)!;
|
||||
const programAddress = programId.toBase58();
|
||||
programUsed.add(programAddress);
|
||||
const frequency = ixFrequency.get(programAddress);
|
||||
ixFrequency.set(programAddress, frequency ? frequency + 1 : 1);
|
||||
};
|
||||
|
||||
message.compiledInstructions.forEach((ix) =>
|
||||
trackProgram(ix.programIdIndex)
|
||||
);
|
||||
tx.meta?.innerInstructions?.forEach((inner) => {
|
||||
totalInstructions += inner.instructions.length;
|
||||
inner.instructions.forEach((innerIx) =>
|
||||
trackProgram(innerIx.programIdIndex)
|
||||
);
|
||||
});
|
||||
|
||||
const successful = tx.meta?.err === null;
|
||||
programUsed.forEach((programId) => {
|
||||
const frequency = txFrequency.get(programId);
|
||||
txFrequency.set(programId, frequency ? frequency + 1 : 1);
|
||||
if (successful) {
|
||||
const count = txSuccesses.get(programId);
|
||||
txSuccesses.set(programId, count ? count + 1 : 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const programEntries = [];
|
||||
for (let entry of txFrequency) {
|
||||
programEntries.push(entry);
|
||||
}
|
||||
|
||||
programEntries.sort((a, b) => {
|
||||
if (a[1] < b[1]) return 1;
|
||||
if (a[1] > b[1]) return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const showSuccessRate = block.transactions.every((tx) => tx.meta !== null);
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-header align-items-center">
|
||||
<h3 className="card-header-title">Block Program Stats</h3>
|
||||
</div>
|
||||
<TableCardBody>
|
||||
<tr>
|
||||
<td className="w-100">Unique Programs Count</td>
|
||||
<td className="text-lg-end font-monospace">
|
||||
{programEntries.length}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="w-100">Total Instructions</td>
|
||||
<td className="text-lg-end font-monospace">{totalInstructions}</td>
|
||||
</tr>
|
||||
</TableCardBody>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-header align-items-center">
|
||||
<h3 className="card-header-title">Block Programs</h3>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-muted">Program</th>
|
||||
<th className="text-muted">Transaction Count</th>
|
||||
<th className="text-muted">% of Total</th>
|
||||
<th className="text-muted">Instruction Count</th>
|
||||
<th className="text-muted">% of Total</th>
|
||||
{showSuccessRate && (
|
||||
<th className="text-muted">Success Rate</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{programEntries.map(([programId, txFreq]) => {
|
||||
const ixFreq = ixFrequency.get(programId) as number;
|
||||
const successes = txSuccesses.get(programId) || 0;
|
||||
return (
|
||||
<tr key={programId}>
|
||||
<td>
|
||||
<Address pubkey={new PublicKey(programId)} link />
|
||||
</td>
|
||||
<td>{txFreq}</td>
|
||||
<td>{((100 * txFreq) / totalTransactions).toFixed(2)}%</td>
|
||||
<td>{ixFreq}</td>
|
||||
<td>{((100 * ixFreq) / totalInstructions).toFixed(2)}%</td>
|
||||
{showSuccessRate && (
|
||||
<td>{((100 * successes) / txFreq).toFixed(0)}%</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,84 +0,0 @@
|
|||
import React from "react";
|
||||
import { SolBalance } from "components/common/SolBalance";
|
||||
import { PublicKey, VersionedBlockResponse } from "@solana/web3.js";
|
||||
import { Address } from "components/common/Address";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export function BlockRewardsCard({ block }: { block: VersionedBlockResponse }) {
|
||||
const [rewardsDisplayed, setRewardsDisplayed] = React.useState(PAGE_SIZE);
|
||||
|
||||
if (!block.rewards || block.rewards.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header align-items-center">
|
||||
<h3 className="card-header-title">Block Rewards</h3>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-muted">Address</th>
|
||||
<th className="text-muted">Type</th>
|
||||
<th className="text-muted">Amount</th>
|
||||
<th className="text-muted">New Balance</th>
|
||||
<th className="text-muted">Percent Change</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rewards.map((reward, index) => {
|
||||
if (index >= rewardsDisplayed - 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let percentChange;
|
||||
if (reward.postBalance !== null && reward.postBalance !== 0) {
|
||||
percentChange = (
|
||||
(Math.abs(reward.lamports) /
|
||||
(reward.postBalance - reward.lamports)) *
|
||||
100
|
||||
).toFixed(9);
|
||||
}
|
||||
return (
|
||||
<tr key={reward.pubkey + reward.rewardType}>
|
||||
<td>
|
||||
<Address pubkey={new PublicKey(reward.pubkey)} link />
|
||||
</td>
|
||||
<td>{reward.rewardType}</td>
|
||||
<td>
|
||||
<SolBalance lamports={reward.lamports} />
|
||||
</td>
|
||||
<td>
|
||||
{reward.postBalance ? (
|
||||
<SolBalance lamports={reward.postBalance} />
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</td>
|
||||
<td>{percentChange ? percentChange + "%" : "-"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{block.rewards.length > rewardsDisplayed && (
|
||||
<div className="card-footer">
|
||||
<button
|
||||
className="btn btn-primary w-100"
|
||||
onClick={() =>
|
||||
setRewardsDisplayed((displayed) => displayed + PAGE_SIZE)
|
||||
}
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
import React from "react";
|
||||
import { Address } from "./Address";
|
||||
import { Account } from "providers/accounts";
|
||||
import { SolBalance } from "components/common/SolBalance";
|
||||
|
||||
type AccountHeaderProps = {
|
||||
title: string;
|
||||
refresh: Function;
|
||||
};
|
||||
|
||||
type AccountProps = {
|
||||
account: Account;
|
||||
};
|
||||
|
||||
export function AccountHeader({ title, refresh }: AccountHeaderProps) {
|
||||
return (
|
||||
<div className="card-header align-items-center">
|
||||
<h3 className="card-header-title">{title}</h3>
|
||||
<button className="btn btn-white btn-sm" onClick={() => refresh()}>
|
||||
<span className="fe fe-refresh-cw me-2"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountAddressRow({ account }: AccountProps) {
|
||||
return (
|
||||
<tr>
|
||||
<td>Address</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={account.pubkey} alignRight raw />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountBalanceRow({ account }: AccountProps) {
|
||||
const { lamports } = account;
|
||||
return (
|
||||
<tr>
|
||||
<td>Balance (SOL)</td>
|
||||
<td className="text-lg-end text-uppercase">
|
||||
<SolBalance lamports={lamports} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
|
@ -1,120 +0,0 @@
|
|||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { PublicKey } from "@solana/web3.js";
|
||||
import { clusterPath } from "utils/url";
|
||||
import { displayAddress } from "utils/tx";
|
||||
import { useCluster } from "providers/cluster";
|
||||
import { Copyable } from "./Copyable";
|
||||
import { useTokenRegistry } from "providers/mints/token-registry";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Connection, programs } from "@metaplex/js";
|
||||
|
||||
type Props = {
|
||||
pubkey: PublicKey;
|
||||
alignRight?: boolean;
|
||||
link?: boolean;
|
||||
raw?: boolean;
|
||||
truncate?: boolean;
|
||||
truncateUnknown?: boolean;
|
||||
truncateChars?: number;
|
||||
useMetadata?: boolean;
|
||||
overrideText?: string;
|
||||
};
|
||||
|
||||
export function Address({
|
||||
pubkey,
|
||||
alignRight,
|
||||
link,
|
||||
raw,
|
||||
truncate,
|
||||
truncateUnknown,
|
||||
truncateChars,
|
||||
useMetadata,
|
||||
overrideText,
|
||||
}: Props) {
|
||||
const address = pubkey.toBase58();
|
||||
const { tokenRegistry } = useTokenRegistry();
|
||||
const { cluster } = useCluster();
|
||||
|
||||
if (
|
||||
truncateUnknown &&
|
||||
address === displayAddress(address, cluster, tokenRegistry)
|
||||
) {
|
||||
truncate = true;
|
||||
}
|
||||
|
||||
let addressLabel = raw
|
||||
? address
|
||||
: displayAddress(address, cluster, tokenRegistry);
|
||||
|
||||
var metaplexData = useTokenMetadata(useMetadata, address);
|
||||
if (metaplexData && metaplexData.data)
|
||||
addressLabel = metaplexData.data.data.name;
|
||||
if (truncateChars && addressLabel === address) {
|
||||
addressLabel = addressLabel.slice(0, truncateChars) + "…";
|
||||
}
|
||||
|
||||
if (overrideText) {
|
||||
addressLabel = overrideText;
|
||||
}
|
||||
|
||||
const content = (
|
||||
<Copyable text={address} replaceText={!alignRight}>
|
||||
<span className="font-monospace">
|
||||
{link ? (
|
||||
<Link
|
||||
className={truncate ? "text-truncate address-truncate" : ""}
|
||||
to={clusterPath(`/address/${address}`)}
|
||||
>
|
||||
{addressLabel}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={truncate ? "text-truncate address-truncate" : ""}>
|
||||
{addressLabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Copyable>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`d-none d-lg-flex align-items-center ${
|
||||
alignRight ? "justify-content-end" : ""
|
||||
}`}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
<div className="d-flex d-lg-none align-items-center">{content}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export const useTokenMetadata = (
|
||||
useMetadata: boolean | undefined,
|
||||
pubkey: string
|
||||
) => {
|
||||
const [data, setData] = useState<programs.metadata.MetadataData>();
|
||||
var { url } = useCluster();
|
||||
|
||||
useEffect(() => {
|
||||
if (!useMetadata) return;
|
||||
if (pubkey && !data) {
|
||||
programs.metadata.Metadata.getPDA(pubkey)
|
||||
.then((pda) => {
|
||||
const connection = new Connection(url);
|
||||
programs.metadata.Metadata.load(connection, pda)
|
||||
.then((metadata) => {
|
||||
setData(metadata.data);
|
||||
})
|
||||
.catch(() => {
|
||||
setData(undefined);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setData(undefined);
|
||||
});
|
||||
}
|
||||
}, [useMetadata, pubkey, url, data, setData]);
|
||||
return { data };
|
||||
};
|
|
@ -1,33 +0,0 @@
|
|||
import React from "react";
|
||||
import { BigNumber } from "bignumber.js";
|
||||
import { SolBalance } from "components/common/SolBalance";
|
||||
|
||||
export function BalanceDelta({
|
||||
delta,
|
||||
isSol = false,
|
||||
}: {
|
||||
delta: BigNumber;
|
||||
isSol?: boolean;
|
||||
}) {
|
||||
let sols;
|
||||
|
||||
if (isSol) {
|
||||
sols = <SolBalance lamports={Math.abs(delta.toNumber())} />;
|
||||
}
|
||||
|
||||
if (delta.gt(0)) {
|
||||
return (
|
||||
<span className="badge bg-success-soft">
|
||||
+{isSol ? sols : delta.toString()}
|
||||
</span>
|
||||
);
|
||||
} else if (delta.lt(0)) {
|
||||
return (
|
||||
<span className="badge bg-warning-soft">
|
||||
{isSol ? <>-{sols}</> : delta.toString()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="badge bg-secondary-soft">0</span>;
|
||||
}
|
|
@ -1,98 +0,0 @@
|
|||
import React, { ReactNode, useState } from "react";
|
||||
|
||||
type CopyState = "copy" | "copied" | "errored";
|
||||
|
||||
export function Copyable({
|
||||
text,
|
||||
children,
|
||||
replaceText,
|
||||
}: {
|
||||
text: string;
|
||||
children: ReactNode;
|
||||
replaceText?: boolean;
|
||||
}) {
|
||||
const [state, setState] = useState<CopyState>("copy");
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setState("copied");
|
||||
} catch (err) {
|
||||
setState("errored");
|
||||
}
|
||||
setTimeout(() => setState("copy"), 1000);
|
||||
};
|
||||
|
||||
function CopyIcon() {
|
||||
if (state === "copy") {
|
||||
return (
|
||||
<span className="fe fe-copy c-pointer" onClick={handleClick}></span>
|
||||
);
|
||||
} else if (state === "copied") {
|
||||
return <span className="fe fe-check-circle"></span>;
|
||||
} else if (state === "errored") {
|
||||
return (
|
||||
<span
|
||||
className="fe fe-x-circle"
|
||||
title="Please check your browser's copy permissions."
|
||||
></span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let message: string | undefined;
|
||||
let textColor = "";
|
||||
if (state === "copied") {
|
||||
message = "Copied";
|
||||
textColor = "text-info";
|
||||
} else if (state === "errored") {
|
||||
message = "Copy Failed";
|
||||
textColor = "text-danger";
|
||||
}
|
||||
|
||||
function PrependCopyIcon() {
|
||||
return (
|
||||
<>
|
||||
<span className="font-size-tiny me-2">
|
||||
<span className={textColor}>
|
||||
{message !== undefined && <span className="me-2">{message}</span>}
|
||||
<CopyIcon />
|
||||
</span>
|
||||
</span>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplaceWithMessage() {
|
||||
return (
|
||||
<span className="d-flex flex-column flex-nowrap">
|
||||
<span className="font-size-tiny">
|
||||
<span className={textColor}>
|
||||
<CopyIcon />
|
||||
<span className="ms-2">{message}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="v-hidden">{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "copy") {
|
||||
return <PrependCopyIcon />;
|
||||
} else if (replaceText) {
|
||||
return <ReplaceWithMessage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="d-none d-lg-inline">
|
||||
<PrependCopyIcon />
|
||||
</span>
|
||||
<span className="d-inline d-lg-none">
|
||||
<ReplaceWithMessage />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
import { ReactNode } from "react";
|
||||
|
||||
export function Downloadable({
|
||||
data,
|
||||
filename,
|
||||
children,
|
||||
}: {
|
||||
data: string;
|
||||
filename: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const handleClick = async () => {
|
||||
const blob = new Blob([Buffer.from(data, "base64")]);
|
||||
const fileDownloadUrl = URL.createObjectURL(blob);
|
||||
const tempLink = document.createElement("a");
|
||||
tempLink.href = fileDownloadUrl;
|
||||
tempLink.setAttribute("download", filename);
|
||||
tempLink.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="fe fe-download c-pointer me-2" onClick={handleClick} />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { clusterPath } from "utils/url";
|
||||
import { Copyable } from "./Copyable";
|
||||
|
||||
type Props = {
|
||||
epoch: number | bigint;
|
||||
link?: boolean;
|
||||
};
|
||||
export function Epoch({ epoch, link }: Props) {
|
||||
return (
|
||||
<span className="font-monospace">
|
||||
{link ? (
|
||||
<Copyable text={epoch.toString()}>
|
||||
<Link to={clusterPath(`/epoch/${epoch}`)}>
|
||||
{epoch.toLocaleString("en-US")}
|
||||
</Link>
|
||||
</Copyable>
|
||||
) : (
|
||||
epoch.toLocaleString("en-US")
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
export function ErrorCard({
|
||||
retry,
|
||||
retryText,
|
||||
text,
|
||||
subtext,
|
||||
}: {
|
||||
retry?: () => void;
|
||||
retryText?: string;
|
||||
text: string;
|
||||
subtext?: string;
|
||||
}) {
|
||||
const buttonText = retryText || "Try Again";
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-body text-center">
|
||||
{text}
|
||||
{retry && (
|
||||
<>
|
||||
<span
|
||||
className="btn btn-white ms-3 d-none d-md-inline"
|
||||
onClick={retry}
|
||||
>
|
||||
{buttonText}
|
||||
</span>
|
||||
<div className="d-block d-md-none mt-4">
|
||||
<span className="btn btn-white w-100" onClick={retry}>
|
||||
{buttonText}
|
||||
</span>
|
||||
</div>
|
||||
{subtext && (
|
||||
<div className="text-muted">
|
||||
<hr></hr>
|
||||
{subtext}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
import React, { ReactNode } from "react";
|
||||
import { Buffer } from "buffer";
|
||||
import { Copyable } from "./Copyable";
|
||||
|
||||
export function HexData({ raw }: { raw: Buffer }) {
|
||||
if (!raw || raw.length === 0) {
|
||||
return <span>No data</span>;
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
const hexString = raw.toString("hex");
|
||||
for (let i = 0; i < hexString.length; i += 2) {
|
||||
chunks.push(hexString.slice(i, i + 2));
|
||||
}
|
||||
|
||||
const SPAN_SIZE = 4;
|
||||
const ROW_SIZE = 4 * SPAN_SIZE;
|
||||
|
||||
const divs: ReactNode[] = [];
|
||||
let spans: ReactNode[] = [];
|
||||
for (let i = 0; i < chunks.length; i += SPAN_SIZE) {
|
||||
const color = i % (2 * SPAN_SIZE) === 0 ? "text-white" : "text-gray-500";
|
||||
spans.push(
|
||||
<span key={i} className={color}>
|
||||
{chunks.slice(i, i + SPAN_SIZE).join(" ")} 
|
||||
</span>
|
||||
);
|
||||
|
||||
if (
|
||||
i % ROW_SIZE === ROW_SIZE - SPAN_SIZE ||
|
||||
i >= chunks.length - SPAN_SIZE
|
||||
) {
|
||||
divs.push(<div key={i / ROW_SIZE}>{spans}</div>);
|
||||
|
||||
// clear spans
|
||||
spans = [];
|
||||
}
|
||||
}
|
||||
|
||||
function Content() {
|
||||
return (
|
||||
<Copyable text={hexString}>
|
||||
<pre className="d-inline-block text-start mb-0">{divs}</pre>
|
||||
</Copyable>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-none d-lg-flex align-items-center justify-content-end">
|
||||
<Content />
|
||||
</div>
|
||||
<div className="d-flex d-lg-none align-items-center">
|
||||
<Content />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
// @ts-ignore
|
||||
import Jazzicon from "@metamask/jazzicon";
|
||||
import bs58 from "bs58";
|
||||
import { PublicKey } from "@solana/web3.js";
|
||||
|
||||
export function Identicon(props: {
|
||||
address?: string | PublicKey;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
}) {
|
||||
const { style, className } = props;
|
||||
const address =
|
||||
typeof props.address === "string"
|
||||
? props.address
|
||||
: props.address?.toBase58();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (address && ref.current) {
|
||||
ref.current.innerHTML = "";
|
||||
ref.current.className = className || "";
|
||||
ref.current.appendChild(
|
||||
Jazzicon(
|
||||
style?.width || 16,
|
||||
parseInt(bs58.decode(address).toString("hex").slice(5, 15), 16)
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [address, style, className]);
|
||||
|
||||
return <div className="identicon-wrapper" ref={ref} style={props.style} />;
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
import React, { useState, ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
children?: ReactNode;
|
||||
bottom?: boolean;
|
||||
right?: boolean;
|
||||
};
|
||||
|
||||
type State = "hide" | "show";
|
||||
|
||||
function Popover({
|
||||
state,
|
||||
bottom,
|
||||
right,
|
||||
text,
|
||||
}: {
|
||||
state: State;
|
||||
bottom?: boolean;
|
||||
right?: boolean;
|
||||
text: string;
|
||||
}) {
|
||||
if (state === "hide") return null;
|
||||
return (
|
||||
<div
|
||||
className={`popover bs-popover-${bottom ? "bottom" : "top"}${
|
||||
right ? " right" : ""
|
||||
} show`}
|
||||
>
|
||||
<div className={`arrow${right ? " right" : ""}`} />
|
||||
<div className="popover-body">{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoTooltip({ bottom, right, text, children }: Props) {
|
||||
const [state, setState] = useState<State>("hide");
|
||||
|
||||
const justify = right ? "end" : "start";
|
||||
return (
|
||||
<div
|
||||
className="popover-container w-100"
|
||||
onMouseOver={() => setState("show")}
|
||||
onMouseOut={() => setState("hide")}
|
||||
>
|
||||
<div className={`d-flex align-items-center justify-content-${justify}`}>
|
||||
{children}
|
||||
<span className="fe fe-help-circle ms-2"></span>
|
||||
</div>
|
||||
<Popover bottom={bottom} right={right} state={state} text={text} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,51 +0,0 @@
|
|||
import React from "react";
|
||||
import { ConfirmedSignatureInfo } from "@solana/web3.js";
|
||||
import {
|
||||
getTokenProgramInstructionName,
|
||||
InstructionType,
|
||||
} from "utils/instruction";
|
||||
|
||||
export function InstructionDetails({
|
||||
instructionType,
|
||||
tx,
|
||||
}: {
|
||||
instructionType: InstructionType;
|
||||
tx: ConfirmedSignatureInfo;
|
||||
}) {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
let instructionTypes = instructionType.innerInstructions
|
||||
.map((ix) => {
|
||||
if ("parsed" in ix && ix.program === "spl-token") {
|
||||
return getTokenProgramInstructionName(ix, tx);
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.filter((type) => type !== undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="tree">
|
||||
{instructionTypes.length > 0 && (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
className={`c-pointer fe me-2 ${
|
||||
expanded ? "fe-minus-square" : "fe-plus-square"
|
||||
}`}
|
||||
></span>
|
||||
)}
|
||||
{instructionType.name}
|
||||
</p>
|
||||
{expanded && (
|
||||
<ul className="tree">
|
||||
{instructionTypes.map((type, index) => {
|
||||
return <li key={index}>{type}</li>;
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
export function LoadingCard({ message }: { message?: string }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-body text-center">
|
||||
<span className="spinner-grow spinner-grow-sm me-2"></span>
|
||||
{message || "Loading"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,333 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Stream } from "@cloudflare/stream-react";
|
||||
import { PublicKey } from "@solana/web3.js";
|
||||
import {
|
||||
programs,
|
||||
MetadataJson,
|
||||
MetaDataJsonCategory,
|
||||
MetadataJsonFile,
|
||||
} from "@metaplex/js";
|
||||
import ContentLoader from "react-content-loader";
|
||||
import ErrorLogo from "img/logos-solana/dark-solana-logo.svg";
|
||||
import { getLast } from "utils";
|
||||
|
||||
export const MAX_TIME_LOADING_IMAGE = 5000; /* 5 seconds */
|
||||
|
||||
const LoadingPlaceholder = () => (
|
||||
<ContentLoader
|
||||
viewBox="0 0 212 200"
|
||||
height={150}
|
||||
width={150}
|
||||
backgroundColor="transparent"
|
||||
>
|
||||
<circle cx="86" cy="100" r="8" />
|
||||
<circle cx="106" cy="100" r="8" />
|
||||
<circle cx="126" cy="100" r="8" />
|
||||
</ContentLoader>
|
||||
);
|
||||
|
||||
const ErrorPlaceHolder = () => (
|
||||
<img src={ErrorLogo} width="120" height="120" alt="Solana Logo" />
|
||||
);
|
||||
|
||||
const ViewOriginalArtContentLink = ({ src }: { src: string }) => {
|
||||
if (!src) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<h6 className={"header-pretitle d-flex justify-content-center mt-2"}>
|
||||
<a href={src}>VIEW ORIGINAL</a>
|
||||
</h6>
|
||||
);
|
||||
};
|
||||
|
||||
export const CachedImageContent = ({ uri }: { uri?: string }) => {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [showError, setShowError] = useState<boolean>(false);
|
||||
const [timeout, setTimeout] = useState<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
// Set the timeout if we don't have a valid uri
|
||||
if (!uri && !timeout) {
|
||||
setTimeout(setInterval(() => setShowError(true), MAX_TIME_LOADING_IMAGE));
|
||||
}
|
||||
|
||||
// We have a uri - clear the timeout
|
||||
if (uri && timeout) {
|
||||
clearInterval(timeout);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeout) {
|
||||
clearInterval(timeout);
|
||||
}
|
||||
};
|
||||
}, [uri, setShowError, timeout, setTimeout]);
|
||||
|
||||
const { cachedBlob } = useCachedImage(uri || "");
|
||||
|
||||
return (
|
||||
<>
|
||||
{showError ? (
|
||||
<div className={"art-error-image-placeholder"}>
|
||||
<ErrorPlaceHolder />
|
||||
<h6 className={"header-pretitle mt-2"}>Error Loading Image</h6>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isLoading && <LoadingPlaceholder />}
|
||||
<div className={`${isLoading ? "d-none" : "d-block"}`}>
|
||||
<img
|
||||
className={`rounded mx-auto ${isLoading ? "d-none" : "d-block"}`}
|
||||
src={cachedBlob}
|
||||
alt={"nft"}
|
||||
style={{
|
||||
width: 150,
|
||||
maxHeight: 200,
|
||||
}}
|
||||
onLoad={() => {
|
||||
setIsLoading(false);
|
||||
}}
|
||||
onError={() => {
|
||||
setShowError(true);
|
||||
}}
|
||||
/>
|
||||
{uri && <ViewOriginalArtContentLink src={uri} />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const VideoArtContent = ({
|
||||
files,
|
||||
uri,
|
||||
animationURL,
|
||||
}: {
|
||||
files?: (MetadataJsonFile | string)[];
|
||||
uri?: string;
|
||||
animationURL?: string;
|
||||
}) => {
|
||||
const likelyVideo = (files || []).filter((f, index, arr) => {
|
||||
if (typeof f !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: filter by fileType
|
||||
return arr.length >= 2 ? index === 1 : index === 0;
|
||||
})?.[0] as string;
|
||||
|
||||
const content =
|
||||
likelyVideo &&
|
||||
likelyVideo.startsWith("https://watch.videodelivery.net/") ? (
|
||||
<div className={"d-block"}>
|
||||
<Stream
|
||||
src={likelyVideo.replace("https://watch.videodelivery.net/", "")}
|
||||
loop={true}
|
||||
height={180}
|
||||
width={320}
|
||||
controls={false}
|
||||
style={{ borderRadius: 12 }}
|
||||
videoDimensions={{
|
||||
videoWidth: 320,
|
||||
videoHeight: 180,
|
||||
}}
|
||||
autoplay={true}
|
||||
muted={true}
|
||||
/>
|
||||
<ViewOriginalArtContentLink
|
||||
src={likelyVideo.replace("https://watch.videodelivery.net/", "")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={"d-block"}>
|
||||
<video
|
||||
playsInline={true}
|
||||
autoPlay={true}
|
||||
muted={true}
|
||||
controls={true}
|
||||
controlsList="nodownload"
|
||||
style={{ borderRadius: 12, width: 320, height: 180 }}
|
||||
loop={true}
|
||||
poster={uri}
|
||||
>
|
||||
{likelyVideo && <source src={likelyVideo} type="video/mp4" />}
|
||||
{animationURL && <source src={animationURL} type="video/mp4" />}
|
||||
{files
|
||||
?.filter((f) => typeof f !== "string")
|
||||
.map((f: any, index: number) => (
|
||||
<source key={index} src={f.uri} type={f.type} />
|
||||
))}
|
||||
</video>
|
||||
{(likelyVideo || animationURL) && (
|
||||
<ViewOriginalArtContentLink src={(likelyVideo || animationURL)!} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
const HTMLContent = ({
|
||||
animationUrl,
|
||||
files,
|
||||
}: {
|
||||
animationUrl?: string;
|
||||
files?: (MetadataJsonFile | string)[];
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [showError, setShowError] = useState<boolean>(false);
|
||||
const htmlURL =
|
||||
files && files.length > 0 && typeof files[0] === "string"
|
||||
? files[0]
|
||||
: animationUrl;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showError ? (
|
||||
<div className={"art-error-image-placeholder"}>
|
||||
<ErrorPlaceHolder />
|
||||
<h6 className={"header-pretitle mt-2"}>Error Loading Image</h6>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!isLoading && <LoadingPlaceholder />}
|
||||
<div className={`${isLoading ? "d-block" : "d-none"}`}>
|
||||
<iframe
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
|
||||
title={"html-content"}
|
||||
sandbox="allow-scripts"
|
||||
frameBorder="0"
|
||||
src={htmlURL}
|
||||
style={{ width: 320, height: 180, borderRadius: 12 }}
|
||||
onLoad={() => {
|
||||
setIsLoading(true);
|
||||
}}
|
||||
onError={() => {
|
||||
setShowError(true);
|
||||
}}
|
||||
></iframe>
|
||||
{htmlURL && <ViewOriginalArtContentLink src={htmlURL} />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ArtContent = ({
|
||||
metadata,
|
||||
category,
|
||||
pubkey,
|
||||
uri,
|
||||
animationURL,
|
||||
files,
|
||||
data,
|
||||
}: {
|
||||
metadata: programs.metadata.MetadataData;
|
||||
category?: MetaDataJsonCategory;
|
||||
pubkey?: PublicKey | string;
|
||||
uri?: string;
|
||||
animationURL?: string;
|
||||
files?: (MetadataJsonFile | string)[];
|
||||
data: MetadataJson | undefined;
|
||||
}) => {
|
||||
if (pubkey && data) {
|
||||
uri = data.image;
|
||||
animationURL = data.animation_url;
|
||||
}
|
||||
|
||||
if (pubkey && data?.properties) {
|
||||
files = data.properties.files;
|
||||
category = data.properties.category;
|
||||
}
|
||||
|
||||
animationURL = animationURL || "";
|
||||
|
||||
const animationUrlExt = new URLSearchParams(
|
||||
getLast(animationURL.split("?"))
|
||||
).get("ext");
|
||||
|
||||
const content =
|
||||
category === "video" ? (
|
||||
<VideoArtContent files={files} uri={uri} animationURL={animationURL} />
|
||||
) : category === "html" || animationUrlExt === "html" ? (
|
||||
<HTMLContent animationUrl={animationURL} files={files} />
|
||||
) : (
|
||||
<CachedImageContent uri={uri} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
enum ArtFetchStatus {
|
||||
ReadyToFetch,
|
||||
Fetching,
|
||||
FetchFailed,
|
||||
FetchSucceeded,
|
||||
}
|
||||
|
||||
const cachedImages = new Map<string, string>();
|
||||
export const useCachedImage = (uri: string) => {
|
||||
const [cachedBlob, setCachedBlob] = useState<string | undefined>(undefined);
|
||||
const [fetchStatus, setFetchStatus] = useState<ArtFetchStatus>(
|
||||
ArtFetchStatus.ReadyToFetch
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!uri) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fetchStatus === ArtFetchStatus.FetchFailed) {
|
||||
setCachedBlob(uri);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = cachedImages.get(uri);
|
||||
if (result) {
|
||||
setCachedBlob(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fetchStatus === ArtFetchStatus.ReadyToFetch) {
|
||||
(async () => {
|
||||
setFetchStatus(ArtFetchStatus.Fetching);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(uri, { cache: "force-cache" });
|
||||
} catch {
|
||||
try {
|
||||
response = await fetch(uri, { cache: "reload" });
|
||||
} catch {
|
||||
if (uri?.startsWith("http")) {
|
||||
setCachedBlob(uri);
|
||||
}
|
||||
setFetchStatus(ArtFetchStatus.FetchFailed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const blobURI = URL.createObjectURL(blob);
|
||||
cachedImages.set(uri, blobURI);
|
||||
setCachedBlob(blobURI);
|
||||
setFetchStatus(ArtFetchStatus.FetchSucceeded);
|
||||
})();
|
||||
}
|
||||
}, [uri, setCachedBlob, fetchStatus, setFetchStatus]);
|
||||
|
||||
return { cachedBlob };
|
||||
};
|
|
@ -1,15 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
type OverlayProps = {
|
||||
show: boolean;
|
||||
};
|
||||
|
||||
export function Overlay({ show }: OverlayProps) {
|
||||
return (
|
||||
<div
|
||||
className={`modal-backdrop fade ${
|
||||
show ? "show" : "disable-pointer-events"
|
||||
}`}
|
||||
></div>
|
||||
);
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
import { PublicKey } from "@solana/web3.js";
|
||||
import { Link } from "react-router-dom";
|
||||
import { fromProgramData } from "utils/security-txt";
|
||||
import { clusterPath } from "utils/url";
|
||||
import { ProgramDataAccountInfo } from "validators/accounts/upgradeable-program";
|
||||
|
||||
export function SecurityTXTBadge({
|
||||
programData,
|
||||
pubkey,
|
||||
}: {
|
||||
programData: ProgramDataAccountInfo;
|
||||
pubkey: PublicKey;
|
||||
}) {
|
||||
const { securityTXT, error } = fromProgramData(programData);
|
||||
if (securityTXT) {
|
||||
return (
|
||||
<h3 className="mb-0">
|
||||
<Link
|
||||
className="c-pointer badge bg-success-soft rank"
|
||||
to={clusterPath(`/address/${pubkey.toBase58()}/security`)}
|
||||
>
|
||||
Included
|
||||
</Link>
|
||||
</h3>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<h3 className="mb-0">
|
||||
<span className="badge bg-warning-soft rank">{error}</span>
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { TransactionSignature } from "@solana/web3.js";
|
||||
import { clusterPath } from "utils/url";
|
||||
import { Copyable } from "./Copyable";
|
||||
|
||||
type Props = {
|
||||
signature: TransactionSignature;
|
||||
alignRight?: boolean;
|
||||
link?: boolean;
|
||||
truncate?: boolean;
|
||||
truncateChars?: number;
|
||||
};
|
||||
|
||||
export function Signature({
|
||||
signature,
|
||||
alignRight,
|
||||
link,
|
||||
truncate,
|
||||
truncateChars,
|
||||
}: Props) {
|
||||
let signatureLabel = signature;
|
||||
|
||||
if (truncateChars) {
|
||||
signatureLabel = signature.slice(0, truncateChars) + "…";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`d-flex align-items-center ${
|
||||
alignRight ? "justify-content-end" : ""
|
||||
}`}
|
||||
>
|
||||
<Copyable text={signature} replaceText={!alignRight}>
|
||||
<span className="font-monospace">
|
||||
{link ? (
|
||||
<Link
|
||||
className={truncate ? "text-truncate signature-truncate" : ""}
|
||||
to={clusterPath(`/tx/${signature}`)}
|
||||
>
|
||||
{signatureLabel}
|
||||
</Link>
|
||||
) : (
|
||||
signatureLabel
|
||||
)}
|
||||
</span>
|
||||
</Copyable>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { clusterPath } from "utils/url";
|
||||
import { Copyable } from "./Copyable";
|
||||
|
||||
type Props = {
|
||||
slot: number;
|
||||
link?: boolean;
|
||||
};
|
||||
export function Slot({ slot, link }: Props) {
|
||||
return (
|
||||
<span className="font-monospace">
|
||||
{link ? (
|
||||
<Copyable text={slot.toString()}>
|
||||
<Link to={clusterPath(`/block/${slot}`)}>
|
||||
{slot.toLocaleString("en-US")}
|
||||
</Link>
|
||||
</Copyable>
|
||||
) : (
|
||||
slot.toLocaleString("en-US")
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
import React from "react";
|
||||
import { lamportsToSolString } from "utils";
|
||||
|
||||
export function SolBalance({
|
||||
lamports,
|
||||
maximumFractionDigits = 9,
|
||||
}: {
|
||||
lamports: number | bigint;
|
||||
maximumFractionDigits?: number;
|
||||
}) {
|
||||
return (
|
||||
<span>
|
||||
◎
|
||||
<span className="font-monospace">
|
||||
{lamportsToSolString(lamports, maximumFractionDigits)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
export function TableCardBody({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<tbody className="list">{children}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { displayTimestampUtc, displayTimestamp } from "utils/date";
|
||||
|
||||
type State = "hide" | "show";
|
||||
|
||||
function Tooltip({ state }: { state: State }) {
|
||||
const tooltip = {
|
||||
maxWidth: "20rem",
|
||||
};
|
||||
|
||||
if (state === "hide") return null;
|
||||
return (
|
||||
<div className="popover bs-popover-bottom show" style={tooltip}>
|
||||
<div className="arrow" />
|
||||
<div className="popover-body">
|
||||
(Click to toggle between local and UTC)
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimestampToggle({ unixTimestamp }: { unixTimestamp: number }) {
|
||||
const [isTimestampTypeUtc, toggleTimestampType] = useState(true);
|
||||
const [showTooltip, toggleTooltip] = useState<State>("hide");
|
||||
|
||||
const toggle = () => {
|
||||
toggleTimestampType(!isTimestampTypeUtc);
|
||||
};
|
||||
|
||||
const tooltipContainer = {
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="popover-container w-100" style={tooltipContainer}>
|
||||
<span
|
||||
onClick={toggle}
|
||||
onMouseOver={() => toggleTooltip("show")}
|
||||
onMouseOut={() => toggleTooltip("hide")}
|
||||
>
|
||||
{isTimestampTypeUtc === true
|
||||
? displayTimestampUtc(unixTimestamp)
|
||||
: displayTimestamp(unixTimestamp)}
|
||||
</span>
|
||||
<Tooltip state={showTooltip} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
import { VerifiableBuild } from "utils/program-verification";
|
||||
|
||||
export function VerifiedBadge({
|
||||
verifiableBuild,
|
||||
deploySlot,
|
||||
}: {
|
||||
verifiableBuild: VerifiableBuild;
|
||||
deploySlot: number;
|
||||
}) {
|
||||
if (verifiableBuild && verifiableBuild.verified_slot === deploySlot) {
|
||||
return (
|
||||
<h3 className="mb-0">
|
||||
<a
|
||||
className="c-pointer badge bg-info-soft rank"
|
||||
href={verifiableBuild.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{verifiableBuild.label}: Verified
|
||||
</a>
|
||||
</h3>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<h3 className="mb-0">
|
||||
<span className="badge bg-warning-soft rank">
|
||||
{verifiableBuild.label}: Unverified
|
||||
</span>
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function CheckingBadge() {
|
||||
return (
|
||||
<h3 className="mb-0">
|
||||
<span className="badge bg-dark rank">Checking</span>
|
||||
</h3>
|
||||
);
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
import React from "react";
|
||||
import { TransactionInstruction, SignatureResult } from "@solana/web3.js";
|
||||
import { InstructionCard } from "./InstructionCard";
|
||||
import { useCluster } from "providers/cluster";
|
||||
import { reportError } from "utils/sentry";
|
||||
import { parseAddressLookupTableInstructionTitle } from "./address-lookup-table/types";
|
||||
|
||||
export function AddressLookupTableDetailsCard({
|
||||
ix,
|
||||
index,
|
||||
result,
|
||||
signature,
|
||||
innerCards,
|
||||
childIndex,
|
||||
}: {
|
||||
ix: TransactionInstruction;
|
||||
index: number;
|
||||
result: SignatureResult;
|
||||
signature: string;
|
||||
innerCards?: JSX.Element[];
|
||||
childIndex?: number;
|
||||
}) {
|
||||
const { url } = useCluster();
|
||||
|
||||
let title;
|
||||
try {
|
||||
title = parseAddressLookupTableInstructionTitle(ix);
|
||||
} catch (error) {
|
||||
reportError(error, {
|
||||
url: url,
|
||||
signature: signature,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<InstructionCard
|
||||
ix={ix}
|
||||
index={index}
|
||||
result={result}
|
||||
title={`Address Lookup Table: ${title || "Unknown"}`}
|
||||
innerCards={innerCards}
|
||||
childIndex={childIndex}
|
||||
defaultRaw
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -1,156 +0,0 @@
|
|||
import { SignatureResult, TransactionInstruction } from "@solana/web3.js";
|
||||
import { InstructionCard } from "./InstructionCard";
|
||||
import {
|
||||
Idl,
|
||||
Program,
|
||||
BorshInstructionCoder,
|
||||
Instruction,
|
||||
} from "@project-serum/anchor";
|
||||
import {
|
||||
getAnchorNameForInstruction,
|
||||
getAnchorProgramName,
|
||||
getAnchorAccountsFromInstruction,
|
||||
mapIxArgsToRows,
|
||||
} from "utils/anchor";
|
||||
import { Address } from "components/common/Address";
|
||||
import { camelToTitleCase } from "utils";
|
||||
import { IdlInstruction } from "@project-serum/anchor/dist/cjs/idl";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export default function AnchorDetailsCard(props: {
|
||||
key: string;
|
||||
ix: TransactionInstruction;
|
||||
index: number;
|
||||
result: SignatureResult;
|
||||
signature: string;
|
||||
innerCards?: JSX.Element[];
|
||||
childIndex?: number;
|
||||
anchorProgram: Program<Idl>;
|
||||
}) {
|
||||
const { ix, anchorProgram } = props;
|
||||
const programName = getAnchorProgramName(anchorProgram) ?? "Unknown Program";
|
||||
|
||||
const ixName =
|
||||
getAnchorNameForInstruction(ix, anchorProgram) ?? "Unknown Instruction";
|
||||
const cardTitle = `${camelToTitleCase(programName)}: ${camelToTitleCase(
|
||||
ixName
|
||||
)}`;
|
||||
|
||||
return (
|
||||
<InstructionCard title={cardTitle} {...props}>
|
||||
<AnchorDetails ix={ix} anchorProgram={anchorProgram} />
|
||||
</InstructionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function AnchorDetails({
|
||||
ix,
|
||||
anchorProgram,
|
||||
}: {
|
||||
ix: TransactionInstruction;
|
||||
anchorProgram: Program;
|
||||
}) {
|
||||
const { ixAccounts, decodedIxData, ixDef } = useMemo(() => {
|
||||
let ixAccounts:
|
||||
| {
|
||||
name: string;
|
||||
isMut: boolean;
|
||||
isSigner: boolean;
|
||||
pda?: Object;
|
||||
}[]
|
||||
| null = null;
|
||||
let decodedIxData: Instruction | null = null;
|
||||
let ixDef: IdlInstruction | undefined;
|
||||
if (anchorProgram) {
|
||||
const coder = new BorshInstructionCoder(anchorProgram.idl);
|
||||
decodedIxData = coder.decode(ix.data);
|
||||
if (decodedIxData) {
|
||||
ixDef = anchorProgram.idl.instructions.find(
|
||||
(ixDef) => ixDef.name === decodedIxData?.name
|
||||
);
|
||||
if (ixDef) {
|
||||
ixAccounts = getAnchorAccountsFromInstruction(
|
||||
decodedIxData,
|
||||
anchorProgram
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ixAccounts,
|
||||
decodedIxData,
|
||||
ixDef,
|
||||
};
|
||||
}, [anchorProgram, ix.data]);
|
||||
|
||||
if (!ixAccounts || !decodedIxData || !ixDef) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-lg-center">
|
||||
Failed to decode account data according to the public Anchor interface
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
const programName = getAnchorProgramName(anchorProgram) ?? "Unknown Program";
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td>Program</td>
|
||||
<td className="text-lg-end" colSpan={2}>
|
||||
<Address
|
||||
pubkey={ix.programId}
|
||||
alignRight
|
||||
link
|
||||
raw
|
||||
overrideText={programName}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="table-sep">
|
||||
<td>Account Name</td>
|
||||
<td className="text-lg-end" colSpan={2}>
|
||||
Address
|
||||
</td>
|
||||
</tr>
|
||||
{ix.keys.map(({ pubkey, isSigner, isWritable }, keyIndex) => {
|
||||
return (
|
||||
<tr key={keyIndex}>
|
||||
<td>
|
||||
<div className="me-2 d-md-inline">
|
||||
{ixAccounts
|
||||
? keyIndex < ixAccounts.length
|
||||
? `${camelToTitleCase(ixAccounts[keyIndex].name)}`
|
||||
: `Remaining Account #${keyIndex + 1 - ixAccounts.length}`
|
||||
: `Account #${keyIndex + 1}`}
|
||||
</div>
|
||||
{isWritable && (
|
||||
<span className="badge bg-info-soft me-1">Writable</span>
|
||||
)}
|
||||
{isSigner && (
|
||||
<span className="badge bg-info-soft me-1">Signer</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-lg-end" colSpan={2}>
|
||||
<Address pubkey={pubkey} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
{decodedIxData && ixDef && ixDef.args.length > 0 && (
|
||||
<>
|
||||
<tr className="table-sep">
|
||||
<td>Argument Name</td>
|
||||
<td>Type</td>
|
||||
<td className="text-lg-end">Value</td>
|
||||
</tr>
|
||||
{mapIxArgsToRows(decodedIxData.data, ixDef, anchorProgram.idl)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
import React from "react";
|
||||
import { ParsedInstruction, PublicKey, SignatureResult } from "@solana/web3.js";
|
||||
import { InstructionCard } from "./InstructionCard";
|
||||
import { Address } from "components/common/Address";
|
||||
|
||||
export function AssociatedTokenDetailsCard({
|
||||
ix,
|
||||
index,
|
||||
result,
|
||||
innerCards,
|
||||
childIndex,
|
||||
}: {
|
||||
ix: ParsedInstruction;
|
||||
index: number;
|
||||
result: SignatureResult;
|
||||
innerCards?: JSX.Element[];
|
||||
childIndex?: number;
|
||||
}) {
|
||||
const info = ix.parsed.info;
|
||||
return (
|
||||
<InstructionCard
|
||||
ix={ix}
|
||||
index={index}
|
||||
result={result}
|
||||
title="Associated Token Program: Create"
|
||||
innerCards={innerCards}
|
||||
childIndex={childIndex}
|
||||
>
|
||||
<tr>
|
||||
<td>Program</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={ix.programId} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Account</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={new PublicKey(info.account)} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Mint</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={new PublicKey(info.mint)} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Wallet</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={new PublicKey(info.wallet)} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
</InstructionCard>
|
||||
);
|
||||
}
|
|
@ -1,170 +0,0 @@
|
|||
import React from "react";
|
||||
import {
|
||||
ComputeBudgetInstruction,
|
||||
SignatureResult,
|
||||
TransactionInstruction,
|
||||
} from "@solana/web3.js";
|
||||
import { InstructionCard } from "./InstructionCard";
|
||||
import { SolBalance } from "components/common/SolBalance";
|
||||
import { Address } from "components/common/Address";
|
||||
import { reportError } from "utils/sentry";
|
||||
import { microLamportsToLamportsString } from "utils";
|
||||
import { useCluster } from "providers/cluster";
|
||||
|
||||
export function ComputeBudgetDetailsCard({
|
||||
ix,
|
||||
index,
|
||||
result,
|
||||
signature,
|
||||
innerCards,
|
||||
childIndex,
|
||||
}: {
|
||||
ix: TransactionInstruction;
|
||||
index: number;
|
||||
result: SignatureResult;
|
||||
signature: string;
|
||||
innerCards?: JSX.Element[];
|
||||
childIndex?: number;
|
||||
}) {
|
||||
const { url } = useCluster();
|
||||
try {
|
||||
const type = ComputeBudgetInstruction.decodeInstructionType(ix);
|
||||
switch (type) {
|
||||
case "RequestUnits": {
|
||||
const { units, additionalFee } =
|
||||
ComputeBudgetInstruction.decodeRequestUnits(ix);
|
||||
return (
|
||||
<InstructionCard
|
||||
ix={ix}
|
||||
index={index}
|
||||
result={result}
|
||||
title="Compute Budget Program: Request Units (Deprecated)"
|
||||
innerCards={innerCards}
|
||||
childIndex={childIndex}
|
||||
>
|
||||
<tr>
|
||||
<td>Program</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={ix.programId} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Requested Compute Units</td>
|
||||
<td className="text-lg-end font-monospace">{`${new Intl.NumberFormat(
|
||||
"en-US"
|
||||
).format(units)} compute units`}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Additional Fee (SOL)</td>
|
||||
<td className="text-lg-end">
|
||||
<SolBalance lamports={additionalFee} />
|
||||
</td>
|
||||
</tr>
|
||||
</InstructionCard>
|
||||
);
|
||||
}
|
||||
case "RequestHeapFrame": {
|
||||
const { bytes } = ComputeBudgetInstruction.decodeRequestHeapFrame(ix);
|
||||
return (
|
||||
<InstructionCard
|
||||
ix={ix}
|
||||
index={index}
|
||||
result={result}
|
||||
title="Compute Budget Program: Request Heap Frame"
|
||||
innerCards={innerCards}
|
||||
childIndex={childIndex}
|
||||
>
|
||||
<tr>
|
||||
<td>Program</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={ix.programId} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Requested Heap Frame (Bytes)</td>
|
||||
<td className="text-lg-end font-monospace">
|
||||
{new Intl.NumberFormat("en-US").format(bytes)}
|
||||
</td>
|
||||
</tr>
|
||||
</InstructionCard>
|
||||
);
|
||||
}
|
||||
case "SetComputeUnitLimit": {
|
||||
const { units } =
|
||||
ComputeBudgetInstruction.decodeSetComputeUnitLimit(ix);
|
||||
return (
|
||||
<InstructionCard
|
||||
ix={ix}
|
||||
index={index}
|
||||
result={result}
|
||||
title="Compute Budget Program: Set Compute Unit Limit"
|
||||
innerCards={innerCards}
|
||||
childIndex={childIndex}
|
||||
>
|
||||
<tr>
|
||||
<td>Program</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={ix.programId} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Compute Unit Limit</td>
|
||||
<td className="text-lg-end font-monospace">{`${new Intl.NumberFormat(
|
||||
"en-US"
|
||||
).format(units)} compute units`}</td>
|
||||
</tr>
|
||||
</InstructionCard>
|
||||
);
|
||||
}
|
||||
case "SetComputeUnitPrice": {
|
||||
const { microLamports } =
|
||||
ComputeBudgetInstruction.decodeSetComputeUnitPrice(ix);
|
||||
return (
|
||||
<InstructionCard
|
||||
ix={ix}
|
||||
index={index}
|
||||
result={result}
|
||||
title="Compute Budget Program: Set Compute Unit Price"
|
||||
innerCards={innerCards}
|
||||
childIndex={childIndex}
|
||||
>
|
||||
<tr>
|
||||
<td>Program</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={ix.programId} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Compute Unit Price</td>
|
||||
<td className="text-lg-end font-monospace">{`${microLamportsToLamportsString(
|
||||
microLamports
|
||||
)} lamports per compute unit`}</td>
|
||||
</tr>
|
||||
</InstructionCard>
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
reportError(error, {
|
||||
url: url,
|
||||
signature: signature,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<InstructionCard
|
||||
ix={ix}
|
||||
index={index}
|
||||
result={result}
|
||||
title="Compute Budget Program: Unknown Instruction"
|
||||
innerCards={innerCards}
|
||||
childIndex={childIndex}
|
||||
defaultRaw
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -1,140 +0,0 @@
|
|||
import React, { useContext } from "react";
|
||||
import {
|
||||
TransactionInstruction,
|
||||
SignatureResult,
|
||||
ParsedInstruction,
|
||||
} from "@solana/web3.js";
|
||||
import { RawDetails } from "./RawDetails";
|
||||
import { RawParsedDetails } from "./RawParsedDetails";
|
||||
import { SignatureContext } from "../../pages/TransactionDetailsPage";
|
||||
import {
|
||||
useFetchRawTransaction,
|
||||
useRawTransactionDetails,
|
||||
} from "providers/transactions/raw";
|
||||
import { Address } from "components/common/Address";
|
||||
import { useScrollAnchor } from "providers/scroll-anchor";
|
||||
import getInstructionCardScrollAnchorId from "utils/get-instruction-card-scroll-anchor-id";
|
||||
|
||||
type InstructionProps = {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
result: SignatureResult;
|
||||
index: number;
|
||||
ix: TransactionInstruction | ParsedInstruction;
|
||||
defaultRaw?: boolean;
|
||||
innerCards?: JSX.Element[];
|
||||
childIndex?: number;
|
||||
};
|
||||
|
||||
export function InstructionCard({
|
||||
title,
|
||||
children,
|
||||
result,
|
||||
index,
|
||||
ix,
|
||||
defaultRaw,
|
||||
innerCards,
|
||||
childIndex,
|
||||
}: InstructionProps) {
|
||||
const [resultClass] = ixResult(result, index);
|
||||
const [showRaw, setShowRaw] = React.useState(defaultRaw || false);
|
||||
const signature = useContext(SignatureContext);
|
||||
const rawDetails = useRawTransactionDetails(signature);
|
||||
|
||||
let raw: TransactionInstruction | undefined = undefined;
|
||||
if (rawDetails && childIndex === undefined) {
|
||||
raw = rawDetails?.data?.raw?.transaction.instructions[index];
|
||||
}
|
||||
|
||||
const fetchRaw = useFetchRawTransaction();
|
||||
const fetchRawTrigger = () => fetchRaw(signature);
|
||||
|
||||
const rawClickHandler = () => {
|
||||
if (!defaultRaw && !showRaw && !raw) {
|
||||
fetchRawTrigger();
|
||||
}
|
||||
|
||||
return setShowRaw((r) => !r);
|
||||
};
|
||||
const scrollAnchorRef = useScrollAnchor(
|
||||
getInstructionCardScrollAnchorId(
|
||||
childIndex != null ? [index + 1, childIndex + 1] : [index + 1]
|
||||
)
|
||||
);
|
||||
return (
|
||||
<div className="card" ref={scrollAnchorRef}>
|
||||
<div className="card-header">
|
||||
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
||||
<span className={`badge bg-${resultClass}-soft me-2`}>
|
||||
#{index + 1}
|
||||
{childIndex !== undefined ? `.${childIndex + 1}` : ""}
|
||||
</span>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<button
|
||||
disabled={defaultRaw}
|
||||
className={`btn btn-sm d-flex ${
|
||||
showRaw ? "btn-black active" : "btn-white"
|
||||
}`}
|
||||
onClick={rawClickHandler}
|
||||
>
|
||||
<span className="fe fe-code me-1"></span>
|
||||
Raw
|
||||
</button>
|
||||
</div>
|
||||
<div className="table-responsive mb-0">
|
||||
<table className="table table-sm table-nowrap card-table">
|
||||
<tbody className="list">
|
||||
{showRaw ? (
|
||||
<>
|
||||
<tr>
|
||||
<td>Program</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={ix.programId} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
{"parsed" in ix ? (
|
||||
<RawParsedDetails ix={ix}>
|
||||
{raw ? <RawDetails ix={raw} /> : null}
|
||||
</RawParsedDetails>
|
||||
) : (
|
||||
<RawDetails ix={ix} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{innerCards && innerCards.length > 0 && (
|
||||
<>
|
||||
<tr className="table-sep">
|
||||
<td colSpan={3}>Inner Instructions</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={3}>
|
||||
<div className="inner-cards">{innerCards}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ixResult(result: SignatureResult, index: number) {
|
||||
if (result.err) {
|
||||
const err = result.err as any;
|
||||
const ixError = err["InstructionError"];
|
||||
if (ixError && Array.isArray(ixError)) {
|
||||
const [errorIndex, error] = ixError;
|
||||
if (Number.isInteger(errorIndex) && errorIndex === index) {
|
||||
return ["warning", `Error: ${JSON.stringify(error)}`];
|
||||
}
|
||||
}
|
||||
return ["dark"];
|
||||
}
|
||||
return ["success"];
|
||||
}
|
|
@ -1,170 +0,0 @@
|
|||
import { SignatureResult, TransactionInstruction } from "@solana/web3.js";
|
||||
import { useCluster } from "providers/cluster";
|
||||
import { reportError } from "utils/sentry";
|
||||
import { InstructionCard } from "./InstructionCard";
|
||||
import { AddOracleDetailsCard } from "./mango/AddOracleDetailsCard";
|
||||
import { AddPerpMarketDetailsCard } from "./mango/AddPerpMarketDetailsCard";
|
||||
import { AddSpotMarketDetailsCard } from "./mango/AddSpotMarketDetailsCard";
|
||||
import { CancelPerpOrderDetailsCard } from "./mango/CancelPerpOrderDetailsCard";
|
||||
import { CancelSpotOrderDetailsCard } from "./mango/CancelSpotOrderDetailsCard";
|
||||
import { ChangePerpMarketParamsDetailsCard } from "./mango/ChangePerpMarketParamsDetailsCard";
|
||||
import { ConsumeEventsDetailsCard } from "./mango/ConsumeEventsDetailsCard";
|
||||
import { GenericMngoAccountDetailsCard } from "./mango/GenericMngoAccountDetailsCard";
|
||||
import { GenericPerpMngoDetailsCard } from "./mango/GenericPerpMngoDetailsCard";
|
||||
import { GenericSpotMngoDetailsCard } from "./mango/GenericSpotMngoDetailsCard";
|
||||
import { PlacePerpOrderDetailsCard } from "./mango/PlacePerpOrderDetailsCard";
|
||||
import { PlaceSpotOrderDetailsCard } from "./mango/PlaceSpotOrderDetailsCard";
|
||||
import {
|
||||
decodeAddPerpMarket,
|
||||
decodeAddSpotMarket,
|
||||
decodeCancelPerpOrder,
|
||||
decodeCancelSpotOrder,
|
||||
decodeChangePerpMarketParams,
|
||||
decodePlacePerpOrder,
|
||||
decodePlacePerpOrder2,
|
||||
decodePlaceSpotOrder,
|
||||
parseMangoInstructionTitle,
|
||||
} from "./mango/types";
|
||||
import { PlacePerpOrder2DetailsCard } from "./mango/PlacePerpOrder2DetailsCard";
|
||||
|
||||
export function MangoDetailsCard(props: {
|
||||
ix: TransactionInstruction;
|
||||
index: number;
|
||||
result: SignatureResult;
|
||||
signature: string;
|
||||
innerCards?: JSX.Element[];
|
||||
childIndex?: number;
|
||||
}) {
|
||||
const { ix, index, result, signature, innerCards, childIndex } = props;
|
||||
|
||||
const { url } = useCluster();
|
||||
|
||||
let title;
|
||||
try {
|
||||
title = parseMangoInstructionTitle(ix);
|
||||
|
||||
switch (title) {
|
||||
case "InitMangoAccount":
|
||||
return (
|
||||
<GenericMngoAccountDetailsCard
|
||||
mangoAccountKeyLocation={1}
|
||||
title={title}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case "Deposit":
|
||||
return (
|
||||
<GenericMngoAccountDetailsCard
|
||||
mangoAccountKeyLocation={1}
|
||||
title={title}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case "Withdraw":
|
||||
return (
|
||||
<GenericMngoAccountDetailsCard
|
||||
mangoAccountKeyLocation={1}
|
||||
title={title}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case "InitSpotOpenOrders":
|
||||
return (
|
||||
<GenericMngoAccountDetailsCard
|
||||
mangoAccountKeyLocation={1}
|
||||
title={title}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case "PlaceSpotOrder":
|
||||
return (
|
||||
<PlaceSpotOrderDetailsCard
|
||||
info={decodePlaceSpotOrder(ix)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case "CancelSpotOrder":
|
||||
return (
|
||||
<CancelSpotOrderDetailsCard
|
||||
info={decodeCancelSpotOrder(ix)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case "AddPerpMarket":
|
||||
return (
|
||||
<AddPerpMarketDetailsCard info={decodeAddPerpMarket(ix)} {...props} />
|
||||
);
|
||||
case "PlacePerpOrder":
|
||||
return (
|
||||
<PlacePerpOrderDetailsCard
|
||||
info={decodePlacePerpOrder(ix)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case "PlacePerpOrder2":
|
||||
return (
|
||||
<PlacePerpOrder2DetailsCard
|
||||
info={decodePlacePerpOrder2(ix)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case "ConsumeEvents":
|
||||
return <ConsumeEventsDetailsCard {...props} />;
|
||||
case "CancelPerpOrder":
|
||||
return (
|
||||
<CancelPerpOrderDetailsCard
|
||||
info={decodeCancelPerpOrder(ix)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case "SettleFunds":
|
||||
return (
|
||||
<GenericSpotMngoDetailsCard
|
||||
accountKeyLocation={2}
|
||||
spotMarketkeyLocation={5}
|
||||
title={title}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case "RedeemMngo":
|
||||
return (
|
||||
<GenericPerpMngoDetailsCard
|
||||
mangoAccountKeyLocation={3}
|
||||
perpMarketKeyLocation={4}
|
||||
title={title}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case "ChangePerpMarketParams":
|
||||
return (
|
||||
<ChangePerpMarketParamsDetailsCard
|
||||
info={decodeChangePerpMarketParams(ix)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case "AddOracle":
|
||||
return <AddOracleDetailsCard {...props} />;
|
||||
case "AddSpotMarket":
|
||||
return (
|
||||
<AddSpotMarketDetailsCard info={decodeAddSpotMarket(ix)} {...props} />
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
reportError(error, {
|
||||
url: url,
|
||||
signature: signature,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<InstructionCard
|
||||
ix={ix}
|
||||
index={index}
|
||||
result={result}
|
||||
title={`Mango Program: ${title || "Unknown"}`}
|
||||
innerCards={innerCards}
|
||||
childIndex={childIndex}
|
||||
defaultRaw
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -1,45 +0,0 @@
|
|||
import React from "react";
|
||||
import { ParsedInstruction, SignatureResult } from "@solana/web3.js";
|
||||
import { InstructionCard } from "./InstructionCard";
|
||||
import { wrap } from "utils";
|
||||
import { Address } from "components/common/Address";
|
||||
|
||||
export function MemoDetailsCard({
|
||||
ix,
|
||||
index,
|
||||
result,
|
||||
innerCards,
|
||||
childIndex,
|
||||
}: {
|
||||
ix: ParsedInstruction;
|
||||
index: number;
|
||||
result: SignatureResult;
|
||||
innerCards?: JSX.Element[];
|
||||
childIndex?: number;
|
||||
}) {
|
||||
const data = wrap(ix.parsed, 50);
|
||||
return (
|
||||
<InstructionCard
|
||||
ix={ix}
|
||||
index={index}
|
||||
result={result}
|
||||
title="Memo Program: Memo"
|
||||
innerCards={innerCards}
|
||||
childIndex={childIndex}
|
||||
>
|
||||
<tr>
|
||||
<td>Program</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={ix.programId} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Data (UTF-8)</td>
|
||||
<td className="text-lg-end">
|
||||
<pre className="d-inline-block text-start mb-0">{data}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
</InstructionCard>
|
||||
);
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
import React from "react";
|
||||
import { TransactionInstruction } from "@solana/web3.js";
|
||||
import { Address } from "components/common/Address";
|
||||
import { HexData } from "components/common/HexData";
|
||||
|
||||
export function RawDetails({ ix }: { ix: TransactionInstruction }) {
|
||||
return (
|
||||
<>
|
||||
{ix.keys.map(({ pubkey, isSigner, isWritable }, keyIndex) => (
|
||||
<tr key={keyIndex}>
|
||||
<td>
|
||||
<div className="me-2 d-md-inline">Account #{keyIndex + 1}</div>
|
||||
{isWritable && (
|
||||
<span className="badge bg-info-soft me-1">Writable</span>
|
||||
)}
|
||||
{isSigner && (
|
||||
<span className="badge bg-info-soft me-1">Signer</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-lg-end">
|
||||
<Address pubkey={pubkey} alignRight link />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
Instruction Data <span className="text-muted">(Hex)</span>
|
||||
</td>
|
||||
<td className="text-lg-end">
|
||||
<HexData raw={ix.data} />
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
import React from "react";
|
||||
import { ParsedInstruction } from "@solana/web3.js";
|
||||
|
||||
export function RawParsedDetails({
|
||||
ix,
|
||||
children,
|
||||
}: {
|
||||
ix: ParsedInstruction;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
Instruction Data <span className="text-muted">(JSON)</span>
|
||||
</td>
|
||||
<td className="text-lg-end">
|
||||
<pre className="d-inline-block text-start json-wrap">
|
||||
{JSON.stringify(ix.parsed, null, 2)}
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,161 +0,0 @@
|
|||
import React from "react";
|
||||
import { TransactionInstruction, SignatureResult } from "@solana/web3.js";
|
||||
import { InstructionCard } from "./InstructionCard";
|
||||
import { useCluster } from "providers/cluster";
|
||||
import { reportError } from "utils/sentry";
|
||||
import {
|
||||
decodeCancelOrder,
|
||||
decodeCancelOrderByClientId,
|
||||
decodeCancelOrderByClientIdV2,
|
||||
decodeCancelOrderV2,
|
||||
decodeCloseOpenOrders,
|
||||
decodeConsumeEvents,
|
||||
decodeConsumeEventsPermissioned,
|
||||
decodeDisableMarket,
|
||||
decodeInitializeMarket,
|
||||
decodeInitOpenOrders,
|
||||
decodeMatchOrders,
|
||||
decodeNewOrder,
|
||||
decodeNewOrderV3,
|
||||
decodePrune,
|
||||
decodeSettleFunds,
|
||||
decodeSweepFees,
|
||||
OPEN_BOOK_PROGRAM_ID,
|
||||
parseSerumInstructionKey,
|
||||
parseSerumInstructionTitle,
|
||||
} from "./serum/types";
|
||||
import { NewOrderDetailsCard } from "./serum/NewOrderDetailsCard";
|
||||
import { MatchOrdersDetailsCard } from "./serum/MatchOrdersDetailsCard";
|
||||
import { InitializeMarketDetailsCard } from "./serum/InitializeMarketDetailsCard";
|
||||
import { ConsumeEventsDetailsCard } from "./serum/ConsumeEventsDetails";
|
||||
import { CancelOrderDetailsCard } from "./serum/CancelOrderDetails";
|
||||
import { CancelOrderByClientIdDetailsCard } from "./serum/CancelOrderByClientIdDetails";
|
||||
import { SettleFundsDetailsCard } from "./serum/SettleFundsDetailsCard";
|
||||
import { DisableMarketDetailsCard } from "./serum/DisableMarketDetails";
|
||||
import { SweepFeesDetailsCard } from "./serum/SweepFeesDetails";
|
||||
import { NewOrderV3DetailsCard } from "./serum/NewOrderV3DetailsCard";
|
||||
import { CancelOrderV2DetailsCard } from "./serum/CancelOrderV2Details";
|
||||
import { CancelOrderByClientIdV2DetailsCard } from "./serum/CancelOrderByClientIdV2Details";
|
||||
import { CloseOpenOrdersDetailsCard } from "./serum/CloseOpenOrdersDetails";
|
||||
import { InitOpenOrdersDetailsCard } from "./serum/InitOpenOrdersDetails";
|
||||
import { PruneDetailsCard } from "./serum/PruneDetails";
|
||||
import { ConsumeEventsPermissionedDetailsCard } from "./serum/ConsumeEventsPermissionedDetails";
|
||||
|
||||
export function SerumDetailsCard(initialProps: {
|
||||
ix: TransactionInstruction;
|
||||
index: number;
|
||||
result: SignatureResult;
|
||||
signature: string;
|
||||
innerCards?: JSX.Element[];
|
||||
childIndex?: number;
|
||||
}) {
|
||||
const { ix, index, result, signature, innerCards, childIndex } = initialProps;
|
||||
|
||||
const props = React.useMemo(() => {
|
||||
const programName =
|
||||
initialProps.ix.programId.toBase58() === OPEN_BOOK_PROGRAM_ID
|
||||
? "OpenBook"
|
||||
: "Serum";
|
||||
return { ...initialProps, programName };
|
||||
}, [initialProps]);
|
||||
|
||||
const { url } = useCluster();
|
||||
|
||||
let title;
|
||||
try {
|
||||
title = parseSerumInstructionTitle(ix);
|
||||
switch (parseSerumInstructionKey(ix)) {
|
||||
case "initializeMarket":
|
||||
return (
|
||||
<InitializeMarketDetailsCard
|
||||
info={decodeInitializeMarket(ix)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case "newOrder":
|
||||
return <NewOrderDetailsCard info={decodeNewOrder(ix)} {...props} />;
|
||||
case "matchOrders":
|
||||
return (
|
||||
<MatchOrdersDetailsCard info={decodeMatchOrders(ix)} {...props} />
|
||||
);
|
||||
case "consumeEvents":
|
||||
return (
|
||||
<ConsumeEventsDetailsCard info={decodeConsumeEvents(ix)} {...props} />
|
||||
);
|
||||
case "cancelOrder":
|
||||
return (
|
||||
<CancelOrderDetailsCard info={decodeCancelOrder(ix)} {...props} />
|
||||
);
|
||||
case "settleFunds":
|
||||
return (
|
||||
<SettleFundsDetailsCard info={decodeSettleFunds(ix)} {...props} />
|
||||
);
|
||||
case "cancelOrderByClientId":
|
||||
return (
|
||||
<CancelOrderByClientIdDetailsCard
|
||||
info={decodeCancelOrderByClientId(ix)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case "disableMarket":
|
||||
return (
|
||||
<DisableMarketDetailsCard info={decodeDisableMarket(ix)} {...props} />
|
||||
);
|
||||
case "sweepFees":
|
||||
return <SweepFeesDetailsCard info={decodeSweepFees(ix)} {...props} />;
|
||||
case "newOrderV3":
|
||||
return <NewOrderV3DetailsCard info={decodeNewOrderV3(ix)} {...props} />;
|
||||
case "cancelOrderV2":
|
||||
return (
|
||||
<CancelOrderV2DetailsCard info={decodeCancelOrderV2(ix)} {...props} />
|
||||
);
|
||||
case "cancelOrderByClientIdV2":
|
||||
return (
|
||||
<CancelOrderByClientIdV2DetailsCard
|
||||
info={decodeCancelOrderByClientIdV2(ix)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case "closeOpenOrders":
|
||||
return (
|
||||
<CloseOpenOrdersDetailsCard
|
||||
info={decodeCloseOpenOrders(ix)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case "initOpenOrders":
|
||||
return (
|
||||
<InitOpenOrdersDetailsCard
|
||||
info={decodeInitOpenOrders(ix)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case "prune":
|
||||
return <PruneDetailsCard info={decodePrune(ix)} {...props} />;
|
||||
case "consumeEventsPermissioned":
|
||||
return (
|
||||
<ConsumeEventsPermissionedDetailsCard
|
||||
info={decodeConsumeEventsPermissioned(ix)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
reportError(error, {
|
||||
url: url,
|
||||
signature: signature,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<InstructionCard
|
||||
ix={ix}
|
||||
index={index}
|
||||
result={result}
|
||||
title={`${props.programName} Program: ${title || "Unknown"}`}
|
||||
innerCards={innerCards}
|
||||
childIndex={childIndex}
|
||||
defaultRaw
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
import React from "react";
|
||||
import { TransactionInstruction, SignatureResult } from "@solana/web3.js";
|
||||
import { InstructionCard } from "./InstructionCard";
|
||||
import { useCluster } from "providers/cluster";
|
||||
import { reportError } from "utils/sentry";
|
||||
import { parseTokenLendingInstructionTitle } from "./token-lending/types";
|
||||
|
||||
export function TokenLendingDetailsCard({
|
||||
ix,
|
||||
index,
|
||||
result,
|
||||
signature,
|
||||
innerCards,
|
||||
childIndex,
|
||||
}: {
|
||||
ix: TransactionInstruction;
|
||||
index: number;
|
||||
result: SignatureResult;
|
||||
signature: string;
|
||||
innerCards?: JSX.Element[];
|
||||
childIndex?: number;
|
||||
}) {
|
||||
const { url } = useCluster();
|
||||
|
||||
let title;
|
||||
try {
|
||||
title = parseTokenLendingInstructionTitle(ix);
|
||||
} catch (error) {
|
||||
reportError(error, {
|
||||
url: url,
|
||||
signature: signature,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<InstructionCard
|
||||
ix={ix}
|
||||
index={index}
|
||||
result={result}
|
||||
title={`Token Lending: ${title || "Unknown"}`}
|
||||
innerCards={innerCards}
|
||||
childIndex={childIndex}
|
||||
defaultRaw
|
||||
/>
|
||||
);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue