From 559ee5a843a013832cb504d1b3a3e07cd9d7f6dd Mon Sep 17 00:00:00 2001 From: Noah Gundotra Date: Wed, 6 Apr 2022 10:22:49 -0700 Subject: [PATCH] Explorer: Add Anchor Decoding to Programs/Accounts/Transactions (#23972) * Add program idl to the Program page * Add instruction decoding to the Tx page * Add account decoding to the Account page --- explorer/package-lock.json | 165 +++++++++++++++- explorer/package.json | 2 +- .../src/components/ProgramLogsCardBody.tsx | 15 +- .../components/account/AnchorAccountCard.tsx | 157 +++++++++++++++ .../components/account/AnchorProgramCard.tsx | 36 ++++ .../instruction/AnchorDetailsCard.tsx | 101 ++++++++++ .../instruction/GenericAnchorDetails.tsx | 167 ---------------- .../components/instruction/anchor/types.ts | 16 -- .../transaction/InstructionsSection.tsx | 106 +++++----- .../transaction/ProgramLogSection.tsx | 3 +- explorer/src/pages/AccountDetailsPage.tsx | 187 +++++++++++++++--- explorer/src/pages/TransactionDetailsPage.tsx | 6 +- .../src/pages/inspector/SimulatorCard.tsx | 9 +- explorer/src/providers/accounts/index.tsx | 9 + explorer/src/providers/anchor.tsx | 47 +++++ explorer/src/utils/anchor.tsx | 109 ++++++++++ 16 files changed, 862 insertions(+), 273 deletions(-) create mode 100644 explorer/src/components/account/AnchorAccountCard.tsx create mode 100644 explorer/src/components/account/AnchorProgramCard.tsx create mode 100644 explorer/src/components/instruction/AnchorDetailsCard.tsx delete mode 100644 explorer/src/components/instruction/GenericAnchorDetails.tsx delete mode 100644 explorer/src/components/instruction/anchor/types.ts create mode 100644 explorer/src/providers/anchor.tsx create mode 100644 explorer/src/utils/anchor.tsx diff --git a/explorer/package-lock.json b/explorer/package-lock.json index b95350336c..86a536421e 100644 --- a/explorer/package-lock.json +++ b/explorer/package-lock.json @@ -14,7 +14,7 @@ "@cloudflare/stream-react": "^1.2.0", "@metamask/jazzicon": "^2.0.0", "@metaplex/js": "4.12.0", - "@project-serum/anchor": "^0.22.1", + "@project-serum/anchor": "0.23.0", "@project-serum/serum": "^0.13.61", "@react-hook/debounce": "^4.0.0", "@sentry/react": "^6.16.1", @@ -4490,12 +4490,12 @@ } }, "node_modules/@project-serum/anchor": { - "version": "0.22.1", - "resolved": "https://registry.npmjs.org/@project-serum/anchor/-/anchor-0.22.1.tgz", - "integrity": "sha512-5pHeyvQhzLahIQ8aZymmDMZJAJFklN0joZdI+YIqFkK2uU/mlKr6rBLQjxysf/j1mLLiNG00tdyLfUtTAdQz7w==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@project-serum/anchor/-/anchor-0.23.0.tgz", + "integrity": "sha512-LV2/ifZOJVFTZ4GbEloXln3iVfCvO1YM8i7BBCrUm4tehP7irMx4nr4/IabHWOzrQcQElsxSP/lb1tBp+2ff8A==", "dependencies": { "@project-serum/borsh": "^0.2.5", - "@solana/web3.js": "^1.17.0", + "@solana/web3.js": "^1.36.0", "base64-js": "^1.5.1", "bn.js": "^5.1.2", "bs58": "^4.0.1", @@ -4514,11 +4514,95 @@ "node": ">=11" } }, + "node_modules/@project-serum/anchor/node_modules/@babel/runtime": { + "version": "7.17.8", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.8.tgz", + "integrity": "sha512-dQpEpK0O9o6lj6oPu0gRDbbnk+4LeHlNcBpspf6Olzt3GIX4P1lWF1gS+pHLDFlaJvbR6q7jCfQ08zA4QJBnmA==", + "dependencies": { + "regenerator-runtime": "^0.13.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@project-serum/anchor/node_modules/@solana/buffer-layout": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@solana/buffer-layout/-/buffer-layout-4.0.0.tgz", + "integrity": "sha512-lR0EMP2HC3+Mxwd4YcnZb0smnaDw7Bl2IQWZiTevRH5ZZBZn6VRWn3/92E3qdU4SSImJkA6IDHawOHAnx/qUvQ==", + "dependencies": { + "buffer": "~6.0.3" + }, + "engines": { + "node": ">=5.10" + } + }, + "node_modules/@project-serum/anchor/node_modules/@solana/buffer-layout/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@project-serum/anchor/node_modules/@solana/web3.js": { + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.37.0.tgz", + "integrity": "sha512-O2iCcgkGdi2FXwVLztPIZHcBuZXdhbVLavMsG+RdEyFGzFD0tQN1rOJ+Xb5eaexjqtgcqRN+Fyg3wAhLcHJbiA==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@ethersproject/sha2": "^5.5.0", + "@solana/buffer-layout": "^4.0.0", + "bn.js": "^5.0.0", + "borsh": "^0.7.0", + "bs58": "^4.0.1", + "buffer": "6.0.1", + "cross-fetch": "^3.1.4", + "jayson": "^3.4.4", + "js-sha3": "^0.8.0", + "rpc-websockets": "^7.4.2", + "secp256k1": "^4.0.2", + "superstruct": "^0.14.2", + "tweetnacl": "^1.0.0" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@project-serum/anchor/node_modules/borsh": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/borsh/-/borsh-0.7.0.tgz", + "integrity": "sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==", + "dependencies": { + "bn.js": "^5.2.0", + "bs58": "^4.0.0", + "text-encoding-utf-8": "^1.0.2" + } + }, "node_modules/@project-serum/anchor/node_modules/pako": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pako/-/pako-2.0.3.tgz", "integrity": "sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==" }, + "node_modules/@project-serum/anchor/node_modules/superstruct": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-0.14.2.tgz", + "integrity": "sha512-nPewA6m9mR3d6k7WkZ8N8zpTWfenFH3q9pA2PkuiZxINr9DKB2+40wEQf0ixn8VaGuJ78AB6iWOtStI+/4FKZQ==" + }, "node_modules/@project-serum/borsh": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/@project-serum/borsh/-/borsh-0.2.5.tgz", @@ -30637,12 +30721,12 @@ "peer": true }, "@project-serum/anchor": { - "version": "0.22.1", - "resolved": "https://registry.npmjs.org/@project-serum/anchor/-/anchor-0.22.1.tgz", - "integrity": "sha512-5pHeyvQhzLahIQ8aZymmDMZJAJFklN0joZdI+YIqFkK2uU/mlKr6rBLQjxysf/j1mLLiNG00tdyLfUtTAdQz7w==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@project-serum/anchor/-/anchor-0.23.0.tgz", + "integrity": "sha512-LV2/ifZOJVFTZ4GbEloXln3iVfCvO1YM8i7BBCrUm4tehP7irMx4nr4/IabHWOzrQcQElsxSP/lb1tBp+2ff8A==", "requires": { "@project-serum/borsh": "^0.2.5", - "@solana/web3.js": "^1.17.0", + "@solana/web3.js": "^1.36.0", "base64-js": "^1.5.1", "bn.js": "^5.1.2", "bs58": "^4.0.1", @@ -30658,10 +30742,73 @@ "toml": "^3.0.0" }, "dependencies": { + "@babel/runtime": { + "version": "7.17.8", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.8.tgz", + "integrity": "sha512-dQpEpK0O9o6lj6oPu0gRDbbnk+4LeHlNcBpspf6Olzt3GIX4P1lWF1gS+pHLDFlaJvbR6q7jCfQ08zA4QJBnmA==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "@solana/buffer-layout": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@solana/buffer-layout/-/buffer-layout-4.0.0.tgz", + "integrity": "sha512-lR0EMP2HC3+Mxwd4YcnZb0smnaDw7Bl2IQWZiTevRH5ZZBZn6VRWn3/92E3qdU4SSImJkA6IDHawOHAnx/qUvQ==", + "requires": { + "buffer": "~6.0.3" + }, + "dependencies": { + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + } + } + }, + "@solana/web3.js": { + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.37.0.tgz", + "integrity": "sha512-O2iCcgkGdi2FXwVLztPIZHcBuZXdhbVLavMsG+RdEyFGzFD0tQN1rOJ+Xb5eaexjqtgcqRN+Fyg3wAhLcHJbiA==", + "requires": { + "@babel/runtime": "^7.12.5", + "@ethersproject/sha2": "^5.5.0", + "@solana/buffer-layout": "^4.0.0", + "bn.js": "^5.0.0", + "borsh": "^0.7.0", + "bs58": "^4.0.1", + "buffer": "6.0.1", + "cross-fetch": "^3.1.4", + "jayson": "^3.4.4", + "js-sha3": "^0.8.0", + "rpc-websockets": "^7.4.2", + "secp256k1": "^4.0.2", + "superstruct": "^0.14.2", + "tweetnacl": "^1.0.0" + } + }, + "borsh": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/borsh/-/borsh-0.7.0.tgz", + "integrity": "sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==", + "requires": { + "bn.js": "^5.2.0", + "bs58": "^4.0.0", + "text-encoding-utf-8": "^1.0.2" + } + }, "pako": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pako/-/pako-2.0.3.tgz", "integrity": "sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==" + }, + "superstruct": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-0.14.2.tgz", + "integrity": "sha512-nPewA6m9mR3d6k7WkZ8N8zpTWfenFH3q9pA2PkuiZxINr9DKB2+40wEQf0ixn8VaGuJ78AB6iWOtStI+/4FKZQ==" } } }, diff --git a/explorer/package.json b/explorer/package.json index 0dece6f4a3..6ef1eee9bc 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -9,7 +9,7 @@ "@cloudflare/stream-react": "^1.2.0", "@metamask/jazzicon": "^2.0.0", "@metaplex/js": "4.12.0", - "@project-serum/anchor": "^0.22.1", + "@project-serum/anchor": "0.23.0", "@project-serum/serum": "^0.13.61", "@react-hook/debounce": "^4.0.0", "@sentry/react": "^6.16.1", diff --git a/explorer/src/components/ProgramLogsCardBody.tsx b/explorer/src/components/ProgramLogsCardBody.tsx index 79012a6dc4..69653ea8cf 100644 --- a/explorer/src/components/ProgramLogsCardBody.tsx +++ b/explorer/src/components/ProgramLogsCardBody.tsx @@ -1,18 +1,19 @@ -import React from "react"; import { Message, ParsedMessage } from "@solana/web3.js"; import { Cluster } from "providers/cluster"; import { TableCardBody } from "components/common/TableCardBody"; -import { programLabel } from "utils/tx"; import { InstructionLogs } from "utils/program-logs"; +import { ProgramName } from "utils/anchor"; export function ProgramLogsCardBody({ message, logs, cluster, + url, }: { message: Message | ParsedMessage; logs: InstructionLogs[]; cluster: Cluster; + url: string; }) { return ( @@ -28,9 +29,6 @@ export function ProgramLogsCardBody({ } else { programId = ix.programId; } - - const programName = - programLabel(programId.toBase58(), cluster) || "Unknown Program"; const programLogs: InstructionLogs | undefined = logs[index]; let badgeColor = "white"; @@ -45,7 +43,12 @@ export function ProgramLogsCardBody({ #{index + 1} - {programName} Instruction + {" "} + Instruction {programLogs && (
diff --git a/explorer/src/components/account/AnchorAccountCard.tsx b/explorer/src/components/account/AnchorAccountCard.tsx new file mode 100644 index 0000000000..74db697096 --- /dev/null +++ b/explorer/src/components/account/AnchorAccountCard.tsx @@ -0,0 +1,157 @@ +import React, { useMemo } from "react"; + +import { Account } from "providers/accounts"; +import { Address } from "components/common/Address"; +import { BorshAccountsCoder } from "@project-serum/anchor"; +import { capitalizeFirstLetter } from "utils/anchor"; +import { ErrorCard } from "components/common/ErrorCard"; +import { PublicKey } from "@solana/web3.js"; +import BN from "bn.js"; + +import ReactJson from "react-json-view"; +import { useCluster } from "providers/cluster"; +import { useAnchorProgram } from "providers/anchor"; + +export function AnchorAccountCard({ account }: { account: Account }) { + const { url } = useCluster(); + const program = useAnchorProgram( + account.details?.owner.toString() ?? "", + url + ); + + const { foundAccountLayoutName, decodedAnchorAccountData } = useMemo(() => { + let foundAccountLayoutName: string | undefined; + let decodedAnchorAccountData: { [key: string]: any } | undefined; + if (program && account.details && account.details.rawData) { + const accountBuffer = account.details.rawData; + const discriminator = accountBuffer.slice(0, 8); + + // Iterate all the structs, see if any of the name-hashes match + Object.keys(program.account).forEach((accountType) => { + const layoutName = capitalizeFirstLetter(accountType); + const discriminatorToCheck = + BorshAccountsCoder.accountDiscriminator(layoutName); + + if (discriminatorToCheck.equals(discriminator)) { + foundAccountLayoutName = layoutName; + const accountDecoder = program.account[accountType]; + decodedAnchorAccountData = accountDecoder.coder.accounts.decode( + layoutName, + accountBuffer + ); + } + }); + } + return { foundAccountLayoutName, decodedAnchorAccountData }; + }, [program, account.details]); + + if (!foundAccountLayoutName || !decodedAnchorAccountData) { + return ( + + ); + } + + return ( + <> +
+
+
+
+

{foundAccountLayoutName}

+
+
+
+ +
+ + + + + + + + + {decodedAnchorAccountData && + Object.keys(decodedAnchorAccountData).map((key) => ( + + ))} + +
KeyValue
+
+
+
+ {decodedAnchorAccountData && + Object.keys(decodedAnchorAccountData).length > 0 + ? `Decoded ${Object.keys(decodedAnchorAccountData).length} Items` + : "No decoded data"} +
+
+
+ + ); +} + +function AccountRow({ valueName, value }: { valueName: string; value: any }) { + let displayValue: JSX.Element; + if (value instanceof PublicKey) { + displayValue =
; + } else if (value instanceof BN) { + displayValue = <>{value.toString()}; + } else if (!(value instanceof Object)) { + displayValue = <>{String(value)}; + } else if (value) { + const displayObject = stringifyPubkeyAndBigNums(value); + displayValue = ( + + ); + } else { + displayValue = <>null; + } + return ( + + {camelToUnderscore(valueName)} + {displayValue} + + ); +} + +function camelToUnderscore(key: string) { + var result = key.replace(/([A-Z])/g, " $1"); + return result.split(" ").join("_").toLowerCase(); +} + +function stringifyPubkeyAndBigNums(object: Object): Object { + if (!Array.isArray(object)) { + if (object instanceof PublicKey) { + return object.toString(); + } else if (object instanceof BN) { + return object.toString(); + } else if (!(object instanceof Object)) { + return object; + } else { + const parsedObject: { [key: string]: Object } = {}; + Object.keys(object).map((key) => { + let value = (object as { [key: string]: any })[key]; + if (value instanceof Object) { + value = stringifyPubkeyAndBigNums(value); + } + parsedObject[key] = value; + return null; + }); + return parsedObject; + } + } + return object.map((innerObject) => + innerObject instanceof Object + ? stringifyPubkeyAndBigNums(innerObject) + : innerObject + ); +} diff --git a/explorer/src/components/account/AnchorProgramCard.tsx b/explorer/src/components/account/AnchorProgramCard.tsx new file mode 100644 index 0000000000..3f047b1885 --- /dev/null +++ b/explorer/src/components/account/AnchorProgramCard.tsx @@ -0,0 +1,36 @@ +import { PublicKey } from "@solana/web3.js"; +import { useAnchorProgram } from "providers/anchor"; +import { useCluster } from "providers/cluster"; +import ReactJson from "react-json-view"; + +export function AnchorProgramCard({ programId }: { programId: PublicKey }) { + const { url } = useCluster(); + const program = useAnchorProgram(programId.toString(), url); + + if (!program) { + return null; + } + + return ( + <> +
+
+
+
+

Anchor IDL

+
+
+
+ +
+ +
+
+ + ); +} diff --git a/explorer/src/components/instruction/AnchorDetailsCard.tsx b/explorer/src/components/instruction/AnchorDetailsCard.tsx new file mode 100644 index 0000000000..596f0fefc6 --- /dev/null +++ b/explorer/src/components/instruction/AnchorDetailsCard.tsx @@ -0,0 +1,101 @@ +import { SignatureResult, TransactionInstruction } from "@solana/web3.js"; +import { InstructionCard } from "./InstructionCard"; +import { Idl, Program, BorshInstructionCoder } from "@project-serum/anchor"; +import { + getAnchorNameForInstruction, + getProgramName, + capitalizeFirstLetter, + getAnchorAccountsFromInstruction, +} from "utils/anchor"; +import { HexData } from "components/common/HexData"; +import { Address } from "components/common/Address"; +import ReactJson from "react-json-view"; + +export default function AnchorDetailsCard(props: { + key: string; + ix: TransactionInstruction; + index: number; + result: SignatureResult; + signature: string; + innerCards?: JSX.Element[]; + childIndex?: number; + anchorProgram: Program; +}) { + const { ix, anchorProgram } = props; + const programName = getProgramName(anchorProgram) ?? "Unknown Program"; + + const ixName = + getAnchorNameForInstruction(ix, anchorProgram) ?? "Unknown Instruction"; + const cardTitle = `${programName}: ${ixName}`; + + return ( + + + + ); +} + +function RawAnchorDetails({ + ix, + anchorProgram, +}: { + ix: TransactionInstruction; + anchorProgram: Program; +}) { + let ixAccounts: + | { + name: string; + isMut: boolean; + isSigner: boolean; + pda?: Object; + }[] + | null = null; + var decodedIxData = null; + if (anchorProgram) { + const decoder = new BorshInstructionCoder(anchorProgram.idl); + decodedIxData = decoder.decode(ix.data); + ixAccounts = getAnchorAccountsFromInstruction(decodedIxData, anchorProgram); + } + + return ( + <> + {ix.keys.map(({ pubkey, isSigner, isWritable }, keyIndex) => { + return ( + + +
+ {ixAccounts && keyIndex < ixAccounts.length + ? `${capitalizeFirstLetter(ixAccounts[keyIndex].name)}` + : `Account #${keyIndex + 1}`} +
+ {isWritable && ( + Writable + )} + {isSigner && ( + Signer + )} + + +
+ + + ); + })} + + + + Instruction Data (Hex) + + {decodedIxData ? ( + + + + ) : ( + + + + )} + + + ); +} diff --git a/explorer/src/components/instruction/GenericAnchorDetails.tsx b/explorer/src/components/instruction/GenericAnchorDetails.tsx deleted file mode 100644 index c5f383f0e5..0000000000 --- a/explorer/src/components/instruction/GenericAnchorDetails.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { - Connection, - SignatureResult, - TransactionInstruction, -} from "@solana/web3.js"; -import { InstructionCard } from "./InstructionCard"; -import { - BorshInstructionCoder, - Idl, - Program, - Provider, -} from "@project-serum/anchor"; -import React, { useEffect, useState } from "react"; -import { useCluster } from "../../providers/cluster"; -import { Address } from "../common/Address"; -import { snakeCase } from "snake-case"; - -export function GenericAnchorDetailsCard(props: { - ix: TransactionInstruction; - index: number; - result: SignatureResult; - signature: string; - innerCards?: JSX.Element[]; - childIndex?: number; -}) { - const { ix, index, result, innerCards, childIndex } = props; - - const cluster = useCluster(); - - const [idl, setIdl] = useState(); - useEffect(() => { - async function fetchIdl() { - if (idl) { - return; - } - - // fetch on chain idl - const idl_: Idl | null = await Program.fetchIdl(ix.programId, { - connection: new Connection(cluster.url), - } as Provider); - setIdl(idl_); - } - - fetchIdl(); - }, [ix.programId, cluster.url, idl]); - - const [programName, setProgramName] = useState(null); - const [ixTitle, setIxTitle] = useState(null); - const [ixAccounts, setIxAccounts] = useState< - { name: string; isMut: boolean; isSigner: boolean; pda?: Object }[] | null - >(null); - - useEffect(() => { - async function parseIxDetailsUsingCoder() { - if (!idl || (programName && ixTitle && ixAccounts)) { - return; - } - - // e.g. voter_stake_registry -> voter stake registry - var _programName = idl.name.replaceAll("_", " ").trim(); - // e.g. voter stake registry -> Voter Stake Registry - _programName = _programName - .toLowerCase() - .split(" ") - .map((word) => word.charAt(0).toUpperCase() + word.substring(1)) - .join(" "); - setProgramName(_programName); - - const coder = new BorshInstructionCoder(idl); - const decodedIx = coder.decode(ix.data); - if (!decodedIx) { - return; - } - - // get ix title, pascal case it - var _ixTitle = decodedIx.name; - _ixTitle = _ixTitle.charAt(0).toUpperCase() + _ixTitle.slice(1); - setIxTitle(_ixTitle); - - // get ix accounts - const idlInstructions = idl.instructions.filter( - (ix) => ix.name === decodedIx.name - ); - if (idlInstructions.length === 0) { - return; - } - setIxAccounts( - idlInstructions[0].accounts as { - // type coercing since anchor doesn't export the underlying type - name: string; - isMut: boolean; - isSigner: boolean; - pda?: Object; - }[] - ); - } - - parseIxDetailsUsingCoder(); - }, [ - ix.programId, - ix.keys, - ix.data, - idl, - cluster, - programName, - ixTitle, - ixAccounts, - ]); - - return ( -
- {idl && ( - - - Program - -
- - - - {ixAccounts != null && - ix.keys.map((am, keyIndex) => ( - - -
- {/* remaining accounts would not have a name */} - {ixAccounts[keyIndex] && - snakeCase(ixAccounts[keyIndex].name)} - {!ixAccounts[keyIndex] && - "remaining account #" + - (keyIndex - ixAccounts.length + 1)} -
- {am.isWritable && ( - Writable - )} - {am.isSigner && ( - Signer - )} - - -
- - - ))} - - )} - {!idl && ( - - )} -
- ); -} diff --git a/explorer/src/components/instruction/anchor/types.ts b/explorer/src/components/instruction/anchor/types.ts deleted file mode 100644 index f1a39b2869..0000000000 --- a/explorer/src/components/instruction/anchor/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TransactionInstruction } from "@solana/web3.js"; - -// list of programs written in anchor -// - should have idl on-chain for GenericAnchorDetailsCard to work out of the box -// - before adding another program to this list, please make sure that the ix -// are decoding without any errors -const knownAnchorPrograms = [ - // https://github.com/blockworks-foundation/voter-stake-registry - "4Q6WW2ouZ6V3iaNm56MTd5n2tnTm4C5fiH8miFHnAFHo", -]; - -export const isInstructionFromAnAnchorProgram = ( - instruction: TransactionInstruction -) => { - return knownAnchorPrograms.includes(instruction.programId.toBase58()); -}; diff --git a/explorer/src/components/transaction/InstructionsSection.tsx b/explorer/src/components/transaction/InstructionsSection.tsx index 34577835b9..fee0a66794 100644 --- a/explorer/src/components/transaction/InstructionsSection.tsx +++ b/explorer/src/components/transaction/InstructionsSection.tsx @@ -5,7 +5,6 @@ import { ParsedInstruction, ParsedTransaction, PartiallyDecodedInstruction, - PublicKey, SignatureResult, TransactionSignature, } from "@solana/web3.js"; @@ -42,9 +41,11 @@ import { AssociatedTokenDetailsCard } from "components/instruction/AssociatedTok import { MangoDetailsCard } from "components/instruction/MangoDetails"; import { isPythInstruction } from "components/instruction/pyth/types"; import { PythDetailsCard } from "components/instruction/pyth/PythDetailsCard"; -import { isInstructionFromAnAnchorProgram } from "../instruction/anchor/types"; -import { GenericAnchorDetailsCard } from "../instruction/GenericAnchorDetails"; +import AnchorDetailsCard from "../instruction/AnchorDetailsCard"; import { isMangoInstruction } from "../instruction/mango/types"; +import { useAnchorProgram } from "providers/anchor"; +import { LoadingCard } from "components/common/LoadingCard"; +import { ErrorBoundary } from "@sentry/react"; export type InstructionDetailsProps = { tx: ParsedTransaction; @@ -58,14 +59,16 @@ export type InstructionDetailsProps = { export function InstructionsSection({ signature }: SignatureProps) { const status = useTransactionStatus(signature); const details = useTransactionDetails(signature); - const { cluster } = useCluster(); + const { cluster, url } = useCluster(); const fetchDetails = useFetchTransactionDetails(); const refreshDetails = () => fetchDetails(signature); - if (!status?.data?.info || !details?.data?.transaction) return null; - - const { transaction } = details.data.transaction; + const result = status?.data?.info?.result; + if (!result || !details?.data?.transaction) { + return ; + } const { meta } = details.data.transaction; + const { transaction } = details.data?.transaction; if (transaction.message.instructions.length === 0) { return ; @@ -91,58 +94,60 @@ export function InstructionsSection({ signature }: SignatureProps) { }); } - const result = status.data.info.result; - const instructionDetails = transaction.message.instructions.map( - (instruction, index) => { - let innerCards: JSX.Element[] = []; - - if (index in innerInstructions) { - innerInstructions[index].forEach((ix, childIndex) => { - if (typeof ix.programId === "string") { - ix.programId = new PublicKey(ix.programId); - } - - let res = renderInstructionCard({ - index, - ix, - result, - signature, - tx: transaction, - childIndex, - }); - - innerCards.push(res); - }); - } - - return renderInstructionCard({ - index, - ix: instruction, - result, - signature, - tx: transaction, - innerCards, - }); - } - ); - return ( <>

- {instructionDetails.length > 1 ? "Instructions" : "Instruction"} + {transaction.message.instructions.length > 1 + ? "Instructions" + : "Instruction"}

- {instructionDetails} + }> + {transaction.message.instructions.map((instruction, index) => { + let innerCards: JSX.Element[] = []; + + if (index in innerInstructions) { + innerInstructions[index].forEach((ix, childIndex) => { + let res = ( + + ); + innerCards.push(res); + }); + } + + return ( + + ); + })} + ); } -function renderInstructionCard({ +function InstructionCard({ ix, tx, result, @@ -150,6 +155,7 @@ function renderInstructionCard({ signature, innerCards, childIndex, + url, }: { ix: ParsedInstruction | PartiallyDecodedInstruction; tx: ParsedTransaction; @@ -158,8 +164,10 @@ function renderInstructionCard({ signature: TransactionSignature; innerCards?: JSX.Element[]; childIndex?: number; + url: string; }) { const key = `${index}-${childIndex}`; + const anchorProgram = useAnchorProgram(ix.programId.toString(), url); if ("parsed" in ix) { const props = { @@ -216,8 +224,6 @@ function renderInstructionCard({ if (isBonfidaBotInstruction(transactionIx)) { return ; - } else if (isInstructionFromAnAnchorProgram(transactionIx)) { - return ; } else if (isMangoInstruction(transactionIx)) { return ; } else if (isSerumInstruction(transactionIx)) { @@ -230,6 +236,12 @@ function renderInstructionCard({ return ; } else if (isPythInstruction(transactionIx)) { return ; + } else if (anchorProgram) { + return ( + }> + + + ); } else { return ; } diff --git a/explorer/src/components/transaction/ProgramLogSection.tsx b/explorer/src/components/transaction/ProgramLogSection.tsx index ae429a9ec3..4be0f3f30e 100644 --- a/explorer/src/components/transaction/ProgramLogSection.tsx +++ b/explorer/src/components/transaction/ProgramLogSection.tsx @@ -6,7 +6,7 @@ import { prettyProgramLogs } from "utils/program-logs"; import { useCluster } from "providers/cluster"; export function ProgramLogSection({ signature }: SignatureProps) { - const { cluster } = useCluster(); + const { cluster, url } = useCluster(); const details = useTransactionDetails(signature); const transaction = details?.data?.transaction; @@ -32,6 +32,7 @@ export function ProgramLogSection({ signature }: SignatureProps) { message={message} logs={prettyLogs} cluster={cluster} + url={url} /> ) : (
diff --git a/explorer/src/pages/AccountDetailsPage.tsx b/explorer/src/pages/AccountDetailsPage.tsx index fb84739e3b..6301abc368 100644 --- a/explorer/src/pages/AccountDetailsPage.tsx +++ b/explorer/src/pages/AccountDetailsPage.tsx @@ -5,7 +5,6 @@ import { useFetchAccountInfo, useAccountInfo, Account, - ProgramData, TokenProgramData, useMintAccountInfo, } from "providers/accounts"; @@ -41,6 +40,9 @@ import { NFTHeader } from "components/account/MetaplexNFTHeader"; import { DomainsCard } from "components/account/DomainsCard"; import isMetaplexNFT from "providers/accounts/utils/isMetaplexNFT"; import { SecurityCard } from "components/account/SecurityCard"; +import { AnchorAccountCard } from "components/account/AnchorAccountCard"; +import { AnchorProgramCard } from "components/account/AnchorProgramCard"; +import { useAnchorProgram } from "providers/anchor"; const IDENTICON_WIDTH = 64; @@ -246,11 +248,16 @@ function DetailsSections({ } const account = info.data; - const data = account?.details?.data; - const tabs = getTabs(data); + const tabComponents = getTabs(pubkey, account).concat( + getAnchorTabs(pubkey, account) + ); let moreTab: MoreTabs = "history"; - if (tab && tabs.filter(({ slug }) => slug === tab).length === 0) { + if ( + tab && + tabComponents.filter((tabComponent) => tabComponent.tab.slug === tab) + .length === 0 + ) { return ; } else if (tab) { moreTab = tab as MoreTabs; @@ -265,7 +272,11 @@ function DetailsSections({
)} {} - {} + component)} + /> ); } @@ -315,6 +326,11 @@ type Tab = { path: string; }; +type TabComponent = { + tab: Tab; + component: JSX.Element | null; +}; + export type MoreTabs = | "history" | "tokens" @@ -328,7 +344,9 @@ export type MoreTabs = | "rewards" | "metadata" | "domains" - | "security"; + | "security" + | "anchor-program" + | "anchor-account"; function MoreSection({ account, @@ -337,29 +355,17 @@ function MoreSection({ }: { account: Account; tab: MoreTabs; - tabs: Tab[]; + tabs: (JSX.Element | null)[]; }) { const pubkey = account.pubkey; - const address = account.pubkey.toBase58(); const data = account?.details?.data; + return ( <>
-
    - {tabs.map(({ title, slug, path }) => ( -
  • - - {title} - -
  • - ))} -
+
    {tabs}
@@ -401,11 +407,29 @@ function MoreSection({ {tab === "security" && data?.program === "bpf-upgradeable-loader" && ( )} + {tab === "anchor-program" && ( + } + > + + + )} + {tab === "anchor-account" && ( + + } + > + + + )} ); } -function getTabs(data?: ProgramData): Tab[] { +function getTabs(pubkey: PublicKey, account: Account): TabComponent[] { + const address = pubkey.toBase58(); + const data = account.details?.data; const tabs: Tab[] = [ { slug: "history", @@ -455,5 +479,122 @@ function getTabs(data?: ProgramData): Tab[] { }); } - return tabs; + return tabs.map((tab) => { + return { + tab, + component: ( +
  • + + {tab.title} + +
  • + ), + }; + }); +} + +function getAnchorTabs(pubkey: PublicKey, account: Account) { + const tabComponents = []; + const anchorProgramTab: Tab = { + slug: "anchor-program", + title: "Anchor Program IDL", + path: "/anchor-program", + }; + tabComponents.push({ + tab: anchorProgramTab, + component: ( + }> + + + ), + }); + + const anchorAccountTab: Tab = { + slug: "anchor-account", + title: "Anchor Account", + path: "/anchor-account", + }; + tabComponents.push({ + tab: anchorAccountTab, + component: ( + }> + + + ), + }); + + return tabComponents; +} + +function AnchorProgramLink({ + tab, + address, + pubkey, +}: { + tab: Tab; + address: string; + pubkey: PublicKey; +}) { + const { url } = useCluster(); + const anchorProgram = useAnchorProgram(pubkey.toString() ?? "", url); + + if (!anchorProgram) { + return null; + } + + return ( +
  • + + {tab.title} + +
  • + ); +} + +function AnchorAccountLink({ + address, + tab, + programId, +}: { + address: string; + tab: Tab; + programId: PublicKey | undefined; +}) { + const { url } = useCluster(); + const accountAnchorProgram = useAnchorProgram( + programId?.toString() ?? "", + url + ); + + if (!accountAnchorProgram) { + return null; + } + + return ( +
  • + + {tab.title} + +
  • + ); } diff --git a/explorer/src/pages/TransactionDetailsPage.tsx b/explorer/src/pages/TransactionDetailsPage.tsx index c1dc9652fc..bdd173bd6a 100644 --- a/explorer/src/pages/TransactionDetailsPage.tsx +++ b/explorer/src/pages/TransactionDetailsPage.tsx @@ -105,7 +105,11 @@ export function TransactionDetailsPage({ signature: raw }: SignatureProps) { ) : ( - + } + > + + )}
    diff --git a/explorer/src/pages/inspector/SimulatorCard.tsx b/explorer/src/pages/inspector/SimulatorCard.tsx index fd549efb77..c0d43cc6ed 100644 --- a/explorer/src/pages/inspector/SimulatorCard.tsx +++ b/explorer/src/pages/inspector/SimulatorCard.tsx @@ -8,7 +8,7 @@ import { ProgramLogsCardBody } from "components/ProgramLogsCardBody"; const DEFAULT_SIGNATURE = bs58.encode(Buffer.alloc(64).fill(0)); export function SimulatorCard({ message }: { message: Message }) { - const { cluster } = useCluster(); + const { cluster, url } = useCluster(); const { simulate, simulating, @@ -67,7 +67,12 @@ export function SimulatorCard({ message }: { message: Message }) { Retry - + ); } diff --git a/explorer/src/providers/accounts/index.tsx b/explorer/src/providers/accounts/index.tsx index 4c9ab7724d..da1060cd19 100644 --- a/explorer/src/providers/accounts/index.tsx +++ b/explorer/src/providers/accounts/index.tsx @@ -90,6 +90,7 @@ export interface Details { owner: PublicKey; space: number; data?: ProgramData; + rawData?: Buffer; } export interface Account { @@ -284,11 +285,19 @@ async function fetchAccountInfo( } } + // If we cannot parse account layout as native spl account + // then keep raw data for other components to decode + let rawData: Buffer | undefined; + if (!data && !("parsed" in result.data)) { + rawData = result.data; + } + details = { space, executable: result.executable, owner: result.owner, data, + rawData, }; } data = { pubkey, lamports, details }; diff --git a/explorer/src/providers/anchor.tsx b/explorer/src/providers/anchor.tsx new file mode 100644 index 0000000000..17c32a6bc7 --- /dev/null +++ b/explorer/src/providers/anchor.tsx @@ -0,0 +1,47 @@ +import { Idl, Program, Provider } from "@project-serum/anchor"; +import { Connection, Keypair } from "@solana/web3.js"; +import { NodeWallet } from "@metaplex/js"; + +const cachedAnchorProgramPromises: Record< + string, + | void + | { __type: "promise"; promise: Promise } + | { __type: "result"; result: Program | null } +> = {}; + +export function useAnchorProgram( + programAddress: string, + url: string +): Program | null { + const key = `${programAddress}-${url}`; + const cacheEntry = cachedAnchorProgramPromises[key]; + + if (cacheEntry === undefined) { + const promise = Program.at( + programAddress, + new Provider(new Connection(url), new NodeWallet(Keypair.generate()), {}) + ) + .then((program) => { + cachedAnchorProgramPromises[key] = { + __type: "result", + result: program, + }; + }) + .catch((_) => { + cachedAnchorProgramPromises[key] = { __type: "result", result: null }; + }); + cachedAnchorProgramPromises[key] = { + __type: "promise", + promise, + }; + throw promise; + } else if (cacheEntry.__type === "promise") { + throw cacheEntry.promise; + } + return cacheEntry.result; +} + +export type AnchorAccount = { + layout: string; + account: Object; +}; diff --git a/explorer/src/utils/anchor.tsx b/explorer/src/utils/anchor.tsx new file mode 100644 index 0000000000..096fb9a0f0 --- /dev/null +++ b/explorer/src/utils/anchor.tsx @@ -0,0 +1,109 @@ +import React from "react"; +import { Cluster } from "providers/cluster"; +import { PublicKey, TransactionInstruction } from "@solana/web3.js"; +import { BorshInstructionCoder, Program } from "@project-serum/anchor"; +import { useAnchorProgram } from "providers/anchor"; +import { programLabel } from "utils/tx"; +import { ErrorBoundary } from "@sentry/react"; + +function snakeToPascal(string: string) { + return string + .split("/") + .map((snake) => + snake + .split("_") + .map((substr) => substr.charAt(0).toUpperCase() + substr.slice(1)) + .join("") + ) + .join("/"); +} + +export function getProgramName(program: Program | null): string | undefined { + return program ? snakeToPascal(program.idl.name) : undefined; +} + +export function capitalizeFirstLetter(input: string) { + return input.charAt(0).toUpperCase() + input.slice(1); +} + +function AnchorProgramName({ + programId, + url, +}: { + programId: PublicKey; + url: string; +}) { + const program = useAnchorProgram(programId.toString(), url); + if (!program) { + throw new Error("No anchor program name found for given programId"); + } + const programName = getProgramName(program); + return <>{programName}; +} + +export function ProgramName({ + programId, + cluster, + url, +}: { + programId: PublicKey; + cluster: Cluster; + url: string; +}) { + const defaultProgramName = + programLabel(programId.toBase58(), cluster) || "Unknown Program"; + + return ( + + {defaultProgramName}}> + + + + ); +} + +export function getAnchorNameForInstruction( + ix: TransactionInstruction, + program: Program +): string | null { + const coder = new BorshInstructionCoder(program.idl); + const decodedIx = coder.decode(ix.data); + + if (!decodedIx) { + return null; + } + + var _ixTitle = decodedIx.name; + return _ixTitle.charAt(0).toUpperCase() + _ixTitle.slice(1); +} + +export function getAnchorAccountsFromInstruction( + decodedIx: Object | null, + program: Program +): + | { + name: string; + isMut: boolean; + isSigner: boolean; + pda?: Object; + }[] + | null { + if (decodedIx) { + // get ix accounts + const idlInstructions = program.idl.instructions.filter( + // @ts-ignore + (ix) => ix.name === decodedIx.name + ); + if (idlInstructions.length === 0) { + return null; + } + return idlInstructions[0].accounts as { + // type coercing since anchor doesn't export the underlying type + name: string; + isMut: boolean; + isSigner: boolean; + pda?: Object; + }[]; + } + return null; +}