Skip to content

Commit

Permalink
Show token transaction history in explorer (#11473)
Browse files Browse the repository at this point in the history
  • Loading branch information
jstarry authored Aug 8, 2020
1 parent fb82268 commit 9215294
Show file tree
Hide file tree
Showing 7 changed files with 319 additions and 16 deletions.
282 changes: 282 additions & 0 deletions explorer/src/components/account/TokenHistoryCard.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
15 changes: 1 addition & 14 deletions explorer/src/components/instruction/token/TokenDetailsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions explorer/src/components/instruction/token/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
8 changes: 7 additions & 1 deletion explorer/src/pages/AccountDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -125,7 +126,12 @@ function MoreSection({ pubkey, tab }: { pubkey: PublicKey; tab: MoreTabs }) {
</div>
</div>
</div>
{tab === "tokens" && <OwnedTokensCard pubkey={pubkey} />}
{tab === "tokens" && (
<>
<OwnedTokensCard pubkey={pubkey} />
<TokenHistoryCard pubkey={pubkey} />
</>
)}
{tab === "history" && <TransactionHistoryCard pubkey={pubkey} />}
</>
);
Expand Down
13 changes: 13 additions & 0 deletions explorer/src/providers/accounts/history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion explorer/src/providers/accounts/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions explorer/src/providers/accounts/tokens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit 9215294

Please sign in to comment.