Account history feed
This commit is contained in:
parent
27650572dd
commit
2312658492
|
@ -1269,9 +1269,9 @@
|
||||||
"integrity": "sha512-DetpxZw1fzPD5xUBrIAoplLChO2VB8DlL5Gg+I1IR9b2wPqYIca2WSUxL5g1vLeR4MsQq1NeWriXAVffV+U1Fw=="
|
"integrity": "sha512-DetpxZw1fzPD5xUBrIAoplLChO2VB8DlL5Gg+I1IR9b2wPqYIca2WSUxL5g1vLeR4MsQq1NeWriXAVffV+U1Fw=="
|
||||||
},
|
},
|
||||||
"@solana/web3.js": {
|
"@solana/web3.js": {
|
||||||
"version": "0.51.0",
|
"version": "0.52.1",
|
||||||
"resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-0.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-0.52.1.tgz",
|
||||||
"integrity": "sha512-mt2JF9QcpL/K2/LSHgj/yqSQXxvCNkLknfvmDClLeI2VbtYMypGcw4tU4C2GrdTzKRUdzM8ncaGONxLJKgFsTQ==",
|
"integrity": "sha512-xv8PknS9sjnMxPXaCZEb8Yk+S0O3lScw91NJyYjnIPYxYnxJ96H7BUCDh1zOj5op4nT8u/n4wRYG+YWT/XRwiA==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/runtime": "^7.3.1",
|
"@babel/runtime": "^7.3.1",
|
||||||
"bn.js": "^5.0.0",
|
"bn.js": "^5.0.0",
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@solana/web3.js": "^0.51.0",
|
"@solana/web3.js": "^0.52.1",
|
||||||
"@testing-library/jest-dom": "^4.2.4",
|
"@testing-library/jest-dom": "^4.2.4",
|
||||||
"@testing-library/react": "^9.3.2",
|
"@testing-library/react": "^9.3.2",
|
||||||
"@testing-library/user-event": "^7.1.2",
|
"@testing-library/user-event": "^7.1.2",
|
||||||
|
|
|
@ -4,10 +4,10 @@ import { PublicKey, StakeProgram } from "@solana/web3.js";
|
||||||
import ClusterStatusButton from "components/ClusterStatusButton";
|
import ClusterStatusButton from "components/ClusterStatusButton";
|
||||||
import { useHistory, useLocation } from "react-router-dom";
|
import { useHistory, useLocation } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Status,
|
FetchStatus,
|
||||||
useFetchAccountInfo,
|
useFetchAccountInfo,
|
||||||
useFetchAccountHistory,
|
|
||||||
useAccountInfo,
|
useAccountInfo,
|
||||||
|
useAccountHistory,
|
||||||
Account
|
Account
|
||||||
} from "providers/accounts";
|
} from "providers/accounts";
|
||||||
import { lamportsToSolString } from "utils";
|
import { lamportsToSolString } from "utils";
|
||||||
|
@ -17,6 +17,7 @@ import { StakeAccountCards } from "components/account/StakeAccountCards";
|
||||||
import ErrorCard from "components/common/ErrorCard";
|
import ErrorCard from "components/common/ErrorCard";
|
||||||
import LoadingCard from "components/common/LoadingCard";
|
import LoadingCard from "components/common/LoadingCard";
|
||||||
import TableCardBody from "components/common/TableCardBody";
|
import TableCardBody from "components/common/TableCardBody";
|
||||||
|
import { useFetchAccountHistory } from "providers/accounts/history";
|
||||||
|
|
||||||
type Props = { address: string };
|
type Props = { address: string };
|
||||||
export default function AccountDetails({ address }: Props) {
|
export default function AccountDetails({ address }: Props) {
|
||||||
|
@ -40,8 +41,9 @@ export default function AccountDetails({ address }: Props) {
|
||||||
|
|
||||||
// Fetch account on load
|
// Fetch account on load
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
setSearch(address);
|
||||||
if (pubkey) fetchAccount(pubkey);
|
if (pubkey) fetchAccount(pubkey);
|
||||||
}, [pubkey?.toBase58()]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [address]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const searchInput = (
|
const searchInput = (
|
||||||
<input
|
<input
|
||||||
|
@ -99,10 +101,10 @@ function AccountCards({ pubkey }: { pubkey: PublicKey }) {
|
||||||
const info = useAccountInfo(address);
|
const info = useAccountInfo(address);
|
||||||
const refresh = useFetchAccountInfo();
|
const refresh = useFetchAccountInfo();
|
||||||
|
|
||||||
if (!info || info.status === Status.Checking) {
|
if (!info || info.status === FetchStatus.Fetching) {
|
||||||
return <LoadingCard />;
|
return <LoadingCard />;
|
||||||
} else if (
|
} else if (
|
||||||
info.status === Status.CheckFailed ||
|
info.status === FetchStatus.FetchFailed ||
|
||||||
info.lamports === undefined
|
info.lamports === undefined
|
||||||
) {
|
) {
|
||||||
return <ErrorCard retry={() => refresh(pubkey)} text="Fetch Failed" />;
|
return <ErrorCard retry={() => refresh(pubkey)} text="Fetch Failed" />;
|
||||||
|
@ -118,22 +120,13 @@ function AccountCards({ pubkey }: { pubkey: PublicKey }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function UnknownAccountCard({ account }: { account: Account }) {
|
function UnknownAccountCard({ account }: { account: Account }) {
|
||||||
const refresh = useFetchAccountInfo();
|
const { details, lamports } = account;
|
||||||
|
|
||||||
const { details, lamports, pubkey } = account;
|
|
||||||
if (lamports === undefined) return null;
|
if (lamports === undefined) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header align-items-center">
|
<div className="card-header align-items-center">
|
||||||
<h3 className="card-header-title">Account Overview</h3>
|
<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>
|
</div>
|
||||||
|
|
||||||
<TableCardBody>
|
<TableCardBody>
|
||||||
|
@ -176,40 +169,59 @@ function UnknownAccountCard({ account }: { account: Account }) {
|
||||||
function HistoryCard({ pubkey }: { pubkey: PublicKey }) {
|
function HistoryCard({ pubkey }: { pubkey: PublicKey }) {
|
||||||
const address = pubkey.toBase58();
|
const address = pubkey.toBase58();
|
||||||
const info = useAccountInfo(address);
|
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;
|
return null;
|
||||||
} else if (info.status === Status.FetchingHistory) {
|
} else if (
|
||||||
return <LoadingCard />;
|
history.fetched === undefined ||
|
||||||
} else if (info.history === undefined) {
|
history.fetchedRange === undefined
|
||||||
|
) {
|
||||||
|
if (history.status === FetchStatus.Fetching) {
|
||||||
|
return <LoadingCard />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorCard
|
<ErrorCard retry={refresh} text="Failed to fetch transaction history" />
|
||||||
retry={() => refresh(pubkey)}
|
|
||||||
text="Failed to fetch transaction history"
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (info.history.size === 0) {
|
if (history.fetched.length === 0) {
|
||||||
return (
|
return (
|
||||||
<ErrorCard
|
<ErrorCard
|
||||||
retry={() => refresh(pubkey)}
|
retry={loadMore}
|
||||||
text="No recent transaction history found"
|
retryText="Look back further"
|
||||||
|
text={
|
||||||
|
"No transaction history found since slot " + history.fetchedRange.min
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const detailsList: React.ReactNode[] = [];
|
const detailsList: React.ReactNode[] = [];
|
||||||
info.history.forEach((slotTransactions, slot) => {
|
const transactions = history.fetched;
|
||||||
const signatures = Array.from(slotTransactions.entries()).map(
|
|
||||||
([signature, err]) => {
|
for (var i = 0; i < transactions.length; i++) {
|
||||||
return <code className="mb-2 mb-last-0">{signature}</code>;
|
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(
|
detailsList.push(
|
||||||
<tr>
|
<tr key={slot}>
|
||||||
<td className="vertical-top">Slot {slot}</td>
|
<td className="vertical-top">Slot {slot}</td>
|
||||||
<td className="text-right">
|
<td className="text-right">
|
||||||
<div className="d-inline-flex flex-column align-items-end">
|
<div className="d-inline-flex flex-column align-items-end">
|
||||||
|
@ -218,22 +230,49 @@ function HistoryCard({ pubkey }: { pubkey: PublicKey }) {
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const fetching = history.status === FetchStatus.Fetching;
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header align-items-center">
|
<div className="card-header align-items-center">
|
||||||
<h3 className="card-header-title">Transaction History</h3>
|
<h3 className="card-header-title">Transaction History</h3>
|
||||||
<button
|
<button
|
||||||
className="btn btn-white btn-sm"
|
className="btn btn-white btn-sm"
|
||||||
onClick={() => refresh(pubkey)}
|
disabled={fetching}
|
||||||
|
onClick={refresh}
|
||||||
>
|
>
|
||||||
<span className="fe fe-refresh-cw mr-2"></span>
|
{fetching ? (
|
||||||
Refresh
|
<>
|
||||||
|
<span className="spinner-grow spinner-grow-sm mr-2"></span>
|
||||||
|
Loading
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="fe fe-refresh-cw mr-2"></span>
|
||||||
|
Refresh
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TableCardBody>{detailsList}</TableCardBody>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
useAccounts,
|
useAccounts,
|
||||||
Account,
|
Account,
|
||||||
Status,
|
FetchStatus,
|
||||||
useFetchAccountInfo
|
useFetchAccountInfo
|
||||||
} from "../providers/accounts";
|
} from "../providers/accounts";
|
||||||
import { assertUnreachable } from "../utils";
|
import { assertUnreachable } from "../utils";
|
||||||
|
@ -109,17 +109,15 @@ const renderAccountRow = (account: Account) => {
|
||||||
let statusText;
|
let statusText;
|
||||||
let statusClass;
|
let statusClass;
|
||||||
switch (account.status) {
|
switch (account.status) {
|
||||||
case Status.CheckFailed:
|
case FetchStatus.FetchFailed:
|
||||||
case Status.HistoryFailed:
|
|
||||||
statusClass = "dark";
|
statusClass = "dark";
|
||||||
statusText = "Cluster Error";
|
statusText = "Cluster Error";
|
||||||
break;
|
break;
|
||||||
case Status.Checking:
|
case FetchStatus.Fetching:
|
||||||
case Status.FetchingHistory:
|
|
||||||
statusClass = "info";
|
statusClass = "info";
|
||||||
statusText = "Fetching";
|
statusText = "Fetching";
|
||||||
break;
|
break;
|
||||||
case Status.Success:
|
case FetchStatus.Fetched:
|
||||||
if (account.details?.executable) {
|
if (account.details?.executable) {
|
||||||
statusClass = "dark";
|
statusClass = "dark";
|
||||||
statusText = "Executable";
|
statusText = "Executable";
|
||||||
|
|
|
@ -2,11 +2,14 @@ import React from "react";
|
||||||
|
|
||||||
export default function ErrorCard({
|
export default function ErrorCard({
|
||||||
retry,
|
retry,
|
||||||
|
retryText,
|
||||||
text
|
text
|
||||||
}: {
|
}: {
|
||||||
retry?: () => void;
|
retry?: () => void;
|
||||||
|
retryText?: string;
|
||||||
text: string;
|
text: string;
|
||||||
}) {
|
}) {
|
||||||
|
const buttonText = retryText || "Try Again";
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-body text-center">
|
<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"
|
className="btn btn-white ml-3 d-none d-md-inline"
|
||||||
onClick={retry}
|
onClick={retry}
|
||||||
>
|
>
|
||||||
Try Again
|
{buttonText}
|
||||||
</span>
|
</span>
|
||||||
<div className="d-block d-md-none mt-4">
|
<div className="d-block d-md-none mt-4">
|
||||||
<hr></hr>
|
<hr></hr>
|
||||||
<span className="btn btn-white" onClick={retry}>
|
<span className="btn btn-white" onClick={retry}>
|
||||||
Try Again
|
{buttonText}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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);
|
||||||
|
};
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,29 +1,17 @@
|
||||||
import React from "react";
|
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 { 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 {
|
export enum FetchStatus {
|
||||||
Checking,
|
Fetching,
|
||||||
CheckFailed,
|
FetchFailed,
|
||||||
FetchingHistory,
|
Fetched
|
||||||
HistoryFailed,
|
|
||||||
Success
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type History = Map<
|
|
||||||
number,
|
|
||||||
Map<TransactionSignature, TransactionError | null>
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface Details {
|
export interface Details {
|
||||||
executable: boolean;
|
executable: boolean;
|
||||||
owner: PublicKey;
|
owner: PublicKey;
|
||||||
|
@ -34,10 +22,9 @@ export interface Details {
|
||||||
export interface Account {
|
export interface Account {
|
||||||
id: number;
|
id: number;
|
||||||
pubkey: PublicKey;
|
pubkey: PublicKey;
|
||||||
status: Status;
|
status: FetchStatus;
|
||||||
lamports?: number;
|
lamports?: number;
|
||||||
details?: Details;
|
details?: Details;
|
||||||
history?: History;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Accounts = { [address: string]: Account };
|
type Accounts = { [address: string]: Account };
|
||||||
|
@ -55,10 +42,9 @@ interface Update {
|
||||||
type: ActionType.Update;
|
type: ActionType.Update;
|
||||||
pubkey: PublicKey;
|
pubkey: PublicKey;
|
||||||
data: {
|
data: {
|
||||||
status: Status;
|
status: FetchStatus;
|
||||||
lamports?: number;
|
lamports?: number;
|
||||||
details?: Details;
|
details?: Details;
|
||||||
history?: History;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,7 +54,7 @@ interface Fetch {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Action = Update | Fetch;
|
type Action = Update | Fetch;
|
||||||
export type Dispatch = (action: Action) => void;
|
type Dispatch = (action: Action) => void;
|
||||||
|
|
||||||
function reducer(state: State, action: Action): State {
|
function reducer(state: State, action: Action): State {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
|
@ -81,7 +67,7 @@ function reducer(state: State, action: Action): State {
|
||||||
[address]: {
|
[address]: {
|
||||||
id: account.id,
|
id: account.id,
|
||||||
pubkey: account.pubkey,
|
pubkey: account.pubkey,
|
||||||
status: Status.Checking
|
status: FetchStatus.Fetching
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return { ...state, accounts };
|
return { ...state, accounts };
|
||||||
|
@ -91,7 +77,7 @@ function reducer(state: State, action: Action): State {
|
||||||
...state.accounts,
|
...state.accounts,
|
||||||
[address]: {
|
[address]: {
|
||||||
id: idCounter,
|
id: idCounter,
|
||||||
status: Status.Checking,
|
status: FetchStatus.Fetching,
|
||||||
pubkey: action.pubkey
|
pubkey: action.pubkey
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -164,7 +150,7 @@ export function AccountsProvider({ children }: AccountsProviderProps) {
|
||||||
return (
|
return (
|
||||||
<StateContext.Provider value={state}>
|
<StateContext.Provider value={state}>
|
||||||
<DispatchContext.Provider value={dispatch}>
|
<DispatchContext.Provider value={dispatch}>
|
||||||
{children}
|
<HistoryProvider>{children}</HistoryProvider>
|
||||||
</DispatchContext.Provider>
|
</DispatchContext.Provider>
|
||||||
</StateContext.Provider>
|
</StateContext.Provider>
|
||||||
);
|
);
|
||||||
|
@ -213,67 +199,15 @@ async function fetchAccountInfo(
|
||||||
data
|
data
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
fetchStatus = Status.FetchingHistory;
|
fetchStatus = FetchStatus.Fetched;
|
||||||
fetchAccountHistory(dispatch, pubkey, url);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch account info", error);
|
console.error("Failed to fetch account info", error);
|
||||||
fetchStatus = Status.CheckFailed;
|
fetchStatus = FetchStatus.FetchFailed;
|
||||||
}
|
}
|
||||||
const data = { status: fetchStatus, lamports, details };
|
const data = { status: fetchStatus, lamports, details };
|
||||||
dispatch({ type: ActionType.Update, data, pubkey });
|
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() {
|
export function useAccounts() {
|
||||||
const context = React.useContext(StateContext);
|
const context = React.useContext(StateContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
@ -297,16 +231,6 @@ export function useAccountInfo(address: string) {
|
||||||
return context.accounts[address];
|
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() {
|
export function useFetchAccountInfo() {
|
||||||
const dispatch = React.useContext(DispatchContext);
|
const dispatch = React.useContext(DispatchContext);
|
||||||
if (!dispatch) {
|
if (!dispatch) {
|
||||||
|
@ -320,17 +244,3 @@ export function useFetchAccountInfo() {
|
||||||
fetchAccountInfo(dispatch, pubkey, url, status);
|
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);
|
|
||||||
};
|
|
||||||
}
|
|
Loading…
Reference in New Issue