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
This commit is contained in:
Noah Gundotra 2022-04-06 10:22:49 -07:00 committed by GitHub
parent cd09390367
commit 559ee5a843
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 862 additions and 273 deletions

View File

@ -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=="
}
}
},

View File

@ -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",

View File

@ -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 (
<TableCardBody>
@ -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({
<span className={`badge bg-${badgeColor}-soft me-2`}>
#{index + 1}
</span>
{programName} Instruction
<ProgramName
programId={programId}
cluster={cluster}
url={url}
/>{" "}
Instruction
</div>
{programLogs && (
<div className="d-flex align-items-start flex-column font-monospace p-2 font-size-sm">

View File

@ -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 (
<ErrorCard text="Failed to decode account data according to its public anchor interface" />
);
}
return (
<>
<div className="card">
<div className="card-header">
<div className="row align-items-center">
<div className="col">
<h3 className="card-header-title">{foundAccountLayoutName}</h3>
</div>
</div>
</div>
<div className="table-responsive mb-0">
<table className="table table-sm table-nowrap card-table">
<thead>
<tr>
<th className="w-1 text-muted">Key</th>
<th className="text-muted">Value</th>
</tr>
</thead>
<tbody className="list">
{decodedAnchorAccountData &&
Object.keys(decodedAnchorAccountData).map((key) => (
<AccountRow
key={key}
valueName={key}
value={decodedAnchorAccountData[key]}
/>
))}
</tbody>
</table>
</div>
<div className="card-footer">
<div className="text-muted text-center">
{decodedAnchorAccountData &&
Object.keys(decodedAnchorAccountData).length > 0
? `Decoded ${Object.keys(decodedAnchorAccountData).length} Items`
: "No decoded data"}
</div>
</div>
</div>
</>
);
}
function AccountRow({ valueName, value }: { valueName: string; value: any }) {
let displayValue: JSX.Element;
if (value instanceof PublicKey) {
displayValue = <Address pubkey={value} link />;
} else if (value instanceof BN) {
displayValue = <>{value.toString()}</>;
} else if (!(value instanceof Object)) {
displayValue = <>{String(value)}</>;
} else if (value) {
const displayObject = stringifyPubkeyAndBigNums(value);
displayValue = (
<ReactJson
src={JSON.parse(JSON.stringify(displayObject))}
collapsed={1}
theme="solarized"
/>
);
} else {
displayValue = <>null</>;
}
return (
<tr>
<td className="w-1 text-monospace">{camelToUnderscore(valueName)}</td>
<td className="text-monospace">{displayValue}</td>
</tr>
);
}
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
);
}

View File

@ -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 (
<>
<div className="card">
<div className="card-header">
<div className="row align-items-center">
<div className="col">
<h3 className="card-header-title">Anchor IDL</h3>
</div>
</div>
</div>
<div className="card metadata-json-viewer m-4">
<ReactJson
src={program.idl}
theme={"solarized"}
style={{ padding: 25 }}
collapsed={1}
/>
</div>
</div>
</>
);
}

View File

@ -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<Idl>;
}) {
const { ix, anchorProgram } = props;
const programName = getProgramName(anchorProgram) ?? "Unknown Program";
const ixName =
getAnchorNameForInstruction(ix, anchorProgram) ?? "Unknown Instruction";
const cardTitle = `${programName}: ${ixName}`;
return (
<InstructionCard title={cardTitle} {...props}>
<RawAnchorDetails ix={ix} anchorProgram={anchorProgram} />
</InstructionCard>
);
}
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 (
<tr key={keyIndex}>
<td>
<div className="me-2 d-md-inline">
{ixAccounts && keyIndex < ixAccounts.length
? `${capitalizeFirstLetter(ixAccounts[keyIndex].name)}`
: `Account #${keyIndex + 1}`}
</div>
{isWritable && (
<span className="badge bg-info-soft me-1">Writable</span>
)}
{isSigner && (
<span className="badge bg-info-soft me-1">Signer</span>
)}
</td>
<td className="text-lg-end">
<Address pubkey={pubkey} alignRight link />
</td>
</tr>
);
})}
<tr>
<td>
Instruction Data <span className="text-muted">(Hex)</span>
</td>
{decodedIxData ? (
<td className="metadata-json-viewer m-4">
<ReactJson src={decodedIxData} theme="solarized" />
</td>
) : (
<td className="text-lg-end">
<HexData raw={ix.data} />
</td>
)}
</tr>
</>
);
}

