diff --git a/explorer/package-lock.json b/explorer/package-lock.json index 02d44699f..574d73f55 100644 --- a/explorer/package-lock.json +++ b/explorer/package-lock.json @@ -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", diff --git a/explorer/src/App.tsx b/explorer/src/App.tsx index c834ce918..138094c1f 100644 --- a/explorer/src/App.tsx +++ b/explorer/src/App.tsx @@ -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 ; }} /> + ( + + )} + /> +
  • + + Inspector + +
  • diff --git a/explorer/src/components/SupplyCard.tsx b/explorer/src/components/SupplyCard.tsx index 0250f22da..9ec62782b 100644 --- a/explorer/src/components/SupplyCard.tsx +++ b/explorer/src/components/SupplyCard.tsx @@ -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() { Total Supply (SOL) - {lamportsToSolString(supply.total, 0)} + Circulating Supply (SOL) - {lamportsToSolString(supply.circulating, 0)} + Non-Circulating Supply (SOL) - {lamportsToSolString(supply.nonCirculating, 0)} + diff --git a/explorer/src/components/TopAccountsCard.tsx b/explorer/src/components/TopAccountsCard.tsx index 3880a1204..ef4d37fb0 100644 --- a/explorer/src/components/TopAccountsCard.tsx +++ b/explorer/src/components/TopAccountsCard.tsx @@ -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 = (
    - {lamportsToSolString(account.lamports, 0)} + + + {`${( (100 * account.lamports) / supply diff --git a/explorer/src/components/account/StakeAccountSection.tsx b/explorer/src/components/account/StakeAccountSection.tsx index 196690a1d..e2398ebf7 100644 --- a/explorer/src/components/account/StakeAccountSection.tsx +++ b/explorer/src/components/account/StakeAccountSection.tsx @@ -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({ Balance (SOL) - {lamportsToSolString(account.lamports || 0)} + Rent Reserve (SOL) - {lamportsToSolString(stakeAccount.meta.rentExemptReserve)} + {hideDelegation && ( @@ -190,7 +190,7 @@ function DelegationCard({ Delegated Stake (SOL) - {lamportsToSolString(stake.delegation.stake)} + @@ -199,14 +199,14 @@ function DelegationCard({ Active Stake (SOL) - {lamportsToSolString(activation.active)} + Inactive Stake (SOL) - {lamportsToSolString(activation.inactive)} + diff --git a/explorer/src/components/account/StakeHistoryCard.tsx b/explorer/src/components/account/StakeHistoryCard.tsx index dea1bdd1a..2f4df710d 100644 --- a/explorer/src/components/account/StakeHistoryCard.tsx +++ b/explorer/src/components/account/StakeHistoryCard.tsx @@ -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) => { {entry.epoch} - {lamportsToSolString(entry.stakeHistory.effective)} + - {lamportsToSolString(entry.stakeHistory.activating)} + - {lamportsToSolString(entry.stakeHistory.deactivating)} + ); diff --git a/explorer/src/components/account/TokenHistoryCard.tsx b/explorer/src/components/account/TokenHistoryCard.tsx index 0a129ba68..d8e2310fd 100644 --- a/explorer/src/components/account/TokenHistoryCard.tsx +++ b/explorer/src/components/account/TokenHistoryCard.tsx @@ -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 { diff --git a/explorer/src/components/account/UnknownAccountCard.tsx b/explorer/src/components/account/UnknownAccountCard.tsx index 17622e03e..bd6750a9c 100644 --- a/explorer/src/components/account/UnknownAccountCard.tsx +++ b/explorer/src/components/account/UnknownAccountCard.tsx @@ -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 }) { )} Balance (SOL) - - {lamportsToSolString(lamports)} + + diff --git a/explorer/src/components/account/UpgradeableLoaderAccountSection.tsx b/explorer/src/components/account/UpgradeableLoaderAccountSection.tsx index 611064940..e41066f12 100644 --- a/explorer/src/components/account/UpgradeableLoaderAccountSection.tsx +++ b/explorer/src/components/account/UpgradeableLoaderAccountSection.tsx @@ -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({ Balance (SOL) - {lamportsToSolString(account.lamports || 0)} + @@ -169,7 +169,7 @@ export function UpgradeableProgramDataSection({ Balance (SOL) - {lamportsToSolString(account.lamports || 0)} + {account.details?.space !== undefined && ( @@ -236,7 +236,7 @@ export function UpgradeableProgramBufferSection({ Balance (SOL) - {lamportsToSolString(account.lamports || 0)} + {account.details?.space !== undefined && ( diff --git a/explorer/src/components/block/BlockRewardsCard.tsx b/explorer/src/components/block/BlockRewardsCard.tsx index 4d740703b..c1085b140 100644 --- a/explorer/src/components/block/BlockRewardsCard.tsx +++ b/explorer/src/components/block/BlockRewardsCard.tsx @@ -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 }) {
    {reward.rewardType} - {lamportsToSolString(reward.lamports)} - {reward.postBalance - ? lamportsToSolString(reward.postBalance) - : "-"} + + + + {reward.postBalance ? ( + + ) : ( + "-" + )} {percentChange ? percentChange + "%" : "-"} diff --git a/explorer/src/components/common/Account.tsx b/explorer/src/components/common/Account.tsx index f2eb5280f..55e6c4a0e 100644 --- a/explorer/src/components/common/Account.tsx +++ b/explorer/src/components/common/Account.tsx @@ -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) { Balance (SOL) - {lamportsToSolString(lamports)} + ); diff --git a/explorer/src/components/common/BalanceDelta.tsx b/explorer/src/components/common/BalanceDelta.tsx index 45981dac7..25c860f58 100644 --- a/explorer/src/components/common/BalanceDelta.tsx +++ b/explorer/src/components/common/BalanceDelta.tsx @@ -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 = ; } if (delta.gt(0)) { diff --git a/explorer/src/components/instruction/InstructionCard.tsx b/explorer/src/components/instruction/InstructionCard.tsx index f42da9a8e..ff22e11dc 100644 --- a/explorer/src/components/instruction/InstructionCard.tsx +++ b/explorer/src/components/instruction/InstructionCard.tsx @@ -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(); diff --git a/explorer/src/components/instruction/stake/SplitDetailsCard.tsx b/explorer/src/components/instruction/stake/SplitDetailsCard.tsx index e09e37824..2f6b50957 100644 --- a/explorer/src/components/instruction/stake/SplitDetailsCard.tsx +++ b/explorer/src/components/instruction/stake/SplitDetailsCard.tsx @@ -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: { Split Amount (SOL) - {lamportsToSolString(info.lamports)} + + + ); diff --git a/explorer/src/components/instruction/stake/WithdrawDetailsCard.tsx b/explorer/src/components/instruction/stake/WithdrawDetailsCard.tsx index 302b7ae7d..58becc85d 100644 --- a/explorer/src/components/instruction/stake/WithdrawDetailsCard.tsx +++ b/explorer/src/components/instruction/stake/WithdrawDetailsCard.tsx @@ -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: { Withdraw Amount (SOL) - {lamportsToSolString(info.lamports)} + + + ); diff --git a/explorer/src/components/instruction/system/CreateDetailsCard.tsx b/explorer/src/components/instruction/system/CreateDetailsCard.tsx index c9f9a2d2d..3e6c2d686 100644 --- a/explorer/src/components/instruction/system/CreateDetailsCard.tsx +++ b/explorer/src/components/instruction/system/CreateDetailsCard.tsx @@ -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: { Transfer Amount (SOL) - {lamportsToSolString(info.lamports)} + + + diff --git a/explorer/src/components/instruction/system/CreateWithSeedDetailsCard.tsx b/explorer/src/components/instruction/system/CreateWithSeedDetailsCard.tsx index 28c3a42b3..b2d0d7b3d 100644 --- a/explorer/src/components/instruction/system/CreateWithSeedDetailsCard.tsx +++ b/explorer/src/components/instruction/system/CreateWithSeedDetailsCard.tsx @@ -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: { Transfer Amount (SOL) - {lamportsToSolString(info.lamports)} + + + diff --git a/explorer/src/components/instruction/system/NonceWithdrawDetailsCard.tsx b/explorer/src/components/instruction/system/NonceWithdrawDetailsCard.tsx index 98c50ffb6..a5638b7ed 100644 --- a/explorer/src/components/instruction/system/NonceWithdrawDetailsCard.tsx +++ b/explorer/src/components/instruction/system/NonceWithdrawDetailsCard.tsx @@ -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: { Withdraw Amount (SOL) - {lamportsToSolString(info.lamports)} + + + ); diff --git a/explorer/src/components/instruction/system/TransferDetailsCard.tsx b/explorer/src/components/instruction/system/TransferDetailsCard.tsx index f05a7e7d4..ba1d2ef75 100644 --- a/explorer/src/components/instruction/system/TransferDetailsCard.tsx +++ b/explorer/src/components/instruction/system/TransferDetailsCard.tsx @@ -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: { Transfer Amount (SOL) - {lamportsToSolString(info.lamports)} + + + ); diff --git a/explorer/src/components/instruction/system/TransferWithSeedDetailsCard.tsx b/explorer/src/components/instruction/system/TransferWithSeedDetailsCard.tsx index 8af35baaf..f6c88d206 100644 --- a/explorer/src/components/instruction/system/TransferWithSeedDetailsCard.tsx +++ b/explorer/src/components/instruction/system/TransferWithSeedDetailsCard.tsx @@ -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: { Transfer Amount (SOL) - {lamportsToSolString(info.lamports)} + + + diff --git a/explorer/src/components/transaction/InstructionsSection.tsx b/explorer/src/components/transaction/InstructionsSection.tsx index fe16a5cc0..12d0cb0f2 100644 --- a/explorer/src/components/transaction/InstructionsSection.tsx +++ b/explorer/src/components/transaction/InstructionsSection.tsx @@ -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, diff --git a/explorer/src/pages/TransactionDetailsPage.tsx b/explorer/src/pages/TransactionDetailsPage.tsx index 96677aa3a..a0dc55415 100644 --- a/explorer/src/pages/TransactionDetailsPage.tsx +++ b/explorer/src/pages/TransactionDetailsPage.tsx @@ -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({

    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

    +
    +
    + + + + + + + + + + + {signatureRows} +
    #SignatureSignerValidityDetails
    +
    +
    + ); +} + +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; -}