diff --git a/explorer/src/App.tsx b/explorer/src/App.tsx index bf5195bf50..a7f46b18fb 100644 --- a/explorer/src/App.tsx +++ b/explorer/src/App.tsx @@ -1,99 +1,61 @@ import React from "react"; import { Link, Switch, Route, Redirect } from "react-router-dom"; -import ClusterStatusButton from "./components/ClusterStatusButton"; import AccountsCard from "./components/AccountsCard"; import TransactionsCard from "./components/TransactionsCard"; +import TransactionDetails from "./components/TransactionDetails"; import ClusterModal from "./components/ClusterModal"; -import TransactionModal from "./components/TransactionModal"; import AccountModal from "./components/AccountModal"; import Logo from "./img/logos-solana/light-explorer-logo.svg"; -import { useCurrentTab, Tab } from "./providers/tab"; -import { TX_PATHS } from "./providers/transactions"; +import { TX_ALIASES } from "./providers/transactions"; import { ACCOUNT_PATHS } from "./providers/accounts"; +import TabbedPage from "components/TabbedPage"; function App() { - const [showClusterModal, setShowClusterModal] = React.useState(false); return ( <> - setShowClusterModal(false)} - /> - +
-
-
-
-
-
- setShowClusterModal(true)} - /> -
-
-
-
-
    -
  • - -
  • -
  • - -
  • -
