Add token supply info to mint account page (#11584)
This commit is contained in:
@ -10,6 +10,8 @@ import { coerce } from "superstruct";
|
||||
import { TableCardBody } from "components/common/TableCardBody";
|
||||
import { Address } from "components/common/Address";
|
||||
import { UnknownAccountCard } from "./UnknownAccountCard";
|
||||
import { useFetchTokenSupply, useTokenSupply } from "providers/mints/supply";
|
||||
import { FetchStatus } from "providers/cache";
|
||||
|
||||
export function TokenAccountSection({
|
||||
account,
|
||||
@ -44,7 +46,36 @@ function MintAccountCard({
|
||||
account: Account;
|
||||
info: MintAccountInfo;
|
||||
}) {
|
||||
const refresh = useFetchAccountInfo();
|
||||
const mintAddress = account.pubkey.toBase58();
|
||||
const fetchInfo = useFetchAccountInfo();
|
||||
const supply = useTokenSupply(mintAddress);
|
||||
const fetchSupply = useFetchTokenSupply();
|
||||
const refreshSupply = () => fetchSupply(account.pubkey);
|
||||
const refresh = () => {
|
||||
fetchInfo(account.pubkey);
|
||||
refreshSupply();
|
||||
};
|
||||
|
||||
let renderSupply;
|
||||
const supplyTotal = supply?.data?.uiAmount;
|
||||
if (supplyTotal === undefined) {
|
||||
if (!supply || supply?.status === FetchStatus.Fetching) {
|
||||
renderSupply = (
|
||||
<>
|
||||
<span className="spinner-grow spinner-grow-sm mr-2"></span>
|
||||
Loading
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
renderSupply = "Fetch failed";
|
||||
}
|
||||
} else {
|
||||
renderSupply = supplyTotal;
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!supply) refreshSupply();
|
||||
}, [mintAddress]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
@ -52,10 +83,7 @@ function MintAccountCard({
|
||||
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
||||
Token Mint Account
|
||||
</h3>
|
||||
<button
|
||||
className="btn btn-white btn-sm"
|
||||
onClick={() => refresh(account.pubkey)}
|
||||
>
|
||||
<button className="btn btn-white btn-sm" onClick={refresh}>
|
||||
<span className="fe fe-refresh-cw mr-2"></span>
|
||||
Refresh
|
||||
</button>
|
||||
@ -68,16 +96,20 @@ function MintAccountCard({
|
||||
<Address pubkey={account.pubkey} alignRight raw />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Total Supply</td>
|
||||
<td className="text-lg-right">{renderSupply}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Decimals</td>
|
||||
<td className="text-lg-right">{info.decimals}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td className="text-lg-right">
|
||||
{info.isInitialized ? "Initialized" : "Uninitialized"}
|
||||
</td>
|
||||
</tr>
|
||||
{!info.isInitialized && (
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td className="text-lg-right">Uninitialized</td>
|
||||
</tr>
|
||||
)}
|
||||
{info.owner !== undefined && (
|
||||
<tr>
|
||||
<td>Owner</td>
|
||||
|
96
explorer/src/components/account/TokenLargestAccountsCard.tsx
Normal file
96
explorer/src/components/account/TokenLargestAccountsCard.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import React from "react";
|
||||
import { PublicKey, TokenAccountBalancePair } from "@solana/web3.js";
|
||||
import { LoadingCard } from "components/common/LoadingCard";
|
||||
import { ErrorCard } from "components/common/ErrorCard";
|
||||
import { Address } from "components/common/Address";
|
||||
import { useTokenSupply } from "providers/mints/supply";
|
||||
import {
|
||||
useTokenLargestTokens,
|
||||
useFetchTokenLargestAccounts,
|
||||
} from "providers/mints/largest";
|
||||
import { FetchStatus } from "providers/cache";
|
||||
|
||||
export function TokenLargestAccountsCard({ pubkey }: { pubkey: PublicKey }) {
|
||||
const mintAddress = pubkey.toBase58();
|
||||
const supply = useTokenSupply(mintAddress);
|
||||
const largestAccounts = useTokenLargestTokens(mintAddress);
|
||||
const fetchLargestAccounts = useFetchTokenLargestAccounts();
|
||||
const refreshLargest = () => fetchLargestAccounts(pubkey);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!largestAccounts) refreshLargest();
|
||||
}, [mintAddress]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const supplyTotal = supply?.data?.uiAmount;
|
||||
if (!supplyTotal || !largestAccounts) {
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const accounts = largestAccounts.data.largest;
|
||||
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 table-nowrap card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-muted">Rank</th>
|
||||
<th className="text-muted">Address</th>
|
||||
<th className="text-muted text-right">Balance</th>
|
||||
<th className="text-muted text-right">% of Total Supply</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="list">
|
||||
{accounts.map((account, index) =>
|
||||
renderAccountRow(account, index, supplyTotal)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const renderAccountRow = (
|
||||
account: TokenAccountBalancePair,
|
||||
index: number,
|
||||
supply: number
|
||||
) => {
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
<span className="badge badge-soft-gray badge-pill">{index + 1}</span>
|
||||
</td>
|
||||
<td>
|
||||
<Address pubkey={account.address} link />
|
||||
</td>
|
||||
<td className="text-right">{account.uiAmount}</td>
|
||||
<td className="text-right">{`${(
|
||||
(100 * account.uiAmount) /
|
||||
supply
|
||||
).toFixed(3)}%`}</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
@ -10,6 +10,7 @@ import { SupplyProvider } from "./providers/supply";
|
||||
import { TransactionsProvider } from "./providers/transactions";
|
||||
import { AccountsProvider } from "./providers/accounts";
|
||||
import { StatsProvider } from "providers/stats";
|
||||
import { MintsProvider } from "providers/mints";
|
||||
|
||||
ReactDOM.render(
|
||||
<Router>
|
||||
@ -18,9 +19,11 @@ ReactDOM.render(
|
||||
<SupplyProvider>
|
||||
<RichListProvider>
|
||||
<AccountsProvider>
|
||||
<TransactionsProvider>
|
||||
<App />
|
||||
</TransactionsProvider>
|
||||
<MintsProvider>
|
||||
<TransactionsProvider>
|
||||
<App />
|
||||
</TransactionsProvider>
|
||||
</MintsProvider>
|
||||
</AccountsProvider>
|
||||
</RichListProvider>
|
||||
</SupplyProvider>
|
||||
|
@ -1,33 +1,31 @@
|
||||
import React from "react";
|
||||
import { PublicKey } from "@solana/web3.js";
|
||||
import { FetchStatus } from "providers/cache";
|
||||
import { useFetchAccountInfo, useAccountInfo } from "providers/accounts";
|
||||
import {
|
||||
useFetchAccountInfo,
|
||||
useAccountInfo,
|
||||
Account,
|
||||
} from "providers/accounts";
|
||||
import { StakeAccountSection } from "components/account/StakeAccountSection";
|
||||
import { TokenAccountSection } from "components/account/TokenAccountSection";
|
||||
import { ErrorCard } from "components/common/ErrorCard";
|
||||
import { LoadingCard } from "components/common/LoadingCard";
|
||||
import { useCluster, ClusterStatus } from "providers/cluster";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { NavLink, Redirect, useLocation } from "react-router-dom";
|
||||
import { clusterPath } from "utils/url";
|
||||
import { UnknownAccountCard } from "components/account/UnknownAccountCard";
|
||||
import { OwnedTokensCard } from "components/account/OwnedTokensCard";
|
||||
import { TransactionHistoryCard } from "components/account/TransactionHistoryCard";
|
||||
import { TokenHistoryCard } from "components/account/TokenHistoryCard";
|
||||
import { TokenLargestAccountsCard } from "components/account/TokenLargestAccountsCard";
|
||||
|
||||
type Props = { address: string; tab?: string };
|
||||
export function AccountDetailsPage({ address, tab }: Props) {
|
||||
let pubkey: PublicKey | undefined;
|
||||
|
||||
try {
|
||||
pubkey = new PublicKey(address);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
// TODO handle bad addresses
|
||||
}
|
||||
|
||||
let moreTab: MoreTabs = "history";
|
||||
if (tab === "history" || tab === "tokens") {
|
||||
moreTab = tab;
|
||||
}
|
||||
} catch (err) {}
|
||||
|
||||
return (
|
||||
<div className="container mt-n3">
|
||||
@ -37,18 +35,21 @@ export function AccountDetailsPage({ address, tab }: Props) {
|
||||
<h4 className="header-title">Account</h4>
|
||||
</div>
|
||||
</div>
|
||||
{pubkey && <InfoSection pubkey={pubkey} />}
|
||||
{pubkey && <MoreSection pubkey={pubkey} tab={moreTab} />}
|
||||
{!pubkey ? (
|
||||
<ErrorCard text={`Address "${address}" is not valid`} />
|
||||
) : (
|
||||
<DetailsSections pubkey={pubkey} tab={tab} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoSection({ pubkey }: { pubkey: PublicKey }) {
|
||||
function DetailsSections({ pubkey, tab }: { pubkey: PublicKey; tab?: string }) {
|
||||
const fetchAccount = useFetchAccountInfo();
|
||||
const address = pubkey.toBase58();
|
||||
const info = useAccountInfo(address);
|
||||
const refresh = useFetchAccountInfo();
|
||||
const { status } = useCluster();
|
||||
const location = useLocation();
|
||||
|
||||
// Fetch account on load
|
||||
React.useEffect(() => {
|
||||
@ -61,11 +62,53 @@ function InfoSection({ pubkey }: { pubkey: PublicKey }) {
|
||||
info.status === FetchStatus.FetchFailed ||
|
||||
info.data?.lamports === undefined
|
||||
) {
|
||||
return <ErrorCard retry={() => refresh(pubkey)} text="Fetch Failed" />;
|
||||
return <ErrorCard retry={() => fetchAccount(pubkey)} text="Fetch Failed" />;
|
||||
}
|
||||
|
||||
const account = info.data;
|
||||
const data = account?.details?.data;
|
||||
|
||||
let tabs: Tab[] = [
|
||||
{
|
||||
slug: "history",
|
||||
title: "History",
|
||||
path: "",
|
||||
},
|
||||
];
|
||||
|
||||
if (data && data?.name === "spl-token") {
|
||||
if (data.parsed.type === "mint") {
|
||||
tabs.push({
|
||||
slug: "holders",
|
||||
title: "Holders",
|
||||
path: "/holders",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
tabs.push({
|
||||
slug: "tokens",
|
||||
title: "Tokens",
|
||||
path: "/tokens",
|
||||
});
|
||||
}
|
||||
|
||||
let moreTab: MoreTabs = "history";
|
||||
if (tab && tabs.filter(({ slug }) => slug === tab).length === 0) {
|
||||
return <Redirect to={{ ...location, pathname: `/address/${address}` }} />;
|
||||
} else if (tab) {
|
||||
moreTab = tab as MoreTabs;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{<InfoSection account={account} />}
|
||||
{<MoreSection account={account} tab={moreTab} tabs={tabs} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoSection({ account }: { account: Account }) {
|
||||
const data = account?.details?.data;
|
||||
if (data && data.name === "stake") {
|
||||
let stakeAccountType, stakeAccount;
|
||||
if ("accountType" in data.parsed) {
|
||||
@ -90,11 +133,24 @@ function InfoSection({ pubkey }: { pubkey: PublicKey }) {
|
||||
}
|
||||
}
|
||||
|
||||
type MoreTabs = "history" | "tokens";
|
||||
function MoreSection({ pubkey, tab }: { pubkey: PublicKey; tab: MoreTabs }) {
|
||||
const address = pubkey.toBase58();
|
||||
const info = useAccountInfo(address);
|
||||
if (info?.data === undefined) return null;
|
||||
type Tab = {
|
||||
slug: MoreTabs;
|
||||
title: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type MoreTabs = "history" | "tokens" | "holders";
|
||||
function MoreSection({
|
||||
account,
|
||||
tab,
|
||||
tabs,
|
||||
}: {
|
||||
account: Account;
|
||||
tab: MoreTabs;
|
||||
tabs: Tab[];
|
||||
}) {
|
||||
const pubkey = account.pubkey;
|
||||
const address = account.pubkey.toBase58();
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -102,24 +158,17 @@ function MoreSection({ pubkey, tab }: { pubkey: PublicKey; tab: MoreTabs }) {
|
||||
<div className="header">
|
||||
<div className="header-body pt-0">
|
||||
<ul className="nav nav-tabs nav-overflow header-tabs">
|
||||
<li className="nav-item">
|
||||
<NavLink
|
||||
className="nav-link"
|
||||
to={clusterPath(`/address/${address}`)}
|
||||
exact
|
||||
>
|
||||
History
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<NavLink
|
||||
className="nav-link"
|
||||
to={clusterPath(`/address/${address}/tokens`)}
|
||||
exact
|
||||
>
|
||||
Tokens
|
||||
</NavLink>
|
||||
</li>
|
||||
{tabs.map(({ title, path }) => (
|
||||
<li className="nav-item">
|
||||
<NavLink
|
||||
className="nav-link"
|
||||
to={clusterPath(`/address/${address}${path}`)}
|
||||
exact
|
||||
>
|
||||
{title}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@ -131,6 +180,7 @@ function MoreSection({ pubkey, tab }: { pubkey: PublicKey; tab: MoreTabs }) {
|
||||
</>
|
||||
)}
|
||||
{tab === "history" && <TransactionHistoryCard pubkey={pubkey} />}
|
||||
{tab === "holders" && <TokenLargestAccountsCard pubkey={pubkey} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
12
explorer/src/providers/mints/index.tsx
Normal file
12
explorer/src/providers/mints/index.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import { SupplyProvider } from "./supply";
|
||||
import { LargestAccountsProvider } from "./largest";
|
||||
|
||||
type ProviderProps = { children: React.ReactNode };
|
||||
export function MintsProvider({ children }: ProviderProps) {
|
||||
return (
|
||||
<SupplyProvider>
|
||||
<LargestAccountsProvider>{children}</LargestAccountsProvider>
|
||||
</SupplyProvider>
|
||||
);
|
||||
}
|
99
explorer/src/providers/mints/largest.tsx
Normal file
99
explorer/src/providers/mints/largest.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import React from "react";
|
||||
import { useCluster } from "providers/cluster";
|
||||
import * as Cache from "providers/cache";
|
||||
import { ActionType, FetchStatus } from "providers/cache";
|
||||
import {
|
||||
PublicKey,
|
||||
Connection,
|
||||
TokenAccountBalancePair,
|
||||
} from "@solana/web3.js";
|
||||
|
||||
type LargestAccounts = {
|
||||
largest: TokenAccountBalancePair[];
|
||||
};
|
||||
|
||||
type State = Cache.State<LargestAccounts>;
|
||||
type Dispatch = Cache.Dispatch<LargestAccounts>;
|
||||
|
||||
const StateContext = React.createContext<State | undefined>(undefined);
|
||||
const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
|
||||
|
||||
type ProviderProps = { children: React.ReactNode };
|
||||
export function LargestAccountsProvider({ children }: ProviderProps) {
|
||||
const { url } = useCluster();
|
||||
const [state, dispatch] = Cache.useReducer<LargestAccounts>(url);
|
||||
|
||||
// Clear cache whenever cluster is changed
|
||||
React.useEffect(() => {
|
||||
dispatch({ type: ActionType.Clear, url });
|
||||
}, [dispatch, url]);
|
||||
|
||||
return (
|
||||
<StateContext.Provider value={state}>
|
||||
<DispatchContext.Provider value={dispatch}>
|
||||
{children}
|
||||
</DispatchContext.Provider>
|
||||
</StateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchLargestAccounts(
|
||||
dispatch: Dispatch,
|
||||
pubkey: PublicKey,
|
||||
url: string
|
||||
) {
|
||||
dispatch({
|
||||
type: ActionType.Update,
|
||||
key: pubkey.toBase58(),
|
||||
status: Cache.FetchStatus.Fetching,
|
||||
url,
|
||||
});
|
||||
|
||||
let data;
|
||||
let fetchStatus;
|
||||
try {
|
||||
data = {
|
||||
largest: (
|
||||
await new Connection(url, "single").getTokenLargestAccounts(pubkey)
|
||||
).value,
|
||||
};
|
||||
fetchStatus = FetchStatus.Fetched;
|
||||
} catch (error) {
|
||||
fetchStatus = FetchStatus.FetchFailed;
|
||||
}
|
||||
dispatch({
|
||||
type: ActionType.Update,
|
||||
status: fetchStatus,
|
||||
data,
|
||||
key: pubkey.toBase58(),
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
export function useFetchTokenLargestAccounts() {
|
||||
const dispatch = React.useContext(DispatchContext);
|
||||
if (!dispatch) {
|
||||
throw new Error(
|
||||
`useFetchTokenLargestAccounts must be used within a MintsProvider`
|
||||
);
|
||||
}
|
||||
|
||||
const { url } = useCluster();
|
||||
return (pubkey: PublicKey) => {
|
||||
fetchLargestAccounts(dispatch, pubkey, url);
|
||||
};
|
||||
}
|
||||
|
||||
export function useTokenLargestTokens(
|
||||
address: string
|
||||
): Cache.CacheEntry<LargestAccounts> | undefined {
|
||||
const context = React.useContext(StateContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
`useTokenLargestTokens must be used within a MintsProvider`
|
||||
);
|
||||
}
|
||||
|
||||
return context.entries[address];
|
||||
}
|
80
explorer/src/providers/mints/supply.tsx
Normal file
80
explorer/src/providers/mints/supply.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React from "react";
|
||||
import { useCluster } from "providers/cluster";
|
||||
import * as Cache from "providers/cache";
|
||||
import { ActionType, FetchStatus } from "providers/cache";
|
||||
import { TokenAmount, PublicKey, Connection } from "@solana/web3.js";
|
||||
|
||||
type State = Cache.State<TokenAmount>;
|
||||
type Dispatch = Cache.Dispatch<TokenAmount>;
|
||||
|
||||
const StateContext = React.createContext<State | undefined>(undefined);
|
||||
const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
|
||||
|
||||
type ProviderProps = { children: React.ReactNode };
|
||||
export function SupplyProvider({ children }: ProviderProps) {
|
||||
const { url } = useCluster();
|
||||
const [state, dispatch] = Cache.useReducer<TokenAmount>(url);
|
||||
|
||||
// Clear cache whenever cluster is changed
|
||||
React.useEffect(() => {
|
||||
dispatch({ type: ActionType.Clear, url });
|
||||
}, [dispatch, url]);
|
||||
|
||||
return (
|
||||
<StateContext.Provider value={state}>
|
||||
<DispatchContext.Provider value={dispatch}>
|
||||
{children}
|
||||
</DispatchContext.Provider>
|
||||
</StateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchSupply(dispatch: Dispatch, pubkey: PublicKey, url: string) {
|
||||
dispatch({
|
||||
type: ActionType.Update,
|
||||
key: pubkey.toBase58(),
|
||||
status: Cache.FetchStatus.Fetching,
|
||||
url,
|
||||
});
|
||||
|
||||
let data;
|
||||
let fetchStatus;
|
||||
try {
|
||||
data = (await new Connection(url, "single").getTokenSupply(pubkey)).value;
|
||||
|
||||
fetchStatus = FetchStatus.Fetched;
|
||||
} catch (error) {
|
||||
fetchStatus = FetchStatus.FetchFailed;
|
||||
}
|
||||
dispatch({
|
||||
type: ActionType.Update,
|
||||
status: fetchStatus,
|
||||
data,
|
||||
key: pubkey.toBase58(),
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
export function useFetchTokenSupply() {
|
||||
const dispatch = React.useContext(DispatchContext);
|
||||
if (!dispatch) {
|
||||
throw new Error(`useFetchTokenSupply must be used within a MintsProvider`);
|
||||
}
|
||||
|
||||
const { url } = useCluster();
|
||||
return (pubkey: PublicKey) => {
|
||||
fetchSupply(dispatch, pubkey, url);
|
||||
};
|
||||
}
|
||||
|
||||
export function useTokenSupply(
|
||||
address: string
|
||||
): Cache.CacheEntry<TokenAmount> | undefined {
|
||||
const context = React.useContext(StateContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(`useTokenSupply must be used within a MintsProvider`);
|
||||
}
|
||||
|
||||
return context.entries[address];
|
||||
}
|
Reference in New Issue
Block a user