View File

@ -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<Idl | null>();
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<string | null>(null);
const [ixTitle, setIxTitle] = useState<string | null>(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 (
<div>
{idl && (
<InstructionCard
ix={ix}
index={index}
result={result}
title={`${programName || "Unknown"}: ${ixTitle || "Unknown"}`}
innerCards={innerCards}
childIndex={childIndex}
>
<tr key={ix.programId.toBase58()}>
<td>Program</td>
<td className="text-lg-end">
<Address pubkey={ix.programId} alignRight link />
</td>
</tr>
{ixAccounts != null &&
ix.keys.map((am, keyIndex) => (
<tr key={keyIndex}>
<td>
<div className="me-2 d-md-inline">
{/* remaining accounts would not have a name */}
{ixAccounts[keyIndex] &&
snakeCase(ixAccounts[keyIndex].name)}
{!ixAccounts[keyIndex] &&
"remaining account #" +
(keyIndex - ixAccounts.length + 1)}
</div>
{am.isWritable && (
<span className="badge bg-info-soft me-1">Writable</span>
)}
{am.isSigner && (
<span className="badge bg-info-soft me-1">Signer</span>
)}
</td>
<td>
<Address pubkey={am.pubkey} alignRight link />
</td>
</tr>
))}
</InstructionCard>
)}
{!idl && (
<InstructionCard
ix={ix}
index={index}
result={result}
title={`Unknown Program: Unknown Instruction`}
innerCards={innerCards}
childIndex={childIndex}
defaultRaw
/>
)}
</div>
);
}

View File

@ -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());
};

View File

