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