Add token supply info to mint account page (#11584)

This commit is contained in:
Justin Starry
2020-08-13 01:31:21 +08:00
committed by GitHub
parent 0a94e7e7fa
commit 55b5957d49
7 changed files with 425 additions and 53 deletions

View File

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

View 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>
);
};

View File

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

View File

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

View 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>
);
}

View 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];
}

View 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];
}