Add token registry to explorer (#11612)

This commit is contained in:
Justin Starry
2020-08-13 22:57:53 +08:00
committed by GitHub
parent 00a8f90f79
commit 6162c2d0d5
15 changed files with 438 additions and 111 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -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) {

View File

@ -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>
);
};

View File

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

View File

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

View File

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

View File

@ -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>
</> </>

View File

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

View File

@ -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} />}
</> </>
); );
} }

View File

@ -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,
); );

View File

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

View 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",
},
};

View File

@ -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
); );
} }

View File

@ -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()),
}); });