Account history feed

This commit is contained in:
Justin Starry 2020-05-14 22:14:28 +08:00 committed by Michael Vines
parent 27650572dd
commit 2312658492
8 changed files with 465 additions and 157 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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 = (
<input
@ -99,10 +101,10 @@ function AccountCards({ pubkey }: { pubkey: PublicKey }) {
const info = useAccountInfo(address);
const refresh = useFetchAccountInfo();
if (!info || info.status === Status.Checking) {
if (!info || info.status === FetchStatus.Fetching) {
return <LoadingCard />;
} else if (
info.status === Status.CheckFailed ||
info.status === FetchStatus.FetchFailed ||
info.lamports === undefined
) {
return <ErrorCard retry={() => 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 (
<div className="card">
<div className="card-header align-items-center">
<h3 className="card-header-title">Account Overview</h3>
<button
className="btn btn-white btn-sm"
onClick={() => refresh(pubkey)}
>
<span className="fe fe-refresh-cw mr-2"></span>
Refresh
</button>
</div>
<TableCardBody>
@ -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 <LoadingCard />;
} else if (info.history === undefined) {
} else if (
history.fetched === undefined ||
history.fetchedRange === undefined
) {
if (history.status === FetchStatus.Fetching) {
return <LoadingCard />;
}
return (
<ErrorCard
retry={() => refresh(pubkey)}
text="Failed to fetch transaction history"
/>
<ErrorCard retry={refresh} text="Failed to fetch transaction history" />
);
}
if (info.history.size === 0) {
if (history.fetched.length === 0) {
return (
<ErrorCard
retry={() => 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 <code className="mb-2 mb-last-0">{signature}</code>;
}
);
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 (
<code key={signature} className="mb-2 mb-last-0">
{signature}
</code>
);
});
detailsList.push(
<tr>
<tr key={slot}>
<td className="vertical-top">Slot {slot}</td>
<td className="text-right">
<div className="d-inline-flex flex-column align-items-end">
@ -218,22 +230,49 @@ function HistoryCard({ pubkey }: { pubkey: PublicKey }) {
</td>
</tr>
);
});
}
const fetching = history.status === FetchStatus.Fetching;
return (
<div className="card">
<div className="card-header align-items-center">
<h3 className="card-header-title">Transaction History</h3>
<button
className="btn btn-white btn-sm"
onClick={() => refresh(pubkey)}
disabled={fetching}
onClick={refresh}
>
<span className="fe fe-refresh-cw mr-2"></span>
Refresh
{fetching ? (
<>
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Loading
</>
) : (
<>
<span className="fe fe-refresh-cw mr-2"></span>
Refresh
</>
)}
</button>
</div>
<TableCardBody>{detailsList}</TableCardBody>
<div className="card-footer">
<button
className="btn btn-primary w-100"
onClick={loadMore}
disabled={fetching}
>
{fetching ? (
<>
<span className="spinner-grow spinner-grow-sm mr-2"></span>
Loading
</>
) : (
"Load More"
)}
</button>
</div>
</div>
);
}

View File

@ -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";

View File

@ -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 (
<div className="card">
<div className="card-body text-center">
@ -17,12 +20,12 @@ export default function ErrorCard({
className="btn btn-white ml-3 d-none d-md-inline"
onClick={retry}
>
Try Again
{buttonText}
</span>
<div className="d-block d-md-none mt-4">
<hr></hr>
<span className="btn btn-white" onClick={retry}>
Try Again
{buttonText}
</span>
</div>
</>

View File

@ -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<HistoryManager | undefined>(
undefined
);
const StateContext = React.createContext<State | undefined>(undefined);
const DispatchContext = React.createContext<Dispatch | undefined>(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<string>();
const fetchAddresses = new Set<string>();
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 (
<ManagerContext.Provider value={manager.current}>
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
</ManagerContext.Provider>
);
}
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);
};
}

View File

@ -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<string, SlotRange> = new Map();
accountHistory: Map<string, HistoricalTransaction[]> = new Map();
accountLock: Map<string, boolean> = new Map();
_fullRange: SlotRange | undefined;
connection: Connection;
constructor(url: string) {
this.connection = new Connection(url);
}
async fullRange(refresh: boolean): Promise<SlotRange> {
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);
}
}
}

View File

@ -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<TransactionSignature, TransactionError | null>
>;
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 (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
<HistoryProvider>{children}</HistoryProvider>
</DispatchContext.Provider>
</StateContext.Provider>
);
@ -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);
};
}