solana/explorer/src/pages/inspector/InspectorPage.tsx

382 lines
11 KiB
TypeScript

import React from "react";
import { PACKET_DATA_SIZE, VersionedMessage } from "@solana/web3.js";
import { TableCardBody } from "components/common/TableCardBody";
import { SolBalance } from "components/common/SolBalance";
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 { AddressTableLookupsCard } from "./AddressTableLookupsCard";
import {
AddressWithContext,
createFeePayerValidator,
} from "./AddressWithContext";
import { SimulatorCard } from "./SimulatorCard";
import { MIN_MESSAGE_LENGTH, RawInput } from "./RawInputCard";
import { InstructionsSection } from "./InstructionsSection";
import base58 from "bs58";
import { useFetchAccountInfo } from "providers/accounts";
export type TransactionData = {
rawMessage: Uint8Array;
message: VersionedMessage;
signatures?: (string | null)[];
};
// Decode a url param and return the result. If decoding fails, return whether
// the param should be deleted.
function decodeParam(params: URLSearchParams, name: string): string | boolean {
const param = params.get(name);
if (param === null) return false;
try {
return decodeURIComponent(param);
} catch (err) {
return true;
}
}
// Decode a signatures param and throw an error on failure
function decodeSignatures(signaturesParam: string): (string | null)[] {
let signatures;
try {
signatures = JSON.parse(signaturesParam);
} catch (err) {
throw new Error("Signatures param is not valid JSON");
}
if (!Array.isArray(signatures)) {
throw new Error("Signatures param is not a JSON array");
}
const validSignatures: (string | null)[] = [];
for (const signature of signatures) {
if (signature === null) {
validSignatures.push(signature);
continue;
}
if (typeof signature !== "string") {
throw new Error("Signature is not a string");
}
try {
base58.decode(signature);
validSignatures.push(signature);
} catch (err) {
throw new Error("Signature is not valid base58");
}
}
return validSignatures;
}
// Decodes url params into transaction data if possible. If decoding fails,
// URL params are returned as a string that will prefill the transaction
// message input field for debugging. Returns a tuple of [result, shouldRefreshUrl]
function decodeUrlParams(
params: URLSearchParams
): [TransactionData | string, boolean] {
const messageParam = decodeParam(params, "message");
const signaturesParam = decodeParam(params, "signatures");
let refreshUrl = false;
if (signaturesParam === true) {
params.delete("signatures");
refreshUrl = true;
}
if (typeof messageParam === "boolean") {
if (messageParam) {
params.delete("message");
params.delete("signatures");
refreshUrl = true;
}
return ["", refreshUrl];
}
let signatures: (string | null)[] | undefined = undefined;
if (typeof signaturesParam === "string") {
try {
signatures = decodeSignatures(signaturesParam);
} catch (err) {
params.delete("signatures");
refreshUrl = true;
}
}
try {
const buffer = Uint8Array.from(atob(messageParam), (c) => c.charCodeAt(0));
if (buffer.length < MIN_MESSAGE_LENGTH) {
throw new Error("message buffer is too short");
}
const message = VersionedMessage.deserialize(buffer);
const data = {
message,
rawMessage: buffer,
signatures,
};
return [data, refreshUrl];
} catch (err) {
params.delete("message");
refreshUrl = true;
return [messageParam, true];
}
}
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) {
let shouldRefreshUrl = false;
if (transaction.signatures !== undefined) {
const signaturesParam = encodeURIComponent(
JSON.stringify(transaction.signatures)
);
if (query.get("signatures") !== signaturesParam) {
shouldRefreshUrl = true;
query.set("signatures", signaturesParam);
}
}
const base64 = btoa(
String.fromCharCode.apply(null, [...transaction.rawMessage])
);
const newParam = encodeURIComponent(base64);
if (query.get("message") !== newParam) {
shouldRefreshUrl = true;
query.set("message", newParam);
}
if (shouldRefreshUrl) {
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;
const [result, refreshUrl] = decodeUrlParams(query);
if (refreshUrl) {
history.push({ ...location, search: query.toString() });
}
if (typeof result === "string") {
setParamString(result);
} else {
setTransaction(result);
}
}, [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;
const fetchAccountInfo = useFetchAccountInfo();
React.useEffect(() => {
for (let lookup of message.addressTableLookups) {
fetchAccountInfo(lookup.accountKey, "parsed");
}
}, [message, fetchAccountInfo]);
return (
<>
<OverviewCard message={message} raw={rawMessage} onClear={onClear} />
<SimulatorCard message={message} />
{signatures && (
<TransactionSignatures
message={message}
signatures={signatures}
rawMessage={rawMessage}
/>
)}
<AccountsCard message={message} />
<AddressTableLookupsCard message={message} />
<InstructionsSection message={message} />
</>
);
}
const DEFAULT_FEES = {
lamportsPerSignature: 5000,
};
function OverviewCard({
message,
raw,
onClear,
}: {
message: VersionedMessage;
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-end">
<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-end">
<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 bg-info-soft me-2">Signer</span>
<span className="badge bg-danger-soft me-2">Writable</span>
</span>
</div>
</td>
<td className="text-end">
{message.staticAccountKeys.length === 0 ? (
"No Fee Payer"
) : (
<AddressWithContext
pubkey={message.staticAccountKeys[0]}
validator={feePayerValidator}
/>
)}
</td>
</tr>
</TableCardBody>
</div>
</>
);
}