Use common provider for explorer cached data (#11582)

This commit is contained in:
Justin Starry
2020-08-12 22:41:04 +08:00
committed by GitHub
parent 8ddb116659
commit a992bb5f94
11 changed files with 312 additions and 586 deletions

View File

@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { PublicKey } from "@solana/web3.js"; import { PublicKey } from "@solana/web3.js";
import { FetchStatus } from "providers/accounts"; import { FetchStatus } from "providers/cache";
import { import {
useFetchAccountOwnedTokens, useFetchAccountOwnedTokens,
useAccountOwnedTokens, useAccountOwnedTokens,
@@ -24,7 +24,8 @@ export function OwnedTokensCard({ pubkey }: { pubkey: PublicKey }) {
return null; return null;
} }
const { status, tokens } = ownedTokens; const { status } = ownedTokens;
const tokens = ownedTokens.data?.tokens;
const fetching = status === FetchStatus.Fetching; const fetching = status === FetchStatus.Fetching;
if (fetching && (tokens === undefined || tokens.length === 0)) { if (fetching && (tokens === undefined || tokens.length === 0)) {
return <LoadingCard message="Loading owned tokens" />; return <LoadingCard message="Loading owned tokens" />;

View File

@@ -4,7 +4,7 @@ import {
ConfirmedSignatureInfo, ConfirmedSignatureInfo,
ParsedInstruction, ParsedInstruction,
} from "@solana/web3.js"; } from "@solana/web3.js";
import { FetchStatus } from "providers/accounts"; import { FetchStatus } from "providers/cache";
import { import {
useAccountHistories, useAccountHistories,
useFetchAccountHistory, useFetchAccountHistory,
@@ -34,7 +34,7 @@ export function TokenHistoryCard({ pubkey }: { pubkey: PublicKey }) {
return null; return null;
} }
const { tokens } = ownedTokens; const tokens = ownedTokens.data?.tokens;
if (tokens === undefined || tokens.length === 0) return null; if (tokens === undefined || tokens.length === 0) return null;
return <TokenHistoryTable tokens={tokens} />; return <TokenHistoryTable tokens={tokens} />;
@@ -62,17 +62,17 @@ function TokenHistoryTable({ tokens }: { tokens: TokenInfoWithPubkey[] }) {
const fetchedFullHistory = tokens.every((token) => { const fetchedFullHistory = tokens.every((token) => {
const history = accountHistories[token.pubkey.toBase58()]; const history = accountHistories[token.pubkey.toBase58()];
return history && history.foundOldest === true; return history?.data?.foundOldest === true;
}); });
const fetching = tokens.some((token) => { const fetching = tokens.some((token) => {
const history = accountHistories[token.pubkey.toBase58()]; const history = accountHistories[token.pubkey.toBase58()];
return history && history.status === FetchStatus.Fetching; return history?.status === FetchStatus.Fetching;
}); });
const failed = tokens.some((token) => { const failed = tokens.some((token) => {
const history = accountHistories[token.pubkey.toBase58()]; const history = accountHistories[token.pubkey.toBase58()];
return history && history.status === FetchStatus.FetchFailed; return history?.status === FetchStatus.FetchFailed;
}); });
const mintAndTxs = tokens const mintAndTxs = tokens
@@ -81,12 +81,13 @@ function TokenHistoryTable({ tokens }: { tokens: TokenInfoWithPubkey[] }) {
history: accountHistories[token.pubkey.toBase58()], history: accountHistories[token.pubkey.toBase58()],
})) }))
.filter(({ history }) => { .filter(({ history }) => {
return ( return history?.data?.fetched && history.data.fetched.length > 0;
history !== undefined && history.fetched && history.fetched.length > 0
);
}) })
.flatMap(({ mint, history }) => .flatMap(({ mint, history }) =>
(history.fetched as ConfirmedSignatureInfo[]).map((tx) => ({ mint, tx })) (history?.data?.fetched as ConfirmedSignatureInfo[]).map((tx) => ({
mint,
tx,
}))
); );
if (mintAndTxs.length === 0) { if (mintAndTxs.length === 0) {
@@ -196,7 +197,8 @@ function TokenTransactionRow({
if (!details) fetchDetails(tx.signature); if (!details) fetchDetails(tx.signature);
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
const instructions = details?.transaction?.transaction.message.instructions; const instructions =
details?.data?.transaction?.transaction.message.instructions;
if (instructions) { if (instructions) {
const tokenInstructions = instructions.filter( const tokenInstructions = instructions.filter(
(ix) => "parsed" in ix && ix.program === "spl-token" (ix) => "parsed" in ix && ix.program === "spl-token"

View File

@@ -1,10 +1,7 @@
import React from "react"; import React from "react";
import { PublicKey } from "@solana/web3.js"; import { PublicKey } from "@solana/web3.js";
import { import { FetchStatus } from "providers/cache";
FetchStatus, import { useAccountInfo, useAccountHistory } from "providers/accounts";
useAccountInfo,
useAccountHistory,
} from "providers/accounts";
import { useFetchAccountHistory } from "providers/accounts/history"; import { useFetchAccountHistory } from "providers/accounts/history";
import { Signature } from "components/common/Signature"; import { Signature } from "components/common/Signature";
import { ErrorCard } from "components/common/ErrorCard"; import { ErrorCard } from "components/common/ErrorCard";
@@ -22,9 +19,11 @@ export function TransactionHistoryCard({ pubkey }: { pubkey: PublicKey }) {
if (!history) refresh(); if (!history) refresh();
}, [address]); // eslint-disable-line react-hooks/exhaustive-deps }, [address]); // eslint-disable-line react-hooks/exhaustive-deps
if (!info || !history || info.lamports === undefined) { if (!history || info?.data === undefined) {
return null; return null;
} else if (history.fetched === undefined) { }
if (history?.data === undefined) {
if (history.status === FetchStatus.Fetching) { if (history.status === FetchStatus.Fetching) {
return <LoadingCard message="Loading history" />; return <LoadingCard message="Loading history" />;
} }
@@ -34,7 +33,8 @@ export function TransactionHistoryCard({ pubkey }: { pubkey: PublicKey }) {
); );
} }
if (history.fetched.length === 0) { const transactions = history.data.fetched;
if (transactions.length === 0) {
if (history.status === FetchStatus.Fetching) { if (history.status === FetchStatus.Fetching) {
return <LoadingCard message="Loading history" />; return <LoadingCard message="Loading history" />;
} }
@@ -48,8 +48,6 @@ export function TransactionHistoryCard({ pubkey }: { pubkey: PublicKey }) {
} }
const detailsList: React.ReactNode[] = []; const detailsList: React.ReactNode[] = [];
const transactions = history.fetched;
for (var i = 0; i < transactions.length; i++) { for (var i = 0; i < transactions.length; i++) {
const slot = transactions[i].slot; const slot = transactions[i].slot;
const slotTransactions = [transactions[i]]; const slotTransactions = [transactions[i]];
@@ -126,7 +124,7 @@ export function TransactionHistoryCard({ pubkey }: { pubkey: PublicKey }) {
</div> </div>
<div className="card-footer"> <div className="card-footer">
{history.foundOldest ? ( {history.data.foundOldest ? (
<div className="text-muted text-center">Fetched full history</div> <div className="text-muted text-center">Fetched full history</div>
) : ( ) : (
<button <button

View File

@@ -1,10 +1,7 @@
import React from "react"; import React from "react";
import { PublicKey } from "@solana/web3.js"; import { PublicKey } from "@solana/web3.js";
import { import { FetchStatus } from "providers/cache";
FetchStatus, import { useFetchAccountInfo, useAccountInfo } from "providers/accounts";
useFetchAccountInfo,
useAccountInfo,
} from "providers/accounts";
import { StakeAccountSection } from "components/account/StakeAccountSection"; import { StakeAccountSection } from "components/account/StakeAccountSection";
import { TokenAccountSection } from "components/account/TokenAccountSection"; import { TokenAccountSection } from "components/account/TokenAccountSection";
import { ErrorCard } from "components/common/ErrorCard"; import { ErrorCard } from "components/common/ErrorCard";
@@ -62,12 +59,13 @@ function InfoSection({ pubkey }: { pubkey: PublicKey }) {
return <LoadingCard />; return <LoadingCard />;
} else if ( } else if (
info.status === FetchStatus.FetchFailed || info.status === FetchStatus.FetchFailed ||
info.lamports === undefined info.data?.lamports === undefined
) { ) {
return <ErrorCard retry={() => refresh(pubkey)} text="Fetch Failed" />; return <ErrorCard retry={() => refresh(pubkey)} text="Fetch Failed" />;
} }
const data = info.details?.data; const account = info.data;
const data = account?.details?.data;
if (data && data.name === "stake") { if (data && data.name === "stake") {
let stakeAccountType, stakeAccount; let stakeAccountType, stakeAccount;
if ("accountType" in data.parsed) { if ("accountType" in data.parsed) {
@@ -80,15 +78,15 @@ function InfoSection({ pubkey }: { pubkey: PublicKey }) {
return ( return (
<StakeAccountSection <StakeAccountSection
account={info} account={account}
stakeAccount={stakeAccount} stakeAccount={stakeAccount}
stakeAccountType={stakeAccountType} stakeAccountType={stakeAccountType}
/> />
); );
} else if (data && data.name === "spl-token") { } else if (data && data.name === "spl-token") {
return <TokenAccountSection account={info} tokenAccount={data.parsed} />; return <TokenAccountSection account={account} tokenAccount={data.parsed} />;
} else { } else {
return <UnknownAccountCard account={info} />; return <UnknownAccountCard account={account} />;
} }
} }
@@ -96,7 +94,7 @@ type MoreTabs = "history" | "tokens";
function MoreSection({ pubkey, tab }: { pubkey: PublicKey; tab: MoreTabs }) { function MoreSection({ pubkey, tab }: { pubkey: PublicKey; tab: MoreTabs }) {
const address = pubkey.toBase58(); const address = pubkey.toBase58();
const info = useAccountInfo(address); const info = useAccountInfo(address);
if (!info || info.lamports === undefined) return null; if (info?.data === undefined) return null;
return ( return (
<> <>

View File

@@ -3,7 +3,6 @@ import {
useFetchTransactionStatus, useFetchTransactionStatus,
useTransactionStatus, useTransactionStatus,
useTransactionDetails, useTransactionDetails,
FetchStatus,
} from "providers/transactions"; } from "providers/transactions";
import { useFetchTransactionDetails } from "providers/transactions/details"; import { useFetchTransactionDetails } from "providers/transactions/details";
import { useCluster, ClusterStatus } from "providers/cluster"; import { useCluster, ClusterStatus } from "providers/cluster";
@@ -27,6 +26,7 @@ import { Address } from "components/common/Address";
import { Signature } from "components/common/Signature"; import { Signature } from "components/common/Signature";
import { intoTransactionInstruction } from "utils/tx"; import { intoTransactionInstruction } from "utils/tx";
import { TokenDetailsCard } from "components/instruction/token/TokenDetailsCard"; import { TokenDetailsCard } from "components/instruction/token/TokenDetailsCard";
import { FetchStatus } from "providers/cache";
type Props = { signature: TransactionSignature }; type Props = { signature: TransactionSignature };
export function TransactionDetailsPage({ signature }: Props) { export function TransactionDetailsPage({ signature }: Props) {
@@ -67,13 +67,13 @@ function StatusCard({ signature }: Props) {
} }
}, [signature, clusterStatus]); // eslint-disable-line react-hooks/exhaustive-deps }, [signature, clusterStatus]); // eslint-disable-line react-hooks/exhaustive-deps
if (!status || status.fetchStatus === FetchStatus.Fetching) { if (!status || status.status === FetchStatus.Fetching) {
return <LoadingCard />; return <LoadingCard />;
} else if (status?.fetchStatus === FetchStatus.FetchFailed) { } else if (status.status === FetchStatus.FetchFailed) {
return ( return (
<ErrorCard retry={() => fetchStatus(signature)} text="Fetch Failed" /> <ErrorCard retry={() => fetchStatus(signature)} text="Fetch Failed" />
); );
} else if (!status.info) { } else if (!status.data?.info) {
if (firstAvailableBlock !== undefined) { if (firstAvailableBlock !== undefined) {
return ( return (
<ErrorCard <ErrorCard
@@ -86,7 +86,7 @@ function StatusCard({ signature }: Props) {
return <ErrorCard retry={() => fetchStatus(signature)} text="Not Found" />; return <ErrorCard retry={() => fetchStatus(signature)} text="Not Found" />;
} }
const { info } = status; const { info } = status.data;
const renderResult = () => { const renderResult = () => {
let statusClass = "success"; let statusClass = "success";
let statusText = "Success"; let statusText = "Success";
@@ -102,8 +102,8 @@ function StatusCard({ signature }: Props) {
); );
}; };
const fee = details?.transaction?.meta?.fee; const fee = details?.data?.transaction?.meta?.fee;
const transaction = details?.transaction?.transaction; const transaction = details?.data?.transaction?.transaction;
const blockhash = transaction?.message.recentBlockhash; const blockhash = transaction?.message.recentBlockhash;
const isNonce = (() => { const isNonce = (() => {
if (!transaction) return false; if (!transaction) return false;
@@ -203,18 +203,18 @@ function AccountsCard({ signature }: Props) {
const fetchDetails = useFetchTransactionDetails(); const fetchDetails = useFetchTransactionDetails();
const refreshStatus = () => fetchStatus(signature); const refreshStatus = () => fetchStatus(signature);
const refreshDetails = () => fetchDetails(signature); const refreshDetails = () => fetchDetails(signature);
const transaction = details?.transaction?.transaction; const transaction = details?.data?.transaction?.transaction;
const message = transaction?.message; const message = transaction?.message;
const status = useTransactionStatus(signature); const status = useTransactionStatus(signature);
// Fetch details on load // Fetch details on load
React.useEffect(() => { React.useEffect(() => {
if (status?.info?.confirmations === "max" && !details) { if (status?.data?.info?.confirmations === "max" && !details) {
fetchDetails(signature); fetchDetails(signature);
} }
}, [signature, details, status, fetchDetails]); }, [signature, details, status, fetchDetails]);
if (!status || !status.info) { if (!status?.data?.info) {
return null; return null;
} else if (!details) { } else if (!details) {
return ( return (
@@ -223,15 +223,15 @@ function AccountsCard({ signature }: Props) {
text="Details are not available until the transaction reaches MAX confirmations" text="Details are not available until the transaction reaches MAX confirmations"
/> />
); );
} else if (details.fetchStatus === FetchStatus.Fetching) { } else if (details.status === FetchStatus.Fetching) {
return <LoadingCard />; return <LoadingCard />;
} else if (details?.fetchStatus === FetchStatus.FetchFailed) { } else if (details.status === FetchStatus.FetchFailed) {
return <ErrorCard retry={refreshDetails} text="Fetch Failed" />; return <ErrorCard retry={refreshDetails} text="Fetch Failed" />;
} else if (!details.transaction || !message) { } else if (!details.data?.transaction || !message) {
return <ErrorCard retry={refreshDetails} text="Not Found" />; return <ErrorCard retry={refreshDetails} text="Not Found" />;
} }
const { meta } = details.transaction; const { meta } = details.data.transaction;
if (!meta) { if (!meta) {
if (isCached(url, signature)) { if (isCached(url, signature)) {
return null; return null;
@@ -308,14 +308,14 @@ function InstructionsSection({ signature }: Props) {
const fetchDetails = useFetchTransactionDetails(); const fetchDetails = useFetchTransactionDetails();
const refreshDetails = () => fetchDetails(signature); const refreshDetails = () => fetchDetails(signature);
if (!status || !status.info || !details || !details.transaction) return null; if (!status?.data?.info || !details?.data?.transaction) return null;
const { transaction } = details.transaction; const { transaction } = details.data.transaction;
if (transaction.message.instructions.length === 0) { if (transaction.message.instructions.length === 0) {
return <ErrorCard retry={refreshDetails} text="No instructions found" />; return <ErrorCard retry={refreshDetails} text="No instructions found" />;
} }
const result = status.info.result; const result = status.data.info.result;
const instructionDetails = transaction.message.instructions.map( const instructionDetails = transaction.message.instructions.map(
(next, index) => { (next, index) => {
if ("parsed" in next) { if ("parsed" in next) {

View File

@@ -5,51 +5,29 @@ import {
TransactionSignature, TransactionSignature,
Connection, Connection,
} from "@solana/web3.js"; } from "@solana/web3.js";
import { FetchStatus } from "./index";
import { useCluster } from "../cluster"; import { useCluster } from "../cluster";
import * as Cache from "providers/cache";
import { ActionType, FetchStatus } from "providers/cache";
interface AccountHistory { type AccountHistory = {
status: FetchStatus; fetched: ConfirmedSignatureInfo[];
fetched?: ConfirmedSignatureInfo[];
foundOldest: boolean; foundOldest: boolean;
}
type State = {
url: string;
map: { [address: string]: AccountHistory };
}; };
export enum ActionType { type HistoryUpdate = {
Update, history?: AccountHistory;
Clear,
}
interface Update {
type: ActionType.Update;
url: string;
pubkey: PublicKey;
status: FetchStatus;
fetched?: ConfirmedSignatureInfo[];
before?: TransactionSignature; before?: TransactionSignature;
foundOldest?: boolean; };
}
interface Clear { type State = Cache.State<AccountHistory>;
type: ActionType.Clear; type Dispatch = Cache.Dispatch<HistoryUpdate>;
url: string;
}
type Action = Update | Clear;
type Dispatch = (action: Action) => void;
function combineFetched( function combineFetched(
fetched: ConfirmedSignatureInfo[] | undefined, fetched: ConfirmedSignatureInfo[],
current: ConfirmedSignatureInfo[] | undefined, current: ConfirmedSignatureInfo[] | undefined,
before: TransactionSignature | undefined before: TransactionSignature | undefined
) { ) {
if (fetched === undefined) { if (current === undefined) {
return current;
} else if (current === undefined) {
return fetched; return fetched;
} }
@@ -60,46 +38,19 @@ function combineFetched(
} }
} }
function reducer(state: State, action: Action): State { function reconcile(
switch (action.type) { history: AccountHistory | undefined,
case ActionType.Update: { update: HistoryUpdate | undefined
if (action.url !== state.url) return state; ) {
const address = action.pubkey.toBase58(); if (update?.history === undefined) return;
if (state.map[address]) { return {
return { fetched: combineFetched(
...state, update.history.fetched,
map: { history?.fetched,
...state.map, update?.before
[address]: { ),
status: action.status, foundOldest: update?.history?.foundOldest || history?.foundOldest || false,
fetched: combineFetched( };
action.fetched,
state.map[address].fetched,
action.before
),
foundOldest: action.foundOldest || state.map[address].foundOldest,
},
},
};
} else {
return {
...state,
map: {
...state.map,
[address]: {
status: action.status,
fetched: action.fetched,
foundOldest: action.foundOldest || false,
},
},
};
}
}
case ActionType.Clear: {
return { url: action.url, map: {} };
}
}
} }
const StateContext = React.createContext<State | undefined>(undefined); const StateContext = React.createContext<State | undefined>(undefined);
@@ -108,11 +59,11 @@ const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
type HistoryProviderProps = { children: React.ReactNode }; type HistoryProviderProps = { children: React.ReactNode };
export function HistoryProvider({ children }: HistoryProviderProps) { export function HistoryProvider({ children }: HistoryProviderProps) {
const { url } = useCluster(); const { url } = useCluster();
const [state, dispatch] = React.useReducer(reducer, { url, map: {} }); const [state, dispatch] = Cache.useCustomReducer(url, reconcile);
React.useEffect(() => { React.useEffect(() => {
dispatch({ type: ActionType.Clear, url }); dispatch({ type: ActionType.Clear, url });
}, [url]); }, [dispatch, url]);
return ( return (
<StateContext.Provider value={state}> <StateContext.Provider value={state}>
@@ -132,20 +83,22 @@ async function fetchAccountHistory(
dispatch({ dispatch({
type: ActionType.Update, type: ActionType.Update,
status: FetchStatus.Fetching, status: FetchStatus.Fetching,
pubkey, key: pubkey.toBase58(),
url, url,
}); });
let status; let status;
let fetched; let history;
let foundOldest;
try { try {
const connection = new Connection(url); const connection = new Connection(url);
fetched = await connection.getConfirmedSignaturesForAddress2( const fetched = await connection.getConfirmedSignaturesForAddress2(
pubkey, pubkey,
options options
); );
foundOldest = fetched.length < options.limit; history = {
fetched,
foundOldest: fetched.length < options.limit,
};
status = FetchStatus.Fetched; status = FetchStatus.Fetched;
} catch (error) { } catch (error) {
console.error("Failed to fetch account history", error); console.error("Failed to fetch account history", error);
@@ -154,11 +107,12 @@ async function fetchAccountHistory(
dispatch({ dispatch({
type: ActionType.Update, type: ActionType.Update,
url, url,
key: pubkey.toBase58(),
status, status,
fetched, data: {
before: options?.before, history,
pubkey, before: options?.before,
foundOldest, },
}); });
} }
@@ -171,17 +125,19 @@ export function useAccountHistories() {
); );
} }
return context.map; return context.entries;
} }
export function useAccountHistory(address: string) { export function useAccountHistory(
address: string
): Cache.CacheEntry<AccountHistory> | undefined {
const context = React.useContext(StateContext); const context = React.useContext(StateContext);
if (!context) { if (!context) {
throw new Error(`useAccountHistory must be used within a AccountsProvider`); throw new Error(`useAccountHistory must be used within a AccountsProvider`);
} }
return context.map[address]; return context.entries[address];
} }
export function useFetchAccountHistory() { export function useFetchAccountHistory() {
@@ -195,10 +151,11 @@ export function useFetchAccountHistory() {
} }
return (pubkey: PublicKey, refresh?: boolean) => { return (pubkey: PublicKey, refresh?: boolean) => {
const before = state.map[pubkey.toBase58()]; const before = state.entries[pubkey.toBase58()];
if (!refresh && before && before.fetched && before.fetched.length > 0) { if (!refresh && before?.data?.fetched && before.data.fetched.length > 0) {
if (before.foundOldest) return; if (before.data.foundOldest) return;
const oldest = before.fetched[before.fetched.length - 1].signature; const oldest =
before.data.fetched[before.data.fetched.length - 1].signature;
fetchAccountHistory(dispatch, pubkey, url, { before: oldest, limit: 25 }); fetchAccountHistory(dispatch, pubkey, url, { before: oldest, limit: 25 });
} else { } else {
fetchAccountHistory(dispatch, pubkey, url, { limit: 25 }); fetchAccountHistory(dispatch, pubkey, url, { limit: 25 });

View File

@@ -8,14 +8,10 @@ import { coerce } from "superstruct";
import { ParsedInfo } from "validators"; import { ParsedInfo } from "validators";
import { StakeAccount } from "validators/accounts/stake"; import { StakeAccount } from "validators/accounts/stake";
import { TokenAccount } from "validators/accounts/token"; import { TokenAccount } from "validators/accounts/token";
import * as Cache from "providers/cache";
import { ActionType, FetchStatus } from "providers/cache";
export { useAccountHistory } from "./history"; export { useAccountHistory } from "./history";
export enum FetchStatus {
Fetching,
FetchFailed,
Fetched,
}
export type StakeProgramData = { export type StakeProgramData = {
name: "stake"; name: "stake";
parsed: StakeAccount | StakeAccountWasm; parsed: StakeAccount | StakeAccountWasm;
@@ -37,98 +33,12 @@ export interface Details {
export interface Account { export interface Account {
pubkey: PublicKey; pubkey: PublicKey;
status: FetchStatus; lamports: number;
lamports?: number;
details?: Details; details?: Details;
} }
type Accounts = { [address: string]: Account }; type State = Cache.State<Account>;
interface State { type Dispatch = Cache.Dispatch<Account>;
accounts: Accounts;
url: string;
}
export enum ActionType {
Update,
Fetch,
Clear,
}
interface Update {
type: ActionType.Update;
url: string;
pubkey: PublicKey;
data: {
status: FetchStatus;
lamports?: number;
details?: Details;
};
}
interface Fetch {
type: ActionType.Fetch;
url: string;
pubkey: PublicKey;
}
interface Clear {
type: ActionType.Clear;
url: string;
}
type Action = Update | Fetch | Clear;
type Dispatch = (action: Action) => void;
function reducer(state: State, action: Action): State {
if (action.type === ActionType.Clear) {
return { url: action.url, accounts: {} };
} else if (action.url !== state.url) {
return state;
}
switch (action.type) {
case ActionType.Fetch: {
const address = action.pubkey.toBase58();
const account = state.accounts[address];
if (account) {
const accounts = {
...state.accounts,
[address]: {
pubkey: account.pubkey,
status: FetchStatus.Fetching,
},
};
return { ...state, accounts };
} else {
const accounts = {
...state.accounts,
[address]: {
status: FetchStatus.Fetching,
pubkey: action.pubkey,
},
};
return { ...state, accounts };
}
}
case ActionType.Update: {
const address = action.pubkey.toBase58();
const account = state.accounts[address];
if (account) {
const accounts = {
...state.accounts,
[address]: {
...account,
...action.data,
},
};
return { ...state, accounts };
}
break;
}
}
return state;
}
const StateContext = React.createContext<State | undefined>(undefined); const StateContext = React.createContext<State | undefined>(undefined);
const DispatchContext = React.createContext<Dispatch | undefined>(undefined); const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
@@ -136,15 +46,12 @@ const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
type AccountsProviderProps = { children: React.ReactNode }; type AccountsProviderProps = { children: React.ReactNode };
export function AccountsProvider({ children }: AccountsProviderProps) { export function AccountsProvider({ children }: AccountsProviderProps) {
const { url } = useCluster(); const { url } = useCluster();
const [state, dispatch] = React.useReducer(reducer, { const [state, dispatch] = Cache.useReducer<Account>(url);
url,
accounts: {},
});
// Clear account statuses whenever cluster is changed // Clear accounts cache whenever cluster is changed
React.useEffect(() => { React.useEffect(() => {
dispatch({ type: ActionType.Clear, url }); dispatch({ type: ActionType.Clear, url });
}, [url]); }, [dispatch, url]);
return ( return (
<StateContext.Provider value={state}> <StateContext.Provider value={state}>
@@ -163,18 +70,20 @@ async function fetchAccountInfo(
url: string url: string
) { ) {
dispatch({ dispatch({
type: ActionType.Fetch, type: ActionType.Update,
pubkey, key: pubkey.toBase58(),
status: Cache.FetchStatus.Fetching,
url, url,
}); });
let data;
let fetchStatus; let fetchStatus;
let details;
let lamports;
try { try {
const result = ( const result = (
await new Connection(url, "single").getParsedAccountInfo(pubkey) await new Connection(url, "single").getParsedAccountInfo(pubkey)
).value; ).value;
let lamports, details;
if (result === null) { if (result === null) {
lamports = 0; lamports = 0;
} else { } else {
@@ -227,13 +136,19 @@ async function fetchAccountInfo(
data, data,
}; };
} }
data = { pubkey, lamports, details };
fetchStatus = FetchStatus.Fetched; fetchStatus = FetchStatus.Fetched;
} catch (error) { } catch (error) {
console.error("Failed to fetch account info", error); console.error("Failed to fetch account info", error);
fetchStatus = FetchStatus.FetchFailed; fetchStatus = FetchStatus.FetchFailed;
} }
const data = { status: fetchStatus, lamports, details }; dispatch({
dispatch({ type: ActionType.Update, data, pubkey, url }); type: ActionType.Update,
status: fetchStatus,
data,
key: pubkey.toBase58(),
url,
});
} }
export function useAccounts() { export function useAccounts() {
@@ -241,17 +156,19 @@ export function useAccounts() {
if (!context) { if (!context) {
throw new Error(`useAccounts must be used within a AccountsProvider`); throw new Error(`useAccounts must be used within a AccountsProvider`);
} }
return context.accounts; return context.entries;
} }
export function useAccountInfo(address: string) { export function useAccountInfo(
address: string
): Cache.CacheEntry<Account> | undefined {
const context = React.useContext(StateContext); const context = React.useContext(StateContext);
if (!context) { if (!context) {
throw new Error(`useAccountInfo must be used within a AccountsProvider`); throw new Error(`useAccountInfo must be used within a AccountsProvider`);
} }
return context.accounts[address]; return context.entries[address];
} }
export function useFetchAccountInfo() { export function useFetchAccountInfo() {

View File

@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import { Connection, PublicKey } from "@solana/web3.js"; import { Connection, PublicKey } from "@solana/web3.js";
import { FetchStatus } from "./index"; import * as Cache from "providers/cache";
import { ActionType, FetchStatus } from "providers/cache";
import { TokenAccountInfo } from "validators/accounts/token"; import { TokenAccountInfo } from "validators/accounts/token";
import { useCluster } from "../cluster"; import { useCluster } from "../cluster";
import { coerce } from "superstruct"; import { coerce } from "superstruct";
@@ -11,63 +12,11 @@ export type TokenInfoWithPubkey = {
}; };
interface AccountTokens { interface AccountTokens {
status: FetchStatus;
tokens?: TokenInfoWithPubkey[]; tokens?: TokenInfoWithPubkey[];
} }
interface Update { type State = Cache.State<AccountTokens>;
type: "update"; type Dispatch = Cache.Dispatch<AccountTokens>;
url: string;
pubkey: PublicKey;
status: FetchStatus;
tokens?: TokenInfoWithPubkey[];
}
interface Clear {
type: "clear";
url: string;
}
type Action = Update | Clear;
type State = {
url: string;
map: { [address: string]: AccountTokens };
};
type Dispatch = (action: Action) => void;
function reducer(state: State, action: Action): State {
if (action.type === "clear") {
return {
url: action.url,
map: {},
};
} else if (action.url !== state.url) {
return state;
}
const address = action.pubkey.toBase58();
let addressEntry = state.map[address];
if (addressEntry && action.status === FetchStatus.Fetching) {
addressEntry = {
...addressEntry,
status: FetchStatus.Fetching,
};
} else {
addressEntry = {
tokens: action.tokens,
status: action.status,
};
}
return {
...state,
map: {
...state.map,
[address]: addressEntry,
},
};
}
const StateContext = React.createContext<State | undefined>(undefined); const StateContext = React.createContext<State | undefined>(undefined);
const DispatchContext = React.createContext<Dispatch | undefined>(undefined); const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
@@ -75,11 +24,11 @@ const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
type ProviderProps = { children: React.ReactNode }; type ProviderProps = { children: React.ReactNode };
export function TokensProvider({ children }: ProviderProps) { export function TokensProvider({ children }: ProviderProps) {
const { url } = useCluster(); const { url } = useCluster();
const [state, dispatch] = React.useReducer(reducer, { url, map: {} }); const [state, dispatch] = Cache.useReducer<AccountTokens>(url);
React.useEffect(() => { React.useEffect(() => {
dispatch({ url, type: "clear" }); dispatch({ url, type: ActionType.Clear });
}, [url]); }, [dispatch, url]);
return ( return (
<StateContext.Provider value={state}> <StateContext.Provider value={state}>
@@ -99,33 +48,38 @@ async function fetchAccountTokens(
pubkey: PublicKey, pubkey: PublicKey,
url: string url: string
) { ) {
const key = pubkey.toBase58();
dispatch({ dispatch({
type: "update", type: ActionType.Update,
key,
status: FetchStatus.Fetching, status: FetchStatus.Fetching,
pubkey,
url, url,
}); });
let status; let status;
let tokens; let data;
try { try {
const { value } = await new Connection( const { value } = await new Connection(
url, url,
"recent" "recent"
).getParsedTokenAccountsByOwner(pubkey, { programId: TOKEN_PROGRAM_ID }); ).getParsedTokenAccountsByOwner(pubkey, { programId: TOKEN_PROGRAM_ID });
tokens = value.map((accountInfo) => { data = {
const parsedInfo = accountInfo.account.data.parsed.info; tokens: value.map((accountInfo) => {
const info = coerce(parsedInfo, TokenAccountInfo); const parsedInfo = accountInfo.account.data.parsed.info;
return { info, pubkey: accountInfo.pubkey }; const info = coerce(parsedInfo, TokenAccountInfo);
}); return { info, pubkey: accountInfo.pubkey };
}),
};
status = FetchStatus.Fetched; status = FetchStatus.Fetched;
} catch (error) { } catch (error) {
status = FetchStatus.FetchFailed; status = FetchStatus.FetchFailed;
} }
dispatch({ type: "update", url, status, tokens, pubkey }); dispatch({ type: ActionType.Update, url, status, data, key });
} }
export function useAccountOwnedTokens(address: string) { export function useAccountOwnedTokens(
address: string
): Cache.CacheEntry<AccountTokens> | undefined {
const context = React.useContext(StateContext); const context = React.useContext(StateContext);
if (!context) { if (!context) {
@@ -134,7 +88,7 @@ export function useAccountOwnedTokens(address: string) {
); );
} }
return context.map[address]; return context.entries[address];
} }
export function useFetchAccountOwnedTokens() { export function useFetchAccountOwnedTokens() {

View File

@@ -0,0 +1,108 @@
import React from "react";
export enum FetchStatus {
Fetching,
FetchFailed,
Fetched,
}
export type CacheEntry<T> = {
status: FetchStatus;
data?: T;
};
export type State<T> = {
entries: {
[key: string]: CacheEntry<T>;
};
url: string;
};
export enum ActionType {
Update,
Clear,
}
export type Update<T> = {
type: ActionType.Update;
url: string;
key: string;
status: FetchStatus;
data?: T;
};
export type Clear = {
type: ActionType.Clear;
url: string;
};
export type Action<T> = Update<T> | Clear;
export type Dispatch<T> = (action: Action<T>) => void;
type Reducer<T, U> = (state: State<T>, action: Action<U>) => State<T>;
type Reconciler<T, U> = (
entry: T | undefined,
update: U | undefined
) => T | undefined;
function defaultReconciler<T>(entry: T | undefined, update: T | undefined) {
if (entry) {
if (update) {
return {
...entry,
...update,
};
} else {
return entry;
}
} else {
return update;
}
}
function defaultReducer<T>(state: State<T>, action: Action<T>) {
return reducer(state, action, defaultReconciler);
}
export function useReducer<T>(url: string) {
return React.useReducer<Reducer<T, T>>(defaultReducer, { url, entries: {} });
}
export function useCustomReducer<T, U>(
url: string,
reconciler: Reconciler<T, U>
) {
const customReducer = React.useMemo(() => {
return (state: State<T>, action: Action<U>) => {
return reducer(state, action, reconciler);
};
}, [reconciler]);
return React.useReducer<Reducer<T, U>>(customReducer, { url, entries: {} });
}
export function reducer<T, U>(
state: State<T>,
action: Action<U>,
reconciler: Reconciler<T, U>
): State<T> {
if (action.type === ActionType.Clear) {
return { url: action.url, entries: {} };
} else if (action.url !== state.url) {
return state;
}
switch (action.type) {
case ActionType.Update: {
const key = action.key;
const entry = state.entries[key];
const entries = {
...state.entries,
[key]: {
...entry,
status: action.status,
data: reconciler(entry?.data, action.data),
},
};
return { ...state, entries };
}
}
}

View File

@@ -5,78 +5,16 @@ import {
ParsedConfirmedTransaction, ParsedConfirmedTransaction,
} from "@solana/web3.js"; } from "@solana/web3.js";
import { useCluster } from "../cluster"; import { useCluster } from "../cluster";
import { FetchStatus } from "./index";
import { CACHED_DETAILS, isCached } from "./cached"; import { CACHED_DETAILS, isCached } from "./cached";
import * as Cache from "providers/cache";
import { ActionType, FetchStatus } from "providers/cache";
export interface Details { export interface Details {
fetchStatus: FetchStatus; transaction?: ParsedConfirmedTransaction | null;
transaction: ParsedConfirmedTransaction | null;
} }
type State = { type State = Cache.State<Details>;
entries: { [signature: string]: Details }; type Dispatch = Cache.Dispatch<Details>;
url: string;
};
export enum ActionType {
Update,
Clear,
}
interface Update {
type: ActionType.Update;
url: string;
signature: string;
fetchStatus: FetchStatus;
transaction: ParsedConfirmedTransaction | null;
}
interface Clear {
type: ActionType.Clear;
url: string;
}
type Action = Update | Clear;
type Dispatch = (action: Action) => void;
function reducer(state: State, action: Action): State {
if (action.type === ActionType.Clear) {
return { url: action.url, entries: {} };
} else if (action.url !== state.url) {
return state;
}
switch (action.type) {
case ActionType.Update: {
const signature = action.signature;
const details = state.entries[signature];
if (details) {
return {
...state,
entries: {
...state.entries,
[signature]: {
...details,
fetchStatus: action.fetchStatus,
transaction: action.transaction,
},
},
};
} else {
return {
...state,
entries: {
...state.entries,
[signature]: {
fetchStatus: FetchStatus.Fetching,
transaction: null,
},
},
};
}
}
}
}
export const StateContext = React.createContext<State | undefined>(undefined); export const StateContext = React.createContext<State | undefined>(undefined);
export const DispatchContext = React.createContext<Dispatch | undefined>( export const DispatchContext = React.createContext<Dispatch | undefined>(
@@ -86,11 +24,11 @@ export const DispatchContext = React.createContext<Dispatch | undefined>(
type DetailsProviderProps = { children: React.ReactNode }; type DetailsProviderProps = { children: React.ReactNode };
export function DetailsProvider({ children }: DetailsProviderProps) { export function DetailsProvider({ children }: DetailsProviderProps) {
const { url } = useCluster(); const { url } = useCluster();
const [state, dispatch] = React.useReducer(reducer, { url, entries: {} }); const [state, dispatch] = Cache.useReducer<Details>(url);
React.useEffect(() => { React.useEffect(() => {
dispatch({ type: ActionType.Clear, url }); dispatch({ type: ActionType.Clear, url });
}, [url]); }, [dispatch, url]);
return ( return (
<StateContext.Provider value={state}> <StateContext.Provider value={state}>
@@ -108,14 +46,13 @@ async function fetchDetails(
) { ) {
dispatch({ dispatch({
type: ActionType.Update, type: ActionType.Update,
fetchStatus: FetchStatus.Fetching, status: FetchStatus.Fetching,
transaction: null, key: signature,
signature,
url, url,
}); });
let fetchStatus; let fetchStatus;
let transaction = null; let transaction;
if (isCached(url, signature)) { if (isCached(url, signature)) {
transaction = CACHED_DETAILS[signature]; transaction = CACHED_DETAILS[signature];
fetchStatus = FetchStatus.Fetched; fetchStatus = FetchStatus.Fetched;
@@ -132,9 +69,9 @@ async function fetchDetails(
} }
dispatch({ dispatch({
type: ActionType.Update, type: ActionType.Update,
fetchStatus, status: fetchStatus,
signature, key: signature,
transaction, data: { transaction },
url, url,
}); });
} }
@@ -152,3 +89,17 @@ export function useFetchTransactionDetails() {
url && fetchDetails(dispatch, signature, url); url && fetchDetails(dispatch, signature, url);
}; };
} }
export function useTransactionDetails(
signature: TransactionSignature
): Cache.CacheEntry<Details> | undefined {
const context = React.useContext(StateContext);
if (!context) {
throw new Error(
`useTransactionDetails must be used within a TransactionsProvider`
);
}
return context.entries[signature];
}

View File

@@ -2,27 +2,14 @@ import React from "react";
import { import {
TransactionSignature, TransactionSignature,
Connection, Connection,
SystemProgram,
Account,
SignatureResult, SignatureResult,
PublicKey,
sendAndConfirmTransaction,
} from "@solana/web3.js"; } from "@solana/web3.js";
import { useQuery } from "utils/url"; import { useCluster } from "../cluster";
import { useCluster, Cluster, ClusterStatus } from "../cluster"; import { DetailsProvider } from "./details";
import { import * as Cache from "providers/cache";
DetailsProvider, import { ActionType, FetchStatus } from "providers/cache";
StateContext as DetailsStateContext,
} from "./details";
import base58 from "bs58";
import { useFetchAccountInfo } from "../accounts";
import { CACHED_STATUSES, isCached } from "./cached"; import { CACHED_STATUSES, isCached } from "./cached";
export { useTransactionDetails } from "./details";
export enum FetchStatus {
Fetching,
FetchFailed,
Fetched,
}
export type Confirmations = number | "max"; export type Confirmations = number | "max";
@@ -36,125 +23,27 @@ export interface TransactionStatusInfo {
} }
export interface TransactionStatus { export interface TransactionStatus {
fetchStatus: FetchStatus;
signature: TransactionSignature; signature: TransactionSignature;
info?: TransactionStatusInfo; info: TransactionStatusInfo | null;
}
type Transactions = { [signature: string]: TransactionStatus };
interface State {
transactions: Transactions;
url: string;
}
export enum ActionType {
UpdateStatus,
FetchSignature,
Clear,
}
interface UpdateStatus {
type: ActionType.UpdateStatus;
url: string;
signature: TransactionSignature;
fetchStatus: FetchStatus;
info?: TransactionStatusInfo;
}
interface FetchSignature {
type: ActionType.FetchSignature;
url: string;
signature: TransactionSignature;
}
interface Clear {
type: ActionType.Clear;
url: string;
}
type Action = UpdateStatus | FetchSignature | Clear;
type Dispatch = (action: Action) => void;
function reducer(state: State, action: Action): State {
if (action.type === ActionType.Clear) {
return { url: action.url, transactions: {} };
} else if (action.url !== state.url) {
return state;
}
switch (action.type) {
case ActionType.FetchSignature: {
const signature = action.signature;
const transaction = state.transactions[signature];
if (transaction) {
const transactions = {
...state.transactions,
[action.signature]: {
...transaction,
fetchStatus: FetchStatus.Fetching,
info: undefined,
},
};
return { ...state, transactions };
} else {
const transactions = {
...state.transactions,
[action.signature]: {
signature: action.signature,
fetchStatus: FetchStatus.Fetching,
},
};
return { ...state, transactions };
}
}
case ActionType.UpdateStatus: {
const transaction = state.transactions[action.signature];
if (transaction) {
const transactions = {
...state.transactions,
[action.signature]: {
...transaction,
fetchStatus: action.fetchStatus,
info: action.info,
},
};
return { ...state, transactions };
}
break;
}
}
return state;
} }
export const TX_ALIASES = ["tx", "txn", "transaction"]; export const TX_ALIASES = ["tx", "txn", "transaction"];
type State = Cache.State<TransactionStatus>;
type Dispatch = Cache.Dispatch<TransactionStatus>;
const StateContext = React.createContext<State | undefined>(undefined); const StateContext = React.createContext<State | undefined>(undefined);
const DispatchContext = React.createContext<Dispatch | undefined>(undefined); const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
type TransactionsProviderProps = { children: React.ReactNode }; type TransactionsProviderProps = { children: React.ReactNode };
export function TransactionsProvider({ children }: TransactionsProviderProps) { export function TransactionsProvider({ children }: TransactionsProviderProps) {
const { cluster, status: clusterStatus, url } = useCluster(); const { url } = useCluster();
const [state, dispatch] = React.useReducer(reducer, { const [state, dispatch] = Cache.useReducer<TransactionStatus>(url);
transactions: {},
url,
});
const fetchAccount = useFetchAccountInfo(); // Clear accounts cache whenever cluster is changed
const query = useQuery();
const testFlag = query.get("test");
// Check transaction statuses whenever cluster updates
React.useEffect(() => { React.useEffect(() => {
if (clusterStatus === ClusterStatus.Connecting) { dispatch({ type: ActionType.Clear, url });
dispatch({ type: ActionType.Clear, url }); }, [dispatch, url]);
}
// Create a test transaction
if (cluster === Cluster.Devnet && testFlag !== null) {
createTestTransaction(dispatch, fetchAccount, url, clusterStatus);
}
}, [testFlag, cluster, clusterStatus, url]); // eslint-disable-line react-hooks/exhaustive-deps
return ( return (
<StateContext.Provider value={state}> <StateContext.Provider value={state}>
@@ -165,64 +54,23 @@ export function TransactionsProvider({ children }: TransactionsProviderProps) {
); );
} }
async function createTestTransaction(
dispatch: Dispatch,
fetchAccount: (pubkey: PublicKey) => void,
url: string,
clusterStatus: ClusterStatus
) {
const testKey = process.env.REACT_APP_TEST_KEY;
let testAccount = new Account();
if (testKey) {
testAccount = new Account(base58.decode(testKey));
}
try {
const connection = new Connection(url, "recent");
const signature = await connection.requestAirdrop(
testAccount.publicKey,
100000
);
fetchTransactionStatus(dispatch, signature, url);
fetchAccount(testAccount.publicKey);
} catch (error) {
console.error("Failed to create test success transaction", error);
}
try {
const connection = new Connection(url, "recent");
const tx = SystemProgram.transfer({
fromPubkey: testAccount.publicKey,
toPubkey: testAccount.publicKey,
lamports: 1,
});
const signature = await sendAndConfirmTransaction(
connection,
tx,
[testAccount],
{ confirmations: 1, skipPreflight: false }
);
fetchTransactionStatus(dispatch, signature, url);
} catch (error) {
console.error("Failed to create test failure transaction", error);
}
}
export async function fetchTransactionStatus( export async function fetchTransactionStatus(
dispatch: Dispatch, dispatch: Dispatch,
signature: TransactionSignature, signature: TransactionSignature,
url: string url: string
) { ) {
dispatch({ dispatch({
type: ActionType.FetchSignature, type: ActionType.Update,
signature, key: signature,
status: FetchStatus.Fetching,
url, url,
}); });
let fetchStatus; let fetchStatus;
let info: TransactionStatusInfo | undefined; let data;
if (isCached(url, signature)) { if (isCached(url, signature)) {
info = CACHED_STATUSES[signature]; const info = CACHED_STATUSES[signature];
data = { signature, info };
fetchStatus = FetchStatus.Fetched; fetchStatus = FetchStatus.Fetched;
} else { } else {
try { try {
@@ -231,6 +79,7 @@ export async function fetchTransactionStatus(
searchTransactionHistory: true, searchTransactionHistory: true,
}); });
let info = null;
if (value !== null) { if (value !== null) {
let blockTime = null; let blockTime = null;
try { try {
@@ -260,6 +109,7 @@ export async function fetchTransactionStatus(
result: { err: value.err }, result: { err: value.err },
}; };
} }
data = { signature, info };
fetchStatus = FetchStatus.Fetched; fetchStatus = FetchStatus.Fetched;
} catch (error) { } catch (error) {
console.error("Failed to fetch transaction status", error); console.error("Failed to fetch transaction status", error);
@@ -268,10 +118,10 @@ export async function fetchTransactionStatus(
} }
dispatch({ dispatch({
type: ActionType.UpdateStatus, type: ActionType.Update,
signature, key: signature,
fetchStatus, status: fetchStatus,
info, data,
url, url,
}); });
} }
@@ -286,7 +136,9 @@ export function useTransactions() {
return context; return context;
} }
export function useTransactionStatus(signature: TransactionSignature) { export function useTransactionStatus(
signature: TransactionSignature
): Cache.CacheEntry<TransactionStatus> | undefined {
const context = React.useContext(StateContext); const context = React.useContext(StateContext);
if (!context) { if (!context) {
@@ -295,18 +147,6 @@ export function useTransactionStatus(signature: TransactionSignature) {
); );
} }
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.entries[signature]; return context.entries[signature];
} }