Add token registry to explorer (#11612)
This commit is contained in:
BIN
explorer/public/tokens/serum-32.png
Normal file
BIN
explorer/public/tokens/serum-32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 725 B |
BIN
explorer/public/tokens/serum-64.png
Normal file
BIN
explorer/public/tokens/serum-64.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.2 KiB |
@ -3,13 +3,16 @@ import bs58 from "bs58";
|
|||||||
import { useHistory, useLocation } from "react-router-dom";
|
import { useHistory, useLocation } from "react-router-dom";
|
||||||
import Select, { InputActionMeta, ActionMeta, ValueType } from "react-select";
|
import Select, { InputActionMeta, ActionMeta, ValueType } from "react-select";
|
||||||
import StateManager from "react-select";
|
import StateManager from "react-select";
|
||||||
import { PROGRAM_IDS, SYSVAR_IDS } from "utils/tx";
|
import { PROGRAM_IDS, SYSVAR_IDS, ProgramName } from "utils/tx";
|
||||||
|
import { TokenRegistry } from "tokenRegistry";
|
||||||
|
import { Cluster, useCluster } from "providers/cluster";
|
||||||
|
|
||||||
export function SearchBar() {
|
export function SearchBar() {
|
||||||
const [search, setSearch] = React.useState("");
|
const [search, setSearch] = React.useState("");
|
||||||
const selectRef = React.useRef<StateManager<any> | null>(null);
|
const selectRef = React.useRef<StateManager<any> | null>(null);
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { cluster } = useCluster();
|
||||||
|
|
||||||
const onChange = ({ pathname }: ValueType<any>, meta: ActionMeta<any>) => {
|
const onChange = ({ pathname }: ValueType<any>, meta: ActionMeta<any>) => {
|
||||||
if (meta.action === "select-option") {
|
if (meta.action === "select-option") {
|
||||||
@ -29,9 +32,9 @@ export function SearchBar() {
|
|||||||
<div className="col">
|
<div className="col">
|
||||||
<Select
|
<Select
|
||||||
ref={(ref) => (selectRef.current = ref)}
|
ref={(ref) => (selectRef.current = ref)}
|
||||||
options={buildOptions(search)}
|
options={buildOptions(search, cluster)}
|
||||||
noOptionsMessage={() => "No Results"}
|
noOptionsMessage={() => "No Results"}
|
||||||
placeholder="Search by address or signature"
|
placeholder="Search for accounts, transactions, programs, and tokens"
|
||||||
value={resetValue}
|
value={resetValue}
|
||||||
inputValue={search}
|
inputValue={search}
|
||||||
blurInputOnSelect
|
blurInputOnSelect
|
||||||
@ -47,22 +50,31 @@ export function SearchBar() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEARCHABLE_PROGRAMS = ["Config", "Stake", "System", "Vote", "Token"];
|
const SEARCHABLE_PROGRAMS: ProgramName[] = [
|
||||||
|
"Config Program",
|
||||||
|
"Stake Program",
|
||||||
|
"System Program",
|
||||||
|
"Vote Program",
|
||||||
|
"SPL Token",
|
||||||
|
];
|
||||||
|
|
||||||
function buildProgramOptions(search: string) {
|
function buildProgramOptions(search: string) {
|
||||||
const matchedPrograms = Object.entries(PROGRAM_IDS).filter(([, name]) => {
|
const matchedPrograms = Object.entries(PROGRAM_IDS).filter(
|
||||||
|
([address, name]) => {
|
||||||
return (
|
return (
|
||||||
SEARCHABLE_PROGRAMS.includes(name) &&
|
SEARCHABLE_PROGRAMS.includes(name) &&
|
||||||
name.toLowerCase().includes(search.toLowerCase())
|
(name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
address.includes(search))
|
||||||
|
);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
if (matchedPrograms.length > 0) {
|
if (matchedPrograms.length > 0) {
|
||||||
return {
|
return {
|
||||||
label: "Programs",
|
label: "Programs",
|
||||||
options: matchedPrograms.map(([id, name]) => ({
|
options: matchedPrograms.map(([id, name]) => ({
|
||||||
label: name,
|
label: name,
|
||||||
value: name,
|
value: [name, id],
|
||||||
pathname: "/address/" + id,
|
pathname: "/address/" + id,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@ -70,23 +82,52 @@ function buildProgramOptions(search: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSysvarOptions(search: string) {
|
function buildSysvarOptions(search: string) {
|
||||||
const matchedSysvars = Object.entries(SYSVAR_IDS).filter(([, name]) => {
|
const matchedSysvars = Object.entries(SYSVAR_IDS).filter(
|
||||||
return name.toLowerCase().includes(search.toLowerCase());
|
([address, name]) => {
|
||||||
});
|
return (
|
||||||
|
name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
address.includes(search)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (matchedSysvars.length > 0) {
|
if (matchedSysvars.length > 0) {
|
||||||
return {
|
return {
|
||||||
label: "Sysvars",
|
label: "Sysvars",
|
||||||
options: matchedSysvars.map(([id, name]) => ({
|
options: matchedSysvars.map(([id, name]) => ({
|
||||||
label: name,
|
label: name,
|
||||||
value: name,
|
value: [name, id],
|
||||||
pathname: "/address/" + id,
|
pathname: "/address/" + id,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOptions(search: string) {
|
function buildTokenOptions(search: string, cluster: Cluster) {
|
||||||
|
const matchedTokens = Object.entries(TokenRegistry.all(cluster)).filter(
|
||||||
|
([address, details]) => {
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
details.name.toLowerCase().includes(searchLower) ||
|
||||||
|
details.symbol.toLowerCase().includes(searchLower) ||
|
||||||
|
address.includes(search)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchedTokens.length > 0) {
|
||||||
|
return {
|
||||||
|
label: "Tokens",
|
||||||
|
options: matchedTokens.map(([id, details]) => ({
|
||||||
|
label: details.name,
|
||||||
|
value: [details.name, details.symbol, id],
|
||||||
|
pathname: "/address/" + id,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOptions(search: string, cluster: Cluster) {
|
||||||
if (search.length === 0) return [];
|
if (search.length === 0) return [];
|
||||||
|
|
||||||
const options = [];
|
const options = [];
|
||||||
@ -101,6 +142,14 @@ function buildOptions(search: string) {
|
|||||||
options.push(sysvarOptions);
|
options.push(sysvarOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenOptions = buildTokenOptions(search, cluster);
|
||||||
|
if (tokenOptions) {
|
||||||
|
options.push(tokenOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer nice suggestions over raw suggestions
|
||||||
|
if (options.length > 0) return options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = bs58.decode(search);
|
const decoded = bs58.decode(search);
|
||||||
if (decoded.length === 32) {
|
if (decoded.length === 32) {
|
||||||
|
@ -4,16 +4,36 @@ import { FetchStatus } from "providers/cache";
|
|||||||
import {
|
import {
|
||||||
useFetchAccountOwnedTokens,
|
useFetchAccountOwnedTokens,
|
||||||
useAccountOwnedTokens,
|
useAccountOwnedTokens,
|
||||||
|
TokenInfoWithPubkey,
|
||||||
} from "providers/accounts/tokens";
|
} from "providers/accounts/tokens";
|
||||||
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 { Address } from "components/common/Address";
|
import { Address } from "components/common/Address";
|
||||||
|
import { TokenRegistry } from "tokenRegistry";
|
||||||
|
import { useQuery } from "utils/url";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Location } from "history";
|
||||||
|
import { useCluster } from "providers/cluster";
|
||||||
|
|
||||||
|
type Display = "summary" | "detail" | null;
|
||||||
|
|
||||||
|
const useQueryDisplay = (): Display => {
|
||||||
|
const query = useQuery();
|
||||||
|
const filter = query.get("display");
|
||||||
|
if (filter === "summary" || filter === "detail") {
|
||||||
|
return filter;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export function OwnedTokensCard({ pubkey }: { pubkey: PublicKey }) {
|
export function OwnedTokensCard({ pubkey }: { pubkey: PublicKey }) {
|
||||||
const address = pubkey.toBase58();
|
const address = pubkey.toBase58();
|
||||||
const ownedTokens = useAccountOwnedTokens(address);
|
const ownedTokens = useAccountOwnedTokens(address);
|
||||||
const fetchAccountTokens = useFetchAccountOwnedTokens();
|
const fetchAccountTokens = useFetchAccountOwnedTokens();
|
||||||
const refresh = () => fetchAccountTokens(pubkey);
|
const refresh = () => fetchAccountTokens(pubkey);
|
||||||
|
const [showDropdown, setDropdown] = React.useState(false);
|
||||||
|
const display = useQueryDisplay();
|
||||||
|
|
||||||
// Fetch owned tokens
|
// Fetch owned tokens
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@ -28,9 +48,9 @@ export function OwnedTokensCard({ pubkey }: { pubkey: PublicKey }) {
|
|||||||
const tokens = ownedTokens.data?.tokens;
|
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 token holdings" />;
|
||||||
} else if (tokens === undefined) {
|
} else if (tokens === undefined) {
|
||||||
return <ErrorCard retry={refresh} text="Failed to fetch owned tokens" />;
|
return <ErrorCard retry={refresh} text="Failed to fetch token holdings" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokens.length === 0) {
|
if (tokens.length === 0) {
|
||||||
@ -38,17 +58,100 @@ export function OwnedTokensCard({ pubkey }: { pubkey: PublicKey }) {
|
|||||||
<ErrorCard
|
<ErrorCard
|
||||||
retry={refresh}
|
retry={refresh}
|
||||||
retryText="Try Again"
|
retryText="Try Again"
|
||||||
text={"No owned tokens found"}
|
text={"No token holdings found"}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showDropdown && (
|
||||||
|
<div className="dropdown-exit" onClick={() => setDropdown(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header align-items-center">
|
||||||
|
<h3 className="card-header-title">Token Holdings</h3>
|
||||||
|
<DisplayDropdown
|
||||||
|
display={display}
|
||||||
|
toggle={() => setDropdown((show) => !show)}
|
||||||
|
show={showDropdown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{display === "detail" ? (
|
||||||
|
<HoldingsDetailTable tokens={tokens} />
|
||||||
|
) : (
|
||||||
|
<HoldingsSummaryTable tokens={tokens} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoldingsDetailTable({ tokens }: { tokens: TokenInfoWithPubkey[] }) {
|
||||||
|
const detailsList: React.ReactNode[] = [];
|
||||||
|
const { cluster } = useCluster();
|
||||||
|
const showLogos = tokens.some(
|
||||||
|
(t) => TokenRegistry.get(t.info.mint.toBase58(), cluster) !== undefined
|
||||||
|
);
|
||||||
|
tokens.forEach((tokenAccount) => {
|
||||||
|
const address = tokenAccount.pubkey.toBase58();
|
||||||
|
const mintAddress = tokenAccount.info.mint.toBase58();
|
||||||
|
const tokenDetails = TokenRegistry.get(mintAddress, cluster);
|
||||||
|
detailsList.push(
|
||||||
|
<tr key={address}>
|
||||||
|
{showLogos && (
|
||||||
|
<td className="w-1 p-0 text-center">
|
||||||
|
{tokenDetails && (
|
||||||
|
<img
|
||||||
|
src={tokenDetails.icon}
|
||||||
|
alt="token icon"
|
||||||
|
className="token-icon rounded-circle border border-4 border-gray-dark"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td>
|
||||||
|
<Address pubkey={tokenAccount.pubkey} link truncate />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Address pubkey={tokenAccount.info.mint} link truncate />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{tokenAccount.info.tokenAmount.uiAmount}{" "}
|
||||||
|
{tokenDetails && tokenDetails.symbol}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="table-responsive mb-0">
|
||||||
|
<table className="table table-sm table-nowrap card-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{showLogos && (
|
||||||
|
<th className="text-muted w-1 p-0 text-center">Logo</th>
|
||||||
|
)}
|
||||||
|
<th className="text-muted">Account Address</th>
|
||||||
|
<th className="text-muted">Mint Address</th>
|
||||||
|
<th className="text-muted">Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="list">{detailsList}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoldingsSummaryTable({ tokens }: { tokens: TokenInfoWithPubkey[] }) {
|
||||||
|
const { cluster } = useCluster();
|
||||||
const mappedTokens = new Map<string, number>();
|
const mappedTokens = new Map<string, number>();
|
||||||
for (const { info: token } of tokens) {
|
for (const { info: token } of tokens) {
|
||||||
const mintAddress = token.mint.toBase58();
|
const mintAddress = token.mint.toBase58();
|
||||||
const totalByMint = mappedTokens.get(mintAddress);
|
const totalByMint = mappedTokens.get(mintAddress);
|
||||||
|
|
||||||
let amount = token?.amount || (token?.tokenAmount?.uiAmount as number);
|
let amount = token.tokenAmount.uiAmount;
|
||||||
if (totalByMint !== undefined) {
|
if (totalByMint !== undefined) {
|
||||||
amount += totalByMint;
|
amount += totalByMint;
|
||||||
}
|
}
|
||||||
@ -57,51 +160,100 @@ export function OwnedTokensCard({ pubkey }: { pubkey: PublicKey }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const detailsList: React.ReactNode[] = [];
|
const detailsList: React.ReactNode[] = [];
|
||||||
|
const showLogos = tokens.some((t) =>
|
||||||
|
TokenRegistry.get(t.info.mint.toBase58(), cluster)
|
||||||
|
);
|
||||||
mappedTokens.forEach((totalByMint, mintAddress) => {
|
mappedTokens.forEach((totalByMint, mintAddress) => {
|
||||||
|
const tokenDetails = TokenRegistry.get(mintAddress, cluster);
|
||||||
detailsList.push(
|
detailsList.push(
|
||||||
<tr key={mintAddress}>
|
<tr key={mintAddress}>
|
||||||
|
{showLogos && (
|
||||||
|
<td className="w-1 p-0 text-center">
|
||||||
|
{tokenDetails && (
|
||||||
|
<img
|
||||||
|
src={tokenDetails.icon}
|
||||||
|
alt="token icon"
|
||||||
|
className="token-icon rounded-circle border border-4 border-gray-dark"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
<td>
|
<td>
|
||||||
<Address pubkey={new PublicKey(mintAddress)} link />
|
<Address pubkey={new PublicKey(mintAddress)} link />
|
||||||
</td>
|
</td>
|
||||||
<td>{totalByMint}</td>
|
<td>
|
||||||
|
{totalByMint} {tokenDetails && tokenDetails.symbol}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
|
||||||
<div className="card-header align-items-center">
|
|
||||||
<h3 className="card-header-title">Owned Tokens</h3>
|
|
||||||
<button
|
|
||||||
className="btn btn-white btn-sm"
|
|
||||||
disabled={fetching}
|
|
||||||
onClick={refresh}
|
|
||||||
>
|
|
||||||
{fetching ? (
|
|
||||||
<>
|
|
||||||
<span className="spinner-grow spinner-grow-sm mr-2"></span>
|
|
||||||
Loading
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="fe fe-refresh-cw mr-2"></span>
|
|
||||||
Refresh
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="table-responsive mb-0">
|
<div className="table-responsive mb-0">
|
||||||
<table className="table table-sm table-nowrap card-table">
|
<table className="table table-sm table-nowrap card-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-muted">Token Address</th>
|
{showLogos && (
|
||||||
<th className="text-muted">Balance</th>
|
<th className="text-muted w-1 p-0 text-center">Logo</th>
|
||||||
|
)}
|
||||||
|
<th className="text-muted">Mint Address</th>
|
||||||
|
<th className="text-muted">Total Balance</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="list">{detailsList}</tbody>
|
<tbody className="list">{detailsList}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DropdownProps = {
|
||||||
|
display: Display;
|
||||||
|
toggle: () => void;
|
||||||
|
show: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DisplayDropdown = ({ display, toggle, show }: DropdownProps) => {
|
||||||
|
const buildLocation = (location: Location, display: Display) => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
if (display === null) {
|
||||||
|
params.delete("display");
|
||||||
|
} else {
|
||||||
|
params.set("display", display);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...location,
|
||||||
|
search: params.toString(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const DISPLAY_OPTIONS: Display[] = [null, "detail"];
|
||||||
|
return (
|
||||||
|
<div className="dropdown">
|
||||||
|
<button
|
||||||
|
className="btn btn-white btn-sm dropdown-toggle"
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
{display === "detail" ? "Detailed" : "Summary"}
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={`dropdown-menu-right dropdown-menu${show ? " show" : ""}`}
|
||||||
|
>
|
||||||
|
{DISPLAY_OPTIONS.map((displayOption) => {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={displayOption || "null"}
|
||||||
|
to={(location) => buildLocation(location, displayOption)}
|
||||||
|
className={`dropdown-item${
|
||||||
|
displayOption === display ? " active" : ""
|
||||||
|
}`}
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
{displayOption === "detail" ? "Detailed" : "Summary"}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@ -12,6 +12,8 @@ import { Address } from "components/common/Address";
|
|||||||
import { UnknownAccountCard } from "./UnknownAccountCard";
|
import { UnknownAccountCard } from "./UnknownAccountCard";
|
||||||
import { useFetchTokenSupply, useTokenSupply } from "providers/mints/supply";
|
import { useFetchTokenSupply, useTokenSupply } from "providers/mints/supply";
|
||||||
import { FetchStatus } from "providers/cache";
|
import { FetchStatus } from "providers/cache";
|
||||||
|
import { TokenRegistry } from "tokenRegistry";
|
||||||
|
import { useCluster } from "providers/cluster";
|
||||||
|
|
||||||
export function TokenAccountSection({
|
export function TokenAccountSection({
|
||||||
account,
|
account,
|
||||||
@ -46,6 +48,7 @@ function MintAccountCard({
|
|||||||
account: Account;
|
account: Account;
|
||||||
info: MintAccountInfo;
|
info: MintAccountInfo;
|
||||||
}) {
|
}) {
|
||||||
|
const { cluster } = useCluster();
|
||||||
const mintAddress = account.pubkey.toBase58();
|
const mintAddress = account.pubkey.toBase58();
|
||||||
const fetchInfo = useFetchAccountInfo();
|
const fetchInfo = useFetchAccountInfo();
|
||||||
const supply = useTokenSupply(mintAddress);
|
const supply = useTokenSupply(mintAddress);
|
||||||
@ -70,18 +73,20 @@ function MintAccountCard({
|
|||||||
renderSupply = "Fetch failed";
|
renderSupply = "Fetch failed";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
renderSupply = supplyTotal;
|
const unit = TokenRegistry.get(mintAddress, cluster)?.symbol;
|
||||||
|
renderSupply = unit ? `${supplyTotal} ${unit}` : supplyTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!supply) refreshSupply();
|
if (!supply) refreshSupply();
|
||||||
}, [mintAddress]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [mintAddress]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const tokenInfo = TokenRegistry.get(mintAddress, cluster);
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
<h3 className="card-header-title mb-0 d-flex align-items-center">
|
||||||
Token Mint Account
|
{tokenInfo ? "Overview" : "Token Mint"}
|
||||||
</h3>
|
</h3>
|
||||||
<button className="btn btn-white btn-sm" onClick={refresh}>
|
<button className="btn btn-white btn-sm" onClick={refresh}>
|
||||||
<span className="fe fe-refresh-cw mr-2"></span>
|
<span className="fe fe-refresh-cw mr-2"></span>
|
||||||
@ -100,6 +105,21 @@ function MintAccountCard({
|
|||||||
<td>Total Supply</td>
|
<td>Total Supply</td>
|
||||||
<td className="text-lg-right">{renderSupply}</td>
|
<td className="text-lg-right">{renderSupply}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{tokenInfo && (
|
||||||
|
<tr>
|
||||||
|
<td>Website</td>
|
||||||
|
<td className="text-lg-right">
|
||||||
|
<a
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
href={tokenInfo.website}
|
||||||
|
>
|
||||||
|
{tokenInfo.website}
|
||||||
|
<span className="fe fe-external-link ml-2"></span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
<tr>
|
<tr>
|
||||||
<td>Decimals</td>
|
<td>Decimals</td>
|
||||||
<td className="text-lg-right">{info.decimals}</td>
|
<td className="text-lg-right">{info.decimals}</td>
|
||||||
@ -131,13 +151,11 @@ function TokenAccountCard({
|
|||||||
info: TokenAccountInfo;
|
info: TokenAccountInfo;
|
||||||
}) {
|
}) {
|
||||||
const refresh = useFetchAccountInfo();
|
const refresh = useFetchAccountInfo();
|
||||||
|
const { cluster } = useCluster();
|
||||||
|
|
||||||
let balance;
|
const balance = info.tokenAmount?.uiAmount;
|
||||||
if ("amount" in info) {
|
const unit =
|
||||||
balance = info.amount;
|
TokenRegistry.get(info.mint.toBase58(), cluster)?.symbol || "tokens";
|
||||||
} else {
|
|
||||||
balance = info.tokenAmount?.uiAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@ -174,15 +192,15 @@ function TokenAccountCard({
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Balance (tokens)</td>
|
<td>Balance ({unit})</td>
|
||||||
<td className="text-lg-right">{balance}</td>
|
<td className="text-lg-right">{balance}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{!info.isInitialized && (
|
||||||
<tr>
|
<tr>
|
||||||
<td>Status</td>
|
<td>Status</td>
|
||||||
<td className="text-lg-right">
|
<td className="text-lg-right">Uninitialized</td>
|
||||||
{info.isInitialized ? "Initialized" : "Uninitialized"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
)}
|
||||||
</TableCardBody>
|
</TableCardBody>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -235,12 +253,12 @@ function MultisigAccountCard({
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
{!info.isInitialized && (
|
||||||
<tr>
|
<tr>
|
||||||
<td>Status</td>
|
<td>Status</td>
|
||||||
<td className="text-lg-right">
|
<td className="text-lg-right">Uninitialized</td>
|
||||||
{info.isInitialized ? "Initialized" : "Uninitialized"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
)}
|
||||||
</TableCardBody>
|
</TableCardBody>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -233,7 +233,7 @@ function TokenTransactionRow({
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<Address pubkey={mint} link />
|
<Address pubkey={mint} link truncate />
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>{typeName}</td>
|
<td>{typeName}</td>
|
||||||
|
@ -9,6 +9,8 @@ import {
|
|||||||
useFetchTokenLargestAccounts,
|
useFetchTokenLargestAccounts,
|
||||||
} from "providers/mints/largest";
|
} from "providers/mints/largest";
|
||||||
import { FetchStatus } from "providers/cache";
|
import { FetchStatus } from "providers/cache";
|
||||||
|
import { TokenRegistry } from "tokenRegistry";
|
||||||
|
import { useCluster } from "providers/cluster";
|
||||||
|
|
||||||
export function TokenLargestAccountsCard({ pubkey }: { pubkey: PublicKey }) {
|
export function TokenLargestAccountsCard({ pubkey }: { pubkey: PublicKey }) {
|
||||||
const mintAddress = pubkey.toBase58();
|
const mintAddress = pubkey.toBase58();
|
||||||
@ -16,6 +18,9 @@ export function TokenLargestAccountsCard({ pubkey }: { pubkey: PublicKey }) {
|
|||||||
const largestAccounts = useTokenLargestTokens(mintAddress);
|
const largestAccounts = useTokenLargestTokens(mintAddress);
|
||||||
const fetchLargestAccounts = useFetchTokenLargestAccounts();
|
const fetchLargestAccounts = useFetchTokenLargestAccounts();
|
||||||
const refreshLargest = () => fetchLargestAccounts(pubkey);
|
const refreshLargest = () => fetchLargestAccounts(pubkey);
|
||||||
|
const { cluster } = useCluster();
|
||||||
|
const unit = TokenRegistry.get(mintAddress, cluster)?.symbol;
|
||||||
|
const unitLabel = unit ? `(${unit})` : "";
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!largestAccounts) refreshLargest();
|
if (!largestAccounts) refreshLargest();
|
||||||
@ -61,7 +66,7 @@ export function TokenLargestAccountsCard({ pubkey }: { pubkey: PublicKey }) {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="text-muted">Rank</th>
|
<th className="text-muted">Rank</th>
|
||||||
<th className="text-muted">Address</th>
|
<th className="text-muted">Address</th>
|
||||||
<th className="text-muted text-right">Balance</th>
|
<th className="text-muted text-right">Balance {unitLabel}</th>
|
||||||
<th className="text-muted text-right">% of Total Supply</th>
|
<th className="text-muted text-right">% of Total Supply</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
@ -4,6 +4,7 @@ import { PublicKey } from "@solana/web3.js";
|
|||||||
import { clusterPath } from "utils/url";
|
import { clusterPath } from "utils/url";
|
||||||
import { displayAddress } from "utils/tx";
|
import { displayAddress } from "utils/tx";
|
||||||
import { Pubkey } from "solana-sdk-wasm";
|
import { Pubkey } from "solana-sdk-wasm";
|
||||||
|
import { useCluster } from "providers/cluster";
|
||||||
|
|
||||||
type CopyState = "copy" | "copied";
|
type CopyState = "copy" | "copied";
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -11,11 +12,13 @@ type Props = {
|
|||||||
alignRight?: boolean;
|
alignRight?: boolean;
|
||||||
link?: boolean;
|
link?: boolean;
|
||||||
raw?: boolean;
|
raw?: boolean;
|
||||||
|
truncate?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Address({ pubkey, alignRight, link, raw }: Props) {
|
export function Address({ pubkey, alignRight, link, raw, truncate }: Props) {
|
||||||
const [state, setState] = useState<CopyState>("copy");
|
const [state, setState] = useState<CopyState>("copy");
|
||||||
const address = pubkey.toBase58();
|
const address = pubkey.toBase58();
|
||||||
|
const { cluster } = useCluster();
|
||||||
|
|
||||||
const copyToClipboard = () => navigator.clipboard.writeText(address);
|
const copyToClipboard = () => navigator.clipboard.writeText(address);
|
||||||
const handleClick = () =>
|
const handleClick = () =>
|
||||||
@ -36,14 +39,16 @@ export function Address({ pubkey, alignRight, link, raw }: Props) {
|
|||||||
<span className="c-pointer font-size-tiny mr-2">{copyIcon}</span>
|
<span className="c-pointer font-size-tiny mr-2">{copyIcon}</span>
|
||||||
<span className="text-monospace">
|
<span className="text-monospace">
|
||||||
{link ? (
|
{link ? (
|
||||||
<Link className="" to={clusterPath(`/address/${address}`)}>
|
<Link
|
||||||
{raw ? address : displayAddress(address)}
|
className={truncate ? "text-truncate address-truncate" : ""}
|
||||||
<span className="fe fe-external-link ml-2"></span>
|
to={clusterPath(`/address/${address}`)}
|
||||||
|
>
|
||||||
|
{raw ? address : displayAddress(address, cluster)}
|
||||||
</Link>
|
</Link>
|
||||||
) : raw ? (
|
|
||||||
address
|
|
||||||
) : (
|
) : (
|
||||||
displayAddress(address)
|
<span className={truncate ? "text-truncate address-truncate" : ""}>
|
||||||
|
{raw ? address : displayAddress(address, cluster)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
|
@ -42,7 +42,6 @@ export function Signature({ signature, alignRight, link }: Props) {
|
|||||||
{link ? (
|
{link ? (
|
||||||
<Link className="" to={clusterPath(`/tx/${signature}`)}>
|
<Link className="" to={clusterPath(`/tx/${signature}`)}>
|
||||||
{signature}
|
{signature}
|
||||||
<span className="fe fe-external-link ml-2"></span>
|
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
signature
|
signature
|
||||||
|
@ -18,6 +18,7 @@ import { OwnedTokensCard } from "components/account/OwnedTokensCard";
|
|||||||
import { TransactionHistoryCard } from "components/account/TransactionHistoryCard";
|
import { TransactionHistoryCard } from "components/account/TransactionHistoryCard";
|
||||||
import { TokenHistoryCard } from "components/account/TokenHistoryCard";
|
import { TokenHistoryCard } from "components/account/TokenHistoryCard";
|
||||||
import { TokenLargestAccountsCard } from "components/account/TokenLargestAccountsCard";
|
import { TokenLargestAccountsCard } from "components/account/TokenLargestAccountsCard";
|
||||||
|
import { TokenRegistry } from "tokenRegistry";
|
||||||
|
|
||||||
type Props = { address: string; tab?: string };
|
type Props = { address: string; tab?: string };
|
||||||
export function AccountDetailsPage({ address, tab }: Props) {
|
export function AccountDetailsPage({ address, tab }: Props) {
|
||||||
@ -31,8 +32,7 @@ export function AccountDetailsPage({ address, tab }: Props) {
|
|||||||
<div className="container mt-n3">
|
<div className="container mt-n3">
|
||||||
<div className="header">
|
<div className="header">
|
||||||
<div className="header-body">
|
<div className="header-body">
|
||||||
<h6 className="header-pretitle">Details</h6>
|
<AccountHeader address={address} />
|
||||||
<h4 className="header-title">Account</h4>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!pubkey ? (
|
{!pubkey ? (
|
||||||
@ -44,6 +44,38 @@ export function AccountDetailsPage({ address, tab }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AccountHeader({ address }: { address: string }) {
|
||||||
|
const { cluster } = useCluster();
|
||||||
|
const tokenDetails = TokenRegistry.get(address, cluster);
|
||||||
|
if (tokenDetails) {
|
||||||
|
return (
|
||||||
|
<div className="row align-items-end">
|
||||||
|
<div className="col-auto">
|
||||||
|
<div className="avatar avatar-lg header-avatar-top">
|
||||||
|
<img
|
||||||
|
src={tokenDetails.logo}
|
||||||
|
alt="token logo"
|
||||||
|
className="avatar-img rounded-circle border border-4 border-body"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col mb-3 ml-n3 ml-md-n2">
|
||||||
|
<h6 className="header-pretitle">Token</h6>
|
||||||
|
<h2 className="header-title">{tokenDetails.name}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h6 className="header-pretitle">Details</h6>
|
||||||
|
<h2 className="header-title">Account</h2>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DetailsSections({ pubkey, tab }: { pubkey: PublicKey; tab?: string }) {
|
function DetailsSections({ pubkey, tab }: { pubkey: PublicKey; tab?: string }) {
|
||||||
const fetchAccount = useFetchAccountInfo();
|
const fetchAccount = useFetchAccountInfo();
|
||||||
const address = pubkey.toBase58();
|
const address = pubkey.toBase58();
|
||||||
@ -79,9 +111,9 @@ function DetailsSections({ pubkey, tab }: { pubkey: PublicKey; tab?: string }) {
|
|||||||
if (data && data?.name === "spl-token") {
|
if (data && data?.name === "spl-token") {
|
||||||
if (data.parsed.type === "mint") {
|
if (data.parsed.type === "mint") {
|
||||||
tabs.push({
|
tabs.push({
|
||||||
slug: "holders",
|
slug: "largest",
|
||||||
title: "Holders",
|
title: "Distribution",
|
||||||
path: "/holders",
|
path: "/largest",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -139,7 +171,7 @@ type Tab = {
|
|||||||
path: string;
|
path: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MoreTabs = "history" | "tokens" | "holders";
|
type MoreTabs = "history" | "tokens" | "largest";
|
||||||
function MoreSection({
|
function MoreSection({
|
||||||
account,
|
account,
|
||||||
tab,
|
tab,
|
||||||
@ -180,7 +212,7 @@ function MoreSection({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{tab === "history" && <TransactionHistoryCard pubkey={pubkey} />}
|
{tab === "history" && <TransactionHistoryCard pubkey={pubkey} />}
|
||||||
{tab === "holders" && <TokenLargestAccountsCard pubkey={pubkey} />}
|
{tab === "largest" && <TokenLargestAccountsCard pubkey={pubkey} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -28,4 +28,5 @@ $input-group-addon-color: $white !default;
|
|||||||
$theme-colors: (
|
$theme-colors: (
|
||||||
"black": $black,
|
"black": $black,
|
||||||
"gray": $gray-600,
|
"gray": $gray-600,
|
||||||
|
"gray-dark": $gray-800-dark,
|
||||||
);
|
);
|
||||||
|
@ -173,3 +173,20 @@ h4.slot-pill {
|
|||||||
.search-bar__menu {
|
.search-bar__menu {
|
||||||
border-radius: $border-radius !important;
|
border-radius: $border-radius !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.token-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-truncate {
|
||||||
|
@include media-breakpoint-down(md) {
|
||||||
|
max-width: 180px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-down(sm) {
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
38
explorer/src/tokenRegistry.ts
Normal file
38
explorer/src/tokenRegistry.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { Cluster } from "providers/cluster";
|
||||||
|
|
||||||
|
export type TokenDetails = {
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
logo: string;
|
||||||
|
icon: string;
|
||||||
|
website: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENABLE_DETAILS = !!new URLSearchParams(window.location.search).get(
|
||||||
|
"test"
|
||||||
|
);
|
||||||
|
|
||||||
|
function get(address: string, cluster: Cluster): TokenDetails | undefined {
|
||||||
|
if (ENABLE_DETAILS && cluster === Cluster.MainnetBeta)
|
||||||
|
return MAINNET_TOKENS[address];
|
||||||
|
}
|
||||||
|
|
||||||
|
function all(cluster: Cluster) {
|
||||||
|
if (ENABLE_DETAILS && cluster === Cluster.MainnetBeta) return MAINNET_TOKENS;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TokenRegistry = {
|
||||||
|
get,
|
||||||
|
all,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAINNET_TOKENS: { [key: string]: TokenDetails } = {
|
||||||
|
MSRMmR98uWsTBgusjwyNkE8nDtV79sJznTedhJLzS4B: {
|
||||||
|
name: "MegaSerum",
|
||||||
|
symbol: "MSRM",
|
||||||
|
logo: "/tokens/serum-64.png",
|
||||||
|
icon: "/tokens/serum-32.png",
|
||||||
|
website: "https://projectserum.com",
|
||||||
|
},
|
||||||
|
};
|
@ -12,17 +12,30 @@ import {
|
|||||||
TransactionInstruction,
|
TransactionInstruction,
|
||||||
Transaction,
|
Transaction,
|
||||||
} from "@solana/web3.js";
|
} from "@solana/web3.js";
|
||||||
|
import { TokenRegistry } from "tokenRegistry";
|
||||||
|
import { Cluster } from "providers/cluster";
|
||||||
|
|
||||||
export const PROGRAM_IDS = {
|
export type ProgramName =
|
||||||
Budget1111111111111111111111111111111111111: "Budget",
|
| "Budget Program"
|
||||||
Config1111111111111111111111111111111111111: "Config",
|
| "Config Program"
|
||||||
Exchange11111111111111111111111111111111111: "Exchange",
|
| "Exchange Program"
|
||||||
[StakeProgram.programId.toBase58()]: "Stake",
|
| "Stake Program"
|
||||||
Storage111111111111111111111111111111111111: "Storage",
|
| "Storage Program"
|
||||||
[SystemProgram.programId.toBase58()]: "System",
|
| "System Program"
|
||||||
Vest111111111111111111111111111111111111111: "Vest",
|
| "Vest Program"
|
||||||
[VOTE_PROGRAM_ID.toBase58()]: "Vote",
|
| "Vote Program"
|
||||||
TokenSVp5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o: "Token",
|
| "SPL Token";
|
||||||
|
|
||||||
|
export const PROGRAM_IDS: { [key: string]: ProgramName } = {
|
||||||
|
Budget1111111111111111111111111111111111111: "Budget Program",
|
||||||
|
Config1111111111111111111111111111111111111: "Config Program",
|
||||||
|
Exchange11111111111111111111111111111111111: "Exchange Program",
|
||||||
|
[StakeProgram.programId.toBase58()]: "Stake Program",
|
||||||
|
Storage111111111111111111111111111111111111: "Storage Program",
|
||||||
|
[SystemProgram.programId.toBase58()]: "System Program",
|
||||||
|
Vest111111111111111111111111111111111111111: "Vest Program",
|
||||||
|
[VOTE_PROGRAM_ID.toBase58()]: "Vote Program",
|
||||||
|
TokenSVp5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o: "SPL Token",
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOADER_IDS = {
|
const LOADER_IDS = {
|
||||||
@ -48,12 +61,13 @@ export const SYSVAR_IDS = {
|
|||||||
[SYSVAR_STAKE_HISTORY_PUBKEY.toBase58()]: "SYSVAR_STAKE_HISTORY",
|
[SYSVAR_STAKE_HISTORY_PUBKEY.toBase58()]: "SYSVAR_STAKE_HISTORY",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function displayAddress(address: string): string {
|
export function displayAddress(address: string, cluster: Cluster): string {
|
||||||
return (
|
return (
|
||||||
PROGRAM_IDS[address] ||
|
PROGRAM_IDS[address] ||
|
||||||
LOADER_IDS[address] ||
|
LOADER_IDS[address] ||
|
||||||
SYSVAR_IDS[address] ||
|
SYSVAR_IDS[address] ||
|
||||||
SYSVAR_ID[address] ||
|
SYSVAR_ID[address] ||
|
||||||
|
TokenRegistry.get(address, cluster)?.name ||
|
||||||
address
|
address
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -22,14 +22,11 @@ export const TokenAccountInfo = pick({
|
|||||||
isNative: boolean(),
|
isNative: boolean(),
|
||||||
mint: Pubkey,
|
mint: Pubkey,
|
||||||
owner: Pubkey,
|
owner: Pubkey,
|
||||||
amount: optional(number()), // TODO remove when ui amount is deployed
|
tokenAmount: pick({
|
||||||
tokenAmount: optional(
|
|
||||||
object({
|
|
||||||
decimals: number(),
|
decimals: number(),
|
||||||
uiAmount: number(),
|
uiAmount: number(),
|
||||||
amount: string(),
|
amount: string(),
|
||||||
})
|
}),
|
||||||
),
|
|
||||||
delegate: nullable(optional(Pubkey)),
|
delegate: nullable(optional(Pubkey)),
|
||||||
delegatedAmount: optional(number()),
|
delegatedAmount: optional(number()),
|
||||||
});
|
});
|
||||||
|
Reference in New Issue
Block a user