diff --git a/explorer/package-lock.json b/explorer/package-lock.json
index 4f0054dd03..b7b826de66 100644
--- a/explorer/package-lock.json
+++ b/explorer/package-lock.json
@@ -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": {
diff --git a/explorer/package.json b/explorer/package.json
index 0d6e5e5b53..35f00a45fd 100644
--- a/explorer/package.json
+++ b/explorer/package.json
@@ -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",
diff --git a/explorer/src/components/account/HistoryCardComponents.tsx b/explorer/src/components/account/HistoryCardComponents.tsx
new file mode 100644
index 0000000000..db5f5b0908
--- /dev/null
+++ b/explorer/src/components/account/HistoryCardComponents.tsx
@@ -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 (
+
+
{title}
+
+
+ );
+}
+
+export function HistoryCardFooter({
+ fetching,
+ foundOldest,
+ loadMore,
+}: {
+ fetching: boolean;
+ foundOldest: boolean;
+ loadMore: Function;
+}) {
+ return (
+
+ {foundOldest ? (
+
Fetched full history
+ ) : (
+
+ )}
+
+ );
+}
+
+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;
+}
diff --git a/explorer/src/components/account/TokenHistoryCard.tsx b/explorer/src/components/account/TokenHistoryCard.tsx
index 1e5768c9e6..4a7d8e1cac 100644
--- a/explorer/src/components/account/TokenHistoryCard.tsx
+++ b/explorer/src/components/account/TokenHistoryCard.tsx
@@ -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;
})
diff --git a/explorer/src/components/account/TransactionHistoryCard.tsx b/explorer/src/components/account/TransactionHistoryCard.tsx
deleted file mode 100644
index 6c166fa6db..0000000000
--- a/explorer/src/components/account/TransactionHistoryCard.tsx
+++ /dev/null
@@ -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 ;
- }
-
- return (
-
- );
- }
-
- const transactions = history.data.fetched;
- if (transactions.length === 0) {
- if (history.status === FetchStatus.Fetching) {
- return ;
- }
- return (
-
- );
- }
-
- 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(
-
-
-
- |
-
- {hasTimestamps && (
-
- {blockTime ? displayTimestamp(blockTime * 1000, true) : "---"}
- |
- )}
-
-
-
- {statusText}
-
- |
-
-
-
- |
-
- );
- });
- }
-
- const fetching = history.status === FetchStatus.Fetching;
- return (
-
-
-
Transaction History
-
-
-
-
-
-
-
- Slot |
- {hasTimestamps && Timestamp | }
- Result |
- Transaction Signature |
-
-
- {detailsList}
-
-
-
-
- {history.data.foundOldest ? (
-
Fetched full history
- ) : (
-
- )}
-
-
- );
-}
diff --git a/explorer/src/components/account/history/TokenInstructionsCard.tsx b/explorer/src/components/account/history/TokenInstructionsCard.tsx
new file mode 100644
index 0000000000..e8bf1bd2e6
--- /dev/null
+++ b/explorer/src/components/account/history/TokenInstructionsCard.tsx
@@ -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();
+ const hasTimestamps = transactionRows.some((element) => element.blockTime);
+ const detailsList: React.ReactNode[] = [];
+ const mintMap = new Map();
+
+ 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(
+
+
+
+ |
+
+ {hasTimestamps && (
+
+ {blockTime && }
+ |
+ )}
+
+ {instructionName} |
+
+
+
+ |
+
+
+
+ {statusText}
+
+ |
+
+ );
+ }
+ });
+ }
+ );
+
+ return {
+ hasTimestamps,
+ detailsList,
+ };
+ }, [history, transactionRows, address, pubkey]);
+
+ if (!history) {
+ return null;
+ }
+
+ if (history?.data === undefined) {
+ if (history.status === FetchStatus.Fetching) {
+ return ;
+ }
+
+ return (
+
+ );
+ }
+
+ const fetching = history.status === FetchStatus.Fetching;
+ return (
+
+
refresh()}
+ title="Token Instructions"
+ />
+
+
+
+
+ Transaction Signature |
+ {hasTimestamps && Age | }
+ Instruction |
+ Program |
+ Result |
+
+
+ {detailsList}
+
+
+ loadMore()}
+ />
+
+ );
+}
+
+function isRelevantInstruction(
+ pubkey: PublicKey,
+ address: string,
+ mintMap: Map,
+ 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)
+ );
+ }
+}
diff --git a/explorer/src/components/account/history/TokenTransfersCard.tsx b/explorer/src/components/account/history/TokenTransfersCard.tsx
new file mode 100644
index 0000000000..5846287394
--- /dev/null
+++ b/explorer/src/components/account/history/TokenTransfersCard.tsx
@@ -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();
+ const hasTimestamps = transactionRows.some((element) => element.blockTime);
+ const detailsList: React.ReactNode[] = [];
+ const mintMap = new Map();
+
+ 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(
+
+
+
+ |
+
+ {hasTimestamps && (
+
+ {blockTime && }
+ |
+ )}
+
+
+
+ |
+
+
+
+ |
+
+
+ {amountString} {units}
+ |
+
+
+
+ {statusText}
+
+ |
+
+ );
+ });
+ }
+ );
+
+ return {
+ hasTimestamps,
+ detailsList,
+ };
+ }, [history, transactionRows, mintDetails, pubkey, address, cluster]);
+
+ if (!history) {
+ return null;
+ }
+
+ if (history?.data === undefined) {
+ if (history.status === FetchStatus.Fetching) {
+ return ;
+ }
+
+ return ;
+ }
+
+ const fetching = history.status === FetchStatus.Fetching;
+ return (
+
+
refresh()}
+ title="Token Transfers"
+ />
+
+
+
+
+ Transaction Signature |
+ {hasTimestamps && Age | }
+ Source |
+ Destination |
+ Amount |
+ Result |
+
+
+ {detailsList}
+
+
+ loadMore()}
+ />
+
+ );
+}
+
+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;
+}
diff --git a/explorer/src/components/account/history/TransactionHistoryCard.tsx b/explorer/src/components/account/history/TransactionHistoryCard.tsx
new file mode 100644
index 0000000000..10fa03dafc
--- /dev/null
+++ b/explorer/src/components/account/history/TransactionHistoryCard.tsx
@@ -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 ;
+ }
+
+ return (
+
+ );
+ }
+
+ const hasTimestamps = transactionRows.some((element) => element.blockTime);
+ const detailsList: React.ReactNode[] = transactionRows.map(
+ ({ slot, signature, blockTime, statusClass, statusText }) => {
+ return (
+
+
+
+ |
+
+
+
+ |
+
+ {hasTimestamps && (
+
+ {blockTime ? : "---"}
+ |
+ )}
+
+
+
+ {statusText}
+
+ |
+
+ );
+ }
+ );
+
+ const fetching = history.status === FetchStatus.Fetching;
+ return (
+
+
refresh()}
+ title="Transaction History"
+ />
+
+
+
+
+ Transaction Signature |
+ Slot |
+ {hasTimestamps && Age | }
+ Result |
+
+
+ {detailsList}
+
+
+ loadMore()}
+ />
+
+ );
+}
diff --git a/explorer/src/components/account/history/common.tsx b/explorer/src/components/account/history/common.tsx
new file mode 100644
index 0000000000..cfbd1d0ef7
--- /dev/null
+++ b/explorer/src/components/account/history/common.tsx
@@ -0,0 +1,33 @@
+import { ParsedConfirmedTransaction } from "@solana/web3.js";
+
+export type MintDetails = {
+ decimals: number;
+ mint: string;
+};
+
+export function extractMintDetails(
+ parsedTransaction: ParsedConfirmedTransaction,
+ mintMap: Map
+) {
+ 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,
+ });
+ });
+ }
+}
diff --git a/explorer/src/components/common/Address.tsx b/explorer/src/components/common/Address.tsx
index 909cb621e7..e0aa2d19fa 100644
--- a/explorer/src/components/common/Address.tsx
+++ b/explorer/src/components/common/Address.tsx
@@ -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 = (
@@ -43,11 +53,11 @@ export function Address({
className={truncate ? "text-truncate address-truncate" : ""}
to={clusterPath(`/address/${address}`)}
>
- {raw ? address : displayAddress(address, cluster, tokenRegistry)}
+ {addressLabel}
) : (
- {raw ? address : displayAddress(address, cluster, tokenRegistry)}
+ {addressLabel}
)}
diff --git a/explorer/src/components/common/InstructionDetails.tsx b/explorer/src/components/common/InstructionDetails.tsx
new file mode 100644
index 0000000000..fc8e1498c4
--- /dev/null
+++ b/explorer/src/components/common/InstructionDetails.tsx
@@ -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 (
+ <>
+
+ {instructionTypes.length > 0 && (
+ {
+ e.preventDefault();
+ setExpanded(!expanded);
+ }}
+ className={`c-pointer fe mr-2 ${
+ expanded ? "fe-minus-square" : "fe-plus-square"
+ }`}
+ >
+ )}
+ {instructionType.name}
+
+ {expanded && (
+
+ {instructionTypes.map((type, index) => {
+ return - {type}
;
+ })}
+
+ )}
+ >
+ );
+}
diff --git a/explorer/src/components/common/Signature.tsx b/explorer/src/components/common/Signature.tsx
index 3f35a67632..df4d409531 100644
--- a/explorer/src/components/common/Signature.tsx
+++ b/explorer/src/components/common/Signature.tsx
@@ -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 (
- {signature}
+ {signatureLabel}
) : (
- signature
+ signatureLabel
)}
diff --git a/explorer/src/components/instruction/token/types.ts b/explorer/src/components/instruction/token/types.ts
index 9b0851fd2a..3dde2fd3d3 100644
--- a/explorer/src/components/instruction/token/types.ts
+++ b/explorer/src/components/instruction/token/types.ts
@@ -42,7 +42,8 @@ const InitializeMultisig = type({
m: number(),
});
-const Transfer = type({
+export type Transfer = Infer
;
+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;
+export const TransferChecked = type({
source: PublicKeyFromString,
mint: PublicKeyFromString,
destination: PublicKeyFromString,
diff --git a/explorer/src/components/transaction/TokenBalancesCard.tsx b/explorer/src/components/transaction/TokenBalancesCard.tsx
index 461d6c5cbb..ff1080b1b9 100644
--- a/explorer/src/components/transaction/TokenBalancesCard.tsx
+++ b/explorer/src/components/transaction/TokenBalancesCard.tsx
@@ -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[]
diff --git a/explorer/src/pages/AccountDetailsPage.tsx b/explorer/src/pages/AccountDetailsPage.tsx
index 13cc2d6033..2c881d3ad8 100644
--- a/explorer/src/pages/AccountDetailsPage.tsx
+++ b/explorer/src/pages/AccountDetailsPage.tsx
@@ -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" && }
+ {tab === "transfers" && }
+ {tab === "instructions" && }
{tab === "largest" && }
{tab === "vote-history" && data?.program === "vote" && (
@@ -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 (
diff --git a/explorer/src/providers/accounts/history.tsx b/explorer/src/providers/accounts/history.tsx
index 5f5580a177..b68b34db38 100644
--- a/explorer/src/providers/accounts/history.tsx
+++ b/explorer/src/providers/accounts/history.tsx
@@ -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;
+
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) {
+ 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]
diff --git a/explorer/src/scss/_solana.scss b/explorer/src/scss/_solana.scss
index 77ca47f6cc..8989decbd5 100644
--- a/explorer/src/scss/_solana.scss
+++ b/explorer/src/scss/_solana.scss
@@ -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;
}
diff --git a/explorer/src/utils/date.ts b/explorer/src/utils/date.ts
index 555192a69e..bc0cb79b0b 100644
--- a/explorer/src/utils/date.ts
+++ b/explorer/src/utils/date.ts
@@ -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", {
diff --git a/explorer/src/utils/instruction.ts b/explorer/src/utils/instruction.ts
new file mode 100644
index 0000000000..6ea01cd6af
--- /dev/null
+++ b/explorer/src/utils/instruction.ts
@@ -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,
+ };
+}