Files
solana/explorer/src/components/account/TokenHistoryCard.tsx

623 lines
17 KiB
TypeScript

import React from "react";
import {
PublicKey,
ConfirmedSignatureInfo,
ParsedInstruction,
PartiallyDecodedInstruction,
} from "@solana/web3.js";
import { CacheEntry, FetchStatus } from "providers/cache";
import {
useAccountHistories,
useFetchAccountHistory,
} from "providers/accounts/history";
import {
useAccountOwnedTokens,
TokenInfoWithPubkey,
TOKEN_PROGRAM_ID,
} 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 { Slot } from "components/common/Slot";
import {
Details,
useFetchTransactionDetails,
useTransactionDetailsCache,
} from "providers/transactions/details";
import { create } from "superstruct";
import { ParsedInfo } from "validators";
import {
TokenInstructionType,
IX_TITLES,
} from "components/instruction/token/types";
import { reportError } from "utils/sentry";
import { intoTransactionInstruction, displayAddress } from "utils/tx";
import {
isTokenSwapInstruction,
parseTokenSwapInstructionTitle,
} from "components/instruction/token-swap/types";
import {
isTokenLendingInstruction,
parseTokenLendingInstructionTitle,
} from "components/instruction/token-lending/types";
import {
isSerumInstruction,
parseSerumInstructionTitle,
} from "components/instruction/serum/types";
import { INNER_INSTRUCTIONS_START_SLOT } from "pages/TransactionDetailsPage";
import { useCluster, Cluster } from "providers/cluster";
import { Link } from "react-router-dom";
import { Location } from "history";
import { useQuery } from "utils/url";
import { TokenInfoMap } from "@solana/spl-token-registry";
import { useTokenRegistry } from "providers/mints/token-registry";
const TRUNCATE_TOKEN_LENGTH = 10;
const ALL_TOKENS = "";
type InstructionType = {
name: string;
innerInstructions: (ParsedInstruction | PartiallyDecodedInstruction)[];
};
export function TokenHistoryCard({ pubkey }: { pubkey: PublicKey }) {
const address = pubkey.toBase58();
const ownedTokens = useAccountOwnedTokens(address);
if (ownedTokens === undefined) {
return null;
}
const tokens = ownedTokens.data?.tokens;
if (tokens === undefined || tokens.length === 0) return null;
if (tokens.length > 25) {
return (
<ErrorCard text="Token transaction history is not available for accounts with over 25 token accounts" />
);
}
return <TokenHistoryTable tokens={tokens} />;
}
const useQueryFilter = (): string => {
const query = useQuery();
const filter = query.get("filter");
return filter || "";
};
type FilterProps = {
filter: string;
toggle: () => void;
show: boolean;
tokens: TokenInfoWithPubkey[];
};
function TokenHistoryTable({ tokens }: { tokens: TokenInfoWithPubkey[] }) {
const accountHistories = useAccountHistories();
const fetchAccountHistory = useFetchAccountHistory();
const transactionDetailsCache = useTransactionDetailsCache();
const [showDropdown, setDropdown] = React.useState(false);
const filter = useQueryFilter();
const filteredTokens = React.useMemo(
() =>
tokens.filter((token) => {
if (filter === ALL_TOKENS) {
return true;
}
return token.info.mint.toBase58() === filter;
}),
[tokens, filter]
);
const fetchHistories = React.useCallback(
(refresh?: boolean) => {
filteredTokens.forEach((token) => {
fetchAccountHistory(token.pubkey, refresh);
});
},
[filteredTokens, fetchAccountHistory]
);
// Fetch histories on load
React.useEffect(() => {
filteredTokens.forEach((token) => {
const address = token.pubkey.toBase58();
if (!accountHistories[address]) {
fetchAccountHistory(token.pubkey, true);
}
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const allFoundOldest = filteredTokens.every((token) => {
const history = accountHistories[token.pubkey.toBase58()];
return history?.data?.foundOldest === true;
});
const allFetchedSome = filteredTokens.every((token) => {
const history = accountHistories[token.pubkey.toBase58()];
return history?.data !== undefined;
});
// Find the oldest slot which we know we have the full history for
let oldestSlot: number | undefined = allFoundOldest ? 0 : undefined;
if (!allFoundOldest && allFetchedSome) {
filteredTokens.forEach((token) => {
const history = accountHistories[token.pubkey.toBase58()];
if (history?.data?.foundOldest === false) {
const earliest =
history.data.fetched[history.data.fetched.length - 1].slot;
if (!oldestSlot) oldestSlot = earliest;
oldestSlot = Math.max(oldestSlot, earliest);
}
});
}
const fetching = filteredTokens.some((token) => {
const history = accountHistories[token.pubkey.toBase58()];
return history?.status === FetchStatus.Fetching;
});
const failed = filteredTokens.some((token) => {
const history = accountHistories[token.pubkey.toBase58()];
return history?.status === FetchStatus.FetchFailed;
});
const sigSet = new Set();
const mintAndTxs = filteredTokens
.map((token) => ({
mint: token.info.mint,
history: accountHistories[token.pubkey.toBase58()],
}))
.filter(({ history }) => {
return history?.data?.fetched && history.data.fetched.length > 0;
})
.flatMap(({ mint, history }) =>
(history?.data?.fetched as ConfirmedSignatureInfo[]).map((tx) => ({
mint,
tx,
}))
)
.filter(({ tx }) => {
if (sigSet.has(tx.signature)) return false;
sigSet.add(tx.signature);
return true;
})
.filter(({ tx }) => {
return oldestSlot !== undefined && tx.slot >= oldestSlot;
});
React.useEffect(() => {
if (!fetching && mintAndTxs.length < 1 && !allFoundOldest) {
fetchHistories();
}
}, [fetching, mintAndTxs, allFoundOldest, fetchHistories]);
if (mintAndTxs.length === 0) {
if (fetching) {
return <LoadingCard message="Loading history" />;
} else if (failed) {
return (
<ErrorCard
retry={() => fetchHistories(true)}
text="Failed to fetch transaction history"
/>
);
}
return (
<ErrorCard
retry={() => 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 (
<div className="card">
<div className="card-header align-items-center">
<h3 className="card-header-title">Token History</h3>
<FilterDropdown
filter={filter}
toggle={() => setDropdown((show) => !show)}
show={showDropdown}
tokens={tokens}
></FilterDropdown>
<button
className="btn btn-white btn-sm"
disabled={fetching}
onClick={() => fetchHistories(true)}
>
{fetching ? (
<>
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Loading
</>
) : (
<>
<span className="fe fe-refresh-cw mr-2"></span>
Refresh
</>
)}
</button>
</div>
<div className="table-responsive mb-0">
<table className="table table-sm table-nowrap card-table">
<thead>
<tr>
<th className="text-muted w-1">Slot</th>
<th className="text-muted">Result</th>
<th className="text-muted">Token</th>
<th className="text-muted">Instruction Type</th>
<th className="text-muted">Transaction Signature</th>
</tr>
</thead>
<tbody className="list">
{mintAndTxs.map(({ mint, tx }) => (
<TokenTransactionRow
key={tx.signature}
mint={mint}
tx={tx}
details={transactionDetailsCache[tx.signature]}
/>
))}
</tbody>
</table>
</div>
<div className="card-footer">
{allFoundOldest ? (
<div className="text-muted text-center">Fetched full history</div>
) : (
<button
className="btn btn-primary w-100"
onClick={() => fetchHistories()}
disabled={fetching}
>
{fetching ? (
<>
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Loading
</>
) : (
"Load More"
)}
</button>
)}
</div>
</div>
);
}
const FilterDropdown = ({ filter, toggle, show, tokens }: FilterProps) => {
const { cluster } = useCluster();
const { tokenRegistry } = useTokenRegistry();
const buildLocation = (location: Location, filter: string) => {
const params = new URLSearchParams(location.search);
if (filter === ALL_TOKENS) {
params.delete("filter");
} else {
params.set("filter", filter);
}
return {
...location,
search: params.toString(),
};
};
const filterOptions: string[] = [ALL_TOKENS];
const nameLookup: Map<string, string> = new Map();
tokens.forEach((token) => {
const address = token.info.mint.toBase58();
if (!nameLookup.has(address)) {
filterOptions.push(address);
nameLookup.set(address, formatTokenName(address, cluster, tokenRegistry));
}
});
return (
<div className="dropdown mr-2">
<small className="mr-2">Filter:</small>
<button
className="btn btn-white btn-sm dropdown-toggle"
type="button"
onClick={toggle}
>
{filter === ALL_TOKENS ? "All Tokens" : nameLookup.get(filter)}
</button>
<div
className={`token-filter dropdown-menu-right dropdown-menu${
show ? " show" : ""
}`}
>
{filterOptions.map((filterOption) => {
return (
<Link
key={filterOption}
to={(location: Location) => buildLocation(location, filterOption)}
className={`dropdown-item${
filterOption === filter ? " active" : ""
}`}
onClick={toggle}
>
{filterOption === ALL_TOKENS
? "All Tokens"
: formatTokenName(filterOption, cluster, tokenRegistry)}
</Link>
);
})}
</div>
</div>
);
};
function instructionTypeName(
ix: ParsedInstruction,
tx: ConfirmedSignatureInfo
): string {
try {
const parsed = create(ix.parsed, ParsedInfo);
const { type: rawType } = parsed;
const type = create(rawType, TokenInstructionType);
return IX_TITLES[type];
} catch (err) {
reportError(err, { signature: tx.signature });
return "Unknown";
}
}
const TokenTransactionRow = React.memo(
({
mint,
tx,
details,
}: {
mint: PublicKey;
tx: ConfirmedSignatureInfo;
details: CacheEntry<Details> | undefined;
}) => {
const fetchDetails = useFetchTransactionDetails();
const { cluster } = useCluster();
// Fetch details on load
React.useEffect(() => {
if (!details) fetchDetails(tx.signature);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
let statusText: string;
let statusClass: string;
if (tx.err) {
statusClass = "warning";
statusText = "Failed";
} else {
statusClass = "success";
statusText = "Success";
}
const instructions =
details?.data?.transaction?.transaction.message.instructions;
if (!instructions)
return (
<tr key={tx.signature}>
<td className="w-1">
<Slot slot={tx.slot} link />
</td>
<td>
<span className={`badge badge-soft-${statusClass}`}>
{statusText}
</span>
</td>
<td>
<Address pubkey={mint} link truncate />
</td>
<td>
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Loading
</td>
<td>
<Signature signature={tx.signature} link />
</td>
</tr>
);
let tokenInstructionNames: InstructionType[] = [];
if (details?.data?.transaction) {
const transaction = details.data.transaction;
tokenInstructionNames = instructions
.map((ix, index): InstructionType | undefined => {
let name = "Unknown";
const innerInstructions: (
| ParsedInstruction
| PartiallyDecodedInstruction
)[] = [];
if (
transaction.meta?.innerInstructions &&
(cluster !== Cluster.MainnetBeta ||
transaction.slot >= INNER_INSTRUCTIONS_START_SLOT)
) {
transaction.meta.innerInstructions.forEach((ix) => {
if (ix.index === index) {
ix.instructions.forEach((inner) => {
innerInstructions.push(inner);
});
}
});
}
let transactionInstruction;
if (transaction?.transaction) {
transactionInstruction = intoTransactionInstruction(
transaction.transaction,
ix
);
}
if ("parsed" in ix) {
if (ix.program === "spl-token") {
name = instructionTypeName(ix, tx);
} else {
return undefined;
}
} else if (
transactionInstruction &&
isSerumInstruction(transactionInstruction)
) {
try {
name = parseSerumInstructionTitle(transactionInstruction);
} catch (error) {
reportError(error, { signature: tx.signature });
return undefined;
}
} else if (
transactionInstruction &&
isTokenSwapInstruction(transactionInstruction)
) {
try {
name = parseTokenSwapInstructionTitle(transactionInstruction);
} catch (error) {
reportError(error, { signature: tx.signature });
return undefined;
}
} else if (
transactionInstruction &&
isTokenLendingInstruction(transactionInstruction)
) {
try {
name = parseTokenLendingInstructionTitle(transactionInstruction);
} catch (error) {
reportError(error, { signature: tx.signature });
return undefined;
}
} else {
if (
ix.accounts.findIndex((account) =>
account.equals(TOKEN_PROGRAM_ID)
) >= 0
) {
name = "Unknown (Inner)";
} else {
return undefined;
}
}
return {
name: name,
innerInstructions: innerInstructions,
};
})
.filter((name) => name !== undefined) as InstructionType[];
}
return (
<>
{tokenInstructionNames.map((instructionType, index) => {
return (
<tr key={index}>
<td className="w-1">
<Slot slot={tx.slot} link />
</td>
<td>
<span className={`badge badge-soft-${statusClass}`}>
{statusText}
</span>
</td>
<td className="forced-truncate">
<Address pubkey={mint} link truncateUnknown />
</td>
<td>
<InstructionDetails instructionType={instructionType} tx={tx} />
</td>
<td className="forced-truncate">
<Signature signature={tx.signature} link truncate />
</td>
</tr>
);
})}
</>
);
}
);
function InstructionDetails({
instructionType,
tx,
}: {
instructionType: InstructionType;
tx: ConfirmedSignatureInfo;
}) {
const [expanded, setExpanded] = React.useState(false);
let instructionTypes = instructionType.innerInstructions
.map((ix) => {
if ("parsed" in ix && ix.program === "spl-token") {
return instructionTypeName(ix, tx);
}
return undefined;
})
.filter((type) => type !== undefined);
return (
<>
<p className="tree">
{instructionTypes.length > 0 && (
<span
onClick={(e) => {
e.preventDefault();
setExpanded(!expanded);
}}
className={`c-pointer fe mr-2 ${
expanded ? "fe-minus-square" : "fe-plus-square"
}`}
></span>
)}
{instructionType.name}
</p>
{expanded && (
<ul className="tree">
{instructionTypes.map((type, index) => {
return <li key={index}>{type}</li>;
})}
</ul>
)}
</>
);
}
function formatTokenName(
pubkey: string,
cluster: Cluster,
tokenRegistry: TokenInfoMap
): string {
let display = displayAddress(pubkey, cluster, tokenRegistry);
if (display === pubkey) {
display = display.slice(0, TRUNCATE_TOKEN_LENGTH) + "\u2026";
}
return display;
}