diff --git a/explorer/package-lock.json b/explorer/package-lock.json index bd13f5ad4d..ba083a05d4 100644 --- a/explorer/package-lock.json +++ b/explorer/package-lock.json @@ -1269,9 +1269,9 @@ "integrity": "sha512-DetpxZw1fzPD5xUBrIAoplLChO2VB8DlL5Gg+I1IR9b2wPqYIca2WSUxL5g1vLeR4MsQq1NeWriXAVffV+U1Fw==" }, "@solana/web3.js": { - "version": "0.51.0", - "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-0.51.0.tgz", - "integrity": "sha512-mt2JF9QcpL/K2/LSHgj/yqSQXxvCNkLknfvmDClLeI2VbtYMypGcw4tU4C2GrdTzKRUdzM8ncaGONxLJKgFsTQ==", + "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-0.52.1.tgz", + "integrity": "sha512-xv8PknS9sjnMxPXaCZEb8Yk+S0O3lScw91NJyYjnIPYxYnxJ96H7BUCDh1zOj5op4nT8u/n4wRYG+YWT/XRwiA==", "requires": { "@babel/runtime": "^7.3.1", "bn.js": "^5.0.0", diff --git a/explorer/package.json b/explorer/package.json index 063e314216..3606424977 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { - "@solana/web3.js": "^0.51.0", + "@solana/web3.js": "^0.52.1", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", diff --git a/explorer/src/components/AccountDetails.tsx b/explorer/src/components/AccountDetails.tsx index cecd7322d0..f228d3931e 100644 --- a/explorer/src/components/AccountDetails.tsx +++ b/explorer/src/components/AccountDetails.tsx @@ -4,10 +4,10 @@ import { PublicKey, StakeProgram } from "@solana/web3.js"; import ClusterStatusButton from "components/ClusterStatusButton"; import { useHistory, useLocation } from "react-router-dom"; import { - Status, + FetchStatus, useFetchAccountInfo, - useFetchAccountHistory, useAccountInfo, + useAccountHistory, Account } from "providers/accounts"; import { lamportsToSolString } from "utils"; @@ -17,6 +17,7 @@ import { StakeAccountCards } from "components/account/StakeAccountCards"; import ErrorCard from "components/common/ErrorCard"; import LoadingCard from "components/common/LoadingCard"; import TableCardBody from "components/common/TableCardBody"; +import { useFetchAccountHistory } from "providers/accounts/history"; type Props = { address: string }; export default function AccountDetails({ address }: Props) { @@ -40,8 +41,9 @@ export default function AccountDetails({ address }: Props) { // Fetch account on load React.useEffect(() => { + setSearch(address); if (pubkey) fetchAccount(pubkey); - }, [pubkey?.toBase58()]); // eslint-disable-line react-hooks/exhaustive-deps + }, [address]); // eslint-disable-line react-hooks/exhaustive-deps const searchInput = ( ; } else if ( - info.status === Status.CheckFailed || + info.status === FetchStatus.FetchFailed || info.lamports === undefined ) { return refresh(pubkey)} text="Fetch Failed" />; @@ -118,22 +120,13 @@ function AccountCards({ pubkey }: { pubkey: PublicKey }) { } function UnknownAccountCard({ account }: { account: Account }) { - const refresh = useFetchAccountInfo(); - - const { details, lamports, pubkey } = account; + const { details, lamports } = account; if (lamports === undefined) return null; return (

Account Overview

-
@@ -176,40 +169,59 @@ function UnknownAccountCard({ account }: { account: Account }) { function HistoryCard({ pubkey }: { pubkey: PublicKey }) { const address = pubkey.toBase58(); const info = useAccountInfo(address); - const refresh = useFetchAccountHistory(); + const history = useAccountHistory(address); + const fetchAccountHistory = useFetchAccountHistory(); + const refresh = () => fetchAccountHistory(pubkey, true); + const loadMore = () => fetchAccountHistory(pubkey); - if (!info || info.lamports === undefined) { + if (!info || !history || info.lamports === undefined) { return null; - } else if (info.status === Status.FetchingHistory) { - return ; - } else if (info.history === undefined) { + } else if ( + history.fetched === undefined || + history.fetchedRange === undefined + ) { + if (history.status === FetchStatus.Fetching) { + return ; + } + return ( - refresh(pubkey)} - text="Failed to fetch transaction history" - /> + ); } - if (info.history.size === 0) { + if (history.fetched.length === 0) { return ( refresh(pubkey)} - text="No recent transaction history found" + retry={loadMore} + retryText="Look back further" + text={ + "No transaction history found since slot " + history.fetchedRange.min + } /> ); } const detailsList: React.ReactNode[] = []; - info.history.forEach((slotTransactions, slot) => { - const signatures = Array.from(slotTransactions.entries()).map( - ([signature, err]) => { - return {signature}; - } - ); + const transactions = history.fetched; + + for (var i = 0; i < transactions.length; i++) { + const slot = transactions[i].status.slot; + const slotTransactions = [transactions[i]]; + while (i + 1 < transactions.length) { + const nextSlot = transactions[i + 1].status.slot; + if (nextSlot !== slot) break; + slotTransactions.push(transactions[++i]); + } + const signatures = slotTransactions.map(({ signature, status }) => { + return ( + + {signature} + + ); + }); detailsList.push( - + Slot {slot}
@@ -218,22 +230,49 @@ function HistoryCard({ pubkey }: { pubkey: PublicKey }) { ); - }); + } + const fetching = history.status === FetchStatus.Fetching; return (

Transaction History

{detailsList} +
+ +
); } diff --git a/explorer/src/components/AccountsCard.tsx b/explorer/src/components/AccountsCard.tsx index 35c2c8b044..981cb51df6 100644 --- a/explorer/src/components/AccountsCard.tsx +++ b/explorer/src/components/AccountsCard.tsx @@ -3,7 +3,7 @@ import { Link } from "react-router-dom"; import { useAccounts, Account, - Status, + FetchStatus, useFetchAccountInfo } from "../providers/accounts"; import { assertUnreachable } from "../utils"; @@ -109,17 +109,15 @@ const renderAccountRow = (account: Account) => { let statusText; let statusClass; switch (account.status) { - case Status.CheckFailed: - case Status.HistoryFailed: + case FetchStatus.FetchFailed: statusClass = "dark"; statusText = "Cluster Error"; break; - case Status.Checking: - case Status.FetchingHistory: + case FetchStatus.Fetching: statusClass = "info"; statusText = "Fetching"; break; - case Status.Success: + case FetchStatus.Fetched: if (account.details?.executable) { statusClass = "dark"; statusText = "Executable"; diff --git a/explorer/src/components/common/ErrorCard.tsx b/explorer/src/components/common/ErrorCard.tsx index 81838ab394..faa91fb9aa 100644 --- a/explorer/src/components/common/ErrorCard.tsx +++ b/explorer/src/components/common/ErrorCard.tsx @@ -2,11 +2,14 @@ import React from "react"; export default function ErrorCard({ retry, + retryText, text }: { retry?: () => void; + retryText?: string; text: string; }) { + const buttonText = retryText || "Try Again"; return (
@@ -17,12 +20,12 @@ export default function ErrorCard({ className="btn btn-white ml-3 d-none d-md-inline" onClick={retry} > - Try Again + {buttonText}

- Try Again + {buttonText}
diff --git a/explorer/src/providers/accounts/history.tsx b/explorer/src/providers/accounts/history.tsx new file mode 100644 index 0000000000..2c4bf90be6 --- /dev/null +++ b/explorer/src/providers/accounts/history.tsx @@ -0,0 +1,203 @@ +import React from "react"; +import { PublicKey } from "@solana/web3.js"; +import { useAccounts, FetchStatus } from "./index"; +import { useCluster } from "../cluster"; +import { + HistoryManager, + HistoricalTransaction, + SlotRange +} from "./historyManager"; + +interface AccountHistory { + status: FetchStatus; + fetched?: HistoricalTransaction[]; + fetchedRange?: SlotRange; +} + +type State = { [address: string]: AccountHistory }; + +export enum ActionType { + Update, + Add, + Remove +} + +interface Update { + type: ActionType.Update; + pubkey: PublicKey; + status: FetchStatus; + fetched?: HistoricalTransaction[]; + fetchedRange?: SlotRange; +} + +interface Add { + type: ActionType.Add; + addresses: string[]; +} + +interface Remove { + type: ActionType.Remove; + addresses: string[]; +} + +type Action = Update | Add | Remove; +type Dispatch = (action: Action) => void; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case ActionType.Add: { + if (action.addresses.length === 0) return state; + const details = { ...state }; + action.addresses.forEach(address => { + if (!details[address]) { + details[address] = { + status: FetchStatus.Fetching + }; + } + }); + return details; + } + + case ActionType.Remove: { + if (action.addresses.length === 0) return state; + const details = { ...state }; + action.addresses.forEach(address => { + delete details[address]; + }); + return details; + } + + case ActionType.Update: { + const address = action.pubkey.toBase58(); + if (state[address]) { + const fetched = action.fetched + ? action.fetched + : state[address].fetched; + const fetchedRange = action.fetchedRange + ? action.fetchedRange + : state[address].fetchedRange; + return { + ...state, + [address]: { + status: action.status, + fetched, + fetchedRange + } + }; + } + break; + } + } + return state; +} + +const ManagerContext = React.createContext( + undefined +); +const StateContext = React.createContext(undefined); +const DispatchContext = React.createContext(undefined); + +type HistoryProviderProps = { children: React.ReactNode }; +export function HistoryProvider({ children }: HistoryProviderProps) { + const [state, dispatch] = React.useReducer(reducer, {}); + const { accounts } = useAccounts(); + const { url } = useCluster(); + + const manager = React.useRef(new HistoryManager(url)); + React.useEffect(() => { + manager.current = new HistoryManager(url); + }, [url]); + + // Fetch history for new accounts + React.useEffect(() => { + const removeAddresses = new Set(); + const fetchAddresses = new Set(); + accounts.forEach(({ pubkey, lamports }) => { + const address = pubkey.toBase58(); + if (lamports !== undefined && !state[address]) + fetchAddresses.add(address); + else if (lamports === undefined && state[address]) + removeAddresses.add(address); + }); + + const removeList: string[] = []; + removeAddresses.forEach(address => { + manager.current.removeAccountHistory(address); + removeList.push(address); + }); + dispatch({ type: ActionType.Remove, addresses: removeList }); + + const fetchList: string[] = []; + fetchAddresses.forEach(s => fetchList.push(s)); + dispatch({ type: ActionType.Add, addresses: fetchList }); + fetchAddresses.forEach(address => { + fetchAccountHistory( + dispatch, + new PublicKey(address), + manager.current, + true + ); + }); + }, [accounts]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + + + {children} + + + + ); +} + +async function fetchAccountHistory( + dispatch: Dispatch, + pubkey: PublicKey, + manager: HistoryManager, + refresh?: boolean +) { + dispatch({ + type: ActionType.Update, + status: FetchStatus.Fetching, + pubkey + }); + + let status; + let fetched; + let fetchedRange; + try { + await manager.fetchAccountHistory(pubkey, refresh || false); + fetched = manager.accountHistory.get(pubkey.toBase58()) || undefined; + fetchedRange = manager.accountRanges.get(pubkey.toBase58()) || undefined; + status = FetchStatus.Fetched; + } catch (error) { + console.error("Failed to fetch account history", error); + status = FetchStatus.FetchFailed; + } + dispatch({ type: ActionType.Update, status, fetched, fetchedRange, pubkey }); +} + +export function useAccountHistory(address: string) { + const context = React.useContext(StateContext); + + if (!context) { + throw new Error(`useAccountHistory must be used within a AccountsProvider`); + } + + return context[address]; +} + +export function useFetchAccountHistory() { + const manager = React.useContext(ManagerContext); + const dispatch = React.useContext(DispatchContext); + if (!manager || !dispatch) { + throw new Error( + `useFetchAccountHistory must be used within a AccountsProvider` + ); + } + + return (pubkey: PublicKey, refresh?: boolean) => { + fetchAccountHistory(dispatch, pubkey, manager, refresh); + }; +} diff --git a/explorer/src/providers/accounts/historyManager.ts b/explorer/src/providers/accounts/historyManager.ts new file mode 100644 index 0000000000..915fc76f59 --- /dev/null +++ b/explorer/src/providers/accounts/historyManager.ts @@ -0,0 +1,155 @@ +import { + TransactionSignature, + Connection, + PublicKey, + SignatureStatus +} from "@solana/web3.js"; + +const MAX_STATUS_BATCH_SIZE = 256; + +export interface SlotRange { + min: number; + max: number; +} + +export type HistoricalTransaction = { + signature: TransactionSignature; + status: SignatureStatus; +}; + +// Manage the transaction history for accounts for a cluster +export class HistoryManager { + accountRanges: Map = new Map(); + accountHistory: Map = new Map(); + accountLock: Map = new Map(); + _fullRange: SlotRange | undefined; + connection: Connection; + + constructor(url: string) { + this.connection = new Connection(url); + } + + async fullRange(refresh: boolean): Promise { + if (refresh || !this._fullRange) { + const max = (await this.connection.getEpochInfo("max")).absoluteSlot; + this._fullRange = { min: 0, max }; + } + return this._fullRange; + } + + removeAccountHistory(address: string) { + this.accountLock.delete(address); + this.accountRanges.delete(address); + this.accountHistory.delete(address); + } + + async fetchAccountHistory(pubkey: PublicKey, refresh: boolean) { + const address = pubkey.toBase58(); + + if (this.accountLock.get(address) === true) return; + this.accountLock.set(address, true); + + try { + let slotLookBack = 100; + const fullRange = await this.fullRange(refresh); + const currentRange = this.accountRanges.get(address); + + // Determine query range based on already queried range + let range; + if (currentRange) { + if (refresh) { + const min = currentRange.max + 1; + const max = Math.min(min + slotLookBack - 1, fullRange.max); + if (max < min) return; + range = { min, max }; + } else { + const max = currentRange.min - 1; + const min = Math.max(max - slotLookBack + 1, fullRange.min); + if (max < min) return; + range = { min, max }; + } + } else { + const max = fullRange.max; + const min = Math.max(fullRange.min, max - slotLookBack + 1); + range = { min, max }; + } + + // Gradually fetch more history if nothing found + let signatures: string[] = []; + let nextRange = { ...range }; + for (var i = 0; i < 5; i++) { + signatures = ( + await this.connection.getConfirmedSignaturesForAddress( + pubkey, + nextRange.min, + nextRange.max + ) + ).reverse(); + if (refresh) break; + if (signatures.length > 0) break; + if (range.min <= fullRange.min) break; + + switch (slotLookBack) { + case 100: + slotLookBack = 1000; + break; + case 1000: + slotLookBack = 10000; + break; + } + + range.min = Math.max(nextRange.min - slotLookBack, fullRange.min); + nextRange = { + min: range.min, + max: nextRange.min - 1 + }; + } + + // Fetch the statuses for all confirmed signatures + const transactions: HistoricalTransaction[] = []; + while (signatures.length > 0) { + const batch = signatures.splice(0, MAX_STATUS_BATCH_SIZE); + const statuses = ( + await this.connection.getSignatureStatuses(batch, { + searchTransactionHistory: true + }) + ).value; + statuses.forEach((status, index) => { + if (status !== null) { + transactions.push({ + signature: batch[index], + status + }); + } + }); + } + + // Check if account lock is still active + if (this.accountLock.get(address) !== true) return; + + // Update fetched slot range + if (currentRange) { + currentRange.max = Math.max(range.max, currentRange.max); + currentRange.min = Math.min(range.min, currentRange.min); + } else { + this.accountRanges.set(address, range); + } + + // Exit early if no new confirmed transactions were found + const currentTransactions = this.accountHistory.get(address) || []; + if (currentTransactions.length > 0 && transactions.length === 0) return; + + // Append / prepend newly fetched statuses + let newTransactions; + if (refresh) { + newTransactions = transactions.concat(currentTransactions); + } else { + newTransactions = currentTransactions.concat(transactions); + } + + this.accountHistory.set(address, newTransactions); + } finally { + this.accountLock.set(address, false); + } + } +} diff --git a/explorer/src/providers/accounts.tsx b/explorer/src/providers/accounts/index.tsx similarity index 68% rename from explorer/src/providers/accounts.tsx rename to explorer/src/providers/accounts/index.tsx index 882d2978e4..9d4c8dddd8 100644 --- a/explorer/src/providers/accounts.tsx +++ b/explorer/src/providers/accounts/index.tsx @@ -1,29 +1,17 @@ import React from "react"; -import { - PublicKey, - Connection, - TransactionSignature, - TransactionError, - SignatureStatus, - StakeProgram -} from "@solana/web3.js"; -import { useQuery } from "../utils/url"; -import { useCluster, ClusterStatus } from "./cluster"; import { StakeAccount } from "solana-sdk-wasm"; +import { PublicKey, Connection, StakeProgram } from "@solana/web3.js"; +import { useQuery } from "../../utils/url"; +import { useCluster, ClusterStatus } from "../cluster"; +import { HistoryProvider } from "./history"; +export { useAccountHistory } from "./history"; -export enum Status { - Checking, - CheckFailed, - FetchingHistory, - HistoryFailed, - Success +export enum FetchStatus { + Fetching, + FetchFailed, + Fetched } -export type History = Map< - number, - Map ->; - export interface Details { executable: boolean; owner: PublicKey; @@ -34,10 +22,9 @@ export interface Details { export interface Account { id: number; pubkey: PublicKey; - status: Status; + status: FetchStatus; lamports?: number; details?: Details; - history?: History; } type Accounts = { [address: string]: Account }; @@ -55,10 +42,9 @@ interface Update { type: ActionType.Update; pubkey: PublicKey; data: { - status: Status; + status: FetchStatus; lamports?: number; details?: Details; - history?: History; }; } @@ -68,7 +54,7 @@ interface Fetch { } type Action = Update | Fetch; -export type Dispatch = (action: Action) => void; +type Dispatch = (action: Action) => void; function reducer(state: State, action: Action): State { switch (action.type) { @@ -81,7 +67,7 @@ function reducer(state: State, action: Action): State { [address]: { id: account.id, pubkey: account.pubkey, - status: Status.Checking + status: FetchStatus.Fetching } }; return { ...state, accounts }; @@ -91,7 +77,7 @@ function reducer(state: State, action: Action): State { ...state.accounts, [address]: { id: idCounter, - status: Status.Checking, + status: FetchStatus.Fetching, pubkey: action.pubkey } }; @@ -164,7 +150,7 @@ export function AccountsProvider({ children }: AccountsProviderProps) { return ( - {children} + {children} ); @@ -213,67 +199,15 @@ async function fetchAccountInfo( data }; } - fetchStatus = Status.FetchingHistory; - fetchAccountHistory(dispatch, pubkey, url); + fetchStatus = FetchStatus.Fetched; } catch (error) { console.error("Failed to fetch account info", error); - fetchStatus = Status.CheckFailed; + fetchStatus = FetchStatus.FetchFailed; } const data = { status: fetchStatus, lamports, details }; dispatch({ type: ActionType.Update, data, pubkey }); } -async function fetchAccountHistory( - dispatch: Dispatch, - pubkey: PublicKey, - url: string -) { - dispatch({ - type: ActionType.Update, - data: { status: Status.FetchingHistory }, - pubkey - }); - - let history; - let status; - try { - const connection = new Connection(url); - const currentSlot = await connection.getSlot(); - const signatures = await connection.getConfirmedSignaturesForAddress( - pubkey, - Math.max(0, currentSlot - 10000 + 1), - currentSlot - ); - - let statuses: (SignatureStatus | null)[] = []; - if (signatures.length > 0) { - statuses = ( - await connection.getSignatureStatuses(signatures, { - searchTransactionHistory: true - }) - ).value; - } - - history = new Map(); - for (let i = 0; i < statuses.length; i++) { - const status = statuses[i]; - if (!status) continue; - let slotSignatures = history.get(status.slot); - if (!slotSignatures) { - slotSignatures = new Map(); - history.set(status.slot, slotSignatures); - } - slotSignatures.set(signatures[i], status.err); - } - status = Status.Success; - } catch (error) { - console.error("Failed to fetch account history", error); - status = Status.HistoryFailed; - } - const data = { status, history }; - dispatch({ type: ActionType.Update, data, pubkey }); -} - export function useAccounts() { const context = React.useContext(StateContext); if (!context) { @@ -297,16 +231,6 @@ export function useAccountInfo(address: string) { return context.accounts[address]; } -export function useAccountsDispatch() { - const context = React.useContext(DispatchContext); - if (!context) { - throw new Error( - `useAccountsDispatch must be used within a AccountsProvider` - ); - } - return context; -} - export function useFetchAccountInfo() { const dispatch = React.useContext(DispatchContext); if (!dispatch) { @@ -320,17 +244,3 @@ export function useFetchAccountInfo() { fetchAccountInfo(dispatch, pubkey, url, status); }; } - -export function useFetchAccountHistory() { - const dispatch = React.useContext(DispatchContext); - if (!dispatch) { - throw new Error( - `useFetchAccountHistory must be used within a AccountsProvider` - ); - } - - const { url } = useCluster(); - return (pubkey: PublicKey) => { - fetchAccountHistory(dispatch, pubkey, url); - }; -}