diff --git a/explorer/src/components/AccountDetails.tsx b/explorer/src/components/AccountDetails.tsx index 3eeb857d45..ae81c95c11 100644 --- a/explorer/src/components/AccountDetails.tsx +++ b/explorer/src/components/AccountDetails.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Link } from "react-router-dom"; -import { PublicKey, StakeProgram } from "@solana/web3.js"; +import { PublicKey, StakeProgram, TokenAccountInfo } from "@solana/web3.js"; import { FetchStatus, useFetchAccountInfo, @@ -16,6 +16,10 @@ import ErrorCard from "components/common/ErrorCard"; import LoadingCard from "components/common/LoadingCard"; import TableCardBody from "components/common/TableCardBody"; import { useFetchAccountHistory } from "providers/accounts/history"; +import { + useFetchAccountOwnedTokens, + useAccountOwnedTokens, +} from "providers/accounts/tokens"; type Props = { address: string }; export default function AccountDetails({ address }: Props) { @@ -36,6 +40,7 @@ export default function AccountDetails({ address }: Props) { {pubkey && } + {pubkey && } {pubkey && } ); @@ -125,6 +130,112 @@ function UnknownAccountCard({ account }: { account: Account }) { ); } +function TokensCard({ pubkey }: { pubkey: PublicKey }) { + const address = pubkey.toBase58(); + const ownedTokens = useAccountOwnedTokens(address); + const fetchAccountTokens = useFetchAccountOwnedTokens(); + const refresh = () => fetchAccountTokens(pubkey); + + if (ownedTokens === undefined) { + return null; + } + + const { status, tokens } = ownedTokens; + const fetching = status === FetchStatus.Fetching; + if (fetching && (tokens === undefined || tokens.length === 0)) { + return ; + } else if (tokens === undefined) { + return ; + } + + if (tokens.length === 0) { + return ( + + ); + } + + const mappedTokens = new Map(); + for (const token of tokens) { + const mintAddress = token.mint.toBase58(); + const tokenInfo = mappedTokens.get(mintAddress); + if (tokenInfo) { + tokenInfo.amount += token.amount; + } else { + mappedTokens.set(mintAddress, token); + } + } + + const detailsList: React.ReactNode[] = []; + mappedTokens.forEach((tokenInfo, mintAddress) => { + const balance = tokenInfo.amount; + detailsList.push( + + + + {mintAddress} + + + + {balance} + + + ({ + ...location, + pathname: "/account/" + mintAddress, + })} + className="btn btn-rounded-circle btn-white btn-sm" + > + + + + + ); + }); + + return ( +
+
+

Tokens

+ +
+ +
+ + + + + + + + + {detailsList} +
Token AddressBalanceDetails
+
+
+ ); +} + function HistoryCard({ pubkey }: { pubkey: PublicKey }) { const address = pubkey.toBase58(); const info = useAccountInfo(address); @@ -140,7 +251,7 @@ function HistoryCard({ pubkey }: { pubkey: PublicKey }) { history.fetchedRange === undefined ) { if (history.status === FetchStatus.Fetching) { - return ; + return ; } return ( @@ -150,7 +261,7 @@ function HistoryCard({ pubkey }: { pubkey: PublicKey }) { if (history.fetched.length === 0) { if (history.status === FetchStatus.Fetching) { - return ; + return ; } return (
- Loading + {message || "Loading"}
); diff --git a/explorer/src/providers/accounts/index.tsx b/explorer/src/providers/accounts/index.tsx index fc3defc2a1..ecb0778f4f 100644 --- a/explorer/src/providers/accounts/index.tsx +++ b/explorer/src/providers/accounts/index.tsx @@ -3,6 +3,7 @@ import { StakeAccount } from "solana-sdk-wasm"; import { PublicKey, Connection, StakeProgram } from "@solana/web3.js"; import { useCluster, ClusterStatus } from "../cluster"; import { HistoryProvider } from "./history"; +import { TokensProvider } from "./tokens"; export { useAccountHistory } from "./history"; export enum FetchStatus { @@ -137,7 +138,9 @@ export function AccountsProvider({ children }: AccountsProviderProps) { return ( - {children} + + {children} + ); diff --git a/explorer/src/providers/accounts/tokens.tsx b/explorer/src/providers/accounts/tokens.tsx new file mode 100644 index 0000000000..8bd6ff9235 --- /dev/null +++ b/explorer/src/providers/accounts/tokens.tsx @@ -0,0 +1,134 @@ +import React from "react"; +import { Connection, PublicKey, TokenAccountInfo } from "@solana/web3.js"; +import { FetchStatus, useAccounts } from "./index"; +import { useCluster } from "../cluster"; + +interface AccountTokens { + status: FetchStatus; + tokens?: TokenAccountInfo[]; +} + +interface Update { + pubkey: PublicKey; + status: FetchStatus; + tokens?: TokenAccountInfo[]; +} + +type Action = Update | "clear"; +type State = { [address: string]: AccountTokens }; +type Dispatch = (action: Action) => void; + +function reducer(state: State, action: Action): State { + if (action === "clear") { + return {}; + } + + const address = action.pubkey.toBase58(); + let addressEntry = state[address]; + if (addressEntry && action.status === FetchStatus.Fetching) { + addressEntry = { + ...addressEntry, + status: FetchStatus.Fetching, + }; + } else { + addressEntry = { + tokens: action.tokens, + status: action.status, + }; + } + + return { + ...state, + [address]: addressEntry, + }; +} + +const StateContext = React.createContext(undefined); +const DispatchContext = React.createContext(undefined); + +type ProviderProps = { children: React.ReactNode }; +export function TokensProvider({ children }: ProviderProps) { + const [state, dispatch] = React.useReducer(reducer, {}); + const { url } = useCluster(); + const { accounts, lastFetchedAddress } = useAccounts(); + + React.useEffect(() => { + dispatch("clear"); + }, [url]); + + // Fetch history for new accounts + React.useEffect(() => { + if (lastFetchedAddress) { + const infoFetched = + accounts[lastFetchedAddress] && + accounts[lastFetchedAddress].lamports !== undefined; + const noRecord = !state[lastFetchedAddress]; + if (infoFetched && noRecord) { + fetchAccountTokens(dispatch, new PublicKey(lastFetchedAddress), url); + } + } + }, [accounts, lastFetchedAddress]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + + {children} + + + ); +} + +const TOKEN_PROGRAM_ID = new PublicKey( + "TokenSVp5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o" +); + +async function fetchAccountTokens( + dispatch: Dispatch, + pubkey: PublicKey, + url: string +) { + dispatch({ + status: FetchStatus.Fetching, + pubkey, + }); + + let status; + let tokens; + try { + const { value } = await new Connection( + url, + "recent" + ).getTokenAccountsByOwner(pubkey, { programId: TOKEN_PROGRAM_ID }); + tokens = value.map((accountInfo) => accountInfo.account.data); + status = FetchStatus.Fetched; + } catch (error) { + status = FetchStatus.FetchFailed; + } + dispatch({ status, tokens, pubkey }); +} + +export function useAccountOwnedTokens(address: string) { + const context = React.useContext(StateContext); + + if (!context) { + throw new Error( + `useAccountOwnedTokens must be used within a AccountsProvider` + ); + } + + return context[address]; +} + +export function useFetchAccountOwnedTokens() { + const dispatch = React.useContext(DispatchContext); + if (!dispatch) { + throw new Error( + `useFetchAccountOwnedTokens must be used within a AccountsProvider` + ); + } + + const { url } = useCluster(); + return (pubkey: PublicKey) => { + fetchAccountTokens(dispatch, pubkey, url); + }; +}