explorer: Token mint histories (token balance and token instructions tabs) (#15861)

* feat: refactor and introduce balances and instructions tabs for mints

* fix: refactor instruction utilities into common file

* refactor: move transaction history components into one spot

* chore: minor cleanup

* fix: show only token instructions

* fix: use better naming for slugs and paths

* feat: refactor and work on transaction status

* feat: show token transfer details

* fix: format code and remove some extra spaces

* fix: exclude non-mint transfers

* feat: introduce react-moment and reorganize history tables

* feat: reintroduce status columns and reorganize columns

* fix: remove ts-ignore

* feat: refactor history card components

* fix: remove detailed history provider

* fix: filter instructions and inner instructions based on mint

* fix: use better key and report parse error

* fix: remove double spacing

* feat: batch transaction map batches

* fix: remove debug code

* fix: pass proper signatureInfo and reduce batch size to 10
This commit is contained in:
Josh 2021-04-14 16:22:40 -07:00 committed by GitHub
parent d92721aab9
commit 39eddfd161
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1217 additions and 298 deletions

View File

@ -2470,16 +2470,23 @@
"superstruct": "^0.8.3",
"tweetnacl": "^1.0.0",
"ws": "^7.0.0"
},
"dependencies": {
"superstruct": {
"version": "0.8.4",
"resolved": "https://registry.npmjs.org/superstruct/-/superstruct-0.8.4.tgz",
"integrity": "sha512-48Ors8IVWZm/tMr8r0Si6+mJiB7mkD7jqvIzktjJ4+EnP5tBp0qOpiM1J8sCUorKx+TXWrfb3i1UcjdD1YK/wA==",
"requires": {
"kind-of": "^6.0.2",
"tiny-invariant": "^1.0.6"
}
}
}
},
"superstruct": {
"version": "0.8.4",
"resolved": "https://registry.npmjs.org/superstruct/-/superstruct-0.8.4.tgz",
"integrity": "sha512-48Ors8IVWZm/tMr8r0Si6+mJiB7mkD7jqvIzktjJ4+EnP5tBp0qOpiM1J8sCUorKx+TXWrfb3i1UcjdD1YK/wA==",
"requires": {
"kind-of": "^6.0.2",
"tiny-invariant": "^1.0.6"
}
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/superstruct/-/superstruct-0.14.2.tgz",
"integrity": "sha512-nPewA6m9mR3d6k7WkZ8N8zpTWfenFH3q9pA2PkuiZxINr9DKB2+40wEQf0ixn8VaGuJ78AB6iWOtStI+/4FKZQ=="
}
}
},
@ -3080,9 +3087,9 @@
"integrity": "sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw=="
},
"@types/connect": {
"version": "3.4.33",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.33.tgz",
"integrity": "sha512-2+FrkXY4zllzTNfJth7jOqEHC+enpLeGslEhpnTAkg21GkRrWV4SsAtqchtT4YS9/nODBU2/ZfsBY2X4J/dX7A==",
"version": "3.4.34",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz",
"integrity": "sha512-ePPA/JuI+X0vb+gSWlPKOY0NdNAie/rPUqX2GUPpbZwiKTkSPhjXWuee47E4MtE54QVzGCQMQkAL6JhV2E1+cQ==",
"requires": {
"@types/node": "*"
}
@ -3102,9 +3109,9 @@
"integrity": "sha512-laIjwTQaD+5DukBZaygQ79K1Z0jb1bPEMRrkXSLjtCcZm+abyp5YbrqpSLzD42FwWW6gK/aS4NYpJ804nG2brg=="
},
"@types/express-serve-static-core": {
"version": "4.17.13",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.13.tgz",
"integrity": "sha512-RgDi5a4nuzam073lRGKTUIaL3eF2+H7LJvJ8eUnCI0wA6SNjXc44DCmWNiTLs/AZ7QlsFWZiw/gTG3nSQGL0fA==",
"version": "4.17.19",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.19.tgz",
"integrity": "sha512-DJOSHzX7pCiSElWaGR8kCprwibCB/3yW6vcT8VG3P0SJjnv19gnWG/AZMfM60Xj/YJIp/YCaDHyvzsFVeniARA==",
"requires": {
"@types/node": "*",
"@types/qs": "*",
@ -3179,9 +3186,9 @@
"integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4="
},
"@types/lodash": {
"version": "4.14.164",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.164.tgz",
"integrity": "sha512-fXCEmONnrtbYUc5014avwBeMdhHHO8YJCkOBflUL9EoJBSKZ1dei+VO74fA7JkTHZ1GvZack2TyIw5U+1lT8jg=="
"version": "4.14.168",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.168.tgz",
"integrity": "sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q=="
},
"@types/minimatch": {
"version": "3.0.3",
@ -3219,9 +3226,9 @@
"integrity": "sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug=="
},
"@types/qs": {
"version": "6.9.5",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.5.tgz",
"integrity": "sha512-/JHkVHtx/REVG0VVToGRGH2+23hsYLHdyG+GrvoUGlGAd0ErauXDyvHtRI/7H7mzLm+tBCKA7pfcpkQ1lf58iQ=="
"version": "6.9.6",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.6.tgz",
"integrity": "sha512-0/HnwIfW4ki2D8L8c9GVcG5I72s9jP5GSLVF0VIXDW00kmIpA6O33G7a8n59Tmh7Nz0WUC3rSb7PTY/sdW2JzA=="
},
"@types/range-parser": {
"version": "1.2.3",
@ -4993,20 +5000,12 @@
"integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk="
},
"bufferutil": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.1.tgz",
"integrity": "sha512-xowrxvpxojqkagPcWRQVXZl0YXhRhAtBEIq3VoER1NH5Mw1n1o0ojdspp+GS2J//2gCVyrzQDApQ4unGF+QOoA==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.3.tgz",
"integrity": "sha512-yEYTwGndELGvfXsImMBLop58eaGW+YdONi1fNjTINSY98tmMmFijBG6WXgdkfuLNt4imzQNtIE+eBp1PVpMCSw==",
"optional": true,
"requires": {
"node-gyp-build": "~3.7.0"
},
"dependencies": {
"node-gyp-build": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-3.7.0.tgz",
"integrity": "sha512-L/Eg02Epx6Si2NXmedx+Okg+4UHqmaf3TNcxd50SF9NQGcJaON3AtU++kax69XV7YWz4tUspqZSAsVofhFKG2w==",
"optional": true
}
"node-gyp-build": "^4.2.0"
}
},
"builtin-modules": {
@ -9792,9 +9791,9 @@
},
"dependencies": {
"@types/node": {
"version": "12.19.16",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.19.16.tgz",
"integrity": "sha512-7xHmXm/QJ7cbK2laF+YYD7gb5MggHIIQwqyjin3bpEGiSuvScMQ5JZZXPvRipi1MwckTQbJZROMns/JxdnIL1Q=="
"version": "12.20.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.7.tgz",
"integrity": "sha512-gWL8VUkg8VRaCAUgG9WmhefMqHmMblxe2rVpMF86nZY/+ZysU+BkAp+3cz03AixWDSSz0ks5WX59yAhv/cDwFA=="
}
}
},
@ -15770,6 +15769,11 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"react-moment": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/react-moment/-/react-moment-1.1.1.tgz",
"integrity": "sha512-WjwvxBSnmLMRcU33do0KixDB+9vP3e84eCse+rd+HNklAMNWyRgZTDEQlay/qK6lcXFPRuEIASJTpEt6pyK7Ww=="
},
"react-refresh": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.8.3.tgz",
@ -16522,9 +16526,9 @@
}
},
"rpc-websockets": {
"version": "7.4.6",
"resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-7.4.6.tgz",
"integrity": "sha512-vDGdyJv858O5ZIc7glov8pQDdFztOqujA7iNyrfPxw87ajHT5s8WQU4MLNEG8pTR/xzqOn06dYH7kef2hijInw==",
"version": "7.4.9",
"resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-7.4.9.tgz",
"integrity": "sha512-5MsJlPDzJkt3eqlUeYHg66A7mxXSSYRE11lKGfNmAXgcMBw4F3a7CLgviwqf6rb850qP3Q1BP8ygp+V+DDq1qQ==",
"requires": {
"@babel/runtime": "^7.11.2",
"assert-args": "^1.2.1",
@ -16537,9 +16541,9 @@
},
"dependencies": {
"uuid": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz",
"integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg=="
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
}
}
},
@ -18618,20 +18622,12 @@
"integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ=="
},
"utf-8-validate": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.2.tgz",
"integrity": "sha512-SwV++i2gTD5qh2XqaPzBnNX88N6HdyhQrNNRykvcS0QKvItV9u3vPEJr+X5Hhfb1JC0r0e1alL0iB09rY8+nmw==",
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.4.tgz",
"integrity": "sha512-MEF05cPSq3AwJ2C7B7sHAA6i53vONoZbMGX8My5auEVm6W+dJ2Jd/TZPyGJ5CH42V2XtbI5FD28HeHeqlPzZ3Q==",
"optional": true,
"requires": {
"node-gyp-build": "~3.7.0"
},
"dependencies": {
"node-gyp-build": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-3.7.0.tgz",
"integrity": "sha512-L/Eg02Epx6Si2NXmedx+Okg+4UHqmaf3TNcxd50SF9NQGcJaON3AtU++kax69XV7YWz4tUspqZSAsVofhFKG2w==",
"optional": true
}
"node-gyp-build": "^4.2.0"
}
},
"util": {

View File

@ -40,6 +40,7 @@
"react-chartjs-2": "^2.11.1",
"react-countup": "^4.3.3",
"react-dom": "^17.0.2",
"react-moment": "^1.1.1",
"react-router-dom": "^5.2.0",
"react-scripts": "^4.0.3",
"react-select": "^4.3.0",

View File

@ -0,0 +1,116 @@
import React from "react";
import { ConfirmedSignatureInfo, TransactionError } from "@solana/web3.js";
export type TransactionRow = {
slot: number;
signature: string;
err: TransactionError | null;
blockTime: number | null | undefined;
statusClass: string;
statusText: string;
signatureInfo: ConfirmedSignatureInfo;
};
export function HistoryCardHeader({
title,
refresh,
fetching,
}: {
title: string;
refresh: Function;
fetching: boolean;
}) {
return (
<div className="card-header align-items-center">
<h3 className="card-header-title">{title}</h3>
<button
className="btn btn-white btn-sm"
disabled={fetching}
onClick={() => refresh()}
>
{fetching ? (
<>
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Loading
</>
) : (
<>
<span className="fe fe-refresh-cw mr-2"></span>
Refresh
</>
)}
</button>
</div>
);
}
export function HistoryCardFooter({
fetching,
foundOldest,
loadMore,
}: {
fetching: boolean;
foundOldest: boolean;
loadMore: Function;
}) {
return (
<div className="card-footer">
{foundOldest ? (
<div className="text-muted text-center">Fetched full history</div>
) : (
<button
className="btn btn-primary w-100"
onClick={() => loadMore()}
disabled={fetching}
>
{fetching ? (
<>
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Loading
</>
) : (
"Load More"
)}
</button>
)}
</div>
);
}
export function getTransactionRows(
transactions: ConfirmedSignatureInfo[]
): TransactionRow[] {
const transactionRows: TransactionRow[] = [];
for (var i = 0; i < transactions.length; i++) {
const slot = transactions[i].slot;
const slotTransactions = [transactions[i]];
while (i + 1 < transactions.length) {
const nextSlot = transactions[i + 1].slot;
if (nextSlot !== slot) break;
slotTransactions.push(transactions[++i]);
}
for (let slotTransaction of slotTransactions) {
let statusText;
let statusClass;
if (slotTransaction.err) {
statusClass = "warning";
statusText = "Failed";
} else {
statusClass = "success";
statusText = "Success";
}
transactionRows.push({
slot,
signature: slotTransaction.signature,
err: slotTransaction.err,
blockTime: slotTransaction.blockTime,
statusClass,
statusText,
signatureInfo: slotTransaction,
});
}
}
return transactionRows;
}

View File

@ -25,12 +25,6 @@ import {
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 {
@ -52,6 +46,7 @@ import { Location } from "history";
import { useQuery } from "utils/url";
import { TokenInfoMap } from "@solana/spl-token-registry";
import { useTokenRegistry } from "providers/mints/token-registry";
import { getTokenProgramInstructionName } from "utils/instruction";
const TRUNCATE_TOKEN_LENGTH = 10;
const ALL_TOKENS = "";
@ -363,21 +358,6 @@ const FilterDropdown = ({ filter, toggle, show, tokens }: FilterProps) => {
);
};
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,
@ -474,7 +454,7 @@ const TokenTransactionRow = React.memo(
if ("parsed" in ix) {
if (ix.program === "spl-token") {
name = instructionTypeName(ix, tx);
name = getTokenProgramInstructionName(ix, tx);
} else {
return undefined;
}
@ -521,8 +501,8 @@ const TokenTransactionRow = React.memo(
}
return {
name: name,
innerInstructions: innerInstructions,
name,
innerInstructions,
};
})
.filter((name) => name !== undefined) as InstructionType[];
@ -574,7 +554,7 @@ function InstructionDetails({
let instructionTypes = instructionType.innerInstructions
.map((ix) => {
if ("parsed" in ix && ix.program === "spl-token") {
return instructionTypeName(ix, tx);
return getTokenProgramInstructionName(ix, tx);
}
return undefined;
})

View File

@ -1,160 +0,0 @@
import React from "react";
import { PublicKey } from "@solana/web3.js";
import { FetchStatus } from "providers/cache";
import { useAccountInfo, useAccountHistory } from "providers/accounts";
import { useFetchAccountHistory } from "providers/accounts/history";
import { Signature } from "components/common/Signature";
import { ErrorCard } from "components/common/ErrorCard";
import { LoadingCard } from "components/common/LoadingCard";
import { Slot } from "components/common/Slot";
import { displayTimestamp } from "utils/date";
export function TransactionHistoryCard({ pubkey }: { pubkey: PublicKey }) {
const address = pubkey.toBase58();
const info = useAccountInfo(address);
const history = useAccountHistory(address);
const fetchAccountHistory = useFetchAccountHistory();
const refresh = () => fetchAccountHistory(pubkey, true);
const loadMore = () => fetchAccountHistory(pubkey);
React.useEffect(() => {
if (!history) refresh();
}, [address]); // eslint-disable-line react-hooks/exhaustive-deps
if (!history || info?.data === undefined) {
return null;
}
if (history?.data === undefined) {
if (history.status === FetchStatus.Fetching) {
return <LoadingCard message="Loading history" />;
}
return (
<ErrorCard retry={refresh} text="Failed to fetch transaction history" />
);
}
const transactions = history.data.fetched;
if (transactions.length === 0) {
if (history.status === FetchStatus.Fetching) {
return <LoadingCard message="Loading history" />;
}
return (
<ErrorCard
retry={loadMore}
retryText="Try again"
text="No transaction history found"
/>
);
}
const hasTimestamps = !!transactions.find((element) => !!element.blockTime);
const detailsList: React.ReactNode[] = [];
for (var i = 0; i < transactions.length; i++) {
const slot = transactions[i].slot;
const slotTransactions = [transactions[i]];
while (i + 1 < transactions.length) {
const nextSlot = transactions[i + 1].slot;
if (nextSlot !== slot) break;
slotTransactions.push(transactions[++i]);
}
slotTransactions.forEach(({ signature, err, blockTime }) => {
let statusText;
let statusClass;
if (err) {
statusClass = "warning";
statusText = "Failed";
} else {
statusClass = "success";
statusText = "Success";
}
detailsList.push(
<tr key={signature}>
<td className="w-1">
<Slot slot={slot} link />
</td>
{hasTimestamps && (
<td className="text-muted">
{blockTime ? displayTimestamp(blockTime * 1000, true) : "---"}
</td>
)}
<td>
<span className={`badge badge-soft-${statusClass}`}>
{statusText}
</span>
</td>
<td>
<Signature signature={signature} link />
</td>
</tr>
);
});
}
const fetching = history.status === FetchStatus.Fetching;
return (
<div className="card">
<div className="card-header align-items-center">
<h3 className="card-header-title">Transaction History</h3>
<button
className="btn btn-white btn-sm"
disabled={fetching}
onClick={refresh}
>
{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>
{hasTimestamps && <th className="text-muted">Timestamp</th>}
<th className="text-muted">Result</th>
<th className="text-muted">Transaction Signature</th>
</tr>
</thead>
<tbody className="list">{detailsList}</tbody>
</table>
</div>
<div className="card-footer">
{history.data.foundOldest ? (
<div className="text-muted text-center">Fetched full history</div>
) : (
<button
className="btn btn-primary w-100"
onClick={loadMore}
disabled={fetching}
>
{fetching ? (
<>
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Loading
</>
) : (
"Load More"
)}
</button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,195 @@
import React from "react";
import {
ParsedConfirmedTransaction,
ParsedInstruction,
PartiallyDecodedInstruction,
PublicKey,
} from "@solana/web3.js";
import { useAccountHistory } from "providers/accounts";
import { Signature } from "components/common/Signature";
import {
getTokenInstructionName,
InstructionContainer,
} from "utils/instruction";
import { Address } from "components/common/Address";
import { LoadingCard } from "components/common/LoadingCard";
import { ErrorCard } from "components/common/ErrorCard";
import { FetchStatus } from "providers/cache";
import { useFetchAccountHistory } from "providers/accounts/history";
import {
getTransactionRows,
HistoryCardFooter,
HistoryCardHeader,
} from "../HistoryCardComponents";
import { extractMintDetails, MintDetails } from "./common";
import Moment from "react-moment";
export function TokenInstructionsCard({ pubkey }: { pubkey: PublicKey }) {
const address = pubkey.toBase58();
const history = useAccountHistory(address);
const fetchAccountHistory = useFetchAccountHistory();
const refresh = () => fetchAccountHistory(pubkey, true, true);
const loadMore = () => fetchAccountHistory(pubkey, true);
const transactionRows = React.useMemo(() => {
if (history?.data?.fetched) {
return getTransactionRows(history.data.fetched);
}
return [];
}, [history]);
React.useEffect(() => {
if (!history || !history.data?.transactionMap?.size) {
refresh();
}
}, [address]); // eslint-disable-line react-hooks/exhaustive-deps
const { hasTimestamps, detailsList } = React.useMemo(() => {
const detailedHistoryMap =
history?.data?.transactionMap ||
new Map<string, ParsedConfirmedTransaction>();
const hasTimestamps = transactionRows.some((element) => element.blockTime);
const detailsList: React.ReactNode[] = [];
const mintMap = new Map<string, MintDetails>();
transactionRows.forEach(
({ signatureInfo, signature, blockTime, statusClass, statusText }) => {
const parsed = detailedHistoryMap.get(signature);
if (!parsed) return;
extractMintDetails(parsed, mintMap);
let instructions: (
| ParsedInstruction
| PartiallyDecodedInstruction
)[] = [];
InstructionContainer.create(parsed).instructions.forEach(
({ instruction, inner }, index) => {
if (isRelevantInstruction(pubkey, address, mintMap, instruction)) {
instructions.push(instruction);
}
instructions.push(
...inner.filter((instruction) =>
isRelevantInstruction(pubkey, address, mintMap, instruction)
)
);
}
);
instructions.forEach((ix, index) => {
const programId = ix.programId;
const instructionName = getTokenInstructionName(
parsed,
ix,
signatureInfo
);
if (instructionName) {
detailsList.push(
<tr key={signature + index}>
<td>
<Signature signature={signature} link truncateChars={48} />
</td>
{hasTimestamps && (
<td className="text-muted">
{blockTime && <Moment date={blockTime * 1000} fromNow />}
</td>
)}
<td>{instructionName}</td>
<td>
<Address
pubkey={programId}
link
truncate
truncateChars={16}
/>
</td>
<td>
<span className={`badge badge-soft-${statusClass}`}>
{statusText}
</span>
</td>
</tr>
);
}
});
}
);
return {
hasTimestamps,
detailsList,
};
}, [history, transactionRows, address, pubkey]);
if (!history) {
return null;
}
if (history?.data === undefined) {
if (history.status === FetchStatus.Fetching) {
return <LoadingCard message="Loading token instructions" />;
}
return (
<ErrorCard retry={refresh} text="Failed to fetch token instructions" />
);
}
const fetching = history.status === FetchStatus.Fetching;
return (
<div className="card">
<HistoryCardHeader
fetching={fetching}
refresh={() => refresh()}
title="Token Instructions"
/>
<div className="table-responsive mb-0">
<table className="table table-sm table-nowrap card-table">
<thead>
<tr>
<th className="text-muted w-1">Transaction Signature</th>
{hasTimestamps && <th className="text-muted">Age</th>}
<th className="text-muted">Instruction</th>
<th className="text-muted">Program</th>
<th className="text-muted">Result</th>
</tr>
</thead>
<tbody className="list">{detailsList}</tbody>
</table>
</div>
<HistoryCardFooter
fetching={fetching}
foundOldest={history.data.foundOldest}
loadMore={() => loadMore()}
/>
</div>
);
}
function isRelevantInstruction(
pubkey: PublicKey,
address: string,
mintMap: Map<string, MintDetails>,
instruction: ParsedInstruction | PartiallyDecodedInstruction
) {
if ("accounts" in instruction) {
return instruction.accounts.some(
(account) =>
account.equals(pubkey) ||
mintMap.get(account.toBase58())?.mint === address
);
} else {
return Object.entries(instruction.parsed.info).some(
([key, value]) =>
value === address ||
(typeof value === "string" && mintMap.get(value)?.mint === address)
);
}
}

View File

@ -0,0 +1,272 @@
import React from "react";
import {
ParsedConfirmedTransaction,
ParsedInstruction,
PartiallyDecodedInstruction,
PublicKey,
} from "@solana/web3.js";
import { useAccountHistory } from "providers/accounts";
import { useTokenRegistry } from "providers/mints/token-registry";
import { create } from "superstruct";
import {
TokenInstructionType,
Transfer,
TransferChecked,
} from "components/instruction/token/types";
import { InstructionContainer } from "utils/instruction";
import { Signature } from "components/common/Signature";
import { Address } from "components/common/Address";
import { normalizeTokenAmount } from "utils";
import {
getTransactionRows,
HistoryCardFooter,
HistoryCardHeader,
} from "../HistoryCardComponents";
import { LoadingCard } from "components/common/LoadingCard";
import { useFetchAccountHistory } from "providers/accounts/history";
import { ErrorCard } from "components/common/ErrorCard";
import { FetchStatus } from "providers/cache";
import Moment from "react-moment";
import { extractMintDetails, MintDetails } from "./common";
import { Cluster, useCluster } from "providers/cluster";
import { reportError } from "utils/sentry";
type IndexedTransfer = {
index: number;
childIndex?: number;
transfer: Transfer | TransferChecked;
};
export function TokenTransfersCard({ pubkey }: { pubkey: PublicKey }) {
const { cluster } = useCluster();
const address = pubkey.toBase58();
const history = useAccountHistory(address);
const fetchAccountHistory = useFetchAccountHistory();
const refresh = () => fetchAccountHistory(pubkey, true, true);
const loadMore = () => fetchAccountHistory(pubkey, true);
const { tokenRegistry } = useTokenRegistry();
const mintDetails = React.useMemo(() => tokenRegistry.get(address), [
address,
tokenRegistry,
]);
const transactionRows = React.useMemo(() => {
if (history?.data?.fetched) {
return getTransactionRows(history.data.fetched);
}
return [];
}, [history]);
React.useEffect(() => {
if (!history || !history.data?.transactionMap?.size) {
refresh();
}
}, [address]); // eslint-disable-line react-hooks/exhaustive-deps
const { hasTimestamps, detailsList } = React.useMemo(() => {
const detailedHistoryMap =
history?.data?.transactionMap ||
new Map<string, ParsedConfirmedTransaction>();
const hasTimestamps = transactionRows.some((element) => element.blockTime);
const detailsList: React.ReactNode[] = [];
const mintMap = new Map<string, MintDetails>();
transactionRows.forEach(
({ signature, blockTime, statusText, statusClass }) => {
const parsed = detailedHistoryMap.get(signature);
if (!parsed) return;
// Extract mint information from token deltas
// (used to filter out non-checked tokens transfers not belonging to this mint)
extractMintDetails(parsed, mintMap);
// Extract all transfers from transaction
let transfers: IndexedTransfer[] = [];
InstructionContainer.create(parsed).instructions.forEach(
({ instruction, inner }, index) => {
const transfer = getTransfer(instruction, cluster, signature);
if (transfer) {
transfers.push({
transfer,
index,
});
}
inner.forEach((instruction, childIndex) => {
const transfer = getTransfer(instruction, cluster, signature);
if (transfer) {
transfers.push({
transfer,
index,
childIndex,
});
}
});
}
);
// Filter out transfers not belonging to this mint
transfers = transfers.filter(({ transfer }) => {
const sourceKey = transfer.source.toBase58();
const destinationKey = transfer.destination.toBase58();
if ("tokenAmount" in transfer && transfer.mint.equals(pubkey)) {
return true;
} else if (
mintMap.has(sourceKey) &&
mintMap.get(sourceKey)?.mint === address
) {
return true;
} else if (
mintMap.has(destinationKey) &&
mintMap.get(destinationKey)?.mint === address
) {
return true;
}
return false;
});
transfers.forEach(({ transfer, index, childIndex }) => {
let units = "Tokens";
let amountString = "";
if (mintDetails?.symbol) {
units = mintDetails.symbol;
}
if ("tokenAmount" in transfer) {
amountString = transfer.tokenAmount.uiAmountString;
} else {
let decimals = 0;
if (mintDetails?.decimals) {
decimals = mintDetails.decimals;
} else if (mintMap.has(transfer.source.toBase58())) {
decimals = mintMap.get(transfer.source.toBase58())?.decimals || 0;
} else if (mintMap.has(transfer.destination.toBase58())) {
decimals =
mintMap.get(transfer.destination.toBase58())?.decimals || 0;
}
amountString = new Intl.NumberFormat("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(normalizeTokenAmount(transfer.amount, decimals));
}
detailsList.push(
<tr key={signature + index + (childIndex || "")}>
<td>
<Signature signature={signature} link truncateChars={24} />
</td>
{hasTimestamps && (
<td className="text-muted">
{blockTime && <Moment date={blockTime * 1000} fromNow />}
</td>
)}
<td>
<Address pubkey={transfer.source} link truncateChars={16} />
</td>
<td>
<Address
pubkey={transfer.destination}
link
truncateChars={16}
/>
</td>
<td>
{amountString} {units}
</td>
<td>
<span className={`badge badge-soft-${statusClass}`}>
{statusText}
</span>
</td>
</tr>
);
});
}
);
return {
hasTimestamps,
detailsList,
};
}, [history, transactionRows, mintDetails, pubkey, address, cluster]);
if (!history) {
return null;
}
if (history?.data === undefined) {
if (history.status === FetchStatus.Fetching) {
return <LoadingCard message="Loading token transfers" />;
}
return <ErrorCard retry={refresh} text="Failed to fetch token transfers" />;
}
const fetching = history.status === FetchStatus.Fetching;
return (
<div className="card">
<HistoryCardHeader
fetching={fetching}
refresh={() => refresh()}
title="Token Transfers"
/>
<div className="table-responsive mb-0">
<table className="table table-sm table-nowrap card-table">
<thead>
<tr>
<th className="text-muted">Transaction Signature</th>
{hasTimestamps && <th className="text-muted">Age</th>}
<th className="text-muted">Source</th>
<th className="text-muted">Destination</th>
<th className="text-muted">Amount</th>
<th className="text-muted">Result</th>
</tr>
</thead>
<tbody className="list">{detailsList}</tbody>
</table>
</div>
<HistoryCardFooter
fetching={fetching}
foundOldest={history.data.foundOldest}
loadMore={() => loadMore()}
/>
</div>
);
}
function getTransfer(
instruction: ParsedInstruction | PartiallyDecodedInstruction,
cluster: Cluster,
signature: string
): Transfer | TransferChecked | undefined {
if ("parsed" in instruction && instruction.program === "spl-token") {
try {
const { type: rawType } = instruction.parsed;
const type = create(rawType, TokenInstructionType);
if (type === "transferChecked") {
return create(instruction.parsed.info, TransferChecked);
} else if (type === "transfer") {
return create(instruction.parsed.info, Transfer);
}
} catch (error) {
if (cluster === Cluster.MainnetBeta) {
reportError(error, {
signature,
});
}
}
}
return undefined;
}

View File

@ -0,0 +1,110 @@
import React from "react";
import { Signature } from "components/common/Signature";
import { Slot } from "components/common/Slot";
import Moment from "react-moment";
import { PublicKey } from "@solana/web3.js";
import {
useAccountHistory,
useFetchAccountHistory,
} from "providers/accounts/history";
import {
getTransactionRows,
HistoryCardFooter,
HistoryCardHeader,
} from "../HistoryCardComponents";
import { FetchStatus } from "providers/cache";
import { LoadingCard } from "components/common/LoadingCard";
import { ErrorCard } from "components/common/ErrorCard";
export function TransactionHistoryCard({ pubkey }: { pubkey: PublicKey }) {
const address = pubkey.toBase58();
const history = useAccountHistory(address);
const fetchAccountHistory = useFetchAccountHistory();
const refresh = () => fetchAccountHistory(pubkey, false, true);
const loadMore = () => fetchAccountHistory(pubkey, false);
const transactionRows = React.useMemo(() => {
if (history?.data?.fetched) {
return getTransactionRows(history.data.fetched);
}
return [];
}, [history]);
React.useEffect(() => {
if (!history) {
refresh();
}
}, [address]); // eslint-disable-line react-hooks/exhaustive-deps
if (!history) {
return null;
}
if (history?.data === undefined) {
if (history.status === FetchStatus.Fetching) {
return <LoadingCard message="Loading history" />;
}
return (
<ErrorCard retry={refresh} text="Failed to fetch transaction history" />
);
}
const hasTimestamps = transactionRows.some((element) => element.blockTime);
const detailsList: React.ReactNode[] = transactionRows.map(
({ slot, signature, blockTime, statusClass, statusText }) => {
return (
<tr key={signature}>
<td>
<Signature signature={signature} link truncate />
</td>
<td className="w-1">
<Slot slot={slot} link />
</td>
{hasTimestamps && (
<td className="text-muted">
{blockTime ? <Moment date={blockTime * 1000} fromNow /> : "---"}
</td>
)}
<td>
<span className={`badge badge-soft-${statusClass}`}>
{statusText}
</span>
</td>
</tr>
);
}
);
const fetching = history.status === FetchStatus.Fetching;
return (
<div className="card">
<HistoryCardHeader
fetching={fetching}
refresh={() => refresh()}
title="Transaction History"
/>
<div className="table-responsive mb-0">
<table className="table table-sm table-nowrap card-table">
<thead>
<tr>
<th className="text-muted w-1">Transaction Signature</th>
<th className="text-muted w-1">Slot</th>
{hasTimestamps && <th className="text-muted w-1">Age</th>}
<th className="text-muted">Result</th>
</tr>
</thead>
<tbody className="list">{detailsList}</tbody>
</table>
</div>
<HistoryCardFooter
fetching={fetching}
foundOldest={history.data.foundOldest}
loadMore={() => loadMore()}
/>
</div>
);
}

View File

@ -0,0 +1,33 @@
import { ParsedConfirmedTransaction } from "@solana/web3.js";
export type MintDetails = {
decimals: number;
mint: string;
};
export function extractMintDetails(
parsedTransaction: ParsedConfirmedTransaction,
mintMap: Map<string, MintDetails>
) {
if (parsedTransaction.meta?.preTokenBalances) {
parsedTransaction.meta.preTokenBalances.forEach((balance) => {
const account =
parsedTransaction.transaction.message.accountKeys[balance.accountIndex];
mintMap.set(account.pubkey.toBase58(), {
decimals: balance.uiTokenAmount.decimals,
mint: balance.mint,
});
});
}
if (parsedTransaction.meta?.postTokenBalances) {
parsedTransaction.meta.postTokenBalances.forEach((balance) => {
const account =
parsedTransaction.transaction.message.accountKeys[balance.accountIndex];
mintMap.set(account.pubkey.toBase58(), {
decimals: balance.uiTokenAmount.decimals,
mint: balance.mint,
});
});
}
}

View File

@ -14,6 +14,7 @@ type Props = {
raw?: boolean;
truncate?: boolean;
truncateUnknown?: boolean;
truncateChars?: number;
};
export function Address({
@ -23,6 +24,7 @@ export function Address({
raw,
truncate,
truncateUnknown,
truncateChars,
}: Props) {
const address = pubkey.toBase58();
const { tokenRegistry } = useTokenRegistry();
@ -35,6 +37,14 @@ export function Address({
truncate = true;
}
let addressLabel = raw
? address
: displayAddress(address, cluster, tokenRegistry);
if (truncateChars && addressLabel === address) {
addressLabel = addressLabel.slice(0, truncateChars) + "…";
}
const content = (
<Copyable text={address} replaceText={!alignRight}>
<span className="text-monospace">
@ -43,11 +53,11 @@ export function Address({
className={truncate ? "text-truncate address-truncate" : ""}
to={clusterPath(`/address/${address}`)}
>
{raw ? address : displayAddress(address, cluster, tokenRegistry)}
{addressLabel}
</Link>
) : (
<span className={truncate ? "text-truncate address-truncate" : ""}>
{raw ? address : displayAddress(address, cluster, tokenRegistry)}
{addressLabel}
</span>
)}
</span>

View File

@ -0,0 +1,51 @@
import React from "react";
import { ConfirmedSignatureInfo } from "@solana/web3.js";
import {
getTokenProgramInstructionName,
InstructionType,
} from "utils/instruction";
export 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 getTokenProgramInstructionName(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>
)}
</>
);
}

View File

@ -9,9 +9,22 @@ type Props = {
alignRight?: boolean;
link?: boolean;
truncate?: boolean;
truncateChars?: number;
};
export function Signature({ signature, alignRight, link, truncate }: Props) {
export function Signature({
signature,
alignRight,
link,
truncate,
truncateChars,
}: Props) {
let signatureLabel = signature;
if (truncateChars) {
signatureLabel = signature.slice(0, truncateChars) + "…";
}
return (
<div
className={`d-flex align-items-center ${
@ -25,10 +38,10 @@ export function Signature({ signature, alignRight, link, truncate }: Props) {
className={truncate ? "text-truncate signature-truncate" : ""}
to={clusterPath(`/tx/${signature}`)}
>
{signature}
{signatureLabel}
</Link>
) : (
signature
signatureLabel
)}
</span>
</Copyable>

View File

@ -42,7 +42,8 @@ const InitializeMultisig = type({
m: number(),
});
const Transfer = type({
export type Transfer = Infer<typeof Transfer>;
export const Transfer = type({
source: PublicKeyFromString,
destination: PublicKeyFromString,
amount: union([string(), number()]),
@ -126,7 +127,8 @@ const ThawAccount = type({
signers: optional(array(PublicKeyFromString)),
});
const TransferChecked = type({
export type TransferChecked = Infer<typeof TransferChecked>;
export const TransferChecked = type({
source: PublicKeyFromString,
mint: PublicKeyFromString,
destination: PublicKeyFromString,

View File

@ -12,7 +12,7 @@ import { SignatureProps } from "pages/TransactionDetailsPage";
import { useTransactionDetails } from "providers/transactions";
import { useTokenRegistry } from "providers/mints/token-registry";
type TokenBalanceRow = {
export type TokenBalanceRow = {
account: PublicKey;
mint: string;
balance: TokenAmount;
@ -92,7 +92,7 @@ export function TokenBalancesCard({ signature }: SignatureProps) {
);
}
function generateTokenBalanceRows(
export function generateTokenBalanceRows(
preTokenBalances: TokenBalance[],
postTokenBalances: TokenBalance[],
accounts: ParsedMessageAccount[]

View File

@ -16,7 +16,6 @@ import { NavLink, Redirect, useLocation } from "react-router-dom";
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";
import { TokenLargestAccountsCard } from "components/account/TokenLargestAccountsCard";
import { VoteAccountSection } from "components/account/VoteAccountSection";
@ -31,34 +30,58 @@ import { useFlaggedAccounts } from "providers/accounts/flagged-accounts";
import { UpgradeableLoaderAccountSection } from "components/account/UpgradeableLoaderAccountSection";
import { useTokenRegistry } from "providers/mints/token-registry";
import { Identicon } from "components/common/Identicon";
import { TransactionHistoryCard } from "components/account/history/TransactionHistoryCard";
import { TokenTransfersCard } from "components/account/history/TokenTransfersCard";
import { TokenInstructionsCard } from "components/account/history/TokenInstructionsCard";
const IDENTICON_WIDTH = 64;
const TABS_LOOKUP: { [id: string]: Tab } = {
"spl-token:mint": {
slug: "largest",
title: "Distribution",
path: "/largest",
},
vote: {
slug: "vote-history",
title: "Vote History",
path: "/vote-history",
},
"sysvar:recentBlockhashes": {
slug: "blockhashes",
title: "Blockhashes",
path: "/blockhashes",
},
"sysvar:slotHashes": {
slug: "slot-hashes",
title: "Slot Hashes",
path: "/slot-hashes",
},
"sysvar:stakeHistory": {
slug: "stake-history",
title: "Stake History",
path: "/stake-history",
},
const TABS_LOOKUP: { [id: string]: Tab[] } = {
"spl-token:mint": [
{
slug: "transfers",
title: "Transfers",
path: "/transfers",
},
{
slug: "instructions",
title: "Instructions",
path: "/instructions",
},
{
slug: "largest",
title: "Distribution",
path: "/largest",
},
],
vote: [
{
slug: "vote-history",
title: "Vote History",
path: "/vote-history",
},
],
"sysvar:recentBlockhashes": [
{
slug: "blockhashes",
title: "Blockhashes",
path: "/blockhashes",
},
],
"sysvar:slotHashes": [
{
slug: "slot-hashes",
title: "Slot Hashes",
path: "/slot-hashes",
},
],
"sysvar:stakeHistory": [
{
slug: "stake-history",
title: "Stake History",
path: "/stake-history",
},
],
};
const TOKEN_TABS_HIDDEN = [
@ -248,14 +271,16 @@ type Tab = {
path: string;
};
type MoreTabs =
export type MoreTabs =
| "history"
| "tokens"
| "largest"
| "vote-history"
| "slot-hashes"
| "stake-history"
| "blockhashes";
| "blockhashes"
| "transfers"
| "instructions";
function MoreSection({
account,
@ -297,6 +322,8 @@ function MoreSection({
</>
)}
{tab === "history" && <TransactionHistoryCard pubkey={pubkey} />}
{tab === "transfers" && <TokenTransfersCard pubkey={pubkey} />}
{tab === "instructions" && <TokenInstructionsCard pubkey={pubkey} />}
{tab === "largest" && <TokenLargestAccountsCard pubkey={pubkey} />}
{tab === "vote-history" && data?.program === "vote" && (
<VotesCard voteAccount={data.parsed} />
@ -335,11 +362,11 @@ function getTabs(data?: ProgramData): Tab[] {
}
if (data && data.program in TABS_LOOKUP) {
tabs.push(TABS_LOOKUP[data.program]);
tabs.push(...TABS_LOOKUP[data.program]);
}
if (data && programTypeKey in TABS_LOOKUP) {
tabs.push(TABS_LOOKUP[programTypeKey]);
tabs.push(...TABS_LOOKUP[programTypeKey]);
}
if (

View File

@ -4,19 +4,26 @@ import {
ConfirmedSignatureInfo,
TransactionSignature,
Connection,
ParsedConfirmedTransaction,
} from "@solana/web3.js";
import { useCluster, Cluster } from "../cluster";
import * as Cache from "providers/cache";
import { ActionType, FetchStatus } from "providers/cache";
import { reportError } from "utils/sentry";
const MAX_TRANSACTION_BATCH_SIZE = 10;
type TransactionMap = Map<string, ParsedConfirmedTransaction>;
type AccountHistory = {
fetched: ConfirmedSignatureInfo[];
transactionMap?: TransactionMap;
foundOldest: boolean;
};
type HistoryUpdate = {
history?: AccountHistory;
transactionMap?: TransactionMap;
before?: TransactionSignature;
};
@ -52,12 +59,19 @@ function reconcile(
update: HistoryUpdate | undefined
) {
if (update?.history === undefined) return history;
let transactionMap = history?.transactionMap || new Map();
if (update.transactionMap) {
transactionMap = new Map([...transactionMap, ...update.transactionMap]);
}
return {
fetched: combineFetched(
update.history.fetched,
history?.fetched,
update?.before
),
transactionMap,
foundOldest: update?.history?.foundOldest || history?.foundOldest || false,
};
}
@ -83,12 +97,42 @@ export function HistoryProvider({ children }: HistoryProviderProps) {
);
}
async function fetchParsedTransactions(
url: string,
transactionSignatures: string[]
) {
const transactionMap = new Map();
const connection = new Connection(url);
while (transactionSignatures.length > 0) {
const signatures = transactionSignatures.splice(
0,
MAX_TRANSACTION_BATCH_SIZE
);
const fetched = await connection.getParsedConfirmedTransactions(signatures);
fetched.forEach(
(parsed: ParsedConfirmedTransaction | null, index: number) => {
if (parsed !== null) {
transactionMap.set(signatures[index], parsed);
}
}
);
}
return transactionMap;
}
async function fetchAccountHistory(
dispatch: Dispatch,
pubkey: PublicKey,
cluster: Cluster,
url: string,
options: { before?: TransactionSignature; limit: number }
options: {
before?: TransactionSignature;
limit: number;
additionalSignatures?: string[];
},
fetchTransactions?: boolean
) {
dispatch({
type: ActionType.Update,
@ -116,6 +160,22 @@ async function fetchAccountHistory(
}
status = FetchStatus.FetchFailed;
}
let transactionMap;
if (fetchTransactions && history?.fetched) {
try {
const signatures = history.fetched
.map((signature) => signature.signature)
.concat(options.additionalSignatures || []);
transactionMap = await fetchParsedTransactions(url, signatures);
} catch (error) {
if (cluster !== Cluster.Custom) {
reportError(error, { url });
}
status = FetchStatus.FetchFailed;
}
}
dispatch({
type: ActionType.Update,
url,
@ -123,6 +183,7 @@ async function fetchAccountHistory(
status,
data: {
history,
transactionMap,
before: options?.before,
},
});
@ -152,6 +213,18 @@ export function useAccountHistory(
return context.entries[address];
}
function getUnfetchedSignatures(before: Cache.CacheEntry<AccountHistory>) {
if (!before.data?.transactionMap) {
return [];
}
const existingMap = before.data.transactionMap;
const allSignatures = before.data.fetched.map(
(signatureInfo) => signatureInfo.signature
);
return allSignatures.filter((signature) => !existingMap.has(signature));
}
export function useFetchAccountHistory() {
const { cluster, url } = useCluster();
const state = React.useContext(StateContext);
@ -163,18 +236,39 @@ export function useFetchAccountHistory() {
}
return React.useCallback(
(pubkey: PublicKey, refresh?: boolean) => {
(pubkey: PublicKey, fetchTransactions?: boolean, refresh?: boolean) => {
const before = state.entries[pubkey.toBase58()];
if (!refresh && before?.data?.fetched && before.data.fetched.length > 0) {
if (before.data.foundOldest) return;
let additionalSignatures: string[] = [];
if (fetchTransactions) {
additionalSignatures = getUnfetchedSignatures(before);
}
const oldest =
before.data.fetched[before.data.fetched.length - 1].signature;
fetchAccountHistory(dispatch, pubkey, cluster, url, {
before: oldest,
limit: 25,
});
fetchAccountHistory(
dispatch,
pubkey,
cluster,
url,
{
before: oldest,
limit: 25,
additionalSignatures,
},
fetchTransactions
);
} else {
fetchAccountHistory(dispatch, pubkey, cluster, url, { limit: 25 });
fetchAccountHistory(
dispatch,
pubkey,
cluster,
url,
{ limit: 25 },
fetchTransactions
);
}
},
[state, dispatch, cluster, url]

View File

@ -3,7 +3,8 @@
// Use this to write your custom SCSS
//
code, pre {
code,
pre {
padding: 0.33rem;
border-radius: $border-radius;
background-color: $gray-200;
@ -21,7 +22,8 @@ ul.log-messages {
max-height: 20rem;
overflow: auto;
font-size: 0.8125rem;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
}
.popover-container {
@ -175,7 +177,7 @@ h4.slot-pill {
}
.search-indicator {
color: hsl(0,0%,60%);
color: hsl(0, 0%, 60%);
display: flex;
padding: 8px 10px;
padding-left: 14px;
@ -184,7 +186,7 @@ h4.slot-pill {
cursor: pointer;
&:hover {
color: hsl(0,0%,40%);
color: hsl(0, 0%, 40%);
}
}
@ -214,7 +216,8 @@ h4.slot-pill {
}
}
.address-truncate, .signature-truncate {
.address-truncate,
.signature-truncate {
@include media-breakpoint-down(md) {
max-width: 180px;
display: inline-block;
@ -239,7 +242,7 @@ p.tree span.c-pointer {
}
ul.tree ul {
margin-left: 1.0em;
margin-left: 1em;
}
ul.tree li {
@ -288,8 +291,8 @@ div.inner-cards {
#chartjs-tooltip {
opacity: 1;
position: absolute;
-webkit-transition: all .1s ease;
transition: all .1s ease;
-webkit-transition: all 0.1s ease;
transition: all 0.1s ease;
pointer-events: none;
-webkit-transform: translate(-50%, -105%);
transform: translate(-50%, -105%);
@ -327,7 +330,7 @@ div.inner-cards {
border-left: 10px solid transparent;
-webkit-transform: translate(-50%, 0);
transform: translate(-50%, 0);
content:'';
content: "";
}
}
@ -341,7 +344,8 @@ div.inner-cards {
border: 1px solid red;
}
pre.data-wrap, pre.json-wrap {
pre.data-wrap,
pre.json-wrap {
max-width: 23rem;
white-space: pre-wrap;
white-space: -moz-pre-wrap;
@ -356,7 +360,7 @@ pre.json-wrap {
.staking-card {
h1 {
margin-bottom: .75rem;
margin-bottom: 0.75rem;
small {
font-size: 1rem;
}
@ -366,12 +370,12 @@ pre.json-wrap {
}
em {
font-style: normal;
color: $primary
color: $primary;
}
}
p.updated-time {
font-size: .66rem;
font-size: 0.66rem;
text-align: right;
}

View File

@ -5,7 +5,7 @@ export function displayTimestamp(
const expireDate = new Date(unixTimestamp);
const dateString = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
month: "short",
day: "numeric",
}).format(expireDate);
const timeString = new Intl.DateTimeFormat("en-US", {

View File

@ -0,0 +1,175 @@
import { create } from "superstruct";
import {
IX_TITLES,
TokenInstructionType,
} from "components/instruction/token/types";
import { ParsedInfo } from "validators";
import { reportError } from "utils/sentry";
import {
ConfirmedSignatureInfo,
ParsedConfirmedTransaction,
ParsedInstruction,
PartiallyDecodedInstruction,
} from "@solana/web3.js";
import { intoTransactionInstruction } 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 { TOKEN_PROGRAM_ID } from "providers/accounts/tokens";
export type InstructionType = {
name: string;
innerInstructions: (ParsedInstruction | PartiallyDecodedInstruction)[];
};
export interface InstructionItem {
instruction: ParsedInstruction | PartiallyDecodedInstruction;
inner: (ParsedInstruction | PartiallyDecodedInstruction)[];
}
export class InstructionContainer {
readonly instructions: InstructionItem[];
static create(parsedTransaction: ParsedConfirmedTransaction) {
return new InstructionContainer(parsedTransaction);
}
constructor(parsedTransaction: ParsedConfirmedTransaction) {
this.instructions = parsedTransaction.transaction.message.instructions.map(
(instruction) => {
if ("parsed" in instruction) {
instruction.parsed = create(instruction.parsed, ParsedInfo);
}
return {
instruction,
inner: [],
};
}
);
if (parsedTransaction.meta?.innerInstructions) {
for (let inner of parsedTransaction.meta.innerInstructions) {
this.instructions[inner.index].inner.push(...inner.instructions);
}
}
}
}
export function getTokenProgramInstructionName(
ix: ParsedInstruction,
signatureInfo: 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: signatureInfo.signature });
return "Unknown";
}
}
export function getTokenInstructionName(
transaction: ParsedConfirmedTransaction,
ix: ParsedInstruction | PartiallyDecodedInstruction,
signatureInfo: ConfirmedSignatureInfo
) {
let name = "Unknown";
let transactionInstruction;
if (transaction?.transaction) {
transactionInstruction = intoTransactionInstruction(
transaction.transaction,
ix
);
}
if ("parsed" in ix) {
if (ix.program === "spl-token") {
name = getTokenProgramInstructionName(ix, signatureInfo);
} else {
return undefined;
}
} else if (
transactionInstruction &&
isSerumInstruction(transactionInstruction)
) {
try {
name = parseSerumInstructionTitle(transactionInstruction);
} catch (error) {
reportError(error, { signature: signatureInfo.signature });
return undefined;
}
} else if (
transactionInstruction &&
isTokenSwapInstruction(transactionInstruction)
) {
try {
name = parseTokenSwapInstructionTitle(transactionInstruction);
} catch (error) {
reportError(error, { signature: signatureInfo.signature });
return undefined;
}
} else if (
transactionInstruction &&
isTokenLendingInstruction(transactionInstruction)
) {
try {
name = parseTokenLendingInstructionTitle(transactionInstruction);
} catch (error) {
reportError(error, { signature: signatureInfo.signature });
return undefined;
}
} else {
if (
ix.accounts.findIndex((account) => account.equals(TOKEN_PROGRAM_ID)) >= 0
) {
name = "Unknown (Inner)";
} else {
return undefined;
}
}
return name;
}
export function getTokenInstructionType(
transaction: ParsedConfirmedTransaction,
ix: ParsedInstruction | PartiallyDecodedInstruction,
signatureInfo: ConfirmedSignatureInfo,
index: number
): InstructionType | undefined {
const innerInstructions: (
| ParsedInstruction
| PartiallyDecodedInstruction
)[] = [];
if (transaction.meta?.innerInstructions) {
transaction.meta.innerInstructions.forEach((ix) => {
if (ix.index === index) {
ix.instructions.forEach((inner) => {
innerInstructions.push(inner);
});
}
});
}
let name =
getTokenInstructionName(transaction, ix, signatureInfo) || "Unknown";
return {
name,
innerInstructions,
};
}