Skip to content

Commit

Permalink
Integrate basic token-2022 support (#311)
Browse files Browse the repository at this point in the history
#### Problem

Token-2022 has been around for awhile, but the explorer still has no
knowledge of it!

#### Summary of changes

This is really just basic support, and it was done in a pretty simple
manner:

* look for instances of `"spl-token"`, which is the program name
returned from JSON-parsed encoding, and make sure that it also supports
`"spl-token-2022"`
* look for instances of `TOKEN_PROGRAM_ID`, and make sure that it also
supports `TOKEN_2022_PROGRAM_ID`

So in the end, accounts and mints are properly displayed, and the
additional token-2022 accounts are fetched too.

There is no parsing of extensions included in this PR, but it can
certainly be done in follow-up work.
  • Loading branch information
joncinque authored Dec 15, 2023
1 parent 44c72db commit 91eab21
Show file tree
Hide file tree
Showing 14 changed files with 122 additions and 42 deletions.
3 changes: 2 additions & 1 deletion app/address/[address]/attributes/page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { MetaplexNFTAttributesCard } from '@components/account/MetaplexNFTAttributesCard';
import { ParsedAccountRenderer } from '@components/account/ParsedAccountRenderer';
import { isTokenProgramData } from '@providers/accounts';
import React from 'react';

type Props = Readonly<{
Expand All @@ -15,7 +16,7 @@ function MetaplexNFTAttributesCardRenderer({
onNotFound,
}: React.ComponentProps<React.ComponentProps<typeof ParsedAccountRenderer>['renderComponent']>) {
const parsedData = account?.data?.parsed;
if (!parsedData || parsedData.program !== 'spl-token' || parsedData.parsed.type !== 'mint' || !parsedData.nftData) {
if (!parsedData || !isTokenProgramData(parsedData) || parsedData.parsed.type !== 'mint' || !parsedData.nftData) {
return onNotFound();
}
return <MetaplexNFTAttributesCard nftData={parsedData.nftData} />;
Expand Down
37 changes: 31 additions & 6 deletions app/address/[address]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { LoadingCard } from '@components/common/LoadingCard';
import {
Account,
AccountsProvider,
isTokenProgramData,
TokenProgramData,
useAccountInfo,
useFetchAccountInfo,
Expand Down Expand Up @@ -76,6 +77,30 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = {
title: 'Concurrent Merkle Tree',
},
],
'spl-token-2022:mint': [
{
path: 'transfers',
slug: 'transfers',
title: 'Transfers',
},
{
path: 'instructions',
slug: 'instructions',
title: 'Instructions',
},
],
'spl-token-2022:mint:metaplexNFT': [
{
path: 'metadata',
slug: 'metadata',
title: 'Metadata',
},
{
path: 'attributes',
slug: 'attributes',
title: 'Attributes',
},
],
'spl-token:mint': [
{
path: 'transfers',
Expand Down Expand Up @@ -142,7 +167,7 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = {
],
};

const TOKEN_TABS_HIDDEN = ['spl-token:mint', 'config', 'vote', 'sysvar', 'config'];
const TOKEN_TABS_HIDDEN = ['spl-token:mint', 'spl-token-2022:mint', 'config', 'vote', 'sysvar', 'config'];

type Props = PropsWithChildren<{ params: { address: string } }>;

Expand All @@ -164,10 +189,10 @@ function AddressLayoutInner({ children, params: { address } }: Props) {
}

const infoStatus = info?.status;
const infoProgram = info?.data?.data.parsed?.program;
const infoParsed = info?.data?.data.parsed;

const { data: fullTokenInfo, isLoading: isFullTokenInfoLoading } = useSWRImmutable(
infoStatus === FetchStatus.Fetched && infoProgram === "spl-token" && pubkey ? ['get-full-token-info', address, cluster, url] : null,
infoStatus === FetchStatus.Fetched && infoParsed && isTokenProgramData(infoParsed) && pubkey ? ['get-full-token-info', address, cluster, url] : null,
fetchFullTokenInfo
);

Expand Down Expand Up @@ -208,7 +233,7 @@ function AccountHeader({ address, account, tokenInfo, isTokenInfoLoading }: { ad
const mintInfo = useMintAccountInfo(address);

const parsedData = account?.data.parsed;
const isToken = parsedData?.program === 'spl-token' && parsedData?.parsed.type === 'mint';
const isToken = parsedData && isTokenProgramData(parsedData) && parsedData?.parsed.type === 'mint';

if (isMetaplexNFT(parsedData, mintInfo) && parsedData.nftData) {
return <MetaplexNFTHeader nftData={parsedData.nftData} address={address} />;
Expand Down Expand Up @@ -346,7 +371,7 @@ function InfoSection({ account, tokenInfo }: { account: Account, tokenInfo?: Ful
);
} else if (account.owner.toBase58() === NFTOKEN_ADDRESS) {
return <NFTokenAccountSection account={account} />;
} else if (parsedData && parsedData.program === 'spl-token') {
} else if (parsedData && isTokenProgramData(parsedData)) {
return <TokenAccountSection account={account} tokenAccount={parsedData.parsed} tokenInfo={tokenInfo} />;
} else if (parsedData && parsedData.program === 'nonce') {
return <NonceAccountSection account={account} nonceAccount={parsedData.parsed} />;
Expand Down Expand Up @@ -447,7 +472,7 @@ function getTabs(pubkey: PublicKey, account: Account): TabComponent[] {
}

// Add the key for Metaplex NFTs
if (parsedData && programTypeKey === 'spl-token:mint' && (parsedData as TokenProgramData).nftData) {
if (parsedData && (programTypeKey === 'spl-token:mint' || programTypeKey == 'spl-token-2022:mint') && (parsedData as TokenProgramData).nftData) {
tabs.push(...TABS_LOOKUP[`${programTypeKey}:metaplexNFT`]);
}

Expand Down
3 changes: 2 additions & 1 deletion app/address/[address]/metadata/page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { MetaplexMetadataCard } from '@components/account/MetaplexMetadataCard';
import { ParsedAccountRenderer } from '@components/account/ParsedAccountRenderer';
import { isTokenProgramData } from '@providers/accounts';
import React from 'react';

type Props = Readonly<{
Expand All @@ -15,7 +16,7 @@ function MetaplexMetadataCardRenderer({
onNotFound,
}: React.ComponentProps<React.ComponentProps<typeof ParsedAccountRenderer>['renderComponent']>) {
const parsedData = account?.data?.parsed;
if (!parsedData || parsedData.program !== 'spl-token' || parsedData.parsed.type !== 'mint' || !parsedData.nftData) {
if (!parsedData || !isTokenProgramData(parsedData) || parsedData.parsed.type !== 'mint' || !parsedData.nftData) {
return onNotFound();
}
return <MetaplexMetadataCard nftData={parsedData.nftData} />;
Expand Down
5 changes: 3 additions & 2 deletions app/components/account/TokenAccountSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Copyable } from '@components/common/Copyable';
import { LoadingCard } from '@components/common/LoadingCard';
import { TableCardBody } from '@components/common/TableCardBody';
import { Account, NFTData, TokenProgramData, useFetchAccountInfo } from '@providers/accounts';
import { TOKEN_2022_PROGRAM_ID } from '@providers/accounts/tokens';
import isMetaplexNFT from '@providers/accounts/utils/isMetaplexNFT';
import { useCluster } from '@providers/cluster';
import { PublicKey } from '@solana/web3.js';
Expand Down Expand Up @@ -151,7 +152,7 @@ function FungibleTokenMintAccountCard({ account, mintInfo, tokenInfo }: { accoun
<div className="card">
<div className="card-header">
<h3 className="card-header-title mb-0 d-flex align-items-center">
{tokenInfo ? 'Overview' : 'Token Mint'}
{tokenInfo ? 'Overview' : account.owner.toBase58() === TOKEN_2022_PROGRAM_ID.toBase58() ? 'Token-2022 Mint' : 'Token Mint'}
</h3>
<button className="btn btn-white btn-sm" onClick={refresh}>
<RefreshCw className="align-text-top me-2" size={13} />
Expand Down Expand Up @@ -370,7 +371,7 @@ function TokenAccountCard({ account, info }: { account: Account; info: TokenAcco
return (
<div className="card">
<div className="card-header">
<h3 className="card-header-title mb-0 d-flex align-items-center">Token Account</h3>
<h3 className="card-header-title mb-0 d-flex align-items-center">Token{ account.owner.toBase58() === TOKEN_2022_PROGRAM_ID.toBase58() && "-2022" } Account</h3>
<button className="btn btn-white btn-sm" onClick={() => refresh(account.pubkey, 'parsed')}>
<RefreshCw className="align-text-top me-2" size={13} />
Refresh
Expand Down
9 changes: 5 additions & 4 deletions app/components/account/TokenHistoryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import {
parseTokenLendingInstructionTitle,
} from '@components/instruction/token-lending/types';
import { isTokenSwapInstruction, parseTokenSwapInstructionTitle } from '@components/instruction/token-swap/types';
import { isTokenProgramData } from '@providers/accounts';
import { useAccountHistories, useFetchAccountHistory } from '@providers/accounts/history';
import { TOKEN_PROGRAM_ID, TokenInfoWithPubkey, useAccountOwnedTokens } from '@providers/accounts/tokens';
import { isTokenProgramId, TokenInfoWithPubkey, useAccountOwnedTokens } from '@providers/accounts/tokens';
import { CacheEntry, FetchStatus } from '@providers/cache';
import { useCluster } from '@providers/cluster';
import { Details, useFetchTransactionDetails, useTransactionDetailsCache } from '@providers/transactions/parsed';
Expand Down Expand Up @@ -391,7 +392,7 @@ const TokenTransactionRow = React.memo(function TokenTransactionRow({
}

if ('parsed' in ix) {
if (ix.program === 'spl-token') {
if (isTokenProgramData(ix)) {
name = getTokenProgramInstructionName(ix, tx);
} else {
return undefined;
Expand Down Expand Up @@ -425,7 +426,7 @@ const TokenTransactionRow = React.memo(function TokenTransactionRow({
return undefined;
}
} else {
if (ix.accounts.findIndex(account => account.equals(TOKEN_PROGRAM_ID)) >= 0) {
if (ix.accounts.findIndex(account => isTokenProgramId(account)) >= 0) {
name = 'Unknown (Inner)';
} else {
return undefined;
Expand Down Expand Up @@ -476,7 +477,7 @@ function InstructionDetails({ instructionType, tx }: { instructionType: Instruct

const instructionTypes = instructionType.innerInstructions
.map(ix => {
if ('parsed' in ix && ix.program === 'spl-token') {
if ('parsed' in ix && isTokenProgramData(ix)) {
return getTokenProgramInstructionName(ix, tx);
}
return undefined;
Expand Down
4 changes: 2 additions & 2 deletions app/components/account/history/TokenTransfersCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ErrorCard } from '@components/common/ErrorCard';
import { LoadingCard } from '@components/common/LoadingCard';
import { Signature } from '@components/common/Signature';
import { TokenInstructionType, Transfer, TransferChecked } from '@components/instruction/token/types';
import { useAccountHistory } from '@providers/accounts';
import { isTokenProgramData, useAccountHistory } from '@providers/accounts';
import { useFetchAccountHistory } from '@providers/accounts/history';
import { FetchStatus } from '@providers/cache';
import { useCluster } from '@providers/cluster';
Expand Down Expand Up @@ -218,7 +218,7 @@ function getTransfer(
cluster: Cluster,
signature: string
): Transfer | TransferChecked | undefined {
if ('parsed' in instruction && instruction.program === 'spl-token') {
if ('parsed' in instruction && isTokenProgramData(instruction)) {
try {
const { type: rawType } = instruction.parsed;
const type = create(rawType, TokenInstructionType);
Expand Down
3 changes: 2 additions & 1 deletion app/components/common/InstructionDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isTokenProgramData } from '@providers/accounts';
import { ConfirmedSignatureInfo } from '@solana/web3.js';
import { getTokenProgramInstructionName, InstructionType } from '@utils/instruction';
import React from 'react';
Expand All @@ -14,7 +15,7 @@ export function InstructionDetails({

const instructionTypes = instructionType.innerInstructions
.map(ix => {
if ('parsed' in ix && ix.program === 'spl-token') {
if ('parsed' in ix && isTokenProgramData(ix)) {
return getTokenProgramInstructionName(ix, tx);
}
return undefined;
Expand Down
27 changes: 17 additions & 10 deletions app/components/inspector/SimulatorCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ProgramLogsCardBody } from '@components/ProgramLogsCardBody';
import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@providers/accounts/tokens';
import { useCluster } from '@providers/cluster';
import { AccountLayout, MintLayout, TOKEN_PROGRAM_ID } from "@solana/spl-token";
import { AccountLayout, MintLayout } from "@solana/spl-token";
import { AccountInfo, AddressLookupTableAccount, Connection, MessageAddressTableLookup, ParsedAccountData, ParsedMessageAccount, SimulatedTransactionAccountInfo, TokenBalance, VersionedMessage, VersionedTransaction } from '@solana/web3.js';
import { PublicKey } from '@solana/web3.js';
import { InstructionLogs, parseProgramLogs } from '@utils/program-logs';
Expand Down Expand Up @@ -140,9 +141,9 @@ function useSimulator(message: VersionedMessage) {
const accountOwnerPost = resp.value.accounts?.at(index)?.owner;

if (
(parsedAccountPre?.owner.toBase58() == TOKEN_PROGRAM_ID.toBase58() ||
parsedAccountPre?.owner.toBase58() == "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb") &&
(parsedAccountPre?.data as ParsedAccountData).parsed.type === 'account'
parsedAccountPre &&
isTokenProgramBase58(parsedAccountPre.owner.toBase58()) &&
(parsedAccountPre.data as ParsedAccountData).parsed.type === 'account'
) {
const mint = (parsedAccountPre?.data as ParsedAccountData).parsed.info.mint;
const owner = (parsedAccountPre?.data as ParsedAccountData).parsed.info.owner;
Expand All @@ -157,8 +158,8 @@ function useSimulator(message: VersionedMessage) {
}

if (
(accountOwnerPost === TOKEN_PROGRAM_ID.toBase58() ||
accountOwnerPost === "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb") &&
accountOwnerPost &&
isTokenProgramBase58(accountOwnerPost) &&
Buffer.from(accountDataPost!, 'base64').length >= 165
) {
const accountParsedPost = AccountLayout.decode(Buffer.from(accountDataPost!, 'base64'));
Expand Down Expand Up @@ -235,6 +236,10 @@ function useSimulator(message: VersionedMessage) {
};
}

function isTokenProgramBase58(programIdBase58: string): boolean {
return programIdBase58 === TOKEN_PROGRAM_ID.toBase58() || programIdBase58 === TOKEN_2022_PROGRAM_ID.toBase58();
}

function getMintDecimals(
accountKeys: PublicKey[],
parsedAccountsPre: (AccountInfo<ParsedAccountData | Buffer> | null)[],
Expand All @@ -249,16 +254,18 @@ function getMintDecimals(

// Token account before
if (
parsedAccount?.owner.toBase58() == TOKEN_PROGRAM_ID.toBase58() &&
(parsedAccount?.data as ParsedAccountData).parsed.type === 'account'
parsedAccount &&
isTokenProgramBase58(parsedAccount.owner.toBase58()) &&
(parsedAccount.data as ParsedAccountData).parsed.type === 'account'
) {
mintToDecimals[(parsedAccount?.data as ParsedAccountData).parsed.info.mint] = (
parsedAccount?.data as ParsedAccountData
).parsed.info.tokenAmount.decimals;
}
// Mint account before
if (
parsedAccount?.owner.toBase58() == TOKEN_PROGRAM_ID.toBase58() &&
parsedAccount &&
isTokenProgramBase58(parsedAccount.owner.toBase58()) &&
(parsedAccount?.data as ParsedAccountData).parsed.type === 'mint'
) {
mintToDecimals[key.toBase58()] = (parsedAccount?.data as ParsedAccountData).parsed.info.decimals;
Expand All @@ -267,7 +274,7 @@ function getMintDecimals(
// Token account after
const accountDataPost = accountDatasPost.at(index)?.data[0];
const accountOwnerPost = accountDatasPost.at(index)?.owner;
if (accountOwnerPost === TOKEN_PROGRAM_ID.toBase58() && Buffer.from(accountDataPost!, 'base64').length === 82) {
if (accountOwnerPost && isTokenProgramBase58(accountOwnerPost) && Buffer.from(accountDataPost!, 'base64').length === 82) {
const accountParsedPost = MintLayout.decode(Buffer.from(accountDataPost!, 'base64'));
mintToDecimals[key.toBase58()] = accountParsedPost.decimals;
}
Expand Down
3 changes: 2 additions & 1 deletion app/components/instruction/token/TokenDetailsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import useSWR from 'swr';

import { useCluster } from '@/app/providers/cluster';
import { Cluster } from '@/app/utils/cluster';
import { TOKEN_IDS } from '@/app/utils/programs';
import { getTokenInfo, getTokenInfoSwrKey } from '@/app/utils/token-info';

import { InstructionCard } from '../InstructionCard';
Expand All @@ -27,7 +28,7 @@ export function TokenDetailsCard(props: DetailsProps) {
const parsed = create(props.ix.parsed, ParsedInfo);
const { type: rawType, info } = parsed;
const type = create(rawType, TokenInstructionType);
const title = `Token Program: ${IX_TITLES[type]}`;
const title = `${TOKEN_IDS[props.ix.programId.toString()]}: ${IX_TITLES[type]}`;
const created = create(info, IX_STRUCTS[type] as any);
return <TokenInstruction title={title} info={created} {...props} />;
}
Expand Down
18 changes: 14 additions & 4 deletions app/providers/accounts/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from '@solana/web3.js';
import { Cluster } from '@utils/cluster';
import { pubkeyToString } from '@utils/index';
import { assertIsTokenProgram, TokenProgram } from '@utils/programs';
import { ParsedAddressLookupTableAccount } from '@validators/accounts/address-lookup-table';
import { ConfigAccount } from '@validators/accounts/config';
import { NonceAccount } from '@validators/accounts/nonce';
Expand Down Expand Up @@ -57,8 +58,16 @@ export type NFTData = {
editionInfo: EditionInfo;
};

export function isTokenProgramData(data: { program: string }): data is TokenProgramData {
try {
assertIsTokenProgram(data.program);
return true;
} catch(e) {
return false;
}
}
export type TokenProgramData = {
program: 'spl-token';
program: TokenProgram;
parsed: TokenAccount;
nftData?: NFTData;
};
Expand Down Expand Up @@ -374,7 +383,8 @@ async function handleParsedAccountData(
};
}

case 'spl-token': {
case 'spl-token':
case 'spl-token-2022': {
const parsed = create(info, TokenAccount);
let nftData;

Expand Down Expand Up @@ -484,7 +494,7 @@ export function useMintAccountInfo(address: string | undefined): MintAccountInfo
try {
const parsedData = account.data.parsed;
if (!parsedData) return;
if (parsedData.program !== 'spl-token' || parsedData.parsed.type !== 'mint') {
if (!isTokenProgramData(parsedData) || parsedData.parsed.type !== 'mint') {
return;
}

Expand All @@ -504,7 +514,7 @@ export function useTokenAccountInfo(address: string | undefined): TokenAccountIn
try {
const parsedData = account.data.parsed;
if (!parsedData) return;
if (parsedData.program !== 'spl-token' || parsedData.parsed.type !== 'account') {
if (!isTokenProgramData(parsedData) || parsedData.parsed.type !== 'account') {
return;
}

Expand Down
11 changes: 9 additions & 2 deletions app/providers/accounts/tokens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ export function TokensProvider({ children }: ProviderProps) {
}

export const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
export const TOKEN_2022_PROGRAM_ID = new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb');
export function isTokenProgramId(programId: PublicKey) {
return programId.equals(TOKEN_PROGRAM_ID) || programId.equals(TOKEN_2022_PROGRAM_ID);
}

async function fetchAccountTokens(dispatch: Dispatch, pubkey: PublicKey, cluster: Cluster, url: string) {
const key = pubkey.toBase58();
Expand All @@ -59,11 +63,14 @@ async function fetchAccountTokens(dispatch: Dispatch, pubkey: PublicKey, cluster
let status;
let data;
try {
const { value } = await new Connection(url, 'processed').getParsedTokenAccountsByOwner(pubkey, {
const { value: tokenAccounts } = await new Connection(url, 'processed').getParsedTokenAccountsByOwner(pubkey, {
programId: TOKEN_PROGRAM_ID,
});
const { value: token2022Accounts } = await new Connection(url, 'processed').getParsedTokenAccountsByOwner(pubkey, {
programId: TOKEN_2022_PROGRAM_ID,
});

const tokens: TokenInfoWithPubkey[] = value.slice(0, 101).map(accountInfo => {
const tokens: TokenInfoWithPubkey[] = tokenAccounts.concat(token2022Accounts).slice(0, 101).map(accountInfo => {
const parsedInfo = accountInfo.account.data.parsed.info;
const info = create(parsedInfo, TokenAccountInfo);
return { info, pubkey: accountInfo.pubkey };
Expand Down
Loading

1 comment on commit 91eab21

@vercel
Copy link

@vercel vercel bot commented on 91eab21 Dec 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

explorer – ./

explorer-solana-labs.vercel.app
explorer-git-master-solana-labs.vercel.app
explorer.solana.com

Please sign in to comment.