Show token transaction history in explorer (#11473)
This commit is contained in:
282
explorer/src/components/account/TokenHistoryCard.tsx
Normal file
282
explorer/src/components/account/TokenHistoryCard.tsx
Normal file
@ -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 <TokenHistoryTable tokens={tokens} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <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>
|
||||||
|
<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} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-footer">
|
||||||
|
{fetchedFullHistory ? (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<tr key={index}>
|
||||||
|
<td className="w-1">{tx.slot}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<span className={`badge badge-soft-${statusClass}`}>
|
||||||
|
{statusText}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<Address pubkey={mint} link />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>{typeName}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<Signature signature={tx.signature} link />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let statusText;
|
||||||
|
let statusClass;
|
||||||
|
if (tx.err) {
|
||||||
|
statusClass = "warning";
|
||||||
|
statusText = "Failed";
|
||||||
|
} else {
|
||||||
|
statusClass = "success";
|
||||||
|
statusText = "Success";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={tx.signature}>
|
||||||
|
<td className="w-1">{tx.slot}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<span className={`badge badge-soft-${statusClass}`}>{statusText}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<span className="spinner-grow spinner-grow-sm mr-2"></span>
|
||||||
|
Loading
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<Address pubkey={mint} link />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<Signature signature={tx.signature} link />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
@ -10,22 +10,9 @@ import {
|
|||||||
import { UnknownDetailsCard } from "../UnknownDetailsCard";
|
import { UnknownDetailsCard } from "../UnknownDetailsCard";
|
||||||
import { InstructionCard } from "../InstructionCard";
|
import { InstructionCard } from "../InstructionCard";
|
||||||
import { Address } from "components/common/Address";
|
import { Address } from "components/common/Address";
|
||||||
import { IX_STRUCTS, TokenInstructionType } from "./types";
|
import { IX_STRUCTS, TokenInstructionType, IX_TITLES } from "./types";
|
||||||
import { ParsedInfo } from "validators";
|
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 = {
|
type DetailsProps = {
|
||||||
tx: ParsedTransaction;
|
tx: ParsedTransaction;
|
||||||
ix: ParsedInstruction;
|
ix: ParsedInstruction;
|
||||||
|
@ -112,3 +112,16 @@ export const IX_STRUCTS = {
|
|||||||
burn: Burn,
|
burn: Burn,
|
||||||
closeAccount: CloseAccount,
|
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",
|
||||||
|
};
|
||||||
|
@ -15,6 +15,7 @@ import { clusterPath } from "utils/url";
|
|||||||
import { UnknownAccountCard } from "components/account/UnknownAccountCard";
|
import { UnknownAccountCard } from "components/account/UnknownAccountCard";
|
||||||
import { OwnedTokensCard } from "components/account/OwnedTokensCard";
|
import { OwnedTokensCard } from "components/account/OwnedTokensCard";
|
||||||
import { TransactionHistoryCard } from "components/account/TransactionHistoryCard";
|
import { TransactionHistoryCard } from "components/account/TransactionHistoryCard";
|
||||||
|
import { TokenHistoryCard } from "components/account/TokenHistoryCard";
|
||||||
|
|
||||||
type Props = { address: string; tab?: string };
|
type Props = { address: string; tab?: string };
|
||||||
export function AccountDetailsPage({ address, tab }: Props) {
|
export function AccountDetailsPage({ address, tab }: Props) {
|
||||||
@ -125,7 +126,12 @@ function MoreSection({ pubkey, tab }: { pubkey: PublicKey; tab: MoreTabs }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{tab === "tokens" && <OwnedTokensCard pubkey={pubkey} />}
|
{tab === "tokens" && (
|
||||||
|
<>
|
||||||
|
<OwnedTokensCard pubkey={pubkey} />
|
||||||
|
<TokenHistoryCard pubkey={pubkey} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{tab === "history" && <TransactionHistoryCard pubkey={pubkey} />}
|
{tab === "history" && <TransactionHistoryCard pubkey={pubkey} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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) {
|
export function useAccountHistory(address: string) {
|
||||||
const context = React.useContext(StateContext);
|
const context = React.useContext(StateContext);
|
||||||
|
|
||||||
@ -185,6 +197,7 @@ export function useFetchAccountHistory() {
|
|||||||
return (pubkey: PublicKey, refresh?: boolean) => {
|
return (pubkey: PublicKey, refresh?: boolean) => {
|
||||||
const before = state.map[pubkey.toBase58()];
|
const before = state.map[pubkey.toBase58()];
|
||||||
if (!refresh && before && before.fetched && before.fetched.length > 0) {
|
if (!refresh && before && before.fetched && before.fetched.length > 0) {
|
||||||
|
if (before.foundOldest) return;
|
||||||
const oldest = before.fetched[before.fetched.length - 1].signature;
|
const oldest = before.fetched[before.fetched.length - 1].signature;
|
||||||
fetchAccountHistory(dispatch, pubkey, url, { before: oldest, limit: 25 });
|
fetchAccountHistory(dispatch, pubkey, url, { before: oldest, limit: 25 });
|
||||||
} else {
|
} else {
|
||||||
|
@ -241,7 +241,7 @@ export function useAccounts() {
|
|||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error(`useAccounts must be used within a AccountsProvider`);
|
throw new Error(`useAccounts must be used within a AccountsProvider`);
|
||||||
}
|
}
|
||||||
return context;
|
return context.accounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAccountInfo(address: string) {
|
export function useAccountInfo(address: string) {
|
||||||
|
@ -5,6 +5,7 @@ import { useCluster } from "../cluster";
|
|||||||
import { number, string, boolean, coerce, object, nullable } from "superstruct";
|
import { number, string, boolean, coerce, object, nullable } from "superstruct";
|
||||||
|
|
||||||
export type TokenAccountData = {
|
export type TokenAccountData = {
|
||||||
|
pubkey: PublicKey;
|
||||||
mint: PublicKey;
|
mint: PublicKey;
|
||||||
owner: PublicKey;
|
owner: PublicKey;
|
||||||
amount: number;
|
amount: number;
|
||||||
@ -129,6 +130,7 @@ async function fetchAccountTokens(
|
|||||||
const parsedInfo = accountInfo.account.data.parsed.info;
|
const parsedInfo = accountInfo.account.data.parsed.info;
|
||||||
const info = coerce(parsedInfo, TokenAccountInfo);
|
const info = coerce(parsedInfo, TokenAccountInfo);
|
||||||
return {
|
return {
|
||||||
|
pubkey: accountInfo.pubkey,
|
||||||
mint: new PublicKey(info.mint),
|
mint: new PublicKey(info.mint),
|
||||||
owner: new PublicKey(info.owner),
|
owner: new PublicKey(info.owner),
|
||||||
amount: info.amount,
|
amount: info.amount,
|
||||||
|
Reference in New Issue
Block a user