@ -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 <ErrorCard retry={refreshDetails} text="No instructions found" />;
}
const { meta } = details.data.transaction;
const { transaction } = details.data?.transaction;
if (transaction.message.instructions.length === 0) {
return <ErrorCard retry={refreshDetails} text="No instructions found" />;
@ -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 (
<>
<div className="container">
<div className="header">
<div className="header-body">
<h3 className="mb-0">
{instructionDetails.length > 1 ? "Instructions" : "Instruction"}
{transaction.message.instructions.length > 1
? "Instructions"
: "Instruction"}
</h3>
</div>
</div>
</div>
{instructionDetails}
<React.Suspense fallback={<LoadingCard message="Loading Instructions" />}>
{transaction.message.instructions.map((instruction, index) => {
let innerCards: JSX.Element[] = [];
if (index in innerInstructions) {
innerInstructions[index].forEach((ix, childIndex) => {
let res = (
<InstructionCard
key={`${index}-${childIndex}`}
index={index}
ix={ix}
result={result}
signature={signature}
tx={transaction}
childIndex={childIndex}
url={url}
/>
);
innerCards.push(res);
});
}
return (
<InstructionCard
key={`${index}`}
index={index}
ix={instruction}
result={result}
signature={signature}
tx={transaction}
innerCards={innerCards}
url={url}
/>
);
})}
</React.Suspense>
</>
);
}
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 <BonfidaBotDetailsCard key={key} {...props} />;
} else if (isInstructionFromAnAnchorProgram(transactionIx)) {
return <GenericAnchorDetailsCard key={key} {...props} />;
} else if (isMangoInstruction(transactionIx)) {
return <MangoDetailsCard key={key} {...props} />;
} else if (isSerumInstruction(transactionIx)) {
@ -230,6 +236,12 @@ function renderInstructionCard({
return <WormholeDetailsCard key={key} {...props} />;
} else if (isPythInstruction(transactionIx)) {
return <PythDetailsCard key={key} {...props} />;
} else if (anchorProgram) {
return (
<ErrorBoundary fallback={<UnknownDetailsCard {...props} />}>
<AnchorDetailsCard key={key} anchorProgram={anchorProgram} {...props} />
</ErrorBoundary>
);
} else {
return <UnknownDetailsCard key={key} {...props} />;
}

View File

@ -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}
/>
) : (
<div className="card-body">

View File

@ -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 <Redirect to={{ ...location, pathname: `/address/${address}` }} />;
} else if (tab) {
moreTab = tab as MoreTabs;
@ -265,7 +272,11 @@ function DetailsSections({
</div>
)}
{<InfoSection account={account} />}
{<MoreSection account={account} tab={moreTab} tabs={tabs} />}
<MoreSection
account={account}
tab={moreTab}
tabs={tabComponents.map(({ component }) => 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 (
<>
<div className="container">
<div className="header">
<div className="header-body pt-0">
<ul className="nav nav-tabs nav-overflow header-tabs">
{tabs.map(({ title, slug, path }) => (
<li key={slug} className="nav-item">
<NavLink
className="nav-link"
to={clusterPath(`/address/${address}${path}`)}
exact
>
{title}
</NavLink>
</li>
))}
</ul>
<ul className="nav nav-tabs nav-overflow header-tabs">{tabs}</ul>
</div>
</div>
</div>
@ -401,11 +407,29 @@ function MoreSection({
{tab === "security" && data?.program === "bpf-upgradeable-loader" && (
<SecurityCard data={data} />
)}
{tab === "anchor-program" && (
<React.Suspense
fallback={<LoadingCard message="Loading anchor program IDL" />}
>
<AnchorProgramCard programId={pubkey} />
</React.Suspense>
)}
{tab === "anchor-account" && (
<React.Suspense
fallback={
<LoadingCard message="Decoding account data using anchor interface" />
}
>
<AnchorAccountCard account={account} />
</React.Suspense>
)}
</>
);
}
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: (
<li key={tab.slug} className="nav-item">
<NavLink
className="nav-link"
to={clusterPath(`/address/${address}${tab.path}`)}
exact
>
{tab.title}
</NavLink>
</li>
),
};
});
}
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: (
<React.Suspense key={anchorProgramTab.slug} fallback={<></>}>
<AnchorProgramLink
tab={anchorProgramTab}
address={pubkey.toString()}
pubkey={pubkey}
/>
</React.Suspense>
),
});
const anchorAccountTab: Tab = {
slug: "anchor-account",
title: "Anchor Account",
path: "/anchor-account",
};
tabComponents.push({
tab: anchorAccountTab,
component: (
<React.Suspense key={anchorAccountTab.slug} fallback={<></>}>
<AnchorAccountLink
tab={anchorAccountTab}
address={pubkey.toString()}
programId={account.details?.owner}
/>
</React.Suspense>
),
});
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 (
<li key={tab.slug} className="nav-item">
<NavLink
className="nav-link"
to={clusterPath(`/address/${address}${tab.path}`)}
exact
>
{tab.title}
</NavLink>
</li>
);
}
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 (
<li key={tab.slug} className="nav-item">
<NavLink
className="nav-link"
to={clusterPath(`/address/${address}${tab.path}`)}
exact
>
{tab.title}
</NavLink>
</li>
);
}

View File

@ -105,7 +105,11 @@ export function TransactionDetailsPage({ signature: raw }: SignatureProps) {
) : (
<SignatureContext.Provider value={signature}>
<StatusCard signature={signature} autoRefresh={autoRefresh} />
<DetailsSection signature={signature} />
<React.Suspense
fallback={<LoadingCard message="Loading transaction details" />}
>
<DetailsSection signature={signature} />
</React.Suspense>
</SignatureContext.Provider>
)}
</div>

View File

@ -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
</button>
</div>
<ProgramLogsCardBody message={message} logs={logs} cluster={cluster} />
<ProgramLogsCardBody
message={message}
logs={logs}
cluster={cluster}
url={url}
/>
</div>
);
}

View File

@ -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 };

View File

@ -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<void> }
| { __type: "result"; result: Program<Idl> | 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;
};

View File

@ -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 (
<React.Suspense fallback={defaultProgramName}>
<ErrorBoundary fallback={<>{defaultProgramName}</>}>
<AnchorProgramName programId={programId} url={url} />
</ErrorBoundary>
</React.Suspense>
);
}
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;
}