-
-
- setShowClusterModal(true)} - /> -
-
-
-
-
- -
- - - - - + + `/${tx}/:signature`)} + render={({ match }) => ( + + )} + /> + `/${tx}s`)}> + - - + + + + - - -
+ + + ( + + )} + > +
); } -function NavLink({ href, tab }: { href: string; tab: Tab }) { - let classes = "nav-link"; - if (tab === useCurrentTab()) { - classes += " active"; - } - - return ( - - {tab} - - ); -} - export default App; diff --git a/explorer/src/components/AccountsCard.tsx b/explorer/src/components/AccountsCard.tsx index a0a015c8e2..1b35e21214 100644 --- a/explorer/src/components/AccountsCard.tsx +++ b/explorer/src/components/AccountsCard.tsx @@ -11,8 +11,9 @@ import { import { assertUnreachable } from "../utils"; import { displayAddress } from "../utils/tx"; import { useCluster } from "../providers/cluster"; -import { PublicKey, LAMPORTS_PER_SOL } from "@solana/web3.js"; +import { PublicKey } from "@solana/web3.js"; import Copyable from "./Copyable"; +import { lamportsToSolString } from "utils"; function AccountsCard() { const { accounts, idCounter } = useAccounts(); @@ -154,7 +155,7 @@ const renderAccountRow = ( let balance = "-"; if (account.lamports !== undefined) { - balance = `◎${(1.0 * account.lamports) / LAMPORTS_PER_SOL}`; + balance = lamportsToSolString(account.lamports); } const renderDetails = () => { diff --git a/explorer/src/components/ClusterModal.tsx b/explorer/src/components/ClusterModal.tsx index 9ce05bf6d0..c916015055 100644 --- a/explorer/src/components/ClusterModal.tsx +++ b/explorer/src/components/ClusterModal.tsx @@ -7,17 +7,15 @@ import { clusterUrl, clusterName, CLUSTERS, - Cluster + Cluster, + useClusterModal } from "../providers/cluster"; import { assertUnreachable } from "../utils"; import Overlay from "./Overlay"; -type Props = { - show: boolean; - onClose: () => void; -}; - -function ClusterModal({ show, onClose }: Props) { +function ClusterModal() { + const [show, setShow] = useClusterModal(); + const onClose = () => setShow(false); return ( <>
+
+
+
+
+ setShow(true)} /> +
+
+
+
+
    +
  • + +
  • +
  • + +
  • +
+
+
+ setShow(true)} /> +
+
+
+
+ + {children} +
+ ); +} + +function NavLink({ + href, + tab, + current +}: { + href: string; + tab: Tab; + current: Tab; +}) { + const location = useLocation(); + let classes = "nav-link"; + if (tab === current) { + classes += " active"; + } + + return ( + + {tab} + + ); +} diff --git a/explorer/src/components/TransactionDetails.tsx b/explorer/src/components/TransactionDetails.tsx new file mode 100644 index 0000000000..038482a9ee --- /dev/null +++ b/explorer/src/components/TransactionDetails.tsx @@ -0,0 +1,482 @@ +import React from "react"; +import { + Source, + useTransactionStatus, + useTransactionDetails, + useTransactionsDispatch, + useDetailsDispatch, + checkTransactionStatus, + ActionType, + FetchStatus +} from "../providers/transactions"; +import { fetchDetails } from "providers/transactions/details"; +import { useCluster, useClusterModal } from "providers/cluster"; +import { + TransactionSignature, + TransactionInstruction, + TransferParams, + CreateAccountParams, + SystemProgram +} from "@solana/web3.js"; +import ClusterStatusButton from "components/ClusterStatusButton"; +import { lamportsToSolString } from "utils"; +import { displayAddress, decodeCreate, decodeTransfer } from "utils/tx"; +import Copyable from "./Copyable"; +import { useHistory, useLocation } from "react-router-dom"; + +type Props = { signature: TransactionSignature }; +export default function TransactionDetails({ signature }: Props) { + const dispatch = useTransactionsDispatch(); + const { url } = useCluster(); + const [, setShow] = useClusterModal(); + const [search, setSearch] = React.useState(signature); + const history = useHistory(); + const location = useLocation(); + + const updateSignature = () => { + history.push({ ...location, pathname: "/tx/" + search }); + }; + + // Fetch transaction on load + React.useEffect(() => { + dispatch({ + type: ActionType.FetchSignature, + signature, + source: Source.Url + }); + checkTransactionStatus(dispatch, signature, url); + }, [signature, dispatch, url]); + + const searchInput = ( + setSearch(e.target.value)} + onKeyUp={e => e.key === "Enter" && updateSignature()} + className="form-control form-control-prepended search text-monospace" + placeholder="Search for signature" + /> + ); + + return ( +
+
+
+
+
+
Details
+

Transaction

+
+
+ setShow(true)} /> +
+
+
+
+ +
+
+
+ {searchInput} +
+
+ +
+
+
+
+
{searchInput}
+
+ +
+
+ + + + +
+ ); +} + +function TransactionStatusCard({ signature }: Props) { + const status = useTransactionStatus(signature); + const dispatch = useTransactionsDispatch(); + const details = useTransactionDetails(signature); + const { url } = useCluster(); + + const refreshStatus = () => { + checkTransactionStatus(dispatch, signature, url); + }; + + if (!status || status.fetchStatus === FetchStatus.Fetching) { + return ; + } else if (status?.fetchStatus === FetchStatus.FetchFailed) { + return ; + } else if (!status.info) { + return ; + } + + const { info } = status; + const renderResult = () => { + let statusClass = "success"; + let statusText = "Success"; + if (info.result.err) { + statusClass = "danger"; + statusText = "Error"; + } + + return ( + {statusText} + ); + }; + + const fee = details?.transaction?.meta?.fee; + return ( +
+
+
+
+
Status
+
+
+ +
+
+
+
+
+
+
+
+
Result
+
+
{renderResult()}
+
+
+ +
+
+
+
Block
+
+
{info.slot}
+
+
+ +
+
+
+
Confirmations
+
+
+ {info.confirmations} +
+
+
+ {fee && ( +
+
+
+
Fee (SOL)
+
+
{lamportsToSolString(fee)}
+
+
+ )} +
+
+
+ ); +} + +function TransactionAccountsCard({ signature }: Props) { + const details = useTransactionDetails(signature); + const dispatch = useDetailsDispatch(); + const { url } = useCluster(); + + const refreshDetails = () => fetchDetails(dispatch, signature, url); + const transaction = details?.transaction?.transaction; + const message = React.useMemo(() => { + return transaction?.compileMessage(); + }, [transaction]); + + if (!details) { + return null; + } else if (details.fetchStatus === FetchStatus.Fetching) { + return ; + } else if (details?.fetchStatus === FetchStatus.FetchFailed) { + return ; + } else if (!details.transaction || !message) { + return ; + } + + const { meta } = details.transaction; + if (!meta) { + return ; + } + + const accountRows = message.accountKeys.map((pubkey, index) => { + const pre = meta.preBalances[index]; + const post = meta.postBalances[index]; + const key = pubkey.toBase58(); + const renderChange = () => { + const change = post - pre; + if (change === 0) return ""; + const sols = lamportsToSolString(change); + if (change > 0) { + return {"+" + sols}; + } else { + return {"-" + sols}; + } + }; + + return ( + + + + {displayAddress(pubkey)} + + + {renderChange()} + {lamportsToSolString(post)} + + {index === 0 && ( + Fee Payer + )} + {!message.isAccountWritable(index) && ( + Readonly + )} + {index < message.header.numRequiredSignatures && ( + Signer + )} + {message.instructions.find(ix => ix.programIdIndex === index) && ( + Program + )} + + + ); + }); + + return ( +
+
+

Accounts

+
+
+ + + + + + + + + + {accountRows} +
AddressChange (SOL)Post Balance (SOL)Details
+
+
+ ); +} + +function TransactionInstructionsCard({ signature }: Props) { + const details = useTransactionDetails(signature); + const dispatch = useDetailsDispatch(); + const { url } = useCluster(); + const refreshDetails = () => fetchDetails(dispatch, signature, url); + + if (!details || !details.transaction) return null; + + const { transaction } = details.transaction; + if (transaction.instructions.length === 0) { + return ; + } + + const instructionDetails = transaction.instructions.map((ix, index) => { + const transfer = decodeTransfer(ix); + if (transfer) + return ; + const create = decodeCreate(ix); + if (create) + return ; + return ; + }); + + return ( + <> +
+
+
Transaction Instruction(s)
+
+
+ {instructionDetails} + + ); +} + +function TransferDetails({ + transfer, + index +}: { + transfer: TransferParams; + index: number; +}) { + const from = transfer.fromPubkey.toBase58(); + const to = transfer.toPubkey.toBase58(); + return ( +
+
+

+ #{index + 1} + Transfer +

+
+
+
+ + {displayAddress(SystemProgram.programId)} + + + + {from} + + + + + {to} + + + + {lamportsToSolString(transfer.lamports)} + +
+
+
+ ); +} + +function CreateDetails({ + create, + index +}: { + create: CreateAccountParams; + index: number; +}) { + const from = create.fromPubkey.toBase58(); + const newKey = create.newAccountPubkey.toBase58(); + return ( +
+
+

+ #{index + 1} + Create Account +

+
+
+
+ + {displayAddress(SystemProgram.programId)} + + + + {from} + + + + + {newKey} + + + + {lamportsToSolString(create.lamports)} + + {create.space} + + {displayAddress(create.programId)} + +
+
+
+ ); +} + +function InstructionDetails({ + ix, + index +}: { + ix: TransactionInstruction; + index: number; +}) { + return ( +
+
+

+ #{index + 1} +

+
+
+
+ + {displayAddress(ix.programId)} + + {ix.keys.map(({ pubkey }, keyIndex) => ( + + + {pubkey.toBase58()} + + + ))} + {ix.data.length} +
+
+
+ ); +} + +function ListGroupItem({ + label, + children +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+
+
+
{label}
+
+
{children}
+
+
+ ); +} + +function LoadingCard() { + return ( +
+
+ + Loading +
+
+ ); +} + +function RetryCard({ retry, text }: { retry: () => void; text: string }) { + return ( +
+
+ {text} + + Try Again + +
+
+ ); +} diff --git a/explorer/src/components/TransactionModal.tsx b/explorer/src/components/TransactionModal.tsx deleted file mode 100644 index bcc0a85c6d..0000000000 --- a/explorer/src/components/TransactionModal.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import React from "react"; -import { - useDetails, - useTransactions, - useTransactionsDispatch, - ActionType -} from "../providers/transactions"; -import { displayAddress, decodeCreate, decodeTransfer } from "../utils/tx"; -import { - LAMPORTS_PER_SOL, - TransferParams, - CreateAccountParams, - TransactionInstruction, - TransactionSignature -} from "@solana/web3.js"; -import Copyable from "./Copyable"; -import Overlay from "./Overlay"; - -function TransactionModal() { - const { selected } = useTransactions(); - const dispatch = useTransactionsDispatch(); - const onClose = () => dispatch({ type: ActionType.Deselect }); - const show = !!selected; - - const renderContent = () => { - if (!selected) return null; - return ( -
-
e.stopPropagation()}> -
-
-

Transaction Details

- - -
- - -
-
-
- ); - }; - - return ( - <> -
- {renderContent()} -
- - - ); -} - -function TransactionDetails({ - signature -}: { - signature: TransactionSignature; -}) { - const details = useDetails(signature); - - const renderError = (content: React.ReactNode) => { - return ( -
- {content} -
- ); - }; - - if (!details) return renderError("Transaction details not found"); - - if (!details.transaction) { - return renderError( - <> - - Loading - - ); - } - - const { transaction } = details.transaction; - if (!transaction) return renderError("Transaction not found"); - - if (transaction.instructions.length === 0) - return renderError("No instructions found"); - - const instructionDetails = transaction.instructions.map((ix, index) => { - const transfer = decodeTransfer(ix); - if (transfer) return ; - const create = decodeCreate(ix); - if (create) return ; - return ; - }); - - return ( - <> - {instructionDetails.map((details, i) => { - return ( -
- {i > 1 ?
: null} - {details} -
- ); - })} - - ); -} - -function TransferDetails({ - transfer, - index -}: { - transfer: TransferParams; - index: number; -}) { - const from = transfer.fromPubkey.toBase58(); - const to = transfer.toPubkey.toBase58(); - return ( -
-

{`Instruction #${index + 1} (Transfer)`}

-
- - - {from} - - - - - {to} - - - - {`◎${(1.0 * transfer.lamports) / LAMPORTS_PER_SOL}`} - -
-
- ); -} - -function CreateDetails({ - create, - index -}: { - create: CreateAccountParams; - index: number; -}) { - const from = create.fromPubkey.toBase58(); - const newKey = create.newAccountPubkey.toBase58(); - return ( -
-

{`Instruction #${index + - 1} (Create Account)`}

-
- - - {from} - - - - - {newKey} - - - - {`◎${(1.0 * create.lamports) / LAMPORTS_PER_SOL}`} - - {create.space} - - {displayAddress(create.programId)} - -
-
- ); -} - -function InstructionDetails({ - ix, - index -}: { - ix: TransactionInstruction; - index: number; -}) { - return ( -
-

{`Instruction #${index + 1}`}

-
- {ix.keys.map(({ pubkey }, keyIndex) => ( - - - {pubkey.toBase58()} - - - ))} - {ix.data.length} - - {displayAddress(ix.programId)} - -
-
- ); -} - -function ListGroupItem({ - label, - children -}: { - label: string; - children: React.ReactNode; -}) { - return ( -
-
-
-
{label}
-
-
{children}
-
-
- ); -} - -export default TransactionModal; diff --git a/explorer/src/components/TransactionsCard.tsx b/explorer/src/components/TransactionsCard.tsx index b4ab4705a9..cbd7b3de8d 100644 --- a/explorer/src/components/TransactionsCard.tsx +++ b/explorer/src/components/TransactionsCard.tsx @@ -1,16 +1,19 @@ import React from "react"; +import { Link } from "react-router-dom"; import { useTransactions, useTransactionsDispatch, checkTransactionStatus, ActionType, - TransactionState, + TransactionStatus, + Source, FetchStatus } from "../providers/transactions"; import bs58 from "bs58"; import { assertUnreachable } from "../utils"; import { useCluster } from "../providers/cluster"; import Copyable from "./Copyable"; +import { useHistory, useLocation } from "react-router-dom"; function TransactionsCard() { const { transactions, idCounter } = useTransactions(); @@ -18,6 +21,7 @@ function TransactionsCard() { const signatureInput = React.useRef(null); const [error, setError] = React.useState(""); const { url } = useCluster(); + const location = useLocation(); const onNew = (signature: string) => { if (signature.length === 0) return; @@ -35,7 +39,11 @@ function TransactionsCard() { return; } - dispatch({ type: ActionType.InputSignature, signature }); + dispatch({ + type: ActionType.FetchSignature, + signature, + source: Source.Input + }); checkTransactionStatus(dispatch, signature, url); const inputEl = signatureInput.current; @@ -93,7 +101,7 @@ function TransactionsCard() { {transactions.map(transaction => - renderTransactionRow(transaction, dispatch, url) + renderTransactionRow(transaction, dispatch, location, url) )} @@ -115,11 +123,12 @@ const renderHeader = () => { }; const renderTransactionRow = ( - transaction: TransactionState, + transactionStatus: TransactionStatus, dispatch: any, + location: any, url: string ) => { - const { fetchStatus, transactionStatus } = transaction; + const { fetchStatus, info, signature, id } = transactionStatus; let statusText; let statusClass; @@ -133,10 +142,10 @@ const renderTransactionRow = ( statusText = "Fetching"; break; case FetchStatus.Fetched: { - if (!transactionStatus) { + if (!info) { statusClass = "warning"; statusText = "Not Found"; - } else if (transactionStatus.result.err) { + } else if (info.result.err) { statusClass = "danger"; statusText = "Failed"; } else { @@ -151,50 +160,46 @@ const renderTransactionRow = ( let slotText = "-"; let confirmationsText = "-"; - if (transactionStatus) { - slotText = `${transactionStatus.slot}`; - confirmationsText = `${transactionStatus.confirmations}`; + if (info) { + slotText = `${info.slot}`; + confirmationsText = `${info.confirmations}`; } const renderDetails = () => { - let onClick, icon; - if (transactionStatus?.confirmations === "max") { - icon = "more-horizontal"; - onClick = () => - dispatch({ - type: ActionType.Select, - signature: transaction.signature - }); + if (info?.confirmations === "max") { + return ( + + + + ); } else { - icon = "refresh-cw"; - onClick = () => { - checkTransactionStatus(dispatch, transaction.signature, url); - }; + return ( + + ); } - - return ( - - ); }; return ( - + - - {transaction.id} - + {id} {statusText} - - {transaction.signature} + + {signature} {confirmationsText} diff --git a/explorer/src/index.tsx b/explorer/src/index.tsx index 96ec23df87..19494e03c2 100644 --- a/explorer/src/index.tsx +++ b/explorer/src/index.tsx @@ -7,19 +7,16 @@ import * as serviceWorker from "./serviceWorker"; import { ClusterProvider } from "./providers/cluster"; import { TransactionsProvider } from "./providers/transactions"; import { AccountsProvider } from "./providers/accounts"; -import { TabProvider } from "./providers/tab"; ReactDOM.render( - - - - - - - - - + + + + + + + , document.getElementById("root") ); diff --git a/explorer/src/providers/cluster.tsx b/explorer/src/providers/cluster.tsx index caded21c76..4b6f9af11c 100644 --- a/explorer/src/providers/cluster.tsx +++ b/explorer/src/providers/cluster.tsx @@ -125,6 +125,10 @@ function initState(): State { }; } +type SetShowModal = React.Dispatch>; +const ModalContext = React.createContext<[boolean, SetShowModal] | undefined>( + undefined +); const StateContext = React.createContext(undefined); const DispatchContext = React.createContext(undefined); @@ -135,6 +139,7 @@ export function ClusterProvider({ children }: ClusterProviderProps) { undefined, initState ); + const [showModal, setShowModal] = React.useState(false); React.useEffect(() => { // Connect to cluster immediately @@ -144,7 +149,9 @@ export function ClusterProvider({ children }: ClusterProviderProps) { return ( - {children} + + {children} + ); @@ -203,3 +210,11 @@ export function useClusterDispatch() { } return context; } + +export function useClusterModal() { + const context = React.useContext(ModalContext); + if (!context) { + throw new Error(`useClusterModal must be used within a ClusterProvider`); + } + return context; +} diff --git a/explorer/src/providers/tab.tsx b/explorer/src/providers/tab.tsx deleted file mode 100644 index 31c9ea0cac..0000000000 --- a/explorer/src/providers/tab.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from "react"; -import { useLocation } from "react-router-dom"; - -import { ACCOUNT_PATHS } from "./accounts"; - -export type Tab = "Transactions" | "Accounts"; -const StateContext = React.createContext(undefined); - -type TabProviderProps = { children: React.ReactNode }; -export function TabProvider({ children }: TabProviderProps) { - const location = useLocation(); - const paths = location.pathname - .slice(1) - .split("/") - .map(name => `/${name}`); - let tab: Tab = "Transactions"; - if (ACCOUNT_PATHS.includes(paths[0].toLowerCase())) { - tab = "Accounts"; - } - - return {children}; -} - -export function useCurrentTab() { - const context = React.useContext(StateContext); - if (!context) { - throw new Error(`useCurrentTab must be used within a TabProvider`); - } - return context; -} diff --git a/explorer/src/providers/transactions/details.tsx b/explorer/src/providers/transactions/details.tsx index 45a8f4f6a6..f1d1bcdd33 100644 --- a/explorer/src/providers/transactions/details.tsx +++ b/explorer/src/providers/transactions/details.tsx @@ -5,17 +5,10 @@ import { ConfirmedTransaction } from "@solana/web3.js"; import { useCluster, ClusterStatus } from "../cluster"; -import { useTransactions } from "./index"; - -export enum Status { - Checking, - CheckFailed, - NotFound, - Found -} +import { useTransactions, FetchStatus } from "./index"; export interface Details { - status: Status; + fetchStatus: FetchStatus; transaction: ConfirmedTransaction | null; } @@ -23,13 +16,14 @@ type State = { [signature: string]: Details }; export enum ActionType { Update, - Add + Add, + Remove } interface Update { type: ActionType.Update; signature: string; - status: Status; + fetchStatus: FetchStatus; transaction: ConfirmedTransaction | null; } @@ -38,7 +32,12 @@ interface Add { signatures: TransactionSignature[]; } -type Action = Update | Add; +interface Remove { + type: ActionType.Remove; + signatures: TransactionSignature[]; +} + +type Action = Update | Add | Remove; type Dispatch = (action: Action) => void; function reducer(state: State, action: Action): State { @@ -49,7 +48,7 @@ function reducer(state: State, action: Action): State { action.signatures.forEach(signature => { if (!details[signature]) { details[signature] = { - status: Status.Checking, + fetchStatus: FetchStatus.Fetching, transaction: null }; } @@ -57,21 +56,26 @@ function reducer(state: State, action: Action): State { return details; } + case ActionType.Remove: { + if (action.signatures.length === 0) return state; + const details = { ...state }; + action.signatures.forEach(signature => { + delete details[signature]; + }); + return details; + } + case ActionType.Update: { let details = state[action.signature]; if (details) { details = { ...details, - status: action.status + fetchStatus: action.fetchStatus, + transaction: action.transaction }; - if (action.transaction !== null) { - details.transaction = action.transaction; - } return { ...state, - ...{ - [action.signature]: details - } + [action.signature]: details }; } break; @@ -94,22 +98,28 @@ export function DetailsProvider({ children }: DetailsProviderProps) { // Filter blocks for current transaction slots React.useEffect(() => { - if (status !== ClusterStatus.Connected) return; - + const removeSignatures = new Set(); const fetchSignatures = new Set(); - transactions.forEach(({ signature, transactionStatus }) => { - if (transactionStatus?.confirmations === "max" && !state[signature]) + transactions.forEach(({ signature, info }) => { + if (info?.confirmations === "max" && !state[signature]) fetchSignatures.add(signature); + else if (info?.confirmations !== "max" && state[signature]) + removeSignatures.add(signature); }); + const removeList: string[] = []; + removeSignatures.forEach(s => removeList.push(s)); + dispatch({ type: ActionType.Remove, signatures: removeList }); + + if (status !== ClusterStatus.Connected) return; + const fetchList: string[] = []; fetchSignatures.forEach(s => fetchList.push(s)); dispatch({ type: ActionType.Add, signatures: fetchList }); - fetchSignatures.forEach(signature => { fetchDetails(dispatch, signature, url); }); - }, [transactions]); // eslint-disable-line react-hooks/exhaustive-deps + }, [status, transactions]); // eslint-disable-line react-hooks/exhaustive-deps return ( @@ -120,30 +130,26 @@ export function DetailsProvider({ children }: DetailsProviderProps) { ); } -async function fetchDetails( +export async function fetchDetails( dispatch: Dispatch, signature: TransactionSignature, url: string ) { dispatch({ type: ActionType.Update, - status: Status.Checking, + fetchStatus: FetchStatus.Fetching, transaction: null, signature }); - let status; + let fetchStatus; let transaction = null; try { transaction = await new Connection(url).getConfirmedTransaction(signature); - if (transaction) { - status = Status.Found; - } else { - status = Status.NotFound; - } + fetchStatus = FetchStatus.Fetched; } catch (error) { console.error("Failed to fetch confirmed transaction", error); - status = Status.CheckFailed; + fetchStatus = FetchStatus.FetchFailed; } - dispatch({ type: ActionType.Update, status, signature, transaction }); + dispatch({ type: ActionType.Update, fetchStatus, signature, transaction }); } diff --git a/explorer/src/providers/transactions/index.tsx b/explorer/src/providers/transactions/index.tsx index 929411eb0f..9dba7edb59 100644 --- a/explorer/src/providers/transactions/index.tsx +++ b/explorer/src/providers/transactions/index.tsx @@ -6,7 +6,7 @@ import { Account, SignatureResult } from "@solana/web3.js"; -import { findGetParameter, findPathSegment } from "../../utils/url"; +import { findGetParameter } from "../../utils/url"; import { useCluster, ClusterStatus } from "../cluster"; import { DetailsProvider, @@ -20,6 +20,7 @@ import { Dispatch as AccountsDispatch, ActionType as AccountsActionType } from "../accounts"; +import { useLocation } from "react-router-dom"; export enum FetchStatus { Fetching, @@ -27,28 +28,29 @@ export enum FetchStatus { Fetched } -enum Source { +export enum Source { Url, - Input + Input, + Test } export type Confirmations = number | "max"; -export interface TransactionStatus { +export interface TransactionStatusInfo { slot: number; result: SignatureResult; confirmations: Confirmations; } -export interface TransactionState { +export interface TransactionStatus { id: number; source: Source; fetchStatus: FetchStatus; signature: TransactionSignature; - transactionStatus?: TransactionStatus; + info?: TransactionStatusInfo; } -type Transactions = { [signature: string]: TransactionState }; +type Transactions = { [signature: string]: TransactionStatus }; interface State { idCounter: number; selected?: TransactionSignature; @@ -57,50 +59,29 @@ interface State { export enum ActionType { UpdateStatus, - InputSignature, - Select, - Deselect -} - -interface SelectTransaction { - type: ActionType.Select; - signature: TransactionSignature; -} - -interface DeselectTransaction { - type: ActionType.Deselect; + FetchSignature } interface UpdateStatus { type: ActionType.UpdateStatus; signature: TransactionSignature; fetchStatus: FetchStatus; - transactionStatus?: TransactionStatus; + info?: TransactionStatusInfo; } -interface InputSignature { - type: ActionType.InputSignature; +interface FetchSignature { + type: ActionType.FetchSignature; signature: TransactionSignature; + source: Source; } -type Action = - | UpdateStatus - | InputSignature - | SelectTransaction - | DeselectTransaction; +type Action = UpdateStatus | FetchSignature; type Dispatch = (action: Action) => void; function reducer(state: State, action: Action): State { switch (action.type) { - case ActionType.Deselect: { - return { ...state, selected: undefined }; - } - case ActionType.Select: { - const tx = state.transactions[action.signature]; - return { ...state, selected: tx.signature }; - } - case ActionType.InputSignature: { + case ActionType.FetchSignature: { if (!!state.transactions[action.signature]) return state; const nextId = state.idCounter + 1; @@ -108,7 +89,7 @@ function reducer(state: State, action: Action): State { ...state.transactions, [action.signature]: { id: nextId, - source: Source.Input, + source: action.source, signature: action.signature, fetchStatus: FetchStatus.Fetching } @@ -120,11 +101,9 @@ function reducer(state: State, action: Action): State { if (transaction) { transaction = { ...transaction, - fetchStatus: action.fetchStatus + fetchStatus: action.fetchStatus, + info: action.info }; - if (action.transactionStatus) { - transaction.transactionStatus = action.transactionStatus; - } const transactions = { ...state.transactions, [action.signature]: transaction @@ -137,74 +116,60 @@ function reducer(state: State, action: Action): State { return state; } -export const TX_PATHS = [ - "/tx", - "/txs", - "/txn", - "/txns", - "/transaction", - "/transactions" -]; - -function urlSignatures(): Array { - const signatures: Array = []; - - TX_PATHS.forEach(path => { - const name = path.slice(1); - const params = findGetParameter(name)?.split(",") || []; - const segments = findPathSegment(name)?.split(",") || []; - signatures.push(...params); - signatures.push(...segments); - }); - - return signatures.filter(s => s.length > 0); -} - -function initState(): State { - let idCounter = 0; - const signatures = urlSignatures(); - const transactions = signatures.reduce( - (transactions: Transactions, signature) => { - if (!!transactions[signature]) return transactions; - const nextId = idCounter + 1; - transactions[signature] = { - id: nextId, - source: Source.Url, - signature, - fetchStatus: FetchStatus.Fetching - }; - idCounter++; - return transactions; - }, - {} - ); - return { idCounter, transactions }; -} +export const TX_ALIASES = ["tx", "txn", "transaction"]; const StateContext = React.createContext(undefined); const DispatchContext = React.createContext(undefined); type TransactionsProviderProps = { children: React.ReactNode }; export function TransactionsProvider({ children }: TransactionsProviderProps) { - const [state, dispatch] = React.useReducer(reducer, undefined, initState); + const [state, dispatch] = React.useReducer(reducer, { + idCounter: 0, + transactions: {} + }); const { status, url } = useCluster(); const accountsDispatch = useAccountsDispatch(); + const search = useLocation().search; - // Check transaction statuses on startup and whenever cluster updates + // Check transaction statuses whenever cluster updates React.useEffect(() => { if (status !== ClusterStatus.Connected) return; + Object.keys(state.transactions).forEach(signature => { + dispatch({ + type: ActionType.FetchSignature, + signature, + source: Source.Url + }); + checkTransactionStatus(dispatch, signature, url); + }); + // Create a test transaction if (findGetParameter("test") !== null) { createTestTransaction(dispatch, accountsDispatch, url); } - - Object.keys(state.transactions).forEach(signature => { - checkTransactionStatus(dispatch, signature, url); - }); }, [status, url]); // eslint-disable-line react-hooks/exhaustive-deps + // Check for transactions in the url params + React.useEffect(() => { + TX_ALIASES.flatMap(key => + (findGetParameter(key)?.split(",") || []).concat( + findGetParameter(key + "s")?.split(",") || [] + ) + ) + .flatMap(paramValue => paramValue?.split(",") || []) + .filter(signature => !state.transactions[signature]) + .forEach(signature => { + dispatch({ + type: ActionType.FetchSignature, + signature, + source: Source.Url + }); + checkTransactionStatus(dispatch, signature, url); + }); + }, [search]); // eslint-disable-line react-hooks/exhaustive-deps + return ( @@ -232,7 +197,11 @@ async function createTestTransaction( 100000, "recent" ); - dispatch({ type: ActionType.InputSignature, signature }); + dispatch({ + type: ActionType.FetchSignature, + signature, + source: Source.Test + }); checkTransactionStatus(dispatch, signature, url); accountsDispatch({ type: AccountsActionType.Input, @@ -251,7 +220,11 @@ async function createTestTransaction( lamports: 1 }); const signature = await connection.sendTransaction(tx, testAccount); - dispatch({ type: ActionType.InputSignature, signature }); + dispatch({ + type: ActionType.FetchSignature, + signature, + source: Source.Test + }); checkTransactionStatus(dispatch, signature, url); } catch (error) { console.error("Failed to create test failure transaction", error); @@ -270,7 +243,7 @@ export async function checkTransactionStatus( }); let fetchStatus; - let transactionStatus: TransactionStatus | undefined; + let info: TransactionStatusInfo | undefined; try { const { value } = await new Connection(url).getSignatureStatus(signature, { searchTransactionHistory: true @@ -284,7 +257,7 @@ export async function checkTransactionStatus( confirmations = "max"; } - transactionStatus = { + info = { slot: value.slot, confirmations, result: { err: value.err } @@ -295,11 +268,12 @@ export async function checkTransactionStatus( console.error("Failed to fetch transaction status", error); fetchStatus = FetchStatus.FetchFailed; } + dispatch({ type: ActionType.UpdateStatus, signature, fetchStatus, - transactionStatus + info }); } @@ -312,13 +286,36 @@ export function useTransactions() { } return { idCounter: context.idCounter, - selected: context.selected, transactions: Object.values(context.transactions).sort((a, b) => a.id <= b.id ? 1 : -1 ) }; } +export function useTransactionStatus(signature: TransactionSignature) { + const context = React.useContext(StateContext); + + if (!context) { + throw new Error( + `useTransactionStatus must be used within a TransactionsProvider` + ); + } + + return context.transactions[signature]; +} + +export function useTransactionDetails(signature: TransactionSignature) { + const context = React.useContext(DetailsStateContext); + + if (!context) { + throw new Error( + `useTransactionDetails must be used within a TransactionsProvider` + ); + } + + return context[signature]; +} + export function useTransactionsDispatch() { const context = React.useContext(DispatchContext); if (!context) { @@ -338,11 +335,3 @@ export function useDetailsDispatch() { } return context; } - -export function useDetails(signature: TransactionSignature) { - const context = React.useContext(DetailsStateContext); - if (!context) { - throw new Error(`useDetails must be used within a TransactionsProvider`); - } - return context[signature]; -} diff --git a/explorer/src/scss/_solana.scss b/explorer/src/scss/_solana.scss index 4e695c7fb2..6d4a27e3be 100644 --- a/explorer/src/scss/_solana.scss +++ b/explorer/src/scss/_solana.scss @@ -1,4 +1,4 @@ -// +// // solana.scss // Use this to write your custom SCSS // @@ -12,6 +12,7 @@ code { .copyable { position: relative; + width: fit-content; & > div:hover { cursor: pointer; @@ -20,7 +21,6 @@ code { .popover.bs-popover-top { background-color: $dark; top: -4rem; - left: 40%; .popover-body { color: white; @@ -60,12 +60,14 @@ code { } } -.text-signature, .text-address { +.text-signature, +.text-address { font-size: 85%; } -input.text-signature, input.text-address { - padding: 0 0.75rem +input.text-signature, +input.text-address { + padding: 0 0.75rem; } h4.ix-pill { @@ -87,19 +89,22 @@ h4.slot-pill { } .list-group-item:last-child { - &.ix-item, &.slot-item { + &.ix-item, + &.slot-item { border-bottom-width: 0px; } } .list-group:last-child .list-group-item:last-child { - &.ix-item, &.slot-item { + &.ix-item, + &.slot-item { border-bottom-width: 1px; } } .list-group-item:first-child { - &.ix-item, &.slot-item { + &.ix-item, + &.slot-item { border-top-width: 1px; } } diff --git a/explorer/src/utils/index.ts b/explorer/src/utils/index.ts index dc288916c0..8d59d029ba 100644 --- a/explorer/src/utils/index.ts +++ b/explorer/src/utils/index.ts @@ -1,3 +1,9 @@ +import { LAMPORTS_PER_SOL } from "@solana/web3.js"; + export function assertUnreachable(x: never): never { throw new Error("Unreachable!"); } + +export function lamportsToSolString(lamports: number): string { + return `◎${(1.0 * Math.abs(lamports)) / LAMPORTS_PER_SOL}`; +}