diff --git a/explorer/src/components/account/TokenHistoryCard.tsx b/explorer/src/components/account/TokenHistoryCard.tsx new file mode 100644 index 00000000000000..07069ab087ff7d --- /dev/null +++ b/explorer/src/components/account/TokenHistoryCard.tsx @@ -0,0 +1,282 @@ +import React from "react"; +import { + PublicKey, + ConfirmedSignatureInfo, + ParsedInstruction, +} from "@solana/web3.js"; +import { FetchStatus } from "providers/accounts"; +import { + useAccountHistories, + useFetchAccountHistory, +} from "providers/accounts/history"; +import { + useAccountOwnedTokens, + TokenAccountData, +} 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 { useTransactionDetails } from "providers/transactions"; +import { useFetchTransactionDetails } from "providers/transactions/details"; +import { coerce } from "superstruct"; +import { ParsedInfo } from "validators"; +import { + TokenInstructionType, + IX_TITLES, +} from "components/instruction/token/types"; + +export function TokenHistoryCard({ pubkey }: { pubkey: PublicKey }) { + const address = pubkey.toBase58(); + const ownedTokens = useAccountOwnedTokens(address); + + if (ownedTokens === undefined) { + return null; + } + + const { tokens } = ownedTokens; + if (tokens === undefined || tokens.length === 0) return null; + + return ; +} + +function TokenHistoryTable({ tokens }: { tokens: TokenAccountData[] }) { + const accountHistories = useAccountHistories(); + const fetchAccountHistory = useFetchAccountHistory(); + + const fetchHistories = (refresh?: boolean) => { + tokens.forEach((token) => { + fetchAccountHistory(token.pubkey, refresh); + }); + }; + + // Fetch histories on load + React.useEffect(() => { + tokens.forEach((token) => { + const address = token.pubkey.toBase58(); + if (!accountHistories[address]) { + fetchAccountHistory(token.pubkey, true); + } + }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const fetchedFullHistory = tokens.every((token) => { + const history = accountHistories[token.pubkey.toBase58()]; + return history && history.foundOldest === true; + }); + + const fetching = tokens.some((token) => { + const history = accountHistories[token.pubkey.toBase58()]; + return history && history.status === FetchStatus.Fetching; + }); + + const failed = tokens.some((token) => { + const history = accountHistories[token.pubkey.toBase58()]; + return history && history.status === FetchStatus.FetchFailed; + }); + + const mintAndTxs = tokens + .map((token) => ({ + mint: token.mint, + history: accountHistories[token.pubkey.toBase58()], + })) + .filter(({ history }) => { + return ( + history !== undefined && history.fetched && history.fetched.length > 0 + ); + }) + .flatMap(({ mint, history }) => + (history.fetched as ConfirmedSignatureInfo[]).map((tx) => ({ mint, tx })) + ); + + if (mintAndTxs.length === 0) { + if (fetching) { + return ; + } else if (failed) { + return ( + fetchHistories(true)} + text="Failed to fetch transaction history" + /> + ); + } + return ( + 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 ( +
+
+

Token History

+ +
+ +
+ + + + + + + + + + + + {mintAndTxs.map(({ mint, tx }) => ( + + ))} + +
SlotResultTokenInstruction TypeTransaction Signature
+
+ +
+ {fetchedFullHistory ? ( +
Fetched full history
+ ) : ( + + )} +
+
+ ); +} + +function TokenTransactionRow({ + mint, + tx, +}: { + mint: PublicKey; + tx: ConfirmedSignatureInfo; +}) { + const details = useTransactionDetails(tx.signature); + const fetchDetails = useFetchTransactionDetails(); + + // Fetch details on load + React.useEffect(() => { + if (!details) fetchDetails(tx.signature); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const instructions = details?.transaction?.transaction.message.instructions; + if (instructions) { + const tokenInstructions = instructions.filter( + (ix) => "parsed" in ix && ix.program === "spl-token" + ) as ParsedInstruction[]; + if (tokenInstructions.length > 0) { + return ( + <> + {tokenInstructions.map((ix, index) => { + const parsed = coerce(ix.parsed, ParsedInfo); + const { type: rawType } = parsed; + const type = coerce(rawType, TokenInstructionType); + const typeName = IX_TITLES[type]; + + let statusText; + let statusClass; + if (tx.err) { + statusClass = "warning"; + statusText = "Failed"; + } else { + statusClass = "success"; + statusText = "Success"; + } + + return ( + + {tx.slot} + + + + {statusText} + + + + +
+ + + {typeName} + + + + + + ); + })} + + ); + } + } + + let statusText; + let statusClass; + if (tx.err) { + statusClass = "warning"; + statusText = "Failed"; + } else { + statusClass = "success"; + statusText = "Success"; + } + + return ( + + {tx.slot} + + + {statusText} + + + + + Loading + + + +
+ + + + + + + ); +} diff --git a/explorer/src/components/instruction/token/TokenDetailsCard.tsx b/explorer/src/components/instruction/token/TokenDetailsCard.tsx index 5e20853b31c5b1..a21dbcd1f617bb 100644 --- a/explorer/src/components/instruction/token/TokenDetailsCard.tsx +++ b/explorer/src/components/instruction/token/TokenDetailsCard.tsx @@ -10,22 +10,9 @@ import { import { UnknownDetailsCard } from "../UnknownDetailsCard"; import { InstructionCard } from "../InstructionCard"; import { Address } from "components/common/Address"; -import { IX_STRUCTS, TokenInstructionType } from "./types"; +import { IX_STRUCTS, TokenInstructionType, IX_TITLES } from "./types"; import { ParsedInfo } from "validators"; -const IX_TITLES = { - initializeMint: "Initialize Mint", - initializeAccount: "Initialize Account", - initializeMultisig: "Initialize Multisig", - transfer: "Transfer", - approve: "Approve", - revoke: "Revoke", - setOwner: "Set Owner", - mintTo: "Mint To", - burn: "Burn", - closeAccount: "Close Account", -}; - type DetailsProps = { tx: ParsedTransaction; ix: ParsedInstruction; diff --git a/explorer/src/components/instruction/token/types.ts b/explorer/src/components/instruction/token/types.ts index b7bcb473113a96..2c81ba184fb51c 100644 --- a/explorer/src/components/instruction/token/types.ts +++ b/explorer/src/components/instruction/token/types.ts @@ -112,3 +112,16 @@ export const IX_STRUCTS = { burn: Burn, closeAccount: CloseAccount, }; + +export const IX_TITLES = { + initializeMint: "Initialize Mint", + initializeAccount: "Initialize Account", + initializeMultisig: "Initialize Multisig", + transfer: "Transfer", + approve: "Approve", + revoke: "Revoke", + setOwner: "Set Owner", + mintTo: "Mint To", + burn: "Burn", + closeAccount: "Close Account", +}; diff --git a/explorer/src/pages/AccountDetailsPage.tsx b/explorer/src/pages/AccountDetailsPage.tsx index 72cb56232f33cf..7cfeb7be1f9349 100644 --- a/explorer/src/pages/AccountDetailsPage.tsx +++ b/explorer/src/pages/AccountDetailsPage.tsx @@ -15,6 +15,7 @@ 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"; type Props = { address: string; tab?: string }; export function AccountDetailsPage({ address, tab }: Props) { @@ -125,7 +126,12 @@ function MoreSection({ pubkey, tab }: { pubkey: PublicKey; tab: MoreTabs }) { - {tab === "tokens" && } + {tab === "tokens" && ( + <> + + + + )} {tab === "history" && } ); diff --git a/explorer/src/providers/accounts/history.tsx b/explorer/src/providers/accounts/history.tsx index 3c9cd355c088d0..be46a9cce6a2b7 100644 --- a/explorer/src/providers/accounts/history.tsx +++ b/explorer/src/providers/accounts/history.tsx @@ -162,6 +162,18 @@ async function fetchAccountHistory( }); } +export function useAccountHistories() { + const context = React.useContext(StateContext); + + if (!context) { + throw new Error( + `useAccountHistories must be used within a AccountsProvider` + ); + } + + return context.map; +} + export function useAccountHistory(address: string) { const context = React.useContext(StateContext); @@ -185,6 +197,7 @@ export function useFetchAccountHistory() { return (pubkey: PublicKey, refresh?: boolean) => { const before = state.map[pubkey.toBase58()]; if (!refresh && before && before.fetched && before.fetched.length > 0) { + if (before.foundOldest) return; const oldest = before.fetched[before.fetched.length - 1].signature; fetchAccountHistory(dispatch, pubkey, url, { before: oldest, limit: 25 }); } else { diff --git a/explorer/src/providers/accounts/index.tsx b/explorer/src/providers/accounts/index.tsx index 8ef21c862fed89..82f3f951990255 100644 --- a/explorer/src/providers/accounts/index.tsx +++ b/explorer/src/providers/accounts/index.tsx @@ -241,7 +241,7 @@ export function useAccounts() { if (!context) { throw new Error(`useAccounts must be used within a AccountsProvider`); } - return context; + return context.accounts; } export function useAccountInfo(address: string) { diff --git a/explorer/src/providers/accounts/tokens.tsx b/explorer/src/providers/accounts/tokens.tsx index 7b4d28120cac73..62794b51eb6ec2 100644 --- a/explorer/src/providers/accounts/tokens.tsx +++ b/explorer/src/providers/accounts/tokens.tsx @@ -5,6 +5,7 @@ import { useCluster } from "../cluster"; import { number, string, boolean, coerce, object, nullable } from "superstruct"; export type TokenAccountData = { + pubkey: PublicKey; mint: PublicKey; owner: PublicKey; amount: number; @@ -129,6 +130,7 @@ async function fetchAccountTokens( const parsedInfo = accountInfo.account.data.parsed.info; const info = coerce(parsedInfo, TokenAccountInfo); return { + pubkey: accountInfo.pubkey, mint: new PublicKey(info.mint), owner: new PublicKey(info.owner), amount: info.amount,