From 55b5957d498002c5feb810711f8c308caed31249 Mon Sep 17 00:00:00 2001 From: Justin Starry Date: Thu, 13 Aug 2020 01:31:21 +0800 Subject: [PATCH] Add token supply info to mint account page (#11584) --- .../account/TokenAccountSection.tsx | 54 ++++++-- .../account/TokenLargestAccountsCard.tsx | 96 +++++++++++++ explorer/src/index.tsx | 9 +- explorer/src/pages/AccountDetailsPage.tsx | 128 ++++++++++++------ explorer/src/providers/mints/index.tsx | 12 ++ explorer/src/providers/mints/largest.tsx | 99 ++++++++++++++ explorer/src/providers/mints/supply.tsx | 80 +++++++++++ 7 files changed, 425 insertions(+), 53 deletions(-) create mode 100644 explorer/src/components/account/TokenLargestAccountsCard.tsx create mode 100644 explorer/src/providers/mints/index.tsx create mode 100644 explorer/src/providers/mints/largest.tsx create mode 100644 explorer/src/providers/mints/supply.tsx diff --git a/explorer/src/components/account/TokenAccountSection.tsx b/explorer/src/components/account/TokenAccountSection.tsx index d0550d2880..9dca6aea44 100644 --- a/explorer/src/components/account/TokenAccountSection.tsx +++ b/explorer/src/components/account/TokenAccountSection.tsx @@ -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 = ( + <> + + Loading + + ); + } else { + renderSupply = "Fetch failed"; + } + } else { + renderSupply = supplyTotal; + } + + React.useEffect(() => { + if (!supply) refreshSupply(); + }, [mintAddress]); // eslint-disable-line react-hooks/exhaustive-deps return (
@@ -52,10 +83,7 @@ function MintAccountCard({

Token Mint Account

- @@ -68,16 +96,20 @@ function MintAccountCard({
+ + Total Supply + {renderSupply} + Decimals {info.decimals} - - Status - - {info.isInitialized ? "Initialized" : "Uninitialized"} - - + {!info.isInitialized && ( + + Status + Uninitialized + + )} {info.owner !== undefined && ( Owner diff --git a/explorer/src/components/account/TokenLargestAccountsCard.tsx b/explorer/src/components/account/TokenLargestAccountsCard.tsx new file mode 100644 index 0000000000..bfc12bcd07 --- /dev/null +++ b/explorer/src/components/account/TokenLargestAccountsCard.tsx @@ -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 ; + } + + return ( + + ); + } + + const accounts = largestAccounts.data.largest; + return ( + <> +
+
+
+
+

Largest Accounts

+
+
+
+ +
+ + + + + + + + + + + {accounts.map((account, index) => + renderAccountRow(account, index, supplyTotal) + )} + +
RankAddressBalance% of Total Supply
+
+
+ + ); +} + +const renderAccountRow = ( + account: TokenAccountBalancePair, + index: number, + supply: number +) => { + return ( + + + {index + 1} + + +
+ + {account.uiAmount} + {`${( + (100 * account.uiAmount) / + supply + ).toFixed(3)}%`} + + ); +}; diff --git a/explorer/src/index.tsx b/explorer/src/index.tsx index b786227253..d1716cd4a7 100644 --- a/explorer/src/index.tsx +++ b/explorer/src/index.tsx @@ -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( @@ -18,9 +19,11 @@ ReactDOM.render( - - - + + + + + diff --git a/explorer/src/pages/AccountDetailsPage.tsx b/explorer/src/pages/AccountDetailsPage.tsx index 83ab6416e8..5b05e8ae20 100644 --- a/explorer/src/pages/AccountDetailsPage.tsx +++ b/explorer/src/pages/AccountDetailsPage.tsx @@ -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 (
@@ -37,18 +35,21 @@ export function AccountDetailsPage({ address, tab }: Props) {

Account

- {pubkey && } - {pubkey && } + {!pubkey ? ( + + ) : ( + + )} ); } -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 refresh(pubkey)} text="Fetch Failed" />; + return 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 ; + } else if (tab) { + moreTab = tab as MoreTabs; + } + + return ( + <> + {} + {} + + ); +} + +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 }) {
    -
  • - - History - -
  • -
  • - - Tokens - -
  • + {tabs.map(({ title, path }) => ( +
  • + + {title} + +
  • + ))}
@@ -131,6 +180,7 @@ function MoreSection({ pubkey, tab }: { pubkey: PublicKey; tab: MoreTabs }) { )} {tab === "history" && } + {tab === "holders" && } ); } diff --git a/explorer/src/providers/mints/index.tsx b/explorer/src/providers/mints/index.tsx new file mode 100644 index 0000000000..04d52557ee --- /dev/null +++ b/explorer/src/providers/mints/index.tsx @@ -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 ( + + {children} + + ); +} diff --git a/explorer/src/providers/mints/largest.tsx b/explorer/src/providers/mints/largest.tsx new file mode 100644 index 0000000000..bfacaf0ba0 --- /dev/null +++ b/explorer/src/providers/mints/largest.tsx @@ -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; +type Dispatch = Cache.Dispatch; + +const StateContext = React.createContext(undefined); +const DispatchContext = React.createContext(undefined); + +type ProviderProps = { children: React.ReactNode }; +export function LargestAccountsProvider({ children }: ProviderProps) { + const { url } = useCluster(); + const [state, dispatch] = Cache.useReducer(url); + + // Clear cache whenever cluster is changed + React.useEffect(() => { + dispatch({ type: ActionType.Clear, url }); + }, [dispatch, url]); + + return ( + + + {children} + + + ); +} + +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 | undefined { + const context = React.useContext(StateContext); + + if (!context) { + throw new Error( + `useTokenLargestTokens must be used within a MintsProvider` + ); + } + + return context.entries[address]; +} diff --git a/explorer/src/providers/mints/supply.tsx b/explorer/src/providers/mints/supply.tsx new file mode 100644 index 0000000000..bd51070c50 --- /dev/null +++ b/explorer/src/providers/mints/supply.tsx @@ -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; +type Dispatch = Cache.Dispatch; + +const StateContext = React.createContext(undefined); +const DispatchContext = React.createContext(undefined); + +type ProviderProps = { children: React.ReactNode }; +export function SupplyProvider({ children }: ProviderProps) { + const { url } = useCluster(); + const [state, dispatch] = Cache.useReducer(url); + + // Clear cache whenever cluster is changed + React.useEffect(() => { + dispatch({ type: ActionType.Clear, url }); + }, [dispatch, url]); + + return ( + + + {children} + + + ); +} + +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 | undefined { + const context = React.useContext(StateContext); + + if (!context) { + throw new Error(`useTokenSupply must be used within a MintsProvider`); + } + + return context.entries[address]; +}