Overview
+
+
+ Inspect
+
{autoRefresh === AutoRefresh.Active ? (
) : (
@@ -288,7 +297,9 @@ function StatusCard({
{fee && (
Fee (SOL) |
- {lamportsToSolString(fee)} |
+
+
+ |
)}
@@ -357,7 +368,9 @@ function AccountsCard({
|
-
{lamportsToSolString(post)} |
+
+
+ |
{index === 0 && (
Fee Payer
diff --git a/explorer/src/pages/inspector/AccountsCard.tsx b/explorer/src/pages/inspector/AccountsCard.tsx
new file mode 100644
index 000000000..b8ddbea52
--- /dev/null
+++ b/explorer/src/pages/inspector/AccountsCard.tsx
@@ -0,0 +1,121 @@
+import React from "react";
+import { Message, PublicKey } from "@solana/web3.js";
+import { TableCardBody } from "components/common/TableCardBody";
+import { AddressWithContext } from "./AddressWithContext";
+import { ErrorCard } from "components/common/ErrorCard";
+
+export function AccountsCard({ message }: { message: Message }) {
+ const [expanded, setExpanded] = React.useState(true);
+
+ const { validMessage, error } = React.useMemo(() => {
+ const {
+ numRequiredSignatures,
+ numReadonlySignedAccounts,
+ numReadonlyUnsignedAccounts,
+ } = message.header;
+
+ if (numReadonlySignedAccounts >= numRequiredSignatures) {
+ return { validMessage: undefined, error: "Invalid header" };
+ } else if (numReadonlyUnsignedAccounts >= message.accountKeys.length) {
+ return { validMessage: undefined, error: "Invalid header" };
+ } else if (message.accountKeys.length === 0) {
+ return { validMessage: undefined, error: "Message has no accounts" };
+ }
+
+ return {
+ validMessage: message,
+ error: undefined,
+ };
+ }, [message]);
+
+ const accountRows = React.useMemo(() => {
+ const message = validMessage;
+ if (!message) return;
+ return message.accountKeys.map((publicKey, accountIndex) => {
+ const {
+ numRequiredSignatures,
+ numReadonlySignedAccounts,
+ numReadonlyUnsignedAccounts,
+ } = message.header;
+
+ let readOnly = false;
+ let signer = false;
+ if (accountIndex < numRequiredSignatures) {
+ signer = true;
+ if (accountIndex >= numRequiredSignatures - numReadonlySignedAccounts) {
+ readOnly = true;
+ }
+ } else if (
+ accountIndex >=
+ message.accountKeys.length - numReadonlyUnsignedAccounts
+ ) {
+ readOnly = true;
+ }
+
+ const props = {
+ accountIndex,
+ publicKey,
+ signer,
+ readOnly,
+ };
+
+ return ;
+ });
+ }, [validMessage]);
+
+ if (error) {
+ return ;
+ }
+
+ return (
+
+
+
+ {`Account List (${message.accountKeys.length})`}
+
+
+
+ {expanded && {accountRows}}
+
+ );
+}
+
+function AccountRow({
+ accountIndex,
+ publicKey,
+ signer,
+ readOnly,
+}: {
+ accountIndex: number;
+ publicKey: PublicKey;
+ signer: boolean;
+ readOnly: boolean;
+}) {
+ return (
+ |
+
+
+ Account #{accountIndex + 1}
+
+ {signer && (
+ Signer
+ )}
+ {!readOnly && (
+ Writable
+ )}
+
+
+ |
+
+
+ |
+
+ );
+}
diff --git a/explorer/src/pages/inspector/AddressWithContext.tsx b/explorer/src/pages/inspector/AddressWithContext.tsx
new file mode 100644
index 000000000..39b869994
--- /dev/null
+++ b/explorer/src/pages/inspector/AddressWithContext.tsx
@@ -0,0 +1,101 @@
+import React from "react";
+import { PublicKey, SystemProgram } from "@solana/web3.js";
+import { Address } from "components/common/Address";
+import {
+ Account,
+ useAccountInfo,
+ useFetchAccountInfo,
+} from "providers/accounts";
+import { ClusterStatus, useCluster } from "providers/cluster";
+import { addressLabel } from "utils/tx";
+import { lamportsToSolString } from "utils";
+
+type AccountValidator = (account: Account) => string | undefined;
+
+export const createFeePayerValidator = (
+ feeLamports: number
+): AccountValidator => {
+ return (account: Account): string | undefined => {
+ if (account.details === undefined) return "Account doesn't exist";
+ if (!account.details.owner.equals(SystemProgram.programId))
+ return "Only system-owned accounts can pay fees";
+ // TODO: Actually nonce accounts can pay fees too
+ if (account.details.space > 0)
+ return "Only unallocated accounts can pay fees";
+ if (account.lamports < feeLamports) {
+ return "Insufficient funds for fees";
+ }
+ return;
+ };
+};
+
+export const programValidator = (account: Account): string | undefined => {
+ if (account.details === undefined) return "Account doesn't exist";
+ if (!account.details.executable)
+ return "Only executable accounts can be invoked";
+ return;
+};
+
+export function AddressWithContext({
+ pubkey,
+ validator,
+}: {
+ pubkey: PublicKey;
+ validator?: AccountValidator;
+}) {
+ return (
+
+ );
+}
+
+function AccountInfo({
+ pubkey,
+ validator,
+}: {
+ pubkey: PublicKey;
+ validator?: AccountValidator;
+}) {
+ const address = pubkey.toBase58();
+ const fetchAccount = useFetchAccountInfo();
+ const info = useAccountInfo(address);
+ const { cluster, status } = useCluster();
+
+ // Fetch account on load
+ React.useEffect(() => {
+ if (!info && status === ClusterStatus.Connected && pubkey) {
+ fetchAccount(pubkey);
+ }
+ }, [address, status]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ if (!info?.data)
+ return (
+
+
+ Loading
+
+ );
+
+ const errorMessage = validator && validator(info.data);
+ if (errorMessage) return
{errorMessage};
+
+ if (info.data.details?.executable) {
+ return
Executable Program;
+ }
+
+ const owner = info.data.details?.owner;
+ const ownerAddress = owner?.toBase58();
+ const ownerLabel = ownerAddress && addressLabel(ownerAddress, cluster);
+
+ return (
+
+ {ownerAddress
+ ? `Owned by ${
+ ownerLabel || ownerAddress
+ }. Balance is ${lamportsToSolString(info.data.lamports)} SOL`
+ : "Account doesn't exist"}
+
+ );
+}
diff --git a/explorer/src/pages/inspector/InspectorPage.tsx b/explorer/src/pages/inspector/InspectorPage.tsx
new file mode 100644
index 000000000..25720365f
--- /dev/null
+++ b/explorer/src/pages/inspector/InspectorPage.tsx
@@ -0,0 +1,275 @@
+import React from "react";
+import { Message, PACKET_DATA_SIZE } from "@solana/web3.js";
+
+import { TableCardBody } from "components/common/TableCardBody";
+import { SolBalance } from "utils";
+import { useQuery } from "utils/url";
+import { useHistory, useLocation } from "react-router";
+import {
+ useFetchRawTransaction,
+ useRawTransactionDetails,
+} from "providers/transactions/raw";
+import { FetchStatus } from "providers/cache";
+import { LoadingCard } from "components/common/LoadingCard";
+import { ErrorCard } from "components/common/ErrorCard";
+import { TransactionSignatures } from "./SignaturesCard";
+import { AccountsCard } from "./AccountsCard";
+import {
+ AddressWithContext,
+ createFeePayerValidator,
+} from "./AddressWithContext";
+import { SimulatorCard } from "./SimulatorCard";
+import { RawInput } from "./RawInputCard";
+import { InstructionsSection } from "./InstructionsSection";
+
+export type TransactionData = {
+ rawMessage: Uint8Array;
+ message: Message;
+ signatures?: (string | null)[];
+};
+
+export function TransactionInspectorPage({
+ signature,
+}: {
+ signature?: string;
+}) {
+ const [transaction, setTransaction] = React.useState
();
+ const query = useQuery();
+ const history = useHistory();
+ const location = useLocation();
+ const [paramString, setParamString] = React.useState();
+
+ // Sync message with url search params
+ React.useEffect(() => {
+ if (signature) return;
+ if (transaction) {
+ const base64 = btoa(
+ String.fromCharCode.apply(null, [...transaction.rawMessage])
+ );
+ const newParam = encodeURIComponent(base64);
+ if (query.get("message") === newParam) return;
+ query.set("message", newParam);
+ history.push({ ...location, search: query.toString() });
+ }
+ }, [query, transaction, signature, history, location]);
+
+ const reset = React.useCallback(() => {
+ query.delete("message");
+ history.push({ ...location, search: query.toString() });
+ setTransaction(undefined);
+ }, [query, location, history]);
+
+ // Decode the message url param whenever it changes
+ React.useEffect(() => {
+ if (transaction || signature) return;
+
+ let messageParam = query.get("message");
+ if (messageParam !== null) {
+ let messageString;
+ try {
+ messageString = decodeURIComponent(messageParam);
+ } catch (err) {
+ query.delete("message");
+ history.push({ ...location, search: query.toString() });
+ return;
+ }
+
+ try {
+ const buffer = Uint8Array.from(atob(messageString), (c) =>
+ c.charCodeAt(0)
+ );
+
+ if (buffer.length < 36) {
+ query.delete("message");
+ history.push({ ...location, search: query.toString() });
+ throw new Error("buffer is too short");
+ }
+
+ const message = Message.from(buffer);
+ setParamString(undefined);
+ setTransaction({
+ message,
+ rawMessage: buffer,
+ });
+ } catch (err) {
+ setParamString(messageString);
+ }
+ } else {
+ setParamString(undefined);
+ }
+ }, [query, transaction, signature, history, location]);
+
+ return (
+
+
+
+
Transaction Inspector
+
+
+ {signature ? (
+
+ ) : transaction ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function PermalinkView({
+ signature,
+}: {
+ signature: string;
+ reset: () => void;
+}) {
+ const details = useRawTransactionDetails(signature);
+ const fetchTransaction = useFetchRawTransaction();
+ const refreshTransaction = () => fetchTransaction(signature);
+ const history = useHistory();
+ const location = useLocation();
+ const transaction = details?.data?.raw;
+ const reset = React.useCallback(() => {
+ history.push({ ...location, pathname: "/tx/inspector" });
+ }, [history, location]);
+
+ // Fetch details on load
+ React.useEffect(() => {
+ if (!details) fetchTransaction(signature);
+ }, [signature, details, fetchTransaction]);
+
+ if (!details || details.status === FetchStatus.Fetching) {
+ return ;
+ } else if (details.status === FetchStatus.FetchFailed) {
+ return (
+
+ );
+ } else if (!transaction) {
+ return (
+
+ );
+ }
+
+ const { message, signatures } = transaction;
+ const tx = { message, rawMessage: message.serialize(), signatures };
+
+ return ;
+}
+
+function LoadedView({
+ transaction,
+ onClear,
+}: {
+ transaction: TransactionData;
+ onClear: () => void;
+}) {
+ const { message, rawMessage, signatures } = transaction;
+
+ return (
+ <>
+
+
+ {signatures && (
+
+ )}
+
+
+ >
+ );
+}
+
+const DEFAULT_FEES = {
+ lamportsPerSignature: 5000,
+};
+
+function OverviewCard({
+ message,
+ raw,
+ onClear,
+}: {
+ message: Message;
+ raw: Uint8Array;
+ onClear: () => void;
+}) {
+ const fee =
+ message.header.numRequiredSignatures * DEFAULT_FEES.lamportsPerSignature;
+ const feePayerValidator = createFeePayerValidator(fee);
+
+ const size = React.useMemo(() => {
+ const sigBytes = 1 + 64 * message.header.numRequiredSignatures;
+ return sigBytes + raw.length;
+ }, [message, raw]);
+
+ return (
+ <>
+
+
+
Transaction Overview
+
+
+
+
+ Serialized Size |
+
+
+ {size} bytes
+
+ Max transaction size is {PACKET_DATA_SIZE} bytes
+
+
+ |
+
+
+ Fees |
+
+
+
+
+ {`Each signature costs ${DEFAULT_FEES.lamportsPerSignature} lamports`}
+
+
+ |
+
+
+
+
+ Fee payer
+
+ Signer
+ Writable
+
+
+ |
+
+ {message.accountKeys.length === 0 ? (
+ "No Fee Payer"
+ ) : (
+
+ )}
+ |
+
+
+
+ >
+ );
+}
diff --git a/explorer/src/pages/inspector/InstructionsSection.tsx b/explorer/src/pages/inspector/InstructionsSection.tsx
new file mode 100644
index 000000000..5da88d1bc
--- /dev/null
+++ b/explorer/src/pages/inspector/InstructionsSection.tsx
@@ -0,0 +1,116 @@
+import React from "react";
+import bs58 from "bs58";
+import { CompiledInstruction, Message } from "@solana/web3.js";
+import { TableCardBody } from "components/common/TableCardBody";
+import { AddressWithContext, programValidator } from "./AddressWithContext";
+import { useCluster } from "providers/cluster";
+import { programLabel } from "utils/tx";
+
+export function InstructionsSection({ message }: { message: Message }) {
+ return (
+ <>
+ {message.instructions.map((ix, index) => {
+ return ;
+ })}
+ >
+ );
+}
+
+function InstructionCard({
+ message,
+ ix,
+ index,
+}: {
+ message: Message;
+ ix: CompiledInstruction;
+ index: number;
+}) {
+ const [expanded, setExpanded] = React.useState(false);
+ const { cluster } = useCluster();
+ const programId = message.accountKeys[ix.programIdIndex];
+ const programName = programLabel(programId.toBase58(), cluster) || "Unknown";
+
+ let data: string = "No data";
+ if (ix.data) {
+ data = "";
+
+ const chunks = [];
+ const hexString = bs58.decode(ix.data).toString("hex");
+ for (let i = 0; i < hexString.length; i += 2) {
+ chunks.push(hexString.slice(i, i + 2));
+ }
+
+ data = chunks.join(" ");
+ }
+
+ return (
+
+
+
+ #{index + 1}
+ {programName} Instruction
+
+
+
+
+ {expanded && (
+
+
+ Program |
+
+
+ |
+
+ {ix.accounts.map((accountIndex, index) => {
+ return (
+
+
+
+ Account #{index + 1}
+
+ {accountIndex < message.header.numRequiredSignatures && (
+
+ Signer
+
+ )}
+ {message.isAccountWritable(accountIndex) && (
+
+ Writable
+
+ )}
+
+
+ |
+
+
+ |
+
+ );
+ })}
+
+
+ Instruction Data (Hex)
+ |
+
+
+ {data}
+
+ |
+
+
+ )}
+
+ );
+}
diff --git a/explorer/src/pages/inspector/RawInputCard.tsx b/explorer/src/pages/inspector/RawInputCard.tsx
new file mode 100644
index 000000000..9d1ba21e1
--- /dev/null
+++ b/explorer/src/pages/inspector/RawInputCard.tsx
@@ -0,0 +1,180 @@
+import React from "react";
+import { Message } from "@solana/web3.js";
+import type { TransactionData } from "./InspectorPage";
+import { useQuery } from "utils/url";
+import { useHistory, useLocation } from "react-router";
+import base58 from "bs58";
+
+function deserializeTransaction(bytes: Uint8Array): {
+ message: Message;
+ signatures: string[];
+} | null {
+ const SIGNATURE_LENGTH = 64;
+ const signatures = [];
+ try {
+ const signaturesLen = bytes[0];
+ bytes = bytes.slice(1);
+ for (let i = 0; i < signaturesLen; i++) {
+ const rawSignature = bytes.slice(0, SIGNATURE_LENGTH);
+ bytes = bytes.slice(SIGNATURE_LENGTH);
+ signatures.push(base58.encode(rawSignature));
+ }
+
+ const requiredSignatures = bytes[0];
+ if (requiredSignatures !== signaturesLen) {
+ throw new Error("Signature length mismatch");
+ }
+ } catch (err) {
+ // Errors above indicate that the bytes do not encode a transaction.
+ return null;
+ }
+
+ const message = Message.from(bytes);
+ return { message, signatures };
+}
+
+const MIN_MESSAGE_LENGTH =
+ 3 + // header
+ 1 + // accounts length
+ 32 + // accounts, must have at least one address for fees
+ 32 + // recent blockhash
+ 1; // instructions length
+
+const MIN_TRANSACTION_LENGTH =
+ 1 + // signatures length
+ 64 + // signatures, must have at least one for fees
+ MIN_MESSAGE_LENGTH;
+
+const MAX_TRANSACTION_SIGNATURES =
+ Math.floor((1232 - MIN_TRANSACTION_LENGTH) / (64 + 32)) + 1;
+
+export function RawInput({
+ value,
+ setTransactionData,
+}: {
+ value?: string;
+ setTransactionData: (param: TransactionData | undefined) => void;
+}) {
+ const rawTransactionInput = React.useRef(null);
+ const [error, setError] = React.useState();
+ const [rows, setRows] = React.useState(3);
+ const query = useQuery();
+ const history = useHistory();
+ const location = useLocation();
+
+ const onInput = React.useCallback(() => {
+ const base64 = rawTransactionInput.current?.value;
+ if (base64) {
+ // Clear url params when input is detected
+ if (query.get("message")) {
+ query.delete("message");
+ history.push({ ...location, search: query.toString() });
+ } else if (query.get("transaction")) {
+ query.delete("transaction");
+ history.push({ ...location, search: query.toString() });
+ }
+
+ // Dynamically expand height based on input length
+ setRows(Math.max(3, Math.min(10, Math.round(base64.length / 150))));
+
+ let buffer;
+ try {
+ buffer = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
+ } catch (err) {
+ console.error(err);
+ setError("Input must be base64 encoded");
+ return;
+ }
+
+ try {
+ if (buffer.length < MIN_MESSAGE_LENGTH) {
+ throw new Error("Input is not long enough to be valid.");
+ } else if (buffer[0] > MAX_TRANSACTION_SIGNATURES) {
+ throw new Error(`Input starts with invalid byte: "${buffer[0]}"`);
+ }
+
+ const tx = deserializeTransaction(buffer);
+ if (tx) {
+ const message = tx.message;
+ const rawMessage = message.serialize();
+ setTransactionData({
+ rawMessage,
+ message,
+ signatures: tx.signatures,
+ });
+ } else {
+ const message = Message.from(buffer);
+ setTransactionData({
+ rawMessage: buffer,
+ message,
+ });
+ }
+
+ setError(undefined);
+ return;
+ } catch (err) {
+ setError(err.message);
+ }
+ } else {
+ setError(undefined);
+ }
+ }, [setTransactionData, history, query, location]);
+
+ React.useEffect(() => {
+ const input = rawTransactionInput.current;
+ if (input && value) {
+ input.value = value;
+ onInput();
+ }
+ }, [value]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const placeholder = "Paste raw base64 encoded transaction message";
+ return (
+
+
+
Encoded Transaction Message
+
+
+
+
+
+ {error && (
+ <>
+
+
+
+
+ {error}
+ >
+ )}
+
+
+
+
+
Instructions
+
+ -
+ CLI: Use
--dump-transaction-message
{" "}
+ flag
+
+ -
+ Rust: Add
base64
crate dependency and{" "}
+
+ println!("{}", base64::encode(&transaction.message_data()));
+
+
+ -
+ JavaScript: Add{" "}
+
console.log(tx.serializeMessage().toString("base64"));
+
+
+
+
+ );
+}
diff --git a/explorer/src/pages/inspector/SignaturesCard.tsx b/explorer/src/pages/inspector/SignaturesCard.tsx
new file mode 100644
index 000000000..a14fa83d5
--- /dev/null
+++ b/explorer/src/pages/inspector/SignaturesCard.tsx
@@ -0,0 +1,120 @@
+import React from "react";
+import bs58 from "bs58";
+import * as nacl from "tweetnacl";
+import { Message, PublicKey } from "@solana/web3.js";
+import { Signature } from "components/common/Signature";
+import { Address } from "components/common/Address";
+
+export function TransactionSignatures({
+ signatures,
+ message,
+ rawMessage,
+}: {
+ signatures: (string | null)[];
+ message: Message;
+ rawMessage: Uint8Array;
+}) {
+ const signatureRows = React.useMemo(() => {
+ return signatures.map((signature, index) => {
+ const publicKey = message.accountKeys[index];
+
+ let verified;
+ if (signature) {
+ const key = publicKey.toBytes();
+ const rawSignature = bs58.decode(signature);
+ verified = verifySignature({
+ message: rawMessage,
+ signature: rawSignature,
+ key,
+ });
+ }
+
+ const props = {
+ index,
+ signature,
+ signer: publicKey,
+ verified,
+ };
+
+ return ;
+ });
+ }, [signatures, message, rawMessage]);
+
+ return (
+
+
+
Signatures
+
+
+
+
+
+ # |
+ Signature |
+ Signer |
+ Validity |
+ Details |
+
+
+ {signatureRows}
+
+
+
+ );
+}
+
+function verifySignature({
+ message,
+ signature,
+ key,
+}: {
+ message: Uint8Array;
+ signature: Uint8Array;
+ key: Uint8Array;
+}): boolean {
+ return nacl.sign.detached.verify(message, signature, key);
+}
+
+function SignatureRow({
+ signature,
+ signer,
+ verified,
+ index,
+}: {
+ signature: string | null;
+ signer: PublicKey;
+ verified?: boolean;
+ index: number;
+}) {
+ return (
+
+
+ {index + 1}
+ |
+
+ {signature ? (
+
+ ) : (
+ "Missing Signature"
+ )}
+ |
+
+
+ |
+
+ {verified === undefined ? (
+ "N/A"
+ ) : verified ? (
+ Valid
+ ) : (
+ Invalid
+ )}
+ |
+
+ {index === 0 && (
+ Fee Payer
+ )}
+ |
+
+ );
+}
diff --git a/explorer/src/pages/inspector/SimulatorCard.tsx b/explorer/src/pages/inspector/SimulatorCard.tsx
new file mode 100644
index 000000000..aba5e99dc
--- /dev/null
+++ b/explorer/src/pages/inspector/SimulatorCard.tsx
@@ -0,0 +1,249 @@
+import React from "react";
+import bs58 from "bs58";
+import { Connection, Message, Transaction } from "@solana/web3.js";
+import { useCluster } from "providers/cluster";
+import { TableCardBody } from "components/common/TableCardBody";
+import { programLabel } from "utils/tx";
+
+type LogMessage = {
+ text: string;
+ prefix: string;
+ style: "muted" | "info" | "success" | "warning";
+};
+
+type InstructionLogs = {
+ logs: LogMessage[];
+ failed: boolean;
+};
+
+const DEFAULT_SIGNATURE = bs58.encode(Buffer.alloc(64).fill(0));
+
+export function SimulatorCard({ message }: { message: Message }) {
+ const { cluster } = useCluster();
+ const { simulate, simulating, simulationLogs: logs } = useSimulator(message);
+ if (simulating) {
+ return (
+
+
+
Transaction Simulation
+
+
+
+ Simulating
+
+
+ );
+ } else if (!logs) {
+ return (
+
+
+
Transaction Simulation
+
+
+
+
+ -
+ Simulation is free and will run this transaction against the
+ latest confirmed ledger state.
+
+ -
+ No state changes will be persisted and all signature checks will
+ be disabled.
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
Transaction Simulation
+
+
+
+ {message.instructions.map((ix, index) => {
+ const programId = message.accountKeys[ix.programIdIndex];
+ const programName =
+ programLabel(programId.toBase58(), cluster) || "Unknown";
+ const programLogs: InstructionLogs | undefined = logs[index];
+
+ let badgeColor = "white";
+ if (programLogs) {
+ badgeColor = programLogs.failed ? "warning" : "success";
+ }
+
+ return (
+
+
+
+
+ #{index + 1}
+
+ {programName} Instruction
+
+ {programLogs && (
+
+ {programLogs.logs.map((log, key) => {
+ return (
+
+ {log.prefix}
+
+ {log.text}
+
+
+ );
+ })}
+
+ )}
+ |
+
+ );
+ })}
+
+
+ );
+}
+
+function useSimulator(message: Message) {
+ const { cluster, url } = useCluster();
+ const [simulating, setSimulating] = React.useState(false);
+ const [logs, setLogs] = React.useState | null>(null);
+
+ React.useEffect(() => {
+ setLogs(null);
+ setSimulating(false);
+ }, [url]);
+
+ const onClick = React.useCallback(() => {
+ if (simulating) return;
+ setSimulating(true);
+
+ const connection = new Connection(url, "confirmed");
+ (async () => {
+ try {
+ const tx = Transaction.populate(
+ message,
+ new Array(message.header.numRequiredSignatures).fill(
+ DEFAULT_SIGNATURE
+ )
+ );
+
+ // Simulate without signers to skip signer verification
+ const resp = await connection.simulateTransaction(tx);
+
+ let depth = 0;
+ let instructionLogs: InstructionLogs[] = [];
+ const prefixBuilder = (depth: number) => {
+ const prefix = new Array(depth - 1).fill("\u00A0\u00A0").join("");
+ return prefix + "> ";
+ };
+
+ let instructionError;
+ const responseLogs = resp.value.logs;
+ if (!responseLogs) {
+ if (resp.value.err) throw new Error(JSON.stringify(resp.value.err));
+ throw new Error("No logs detected");
+ } else if (resp.value.err) {
+ const err = resp.value.err;
+ if (err && typeof err !== "string") {
+ let ixError = (err as any)["InstructionError"];
+ const [index, message] = ixError;
+ if (typeof message === "string") {
+ instructionError = { index, message };
+ }
+ }
+ }
+
+ responseLogs.forEach((log) => {
+ if (log.startsWith("Program log:")) {
+ instructionLogs[instructionLogs.length - 1].logs.push({
+ prefix: prefixBuilder(depth),
+ text: log,
+ style: "muted",
+ });
+ } else {
+ const regex = /Program (\w*) invoke \[(\d)\]/g;
+ const matches = [...log.matchAll(regex)];
+
+ if (matches.length > 0) {
+ const programAddress = matches[0][1];
+ const programName =
+ programLabel(programAddress, cluster) ||
+ `Unknown (${programAddress}) Program`;
+
+ if (depth === 0) {
+ instructionLogs.push({
+ logs: [],
+ failed: false,
+ });
+ } else {
+ instructionLogs[instructionLogs.length - 1].logs.push({
+ prefix: prefixBuilder(depth),
+ style: "info",
+ text: `Invoking ${programName}`,
+ });
+ }
+
+ depth++;
+ } else if (log.includes("success")) {
+ instructionLogs[instructionLogs.length - 1].logs.push({
+ prefix: prefixBuilder(depth),
+ style: "success",
+ text: `Program returned success`,
+ });
+ depth--;
+ } else if (log.includes("failed")) {
+ const instructionLog =
+ instructionLogs[instructionLogs.length - 1];
+ if (!instructionLog.failed) {
+ instructionLog.failed = true;
+ instructionLog.logs.push({
+ prefix: prefixBuilder(depth),
+ style: "warning",
+ text: `Program returned error: ${log.slice(
+ log.indexOf(": ") + 2
+ )}`,
+ });
+ }
+ depth--;
+ } else {
+ // system transactions don't start with "Program log:"
+ instructionLogs[instructionLogs.length - 1].logs.push({
+ prefix: prefixBuilder(depth),
+ text: log,
+ style: "muted",
+ });
+ }
+ }
+ });
+
+ if (
+ instructionError &&
+ instructionError.index === instructionLogs.length - 1
+ ) {
+ const failedIx = instructionLogs[instructionError.index];
+ failedIx.failed = true;
+ failedIx.logs.push({
+ prefix: prefixBuilder(1),
+ text: `Runtime error: ${instructionError.message}`,
+ style: "warning",
+ });
+ }
+
+ setLogs(instructionLogs);
+ } catch (err) {
+ console.error(err);
+ setLogs(null);
+ } finally {
+ setSimulating(false);
+ }
+ })();
+ }, [cluster, url, message, simulating]);
+ return { simulate: onClick, simulating, simulationLogs: logs };
+}
diff --git a/explorer/src/providers/accounts/index.tsx b/explorer/src/providers/accounts/index.tsx
index ebd3829aa..be0f2e2e2 100644
--- a/explorer/src/providers/accounts/index.tsx
+++ b/explorer/src/providers/accounts/index.tsx
@@ -76,7 +76,7 @@ export type ProgramData =
export interface Details {
executable: boolean;
owner: PublicKey;
- space?: number;
+ space: number;
data?: ProgramData;
}
@@ -143,7 +143,7 @@ async function fetchAccountInfo(
lamports = result.lamports;
// Only save data in memory if we can decode it
- let space;
+ let space: number;
if (!("parsed" in result.data)) {
space = result.data.length;
} else {
diff --git a/explorer/src/providers/transactions/index.tsx b/explorer/src/providers/transactions/index.tsx
index 1f06d0250..2406bf618 100644
--- a/explorer/src/providers/transactions/index.tsx
+++ b/explorer/src/providers/transactions/index.tsx
@@ -6,11 +6,12 @@ import {
TransactionConfirmationStatus,
} from "@solana/web3.js";
import { useCluster, Cluster } from "../cluster";
-import { DetailsProvider } from "./details";
+import { DetailsProvider } from "./parsed";
+import { RawDetailsProvider } from "./raw";
import * as Cache from "providers/cache";
import { ActionType, FetchStatus } from "providers/cache";
import { reportError } from "utils/sentry";
-export { useTransactionDetails } from "./details";
+export { useTransactionDetails } from "./parsed";
export type Confirmations = number | "max";
@@ -48,7 +49,9 @@ export function TransactionsProvider({ children }: TransactionsProviderProps) {
return (
- {children}
+
+ {children}
+
);
diff --git a/explorer/src/providers/transactions/details.tsx b/explorer/src/providers/transactions/parsed.tsx
similarity index 71%
rename from explorer/src/providers/transactions/details.tsx
rename to explorer/src/providers/transactions/parsed.tsx
index b65a7eb1d..33b214cd9 100644
--- a/explorer/src/providers/transactions/details.tsx
+++ b/explorer/src/providers/transactions/parsed.tsx
@@ -3,7 +3,6 @@ import {
Connection,
TransactionSignature,
ParsedConfirmedTransaction,
- Transaction,
} from "@solana/web3.js";
import { useCluster, Cluster } from "../cluster";
import * as Cache from "providers/cache";
@@ -12,7 +11,6 @@ import { reportError } from "utils/sentry";
export interface Details {
transaction?: ParsedConfirmedTransaction | null;
- raw?: Transaction | null;
}
type State = Cache.State;
@@ -121,53 +119,3 @@ export function useTransactionDetailsCache(): TransactionDetailsCache {
return context.entries;
}
-
-async function fetchRawTransaction(
- dispatch: Dispatch,
- signature: TransactionSignature,
- cluster: Cluster,
- url: string
-) {
- let fetchStatus;
- try {
- const response = await new Connection(url).getTransaction(signature);
- fetchStatus = FetchStatus.Fetched;
-
- let data: Details = { raw: null };
- if (response !== null) {
- const { message, signatures } = response.transaction;
- data = {
- raw: Transaction.populate(message, signatures),
- };
- }
-
- dispatch({
- type: ActionType.Update,
- status: fetchStatus,
- key: signature,
- data,
- url,
- });
- } catch (error) {
- if (cluster !== Cluster.Custom) {
- reportError(error, { url });
- }
- }
-}
-
-export function useFetchRawTransaction() {
- const dispatch = React.useContext(DispatchContext);
- if (!dispatch) {
- throw new Error(
- `useFetchRawTransaaction must be used within a TransactionsProvider`
- );
- }
-
- const { cluster, url } = useCluster();
- return React.useCallback(
- (signature: TransactionSignature) => {
- url && fetchRawTransaction(dispatch, signature, cluster, url);
- },
- [dispatch, cluster, url]
- );
-}
diff --git a/explorer/src/providers/transactions/raw.tsx b/explorer/src/providers/transactions/raw.tsx
new file mode 100644
index 000000000..3e928b20e
--- /dev/null
+++ b/explorer/src/providers/transactions/raw.tsx
@@ -0,0 +1,113 @@
+import React from "react";
+import {
+ Connection,
+ TransactionSignature,
+ Transaction,
+ Message,
+} 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";
+
+export interface Details {
+ raw?: {
+ transaction: Transaction;
+ message: Message;
+ signatures: string[];
+ } | null;
+}
+
+type State = Cache.State;
+type Dispatch = Cache.Dispatch;
+
+export const StateContext = React.createContext(undefined);
+export const DispatchContext = React.createContext(
+ undefined
+);
+
+type DetailsProviderProps = { children: React.ReactNode };
+export function RawDetailsProvider({ children }: DetailsProviderProps) {
+ const { url } = useCluster();
+ const [state, dispatch] = Cache.useReducer(url);
+
+ React.useEffect(() => {
+ dispatch({ type: ActionType.Clear, url });
+ }, [dispatch, url]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export function useRawTransactionDetails(
+ signature: TransactionSignature
+): Cache.CacheEntry | undefined {
+ const context = React.useContext(StateContext);
+
+ if (!context) {
+ throw new Error(
+ `useRawTransactionDetails must be used within a TransactionsProvider`
+ );
+ }
+
+ return context.entries[signature];
+}
+
+async function fetchRawTransaction(
+ dispatch: Dispatch,
+ signature: TransactionSignature,
+ cluster: Cluster,
+ url: string
+) {
+ let fetchStatus;
+ try {
+ const response = await new Connection(url).getTransaction(signature);
+ fetchStatus = FetchStatus.Fetched;
+
+ let data: Details = { raw: null };
+ if (response !== null) {
+ const { message, signatures } = response.transaction;
+ data = {
+ raw: {
+ message,
+ signatures,
+ transaction: Transaction.populate(message, signatures),
+ },
+ };
+ }
+
+ dispatch({
+ type: ActionType.Update,
+ status: fetchStatus,
+ key: signature,
+ data,
+ url,
+ });
+ } catch (error) {
+ if (cluster !== Cluster.Custom) {
+ reportError(error, { url });
+ }
+ }
+}
+
+export function useFetchRawTransaction() {
+ const dispatch = React.useContext(DispatchContext);
+ if (!dispatch) {
+ throw new Error(
+ `useFetchRawTransaction must be used within a TransactionsProvider`
+ );
+ }
+
+ const { cluster, url } = useCluster();
+ return React.useCallback(
+ (signature: TransactionSignature) => {
+ url && fetchRawTransaction(dispatch, signature, cluster, url);
+ },
+ [dispatch, cluster, url]
+ );
+}
diff --git a/explorer/src/scss/_solana.scss b/explorer/src/scss/_solana.scss
index 8989decbd..53e020564 100644
--- a/explorer/src/scss/_solana.scss
+++ b/explorer/src/scss/_solana.scss
@@ -91,6 +91,10 @@ ul.log-messages {
bottom: 0;
}
+.border-bottom-none {
+ border-bottom: 0px;
+}
+
.opacity-50 {
opacity: 0.5;
}
diff --git a/explorer/src/utils/index.tsx b/explorer/src/utils/index.tsx
index 29d45f525..9f81b093a 100644
--- a/explorer/src/utils/index.tsx
+++ b/explorer/src/utils/index.tsx
@@ -1,4 +1,4 @@
-import React, { ReactNode } from "react";
+import React from "react";
import BN from "bn.js";
import {
HumanizeDuration,
@@ -51,15 +51,25 @@ export function lamportsToSol(lamports: number | BN): number {
export function lamportsToSolString(
lamports: number | BN,
maximumFractionDigits: number = 9
-): ReactNode {
+): string {
const sol = lamportsToSol(lamports);
+ return new Intl.NumberFormat("en-US", { maximumFractionDigits }).format(sol);
+}
+
+export function SolBalance({
+ lamports,
+ maximumFractionDigits = 9,
+}: {
+ lamports: number | BN;
+ maximumFractionDigits?: number;
+}) {
return (
- <>
+
◎
- {new Intl.NumberFormat("en-US", { maximumFractionDigits }).format(sol)}
+ {lamportsToSolString(lamports, maximumFractionDigits)}
- >
+
);
}
diff --git a/explorer/src/utils/url.ts b/explorer/src/utils/url.ts
index dad9e6a27..8dc658f96 100644
--- a/explorer/src/utils/url.ts
+++ b/explorer/src/utils/url.ts
@@ -5,20 +5,23 @@ export function useQuery() {
return new URLSearchParams(useLocation().search);
}
-export const clusterPath = (pathname: string) => {
+export const clusterPath = (pathname: string, params?: URLSearchParams) => {
return (location: Location) => ({
- ...pickClusterParams(location),
+ ...pickClusterParams(location, params),
pathname,
});
};
-export function pickClusterParams(location: Location): Location {
+export function pickClusterParams(
+ location: Location,
+ newParams?: URLSearchParams
+): Location {
const urlParams = new URLSearchParams(location.search);
const cluster = urlParams.get("cluster");
const customUrl = urlParams.get("customUrl");
// Pick the params we care about
- const newParams = new URLSearchParams();
+ newParams = newParams || new URLSearchParams();
if (cluster) newParams.set("cluster", cluster);
if (customUrl) newParams.set("customUrl", customUrl);
@@ -27,36 +30,3 @@ export function pickClusterParams(location: Location): Location {
search: newParams.toString(),
};
}
-
-export function findGetParameter(parameterName: string): string | null {
- let result = null,
- tmp = [];
- window.location.search
- .substr(1)
- .split("&")
- .forEach(function (item) {
- tmp = item.split("=");
- if (tmp[0].toLowerCase() === parameterName.toLowerCase()) {
- if (tmp.length === 2) {
- result = decodeURIComponent(tmp[1]);
- } else if (tmp.length === 1) {
- result = "";
- }
- }
- });
- return result;
-}
-
-export function findPathSegment(pathName: string): string | null {
- const segments = window.location.pathname.substr(1).split("/");
- if (segments.length < 2) return null;
-
- // remove all but last two segments
- segments.splice(0, segments.length - 2);
-
- if (segments[0] === pathName) {
- return segments[1];
- }
-
- return null;
-}