From 39eddfd161d0992f335eae5f7cd326b5db28eec5 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 14 Apr 2021 16:22:40 -0700 Subject: [PATCH] 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 --- explorer/package-lock.json | 100 ++++--- explorer/package.json | 1 + .../account/HistoryCardComponents.tsx | 116 ++++++++ .../components/account/TokenHistoryCard.tsx | 30 +- .../account/TransactionHistoryCard.tsx | 160 ----------- .../account/history/TokenInstructionsCard.tsx | 195 +++++++++++++ .../account/history/TokenTransfersCard.tsx | 272 ++++++++++++++++++ .../history/TransactionHistoryCard.tsx | 110 +++++++ .../src/components/account/history/common.tsx | 33 +++ explorer/src/components/common/Address.tsx | 14 +- .../components/common/InstructionDetails.tsx | 51 ++++ explorer/src/components/common/Signature.tsx | 19 +- .../src/components/instruction/token/types.ts | 6 +- .../transaction/TokenBalancesCard.tsx | 4 +- explorer/src/pages/AccountDetailsPage.tsx | 89 ++++-- explorer/src/providers/accounts/history.tsx | 108 ++++++- explorer/src/scss/_solana.scss | 30 +- explorer/src/utils/date.ts | 2 +- explorer/src/utils/instruction.ts | 175 +++++++++++ 19 files changed, 1217 insertions(+), 298 deletions(-) create mode 100644 explorer/src/components/account/HistoryCardComponents.tsx delete mode 100644 explorer/src/components/account/TransactionHistoryCard.tsx create mode 100644 explorer/src/components/account/history/TokenInstructionsCard.tsx create mode 100644 explorer/src/components/account/history/TokenTransfersCard.tsx create mode 100644 explorer/src/components/account/history/TransactionHistoryCard.tsx create mode 100644 explorer/src/components/account/history/common.tsx create mode 100644 explorer/src/components/common/InstructionDetails.tsx create mode 100644 explorer/src/utils/instruction.ts diff --git a/explorer/package-lock.json b/explorer/package-lock.json index 4f0054dd0..b7b826de6 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 0d6e5e5b5..35f00a45f 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 000000000..db5f5b090 --- /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 1e5768c9e..4a7d8e1ca 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 6c166fa6d..000000000 --- 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

- -
- -
- - - - - {hasTimestamps && } - - - - - {detailsList} -
SlotTimestampResultTransaction Signature
-
- -
- {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 000000000..e8bf1bd2e --- /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" + /> +
+ + + + + {hasTimestamps && } + + + + + + {detailsList} +
Transaction SignatureAgeInstructionProgramResult
+
+ 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 000000000..584628739 --- /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" + /> +
+ + + + + {hasTimestamps && } + + + + + + + {detailsList} +
Transaction SignatureAgeSourceDestinationAmountResult
+
+ 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 000000000..10fa03daf --- /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" + /> +
+ + + + + + {hasTimestamps && } + + + + {detailsList} +
Transaction SignatureSlotAgeResult
+
+ loadMore()} + /> +
+ ); +} diff --git a/explorer/src/components/account/history/common.tsx b/explorer/src/components/account/history/common.tsx new file mode 100644 index 000000000..cfbd1d0ef --- /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 909cb621e..e0aa2d19f 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 000000000..fc8e1498c --- /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 3f35a6763..df4d40953 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 9b0851fd2..3dde2fd3d 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 461d6c5cb..ff1080b1b 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 13cc2d603..2c881d3ad 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 5f5580a17..b68b34db3 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 77ca47f6c..8989decbd 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 555192a69..bc0cb79b0 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 000000000..6ea01cd6a --- /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, + }; +}