solana/explorer/src/providers/accounts/index.tsx

613 lines
16 KiB
TypeScript

import React from "react";
import { pubkeyToString } from "utils";
import {
PublicKey,
Connection,
StakeActivationData,
AddressLookupTableAccount,
AddressLookupTableProgram,
SystemProgram,
ParsedAccountData,
} from "@solana/web3.js";
import { useCluster, Cluster } from "../cluster";
import { HistoryProvider } from "./history";
import { TokensProvider } from "./tokens";
import { create } from "superstruct";
import { ParsedInfo } from "validators";
import { StakeAccount } from "validators/accounts/stake";
import {
TokenAccount,
MintAccountInfo,
TokenAccountInfo,
} from "validators/accounts/token";
import * as Cache from "providers/cache";
import { ActionType, FetchStatus } from "providers/cache";
import { reportError } from "utils/sentry";
import { VoteAccount } from "validators/accounts/vote";
import { NonceAccount } from "validators/accounts/nonce";
import { SysvarAccount } from "validators/accounts/sysvar";
import { ConfigAccount } from "validators/accounts/config";
import { ParsedAddressLookupTableAccount } from "validators/accounts/address-lookup-table";
import { FlaggedAccountsProvider } from "./flagged-accounts";
import {
ProgramDataAccount,
ProgramDataAccountInfo,
UpgradeableLoaderAccount,
} from "validators/accounts/upgradeable-program";
import { RewardsProvider } from "./rewards";
import { programs, MetadataJson } from "@metaplex/js";
import getEditionInfo, { EditionInfo } from "./utils/getEditionInfo";
export { useAccountHistory } from "./history";
const Metadata = programs.metadata.Metadata;
export type StakeProgramData = {
program: "stake";
parsed: StakeAccount;
activation?: StakeActivationData;
};
export type UpgradeableLoaderAccountData = {
program: "bpf-upgradeable-loader";
parsed: UpgradeableLoaderAccount;
programData?: ProgramDataAccountInfo;
};
export type NFTData = {
metadata: programs.metadata.MetadataData;
json: MetadataJson | undefined;
editionInfo: EditionInfo;
};
export type TokenProgramData = {
program: "spl-token";
parsed: TokenAccount;
nftData?: NFTData;
};
export type VoteProgramData = {
program: "vote";
parsed: VoteAccount;
};
export type NonceProgramData = {
program: "nonce";
parsed: NonceAccount;
};
export type SysvarProgramData = {
program: "sysvar";
parsed: SysvarAccount;
};
export type ConfigProgramData = {
program: "config";
parsed: ConfigAccount;
};
export type AddressLookupTableProgramData = {
program: "address-lookup-table";
parsed: ParsedAddressLookupTableAccount;
};
export type ParsedData =
| UpgradeableLoaderAccountData
| StakeProgramData
| TokenProgramData
| VoteProgramData
| NonceProgramData
| SysvarProgramData
| ConfigProgramData
| AddressLookupTableProgramData;
export interface AccountData {
parsed?: ParsedData;
raw?: Buffer;
}
export interface Account {
pubkey: PublicKey;
lamports: number;
executable: boolean;
owner: PublicKey;
space?: number;
data: AccountData;
}
type State = Cache.State<Account>;
type Dispatch = Cache.Dispatch<Account>;
type Fetchers = { [mode in FetchAccountDataMode]: MultipleAccountFetcher };
const FetchersContext = React.createContext<Fetchers | undefined>(undefined);
const StateContext = React.createContext<State | undefined>(undefined);
const DispatchContext = React.createContext<Dispatch | undefined>(undefined);
class MultipleAccountFetcher {
pubkeys: PublicKey[] = [];
fetchTimeout?: NodeJS.Timeout;
constructor(
private dispatch: Dispatch,
private cluster: Cluster,
private url: string,
private dataMode: FetchAccountDataMode
) {}
fetch = (pubkey: PublicKey) => {
if (this.pubkeys !== undefined) this.pubkeys.push(pubkey);
if (this.fetchTimeout === undefined) {
this.fetchTimeout = setTimeout(() => {
this.fetchTimeout = undefined;
if (this.pubkeys !== undefined) {
const pubkeys = this.pubkeys;
this.pubkeys = [];
const { dispatch, cluster, url, dataMode } = this;
fetchMultipleAccounts({ dispatch, pubkeys, cluster, url, dataMode });
}
}, 100);
}
};
}
export type FetchAccountDataMode = "parsed" | "raw" | "skip";
type AccountsProviderProps = { children: React.ReactNode };
export function AccountsProvider({ children }: AccountsProviderProps) {
const { cluster, url } = useCluster();
const [state, dispatch] = Cache.useReducer<Account>(url);
const [fetchers, setFetchers] = React.useState<Fetchers>(() => ({
skip: new MultipleAccountFetcher(dispatch, cluster, url, "skip"),
raw: new MultipleAccountFetcher(dispatch, cluster, url, "raw"),
parsed: new MultipleAccountFetcher(dispatch, cluster, url, "parsed"),
}));
// Clear accounts cache whenever cluster is changed
React.useEffect(() => {
dispatch({ type: ActionType.Clear, url });
setFetchers({
skip: new MultipleAccountFetcher(dispatch, cluster, url, "skip"),
raw: new MultipleAccountFetcher(dispatch, cluster, url, "raw"),
parsed: new MultipleAccountFetcher(dispatch, cluster, url, "parsed"),
});
}, [dispatch, cluster, url]);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
<FetchersContext.Provider value={fetchers}>
<TokensProvider>
<HistoryProvider>
<RewardsProvider>
<FlaggedAccountsProvider>{children}</FlaggedAccountsProvider>
</RewardsProvider>
</HistoryProvider>
</TokensProvider>
</FetchersContext.Provider>
</DispatchContext.Provider>
</StateContext.Provider>
);
}
async function fetchMultipleAccounts({
dispatch,
pubkeys,
dataMode,
cluster,
url,
}: {
dispatch: Dispatch;
pubkeys: PublicKey[];
dataMode: FetchAccountDataMode;
cluster: Cluster;
url: string;
}) {
for (let pubkey of pubkeys) {
dispatch({
type: ActionType.Update,
key: pubkey.toBase58(),
status: Cache.FetchStatus.Fetching,
url,
});
}
const BATCH_SIZE = 100;
const connection = new Connection(url, "confirmed");
let nextBatchStart = 0;
while (nextBatchStart < pubkeys.length) {
const batch = pubkeys.slice(nextBatchStart, nextBatchStart + BATCH_SIZE);
nextBatchStart += BATCH_SIZE;
try {
let results;
if (dataMode === "parsed") {
results = (await connection.getMultipleParsedAccounts(batch)).value;
} else if (dataMode === "raw") {
results = await connection.getMultipleAccountsInfo(batch);
} else {
results = await connection.getMultipleAccountsInfo(batch, {
dataSlice: { length: 0, offset: 0 },
});
}
for (let i = 0; i < batch.length; i++) {
const pubkey = batch[i];
const result = results[i];
let account: Account;
if (result === null) {
account = {
pubkey,
lamports: 0,
owner: SystemProgram.programId,
space: 0,
executable: false,
data: { raw: Buffer.alloc(0) },
};
} else {
let space: number | undefined = undefined;
let parsedData: ParsedData | undefined;
if ("parsed" in result.data) {
const accountData: ParsedAccountData = result.data;
space = result.data.space;
try {
parsedData = await handleParsedAccountData(
connection,
pubkey,
accountData
);
} catch (error) {
reportError(error, { url, address: pubkey.toBase58() });
}
}
// If we cannot parse account layout as native spl account
// then keep raw data for other components to decode
let rawData: Buffer | undefined;
if (
!parsedData &&
!("parsed" in result.data) &&
dataMode !== "skip"
) {
space = result.data.length;
rawData = result.data;
}
account = {
pubkey,
lamports: result.lamports,
executable: result.executable,
owner: result.owner,
space,
data: {
parsed: parsedData,
raw: rawData,
},
};
}
dispatch({
type: ActionType.Update,
status: FetchStatus.Fetched,
data: account,
key: pubkey.toBase58(),
url,
});
}
} catch (error) {
if (cluster !== Cluster.Custom) {
reportError(error, { url });
}
for (let pubkey of batch) {
dispatch({
type: ActionType.Update,
status: FetchStatus.FetchFailed,
key: pubkey.toBase58(),
url,
});
}
}
}
}
async function handleParsedAccountData(
connection: Connection,
accountKey: PublicKey,
accountData: ParsedAccountData
): Promise<ParsedData | undefined> {
const info = create(accountData.parsed, ParsedInfo);
switch (accountData.program) {
case "bpf-upgradeable-loader": {
const parsed = create(info, UpgradeableLoaderAccount);
// Fetch program data to get program upgradeability info
let programData: ProgramDataAccountInfo | undefined;
if (parsed.type === "program") {
const result = (
await connection.getParsedAccountInfo(parsed.info.programData)
).value;
if (
result &&
"parsed" in result.data &&
result.data.program === "bpf-upgradeable-loader"
) {
const info = create(result.data.parsed, ParsedInfo);
programData = create(info, ProgramDataAccount).info;
}
}
return {
program: accountData.program,
parsed,
programData,
};
}
case "stake": {
const parsed = create(info, StakeAccount);
const isDelegated = parsed.type === "delegated";
const activation = isDelegated
? await connection.getStakeActivation(accountKey)
: undefined;
return {
program: accountData.program,
parsed,
activation,
};
}
case "vote": {
return {
program: accountData.program,
parsed: create(info, VoteAccount),
};
}
case "nonce": {
return {
program: accountData.program,
parsed: create(info, NonceAccount),
};
}
case "sysvar": {
return {
program: accountData.program,
parsed: create(info, SysvarAccount),
};
}
case "config": {
return {
program: accountData.program,
parsed: create(info, ConfigAccount),
};
}
case "address-lookup-table": {
const parsed = create(info, ParsedAddressLookupTableAccount);
return {
program: accountData.program,
parsed,
};
}
case "spl-token": {
const parsed = create(info, TokenAccount);
let nftData;
try {
// Generate a PDA and check for a Metadata Account
if (parsed.type === "mint") {
const metadata = await Metadata.load(
connection,
await Metadata.getPDA(accountKey)
);
if (metadata) {
// We have a valid Metadata account. Try and pull edition data.
const editionInfo = await getEditionInfo(metadata, connection);
const id = pubkeyToString(accountKey);
const metadataJSON = await getMetaDataJSON(id, metadata.data);
nftData = {
metadata: metadata.data,
json: metadataJSON,
editionInfo,
};
}
}
} catch (error) {
// unable to find NFT metadata account
}
return {
program: accountData.program,
parsed,
nftData,
};
}
}
}
const IMAGE_MIME_TYPE_REGEX = /data:image\/(svg\+xml|png|jpeg|gif)/g;
const getMetaDataJSON = async (
id: string,
metadata: programs.metadata.MetadataData
): Promise<MetadataJson | undefined> => {
return new Promise(async (resolve, reject) => {
const uri = metadata.data.uri;
if (!uri) return resolve(undefined);
const processJson = (extended: any) => {
if (!extended || extended?.properties?.files?.length === 0) {
return;
}
if (extended?.image) {
extended.image =
extended.image.startsWith("http") ||
IMAGE_MIME_TYPE_REGEX.test(extended.image)
? extended.image
: `${metadata.data.uri}/${extended.image}`;
}
return extended;
};
try {
fetch(uri)
.then(async (_) => {
try {
const data = await _.json();
try {
localStorage.setItem(uri, JSON.stringify(data));
} catch {
// ignore
}
resolve(processJson(data));
} catch {
resolve(undefined);
}
})
.catch(() => {
resolve(undefined);
});
} catch (ex) {
console.error(ex);
resolve(undefined);
}
});
};
export function useAccounts() {
const context = React.useContext(StateContext);
if (!context) {
throw new Error(`useAccounts must be used within a AccountsProvider`);
}
return context.entries;
}
export function useAccountInfo(
address: string | undefined
): Cache.CacheEntry<Account> | undefined {
const context = React.useContext(StateContext);
if (!context) {
throw new Error(`useAccountInfo must be used within a AccountsProvider`);
}
if (address === undefined) return;
return context.entries[address];
}
export function useMintAccountInfo(
address: string | undefined
): MintAccountInfo | undefined {
const accountInfo = useAccountInfo(address);
return React.useMemo(() => {
if (address === undefined || accountInfo?.data === undefined) return;
const account = accountInfo.data;
try {
const parsedData = account.data.parsed;
if (!parsedData) return;
if (
parsedData.program !== "spl-token" ||
parsedData.parsed.type !== "mint"
) {
return;
}
return create(parsedData.parsed.info, MintAccountInfo);
} catch (err) {
reportError(err, { address });
}
}, [address, accountInfo]);
}
export function useTokenAccountInfo(
address: string | undefined
): TokenAccountInfo | undefined {
const accountInfo = useAccountInfo(address);
return React.useMemo(() => {
if (address === undefined || accountInfo?.data === undefined) return;
const account = accountInfo.data;
try {
const parsedData = account.data.parsed;
if (!parsedData) return;
if (
parsedData.program !== "spl-token" ||
parsedData.parsed.type !== "account"
) {
return;
}
return create(parsedData.parsed.info, TokenAccountInfo);
} catch (err) {
reportError(err, { address });
}
}, [address, accountInfo]);
}
export function useAddressLookupTable(
address: string
): [AddressLookupTableAccount | string | undefined, FetchStatus] | undefined {
const accountInfo = useAccountInfo(address);
return React.useMemo(() => {
if (accountInfo === undefined) return;
const account = accountInfo.data;
if (account === undefined) return [account, accountInfo.status];
if (account.lamports === 0)
return ["Lookup Table Not Found", accountInfo.status];
const { parsed: parsedData, raw: rawData } = account.data;
const key = new PublicKey(address);
if (parsedData && parsedData.program === "address-lookup-table") {
if (parsedData.parsed.type === "lookupTable") {
return [
new AddressLookupTableAccount({
key,
state: parsedData.parsed.info,
}),
accountInfo.status,
];
} else if (parsedData.parsed.type === "uninitialized") {
return ["Lookup Table Uninitialized", accountInfo.status];
}
} else if (
rawData &&
account.owner.equals(AddressLookupTableProgram.programId)
) {
try {
return [
new AddressLookupTableAccount({
key,
state: AddressLookupTableAccount.deserialize(rawData),
}),
accountInfo.status,
];
} catch {}
}
return ["Invalid Lookup Table", accountInfo.status];
}, [address, accountInfo]);
}
export function useFetchAccountInfo() {
const fetchers = React.useContext(FetchersContext);
if (!fetchers) {
throw new Error(
`useFetchAccountInfo must be used within a AccountsProvider`
);
}
return React.useCallback(
(pubkey: PublicKey, dataMode: FetchAccountDataMode) => {
fetchers[dataMode].fetch(pubkey);
},
[fetchers]
);
}