explorer: Add transaction inspector (#17769)

* Add SolBalance component helper

* Add transaction inspector

* Feedback
This commit is contained in:
Justin Starry 2021-06-21 18:53:06 -05:00 committed by GitHub
parent 699ca6e71a
commit 1c88189a1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1419 additions and 156 deletions

View File

@ -16431,6 +16431,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/node-sass/-/node-sass-5.0.0.tgz",
"integrity": "sha512-opNgmlu83ZCF792U281Ry7tak9IbVC+AKnXGovcQ8LG8wFaJv6cLnRlc6DIHlmNxWEexB5bZxi9SZ9JyUuOYjw==",
"hasInstallScript": true,
"dependencies": {
"async-foreach": "^0.1.3",
"chalk": "^1.1.1",

View File

@ -8,6 +8,7 @@ import { ClusterStatusBanner } from "components/ClusterStatusButton";
import { SearchBar } from "components/SearchBar";
import { AccountDetailsPage } from "pages/AccountDetailsPage";
import { TransactionInspectorPage } from "pages/inspector/InspectorPage";
import { ClusterStatsPage } from "pages/ClusterStatsPage";
import { SupplyPage } from "pages/SupplyPage";
import { TransactionDetailsPage } from "pages/TransactionDetailsPage";
@ -37,6 +38,13 @@ function App() {
return <Redirect to={{ ...location, pathname }} />;
}}
/>
<Route
exact
path={["/tx/inspector", "/tx/:signature/inspect"]}
render={({ match }) => (
<TransactionInspectorPage signature={match.params.signature} />
)}
/>
<Route
exact
path={"/tx/:signature"}

View File

@ -39,6 +39,11 @@ export function Navbar() {
Supply
</NavLink>
</li>
<li className="nav-item">
<NavLink className="nav-link" to={clusterPath("/tx/inspector")}>
Inspector
</NavLink>
</li>
</ul>
</div>

View File

@ -2,7 +2,7 @@ import React from "react";
import { useSupply, useFetchSupply, Status } from "providers/supply";
import { LoadingCard } from "./common/LoadingCard";
import { ErrorCard } from "./common/ErrorCard";
import { lamportsToSolString } from "utils";
import { SolBalance } from "utils";
import { TableCardBody } from "./common/TableCardBody";
export function SupplyCard() {
@ -33,21 +33,27 @@ export function SupplyCard() {
<tr>
<td className="w-100">Total Supply (SOL)</td>
<td className="text-lg-right">
{lamportsToSolString(supply.total, 0)}
<SolBalance lamports={supply.total} maximumFractionDigits={0} />
</td>
</tr>
<tr>
<td className="w-100">Circulating Supply (SOL)</td>
<td className="text-lg-right">
{lamportsToSolString(supply.circulating, 0)}
<SolBalance
lamports={supply.circulating}
maximumFractionDigits={0}
/>
</td>
</tr>
<tr>
<td className="w-100">Non-Circulating Supply (SOL)</td>
<td className="text-lg-right">
{lamportsToSolString(supply.nonCirculating, 0)}
<SolBalance
lamports={supply.nonCirculating}
maximumFractionDigits={0}
/>
</td>
</tr>
</TableCardBody>

View File

@ -5,7 +5,7 @@ import { AccountBalancePair } from "@solana/web3.js";
import { useRichList, useFetchRichList, Status } from "providers/richList";
import { LoadingCard } from "./common/LoadingCard";
import { ErrorCard } from "./common/ErrorCard";
import { lamportsToSolString } from "utils";
import { SolBalance } from "utils";
import { useQuery } from "utils/url";
import { useSupply } from "providers/supply";
import { Address } from "./common/Address";
@ -133,7 +133,9 @@ const renderAccountRow = (
<td>
<Address pubkey={account.address} link />
</td>
<td className="text-right">{lamportsToSolString(account.lamports, 0)}</td>
<td className="text-right">
<SolBalance lamports={account.lamports} maximumFractionDigits={0} />
</td>
<td className="text-right">{`${(
(100 * account.lamports) /
supply

View File

@ -1,6 +1,6 @@
import React from "react";
import { TableCardBody } from "components/common/TableCardBody";
import { lamportsToSolString } from "utils";
import { SolBalance } from "utils";
import { displayTimestampUtc } from "utils/date";
import { Account, useFetchAccountInfo } from "providers/accounts";
import { Address } from "components/common/Address";
@ -125,13 +125,13 @@ function OverviewCard({
<tr>
<td>Balance (SOL)</td>
<td className="text-lg-right text-uppercase">
{lamportsToSolString(account.lamports || 0)}
<SolBalance lamports={account.lamports || 0} />
</td>
</tr>
<tr>
<td>Rent Reserve (SOL)</td>
<td className="text-lg-right">
{lamportsToSolString(stakeAccount.meta.rentExemptReserve)}
<SolBalance lamports={stakeAccount.meta.rentExemptReserve} />
</td>
</tr>
{hideDelegation && (
@ -190,7 +190,7 @@ function DelegationCard({
<tr>
<td>Delegated Stake (SOL)</td>
<td className="text-lg-right">
{lamportsToSolString(stake.delegation.stake)}
<SolBalance lamports={stake.delegation.stake} />
</td>
</tr>
@ -199,14 +199,14 @@ function DelegationCard({
<tr>
<td>Active Stake (SOL)</td>
<td className="text-lg-right">
{lamportsToSolString(activation.active)}
<SolBalance lamports={activation.active} />
</td>
</tr>
<tr>
<td>Inactive Stake (SOL)</td>
<td className="text-lg-right">
{lamportsToSolString(activation.inactive)}
<SolBalance lamports={activation.inactive} />
</td>
</tr>
</>

View File

@ -1,5 +1,5 @@
import React from "react";
import { lamportsToSolString } from "utils";
import { SolBalance } from "utils";
import {
SysvarAccount,
StakeHistoryInfo,
@ -57,13 +57,13 @@ const renderAccountRow = (entry: StakeHistoryEntry, index: number) => {
<tr key={index}>
<td className="w-1 text-monospace">{entry.epoch}</td>
<td className="text-monospace">
{lamportsToSolString(entry.stakeHistory.effective)}
<SolBalance lamports={entry.stakeHistory.effective} />
</td>
<td className="text-monospace">
{lamportsToSolString(entry.stakeHistory.activating)}
<SolBalance lamports={entry.stakeHistory.activating} />
</td>
<td className="text-monospace">
{lamportsToSolString(entry.stakeHistory.deactivating)}
<SolBalance lamports={entry.stakeHistory.deactivating} />
</td>
</tr>
);

View File

@ -24,7 +24,7 @@ import {
Details,
useFetchTransactionDetails,
useTransactionDetailsCache,
} from "providers/transactions/details";
} from "providers/transactions/parsed";
import { reportError } from "utils/sentry";
import { intoTransactionInstruction, displayAddress } from "utils/tx";
import {

View File

@ -1,6 +1,6 @@
import React from "react";
import { Account } from "providers/accounts";
import { lamportsToSolString } from "utils";
import { SolBalance } from "utils";
import { TableCardBody } from "components/common/TableCardBody";
import { Address } from "components/common/Address";
import { addressLabel } from "utils/tx";
@ -35,8 +35,8 @@ export function UnknownAccountCard({ account }: { account: Account }) {
)}
<tr>
<td>Balance (SOL)</td>
<td className="text-lg-right text-uppercase">
{lamportsToSolString(lamports)}
<td className="text-lg-right">
<SolBalance lamports={lamports} />
</td>
</tr>

View File

@ -1,6 +1,6 @@
import React from "react";
import { TableCardBody } from "components/common/TableCardBody";
import { lamportsToSolString } from "utils";
import { SolBalance } from "utils";
import { Account, useFetchAccountInfo } from "providers/accounts";
import { Address } from "components/common/Address";
import {
@ -98,7 +98,7 @@ export function UpgradeableProgramSection({
<tr>
<td>Balance (SOL)</td>
<td className="text-lg-right text-uppercase">
{lamportsToSolString(account.lamports || 0)}
<SolBalance lamports={account.lamports || 0} />
</td>
</tr>
<tr>
@ -169,7 +169,7 @@ export function UpgradeableProgramDataSection({
<tr>
<td>Balance (SOL)</td>
<td className="text-lg-right text-uppercase">
{lamportsToSolString(account.lamports || 0)}
<SolBalance lamports={account.lamports || 0} />
</td>
</tr>
{account.details?.space !== undefined && (
@ -236,7 +236,7 @@ export function UpgradeableProgramBufferSection({
<tr>
<td>Balance (SOL)</td>
<td className="text-lg-right text-uppercase">
{lamportsToSolString(account.lamports || 0)}
<SolBalance lamports={account.lamports || 0} />
</td>
</tr>
{account.details?.space !== undefined && (

View File

@ -1,5 +1,5 @@
import React from "react";
import { lamportsToSolString } from "utils";
import { SolBalance } from "utils";
import { BlockResponse, PublicKey } from "@solana/web3.js";
import { Address } from "components/common/Address";
@ -49,11 +49,15 @@ export function BlockRewardsCard({ block }: { block: BlockResponse }) {
<Address pubkey={new PublicKey(reward.pubkey)} link />
</td>
<td>{reward.rewardType}</td>
<td>{lamportsToSolString(reward.lamports)}</td>
<td>
{reward.postBalance
? lamportsToSolString(reward.postBalance)
: "-"}
<SolBalance lamports={reward.lamports} />
</td>
<td>
{reward.postBalance ? (
<SolBalance lamports={reward.postBalance} />
) : (
"-"
)}
</td>
<td>{percentChange ? percentChange + "%" : "-"}</td>
</tr>

View File

@ -1,7 +1,7 @@
import React from "react";
import { Address } from "./Address";
import { Account } from "providers/accounts";
import { lamportsToSolString } from "utils";
import { SolBalance } from "utils";
type AccountHeaderProps = {
title: string;
@ -41,7 +41,7 @@ export function AccountBalanceRow({ account }: AccountProps) {
<tr>
<td>Balance (SOL)</td>
<td className="text-lg-right text-uppercase">
{lamportsToSolString(lamports)}
<SolBalance lamports={lamports} />
</td>
</tr>
);

View File

@ -1,6 +1,6 @@
import React from "react";
import { BigNumber } from "bignumber.js";
import { lamportsToSolString } from "utils";
import { SolBalance } from "utils";
export function BalanceDelta({
delta,
@ -12,7 +12,7 @@ export function BalanceDelta({
let sols;
if (isSol) {
sols = lamportsToSolString(delta.toNumber());
sols = <SolBalance lamports={delta.toNumber()} />;
}
if (delta.gt(0)) {

View File

@ -8,9 +8,9 @@ import { RawDetails } from "./RawDetails";
import { RawParsedDetails } from "./RawParsedDetails";
import { SignatureContext } from "../../pages/TransactionDetailsPage";
import {
useTransactionDetails,
useFetchRawTransaction,
} from "providers/transactions/details";
useRawTransactionDetails,
} from "providers/transactions/raw";
import { Address } from "components/common/Address";
type InstructionProps = {
@ -37,11 +37,11 @@ export function InstructionCard({
const [resultClass] = ixResult(result, index);
const [showRaw, setShowRaw] = React.useState(defaultRaw || false);
const signature = useContext(SignatureContext);
const details = useTransactionDetails(signature);
const rawDetails = useRawTransactionDetails(signature);
let raw: TransactionInstruction | undefined = undefined;
if (details && childIndex === undefined) {
raw = details?.data?.raw?.instructions[index];
if (rawDetails && childIndex === undefined) {
raw = rawDetails?.data?.raw?.transaction.instructions[index];
}
const fetchRaw = useFetchRawTransaction();

View File

@ -4,7 +4,7 @@ import {
StakeProgram,
ParsedInstruction,
} from "@solana/web3.js";
import { lamportsToSolString } from "utils";
import { SolBalance } from "utils";
import { InstructionCard } from "../InstructionCard";
import { Address } from "components/common/Address";
import { SplitInfo } from "./types";
@ -58,7 +58,9 @@ export function SplitDetailsCard(props: {
<tr>
<td>Split Amount (SOL)</td>
<td className="text-lg-right">{lamportsToSolString(info.lamports)}</td>
<td className="text-lg-right">
<SolBalance lamports={info.lamports} />
</td>
</tr>
</InstructionCard>
);

View File

@ -4,7 +4,7 @@ import {
StakeProgram,
ParsedInstruction,
} from "@solana/web3.js";
import { lamportsToSolString } from "utils";
import { SolBalance } from "utils";
import { InstructionCard } from "../InstructionCard";
import { Address } from "components/common/Address";
import { WithdrawInfo } from "./types";
@ -58,7 +58,9 @@ export function WithdrawDetailsCard(props: {
<tr>
<td>Withdraw Amount (SOL)</td>
<td className="text-lg-right">{lamportsToSolString(info.lamports)}</td>
<td className="text-lg-right">
<SolBalance lamports={info.lamports} />
</td>
</tr>
</InstructionCard>
);

View File

@ -4,7 +4,7 @@ import {
SignatureResult,
ParsedInstruction,
} from "@solana/web3.js";
import { lamportsToSolString } from "utils";
import { SolBalance } from "utils";
import { InstructionCard } from "../InstructionCard";
import { Address } from "components/common/Address";
import { CreateAccountInfo } from "./types";
@ -51,7 +51,9 @@ export function CreateDetailsCard(props: {
<tr>
<td>Transfer Amount (SOL)</td>
<td className="text-lg-right">{lamportsToSolString(info.lamports)}</td>
<td className="text-lg-right">
<SolBalance lamports={info.lamports} />
</td>
</tr>
<tr>

View File

@ -4,7 +4,7 @@ import {
SignatureResult,
ParsedInstruction,
} from "@solana/web3.js";
import { lamportsToSolString } from "utils";
import { SolBalance } from "utils";
import { InstructionCard } from "../InstructionCard";
import { Copyable } from "components/common/Copyable";
import { Address } from "components/common/Address";
@ -68,7 +68,9 @@ export function CreateWithSeedDetailsCard(props: {
<tr>
<td>Transfer Amount (SOL)</td>
<td className="text-lg-right">{lamportsToSolString(info.lamports)}</td>
<td className="text-lg-right">
<SolBalance lamports={info.lamports} />
</td>
</tr>
<tr>

View File

@ -4,7 +4,7 @@ import {
SignatureResult,
ParsedInstruction,
} from "@solana/web3.js";
import { lamportsToSolString } from "utils";
import { SolBalance } from "utils";
import { InstructionCard } from "../InstructionCard";
import { Address } from "components/common/Address";
import { WithdrawNonceInfo } from "./types";
@ -58,7 +58,9 @@ export function NonceWithdrawDetailsCard(props: {
<tr>
<td>Withdraw Amount (SOL)</td>
<td className="text-lg-right">{lamportsToSolString(info.lamports)}</td>
<td className="text-lg-right">
<SolBalance lamports={info.lamports} />
</td>
</tr>
</InstructionCard>
);

View File

@ -4,7 +4,7 @@ import {
SignatureResult,
ParsedInstruction,
} from "@solana/web3.js";
import { lamportsToSolString } from "utils";
import { SolBalance } from "utils";
import { InstructionCard } from "../InstructionCard";
import { Address } from "components/common/Address";
import { TransferInfo } from "./types";
@ -51,7 +51,9 @@ export function TransferDetailsCard(props: {
<tr>
<td>Transfer Amount (SOL)</td>
<td className="text-lg-right">{lamportsToSolString(info.lamports)}</td>
<td className="text-lg-right">
<SolBalance lamports={info.lamports} />
</td>
</tr>
</InstructionCard>
);

View File

@ -4,7 +4,7 @@ import {
SignatureResult,
ParsedInstruction,
} from "@solana/web3.js";
import { lamportsToSolString } from "utils";
import { SolBalance } from "utils";
import { InstructionCard } from "../InstructionCard";
import { Copyable } from "components/common/Copyable";
import { Address } from "components/common/Address";
@ -59,7 +59,9 @@ export function TransferWithSeedDetailsCard(props: {
<tr>
<td>Transfer Amount (SOL)</td>
<td className="text-lg-right">{lamportsToSolString(info.lamports)}</td>
<td className="text-lg-right">
<SolBalance lamports={info.lamports} />
</td>
</tr>
<tr>

View File

@ -29,7 +29,7 @@ import { isSerumInstruction } from "components/instruction/serum/types";
import { isTokenLendingInstruction } from "components/instruction/token-lending/types";
import { isTokenSwapInstruction } from "components/instruction/token-swap/types";
import { isBonfidaBotInstruction } from "components/instruction/bonfida-bot/types";
import { useFetchTransactionDetails } from "providers/transactions/details";
import { useFetchTransactionDetails } from "providers/transactions/parsed";
import {
useTransactionDetails,
useTransactionStatus,

View File

@ -1,18 +1,19 @@
import React from "react";
import { Link } from "react-router-dom";
import bs58 from "bs58";
import {
useFetchTransactionStatus,
useTransactionStatus,
useTransactionDetails,
} from "providers/transactions";
import { useFetchTransactionDetails } from "providers/transactions/details";
import { useFetchTransactionDetails } from "providers/transactions/parsed";
import { useCluster, ClusterStatus } from "providers/cluster";
import {
TransactionSignature,
SystemProgram,
SystemInstruction,
} from "@solana/web3.js";
import { lamportsToSolString } from "utils";
import { SolBalance } from "utils";
import { ErrorCard } from "components/common/ErrorCard";
import { LoadingCard } from "components/common/LoadingCard";
import { TableCardBody } from "components/common/TableCardBody";
@ -28,6 +29,7 @@ import { BalanceDelta } from "components/common/BalanceDelta";
import { TokenBalancesCard } from "components/transaction/TokenBalancesCard";
import { InstructionsSection } from "components/transaction/InstructionsSection";
import { ProgramLogSection } from "components/transaction/ProgramLogSection";
import { clusterPath } from "utils/url";
const AUTO_REFRESH_INTERVAL = 2000;
const ZERO_CONFIRMATION_BAILOUT = 5;
@ -206,6 +208,13 @@ function StatusCard({
<div className="card">
<div className="card-header align-items-center">
<h3 className="card-header-title">Overview</h3>
<Link
to={clusterPath(`/tx/${signature}/inspect`)}
className="btn btn-white btn-sm mr-2"
>
<span className="fe fe-settings mr-2"></span>
Inspect
</Link>
{autoRefresh === AutoRefresh.Active ? (
<span className="spinner-grow spinner-grow-sm"></span>
) : (
@ -288,7 +297,9 @@ function StatusCard({
{fee && (
<tr>
<td>Fee (SOL)</td>
<td className="text-lg-right">{lamportsToSolString(fee)}</td>
<td className="text-lg-right">
<SolBalance lamports={fee} />
</td>
</tr>
)}
</TableCardBody>
@ -357,7 +368,9 @@ function AccountsCard({
<td>
<BalanceDelta delta={delta} isSol />
</td>
<td>{lamportsToSolString(post)}</td>
<td>
<SolBalance lamports={post} />
</td>
<td>
{index === 0 && (
<span className="badge badge-soft-info mr-1">Fee Payer</span>

View File

@ -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 <AccountRow key={accountIndex} {...props} />;
});
}, [validMessage]);
if (error) {
return <ErrorCard text={`Unable to display accounts. ${error}`} />;
}
return (
<div className="card">
<div className="card-header">
<h3 className="card-header-title">
{`Account List (${message.accountKeys.length})`}
</h3>
<button
className={`btn btn-sm d-flex ${
expanded ? "btn-black active" : "btn-white"
}`}
onClick={() => setExpanded((e) => !e)}
>
{expanded ? "Collapse" : "Expand"}
</button>
</div>
{expanded && <TableCardBody>{accountRows}</TableCardBody>}
</div>
);
}
function AccountRow({
accountIndex,
publicKey,
signer,
readOnly,
}: {
accountIndex: number;
publicKey: PublicKey;
signer: boolean;
readOnly: boolean;
}) {
return (
<tr>
<td>
<div className="d-flex align-items-start flex-column">
Account #{accountIndex + 1}
<span className="mt-1">
{signer && (
<span className="badge badge-soft-info mr-1">Signer</span>
)}
{!readOnly && (
<span className="badge badge-soft-danger">Writable</span>
)}
</span>
</div>
</td>
<td className="text-lg-right">
<AddressWithContext pubkey={publicKey} />
</td>
</tr>
);
}

View File

@ -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 (
<div className="d-flex align-items-end flex-column">
<Address pubkey={pubkey} link />
<AccountInfo pubkey={pubkey} validator={validator} />
</div>
);
}
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 (
<span className="text-muted">
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Loading
</span>
);
const errorMessage = validator && validator(info.data);
if (errorMessage) return <span className="text-warning">{errorMessage}</span>;
if (info.data.details?.executable) {
return <span className="text-muted">Executable Program</span>;
}
const owner = info.data.details?.owner;
const ownerAddress = owner?.toBase58();
const ownerLabel = ownerAddress && addressLabel(ownerAddress, cluster);
return (
<span className="text-muted">
{ownerAddress
? `Owned by ${
ownerLabel || ownerAddress
}. Balance is ${lamportsToSolString(info.data.lamports)} SOL`
: "Account doesn't exist"}
</span>
);
}

View File

@ -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<TransactionData>();
const query = useQuery();
const history = useHistory();
const location = useLocation();
const [paramString, setParamString] = React.useState<string>();
// 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 (
<div className="container mt-4">
<div className="header">
<div className="header-body">
<h2 className="header-title">Transaction Inspector</h2>
</div>
</div>
{signature ? (
<PermalinkView signature={signature} reset={reset} />
) : transaction ? (
<LoadedView transaction={transaction} onClear={reset} />
) : (
<RawInput value={paramString} setTransactionData={setTransaction} />
)}
</div>
);
}
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 <LoadingCard />;
} else if (details.status === FetchStatus.FetchFailed) {
return (
<ErrorCard
retry={refreshTransaction}
text="Failed to fetch transaction"
/>
);
} else if (!transaction) {
return (
<ErrorCard
text="Transaction was not found"
retry={reset}
retryText="Reset"
/>
);
}
const { message, signatures } = transaction;
const tx = { message, rawMessage: message.serialize(), signatures };
return <LoadedView transaction={tx} onClear={reset} />;
}
function LoadedView({
transaction,
onClear,
}: {
transaction: TransactionData;
onClear: () => void;
}) {
const { message, rawMessage, signatures } = transaction;
return (
<>
<OverviewCard message={message} raw={rawMessage} onClear={onClear} />
<SimulatorCard message={message} />
{signatures && (
<TransactionSignatures
message={message}
signatures={signatures}
rawMessage={rawMessage}
/>
)}
<AccountsCard message={message} />
<InstructionsSection message={message} />
</>
);
}
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 (
<>
<div className="card">
<div className="card-header">
<h3 className="card-header-title">Transaction Overview</h3>
<button className="btn btn-sm d-flex btn-white" onClick={onClear}>
Clear
</button>
</div>
<TableCardBody>
<tr>
<td>Serialized Size</td>
<td className="text-lg-right">
<div className="d-flex align-items-end flex-column">
{size} bytes
<span
className={
size <= PACKET_DATA_SIZE ? "text-muted" : "text-warning"
}
>
Max transaction size is {PACKET_DATA_SIZE} bytes
</span>
</div>
</td>
</tr>
<tr>
<td>Fees</td>
<td className="text-lg-right">
<div className="d-flex align-items-end flex-column">
<SolBalance lamports={fee} />
<span className="text-muted">
{`Each signature costs ${DEFAULT_FEES.lamportsPerSignature} lamports`}
</span>
</div>
</td>
</tr>
<tr>
<td>
<div className="d-flex align-items-start flex-column">
Fee payer
<span className="mt-1">
<span className="badge badge-soft-info mr-2">Signer</span>
<span className="badge badge-soft-danger mr-2">Writable</span>
</span>
</div>
</td>
<td className="text-right">
{message.accountKeys.length === 0 ? (
"No Fee Payer"
) : (
<AddressWithContext
pubkey={message.accountKeys[0]}
validator={feePayerValidator}
/>
)}
</td>
</tr>
</TableCardBody>
</div>
</>
);
}

View File

@ -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 <InstructionCard key={index} {...{ message, ix, index }} />;
})}
</>
);
}
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 (
<div className="card" id={`instruction-index-${index + 1}`} key={index}>
<div className={`card-header${!expanded ? " border-bottom-none" : ""}`}>
<h3 className="card-header-title mb-0 d-flex align-items-center">
<span className={`badge badge-soft-info mr-2`}>#{index + 1}</span>
{programName} Instruction
</h3>
<button
className={`btn btn-sm d-flex ${
expanded ? "btn-black active" : "btn-white"
}`}
onClick={() => setExpanded((e) => !e)}
>
{expanded ? "Collapse" : "Expand"}
</button>
</div>
{expanded && (
<TableCardBody>
<tr>
<td>Program</td>
<td className="text-lg-right">
<AddressWithContext
pubkey={message.accountKeys[ix.programIdIndex]}
validator={programValidator}
/>
</td>
</tr>
{ix.accounts.map((accountIndex, index) => {
return (
<tr key={index}>
<td>
<div className="d-flex align-items-start flex-column">
Account #{index + 1}
<span className="mt-1">
{accountIndex < message.header.numRequiredSignatures && (
<span className="badge badge-soft-info mr-2">
Signer
</span>
)}
{message.isAccountWritable(accountIndex) && (
<span className="badge badge-soft-danger mr-2">
Writable
</span>
)}
</span>
</div>
</td>
<td className="text-lg-right">
<AddressWithContext
pubkey={message.accountKeys[accountIndex]}
/>
</td>
</tr>
);
})}
<tr>
<td>
Instruction Data <span className="text-muted">(Hex)</span>
</td>
<td className="text-lg-right">
<pre className="d-inline-block text-left mb-0 data-wrap">
{data}
</pre>
</td>
</tr>
</TableCardBody>
)}
</div>
);
}

View File

@ -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<HTMLTextAreaElement>(null);
const [error, setError] = React.useState<string>();
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 (
<div className="card">
<div className="card-header">
<h3 className="card-header-title">Encoded Transaction Message</h3>
</div>
<div className="card-body">
<textarea
rows={rows}
onInput={onInput}
ref={rawTransactionInput}
className="form-control form-control-flush form-control-auto text-monospace"
placeholder={placeholder}
></textarea>
<div className="row align-items-center">
<div className="col d-flex align-items-center">
{error && (
<>
<span className="text-warning small mr-2">
<i className="fe fe-alert-circle"></i>
</span>
<span className="text-warning">{error}</span>
</>
)}
</div>
</div>
</div>
<div className="card-footer">
<h3>Instructions</h3>
<ul>
<li className="mb-2">
<strong>CLI: </strong>Use <code>--dump-transaction-message</code>{" "}
flag
</li>
<li className="mb-2">
<strong>Rust: </strong>Add <code>base64</code> crate dependency and{" "}
<code>
println!("{}", base64::encode(&transaction.message_data()));
</code>
</li>
<li>
<strong>JavaScript: </strong>Add{" "}
<code>console.log(tx.serializeMessage().toString("base64"));</code>
</li>
</ul>
</div>
</div>
);
}

View File

@ -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 <SignatureRow key={publicKey.toBase58()} {...props} />;
});
}, [signatures, message, rawMessage]);
return (
<div className="card">
<div className="card-header">
<h3 className="card-header-title">Signatures</h3>
</div>
<div className="table-responsive mb-0">
<table className="table table-sm table-nowrap card-table">
<thead>
<tr>
<th className="text-muted">#</th>
<th className="text-muted">Signature</th>
<th className="text-muted">Signer</th>
<th className="text-muted">Validity</th>
<th className="text-muted">Details</th>
</tr>
</thead>
<tbody className="list">{signatureRows}</tbody>
</table>
</div>
</div>
);
}
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 (
<tr>
<td>
<span className="badge badge-soft-info mr-1">{index + 1}</span>
</td>
<td>
{signature ? (
<Signature signature={signature} truncateChars={40} />
) : (
"Missing Signature"
)}
</td>
<td>
<Address pubkey={signer} link />
</td>
<td>
{verified === undefined ? (
"N/A"
) : verified ? (
<span className="badge badge-soft-success mr-1">Valid</span>
) : (
<span className="badge badge-soft-warning mr-1">Invalid</span>
)}
</td>
<td>
{index === 0 && (
<span className="badge badge-soft-info mr-1">Fee Payer</span>
)}
</td>
</tr>
);
}

View File

@ -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 (
<div className="card">
<div className="card-header">
<h3 className="card-header-title">Transaction Simulation</h3>
</div>
<div className="card-body text-center">
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Simulating
</div>
</div>
);
} else if (!logs) {
return (
<div className="card">
<div className="card-header">
<h3 className="card-header-title">Transaction Simulation</h3>
<button className="btn btn-sm d-flex btn-white" onClick={simulate}>
Simulate
</button>
</div>
<div className="card-body text-muted">
<ul>
<li>
Simulation is free and will run this transaction against the
latest confirmed ledger state.
</li>
<li>
No state changes will be persisted and all signature checks will
be disabled.
</li>
</ul>
</div>
</div>
);
}
return (
<div className="card">
<div className="card-header">
<h3 className="card-header-title">Transaction Simulation</h3>
<button className="btn btn-sm d-flex btn-white" onClick={simulate}>
Retry
</button>
</div>
<TableCardBody>
{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 (
<tr key={index}>
<td>
<div className="d-flex align-items-center">
<span className={`badge badge-soft-${badgeColor} mr-2`}>
#{index + 1}
</span>
{programName} Instruction
</div>
{programLogs && (
<div className="d-flex align-items-start flex-column text-monospace p-2 font-size-sm">
{programLogs.logs.map((log, key) => {
return (
<span key={key}>
<span className="text-muted">{log.prefix}</span>
<span className={`text-${log.style}`}>
{log.text}
</span>
</span>
);
})}
</div>
)}
</td>
</tr>
);
})}
</TableCardBody>
</div>
);
}
function useSimulator(message: Message) {
const { cluster, url } = useCluster();
const [simulating, setSimulating] = React.useState(false);
const [logs, setLogs] = React.useState<Array<InstructionLogs> | 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 };
}

View File

@ -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 {

View File

@ -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 (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
<DetailsProvider>{children}</DetailsProvider>
<RawDetailsProvider>
<DetailsProvider>{children}</DetailsProvider>
</RawDetailsProvider>
</DispatchContext.Provider>
</StateContext.Provider>
);

View File

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

View File

@ -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<Details>;
type Dispatch = Cache.Dispatch<Details>;
export const StateContext = React.createContext<State | undefined>(undefined);
export const DispatchContext = React.createContext<Dispatch | undefined>(
undefined
);
type DetailsProviderProps = { children: React.ReactNode };
export function RawDetailsProvider({ children }: DetailsProviderProps) {
const { url } = useCluster();
const [state, dispatch] = Cache.useReducer<Details>(url);
React.useEffect(() => {
dispatch({ type: ActionType.Clear, url });
}, [dispatch, url]);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}
export function useRawTransactionDetails(
signature: TransactionSignature
): Cache.CacheEntry<Details> | 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]
);
}

View File

@ -91,6 +91,10 @@ ul.log-messages {
bottom: 0;
}
.border-bottom-none {
border-bottom: 0px;
}
.opacity-50 {
opacity: 0.5;
}

View File

@ -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 (
<>
<span>
<span className="text-monospace">
{new Intl.NumberFormat("en-US", { maximumFractionDigits }).format(sol)}
{lamportsToSolString(lamports, maximumFractionDigits)}
</span>
</>
</span>
);
}

View File

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