diff --git a/app/address/[address]/layout.tsx b/app/address/[address]/layout.tsx
index d6b1030a..0b67d0e1 100644
--- a/app/address/[address]/layout.tsx
+++ b/app/address/[address]/layout.tsx
@@ -46,6 +46,27 @@ import { Base58EncodedAddress } from 'web3js-experimental';
import { FullTokenInfo, getFullTokenInfo } from '@/app/utils/token-info';
+require('@solana/wallet-adapter-react-ui/styles.css');
+
+import { WalletProvider } from '@solana/wallet-adapter-react';
+import { ConnectionProvider } from '@solana/wallet-adapter-react';
+import { WalletModalProvider } from '@solana/wallet-adapter-react-ui';
+
+import { Token22NFTHeader } from '@/app/components/Token22MetadataHeader';
+import isT22NFT from '@/app/providers/accounts/utils/isT22NFT';
+
+function WalletAdapterProviders({ children }: { children: React.ReactNode }) {
+ const { url } = useCluster();
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
const IDENTICON_WIDTH = 64;
const TABS_LOOKUP: { [id: string]: Tab[] } = {
@@ -63,6 +84,13 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = {
title: 'Security',
},
],
+ msa: [
+ {
+ path: 'program-interface',
+ slug: 'program-interface',
+ title: 'Program Interface',
+ },
+ ],
'nftoken:collection': [
{
path: 'nfts',
@@ -101,6 +129,13 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = {
title: 'Attributes',
},
],
+ 'spl-token-metadata-interface': [
+ {
+ path: 'spl-token-metadata-interface',
+ slug: 'spl-token-metadata-interface',
+ title: 'SPL Token Metadata',
+ },
+ ],
'spl-token:mint': [
{
path: 'transfers',
@@ -192,7 +227,9 @@ function AddressLayoutInner({ children, params: { address } }: Props) {
const infoParsed = info?.data?.data.parsed;
const { data: fullTokenInfo, isLoading: isFullTokenInfoLoading } = useSWRImmutable(
- infoStatus === FetchStatus.Fetched && infoParsed && isTokenProgramData(infoParsed) && 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
);
@@ -207,13 +244,23 @@ function AddressLayoutInner({ children, params: { address } }: Props) {
{!pubkey ? (
) : (
-
+
{children}
)}
@@ -224,12 +271,24 @@ function AddressLayoutInner({ children, params: { address } }: Props) {
export default function AddressLayout({ children, params }: Props) {
return (
- {children}
+
+ {children}
+
);
}
-function AccountHeader({ address, account, tokenInfo, isTokenInfoLoading }: { address: string; account?: Account, tokenInfo?: FullTokenInfo, isTokenInfoLoading: boolean }) {
+function AccountHeader({
+ address,
+ account,
+ tokenInfo,
+ isTokenInfoLoading,
+}: {
+ address: string;
+ account?: Account;
+ tokenInfo?: FullTokenInfo;
+ isTokenInfoLoading: boolean;
+}) {
const mintInfo = useMintAccountInfo(address);
const parsedData = account?.data.parsed;
@@ -239,6 +298,10 @@ function AccountHeader({ address, account, tokenInfo, isTokenInfoLoading }: { ad
return ;
}
+ if (isT22NFT(parsedData)) {
+ return ;
+ }
+
const nftokenNFT = account && isNFTokenAccount(account);
if (nftokenNFT && account) {
return ;
@@ -314,7 +377,7 @@ function DetailsSections({
tab,
info,
tokenInfo,
- isTokenInfoLoading
+ isTokenInfoLoading,
}: {
children: React.ReactNode;
pubkey: PublicKey;
@@ -348,7 +411,7 @@ function DetailsSections({
);
}
-function InfoSection({ account, tokenInfo }: { account: Account, tokenInfo?: FullTokenInfo }) {
+function InfoSection({ account, tokenInfo }: { account: Account; tokenInfo?: FullTokenInfo }) {
const parsedData = account.data.parsed;
const rawData = account.data.raw;
@@ -425,7 +488,9 @@ export type MoreTabs =
| 'anchor-program'
| 'anchor-account'
| 'entries'
- | 'concurrent-merkle-tree';
+ | 'concurrent-merkle-tree'
+ | 'program-interface'
+ | 'spl-token-metadata-interface';
function MoreSection({ children, tabs }: { children: React.ReactNode; tabs: (JSX.Element | null)[] }) {
return (
@@ -467,12 +532,25 @@ function getTabs(pubkey: PublicKey, account: Account): TabComponent[] {
}
// Add the key for address lookup tables
- if (account.data.raw && isAddressLookupTableAccount(account.owner.toBase58() as Base58EncodedAddress, account.data.raw)) {
+ if (
+ account.data.raw &&
+ isAddressLookupTableAccount(account.owner.toBase58() as Base58EncodedAddress, account.data.raw)
+ ) {
tabs.push(...TABS_LOOKUP['address-lookup-table']);
}
+ // Add SPL Token Metadata Interface tab
+ console.log('Parsed data', parsedData);
+ if (isT22NFT(parsedData)) {
+ tabs.push(TABS_LOOKUP['spl-token-metadata-interface'][0]);
+ }
+
// Add the key for Metaplex NFTs
- if (parsedData && (programTypeKey === 'spl-token:mint' || programTypeKey == 'spl-token-2022: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`]);
}
@@ -545,6 +623,20 @@ function getAnchorTabs(pubkey: PublicKey, account: Account) {
tab: anchorProgramTab,
});
+ const programInterfaceTab: Tab = {
+ path: 'program-interface',
+ slug: 'program-interface',
+ title: 'Program Interface',
+ };
+ tabComponents.push({
+ component: (
+ >}>
+
+
+ ),
+ tab: programInterfaceTab,
+ });
+
const accountDataTab: Tab = {
path: 'anchor-account',
slug: 'anchor-account',
@@ -581,6 +673,25 @@ function AnchorProgramLink({ tab, address, pubkey }: { tab: Tab; address: string
);
}
+function ProgramInterfaceLink({ tab, address, pubkey }: { tab: Tab; address: string; pubkey: PublicKey }) {
+ const { url } = useCluster();
+ const anchorProgram = useAnchorProgram(pubkey.toString(), url);
+ const anchorProgramPath = useClusterPath({ pathname: `/address/${address}/${tab.path}` });
+ const selectedLayoutSegment = useSelectedLayoutSegment();
+ const isActive = selectedLayoutSegment === tab.path;
+ if (!anchorProgram) {
+ return null;
+ }
+
+ return (
+
+
+ {tab.title}
+
+
+ );
+}
+
function AccountDataLink({ address, tab, programId }: { address: string; tab: Tab; programId: PublicKey }) {
const { url } = useCluster();
const accountAnchorProgram = useAnchorProgram(programId.toString(), url);
diff --git a/app/address/[address]/program-interface/page-client.tsx b/app/address/[address]/program-interface/page-client.tsx
new file mode 100644
index 00000000..14ddb9a4
--- /dev/null
+++ b/app/address/[address]/program-interface/page-client.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import { ParsedAccountRenderer } from '@components/account/ParsedAccountRenderer';
+import { LoadingCard } from '@components/common/LoadingCard';
+import { Suspense } from 'react';
+import React from 'react';
+
+import { ProgramInterfaceCard } from '@/app/components/account/ProgramInterfaceCard';
+
+type Props = Readonly<{
+ params: {
+ address: string;
+ };
+}>;
+
+function ProgramInterfaceCardRenderer({
+ account,
+ onNotFound,
+}: React.ComponentProps['renderComponent']>) {
+ if (!account) {
+ return onNotFound();
+ }
+ return (
+ }>
+
+
+ );
+}
+
+export default function ProgramInterfacePageClient({ params: { address } }: Props) {
+ return ;
+}
diff --git a/app/address/[address]/program-interface/page.tsx b/app/address/[address]/program-interface/page.tsx
new file mode 100644
index 00000000..92be3e5a
--- /dev/null
+++ b/app/address/[address]/program-interface/page.tsx
@@ -0,0 +1,21 @@
+import getReadableTitleFromAddress, { AddressPageMetadataProps } from '@utils/get-readable-title-from-address';
+import { Metadata } from 'next/types';
+
+import ProgramInterfacePageClient from './page-client';
+
+type Props = Readonly<{
+ params: {
+ address: string;
+ };
+}>;
+
+export async function generateMetadata(props: AddressPageMetadataProps): Promise {
+ return {
+ description: `Human usable Solana actions for the program at address ${props.params.address} on Solana`,
+ title: `Program Interface | ${await getReadableTitleFromAddress(props)} | Solana`,
+ };
+}
+
+export default function ProgramInterfacePage(props: Props) {
+ return ;
+}
diff --git a/app/address/[address]/spl-token-metadata-interface/page-client.tsx b/app/address/[address]/spl-token-metadata-interface/page-client.tsx
new file mode 100644
index 00000000..78357b13
--- /dev/null
+++ b/app/address/[address]/spl-token-metadata-interface/page-client.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import { ParsedAccountRenderer } from '@components/account/ParsedAccountRenderer';
+import { LoadingCard } from '@components/common/LoadingCard';
+import { Suspense } from 'react';
+import React from 'react';
+
+import { SplTokenMetadataInterfaceCard } from '@/app/components/account/SplTokenMetadataInterfaceCard';
+
+type Props = Readonly<{
+ params: {
+ address: string;
+ };
+}>;
+
+function SplTokenMetadataInterfaceCardRenderer({
+ account,
+ onNotFound,
+}: React.ComponentProps['renderComponent']>) {
+ if (!account) {
+ return onNotFound();
+ }
+ return (
+ }>
+
+
+ );
+}
+
+export default function SplTokenMetadataInterfacePageClient({ params: { address } }: Props) {
+ return ;
+}
diff --git a/app/address/[address]/spl-token-metadata-interface/page.tsx b/app/address/[address]/spl-token-metadata-interface/page.tsx
new file mode 100644
index 00000000..24e0447b
--- /dev/null
+++ b/app/address/[address]/spl-token-metadata-interface/page.tsx
@@ -0,0 +1,21 @@
+import getReadableTitleFromAddress, { AddressPageMetadataProps } from '@utils/get-readable-title-from-address';
+import { Metadata } from 'next/types';
+
+import SplTokenMetadataInterfacePageClient from './page-client';
+
+type Props = Readonly<{
+ params: {
+ address: string;
+ };
+}>;
+
+export async function generateMetadata(props: AddressPageMetadataProps): Promise {
+ return {
+ description: `SPL token metadata for ${props.params.address} on Solana`,
+ title: `SPL Token Metadata | ${await getReadableTitleFromAddress(props)} | Solana`,
+ };
+}
+
+export default function SplTokenMetadataInterfacePage(props: Props) {
+ return ;
+}
diff --git a/app/api/program-interface/resolution.ts b/app/api/program-interface/resolution.ts
new file mode 100644
index 00000000..f34aadd0
--- /dev/null
+++ b/app/api/program-interface/resolution.ts
@@ -0,0 +1,275 @@
+import { sha256 } from '@noble/hashes/sha256';
+import * as anchor from '@project-serum/anchor';
+import { AccountMeta, PublicKey } from '@solana/web3.js';
+
+import { PRE_INSTRUCTIONS, sendTransaction } from './sendTransaction';
+
+type AdditionalAccounts = {
+ accounts: anchor.web3.AccountMeta[];
+ hasMore: boolean;
+};
+
+const MAX_ACCOUNTS = 30;
+
+/**
+ *
+ * @param program
+ * @param instructions
+ * @returns
+ */
+export async function resolveRemainingAccounts(
+ connection: anchor.web3.Connection,
+ payer: anchor.web3.PublicKey,
+ instructions: anchor.web3.TransactionInstruction[],
+ verbose = false,
+ slut: anchor.web3.PublicKey | undefined = undefined
+): Promise {
+ // Simulate transaction
+ let lookupTable: anchor.web3.AddressLookupTableAccount | null = null;
+ if (slut) {
+ if (verbose) {
+ console.log(`SLUT resolution with ${slut.toBase58()}`);
+ }
+ while (!lookupTable) {
+ lookupTable = (await connection.getAddressLookupTable(slut)).value;
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ }
+ }
+
+ const message = anchor.web3.MessageV0.compile({
+ addressLookupTableAccounts: slut ? [lookupTable!] : undefined,
+ instructions: PRE_INSTRUCTIONS.concat(instructions),
+ payerKey: payer,
+ recentBlockhash: (await connection.getLatestBlockhashAndContext()).value.blockhash,
+ });
+ const transaction = new anchor.web3.VersionedTransaction(message);
+
+ const simulationResult = await connection.simulateTransaction(transaction, {
+ commitment: 'confirmed',
+ });
+ const logs = simulationResult.value.logs;
+ const unitsConsumed = simulationResult.value.unitsConsumed;
+ const err = simulationResult.value.err;
+
+ if (verbose) {
+ console.log('CUs consumed:', unitsConsumed);
+ console.log('Logs', logs);
+ console.log('Result', err);
+ }
+
+ // When the simulation RPC response is fixed, then the following code will work
+ // but until then, we have to parse the logs manually.
+ //
+ // ISSUE: rpc truncates trailing 0 bytes in `returnData` field, so we have
+ // to actually parse the logs for the whole return data
+ // ===============================================================
+ // let returnDataTuple = simulationResult.value.returnData;
+ // let [b64Data, encoding] = returnDataTuple["data"];
+ // if (encoding !== "base64") {
+ // throw new Error("Unsupported encoding: " + encoding);
+ // }
+ // ===============================================================
+
+ if (!logs) {
+ throw new Error('No logs found in preflight simulation. This is likely an RPC error.');
+ }
+
+ try {
+ const b64Data = anchor.utils.bytes.base64.decode(logs[logs.length - 2].split(' ')[3]);
+ const data = b64Data;
+
+ if (!data.length) {
+ throw new Error(
+ `No return data found in preflight simulation:
+ ${logs}`
+ );
+ }
+
+ if (data.length !== 1024) {
+ throw new Error(
+ `Return data incorrect size in preflight simulation:
+ ${data.length} (expected 1024)`
+ );
+ }
+
+ // We start deserializing the Vec from the 5th byte
+ // The first 4 bytes are u32 for the Vec of the return data
+ const protocolVersion = data[0];
+ if (protocolVersion !== 0) {
+ throw new Error(`Unsupported Account Resolution Protocol version: ${protocolVersion}`);
+ }
+ const hasMore = data[1];
+ const numAccounts = data.slice(4, 8);
+ const numMetas = new anchor.BN(numAccounts, undefined, 'le');
+
+ const offset = 8;
+ const realAccountMetas: anchor.web3.AccountMeta[] = [];
+ for (let i = 0; i < numMetas.toNumber(); i += 1) {
+ const pubkey = new anchor.web3.PublicKey(data.slice(offset + i * 32, offset + (i + 1) * 32));
+ const writable = data[offset + MAX_ACCOUNTS * 32 + i];
+ realAccountMetas.push({
+ isSigner: false,
+ isWritable: writable === 1,
+ pubkey,
+ });
+ }
+
+ return {
+ accounts: realAccountMetas,
+ hasMore: hasMore != 0,
+ };
+ } catch (e) {
+ throw new Error('Failed to parse return data: ' + e + '\n' + logs.join('\n'));
+ }
+}
+
+async function extendLookupTable(
+ additionalAccounts: anchor.web3.AccountMeta[],
+ payer: PublicKey,
+ lastSize: number,
+ connection: anchor.web3.Connection,
+ lookupTable: anchor.web3.PublicKey
+): Promise {
+ while (additionalAccounts.flat().length - lastSize) {
+ // 29 is max number of accounts we can extend a lookup table by in a single transaction
+ // ironically due to tx limits
+ const batchSize = Math.min(29, additionalAccounts.length - lastSize);
+
+ const ix = anchor.web3.AddressLookupTableProgram.extendLookupTable({
+ addresses: additionalAccounts
+ .flat()
+ .slice(lastSize, lastSize + batchSize)
+ .map(acc => acc.pubkey),
+ authority: payer,
+ lookupTable,
+ payer,
+ });
+
+ await sendTransaction(connection, payer, [ix]);
+ lastSize += batchSize;
+ }
+ return lastSize;
+}
+
+async function pollForActiveLookupTable(
+ additionalAccounts: anchor.web3.AccountMeta[],
+ connection: anchor.web3.Connection,
+ lookupTable: anchor.web3.PublicKey
+) {
+ let activeSlut = false;
+ while (!activeSlut) {
+ const table = await connection.getAddressLookupTable(lookupTable, {
+ commitment: 'finalized',
+ });
+ if (table.value) {
+ activeSlut = table.value.isActive() && table.value.state.addresses.length === additionalAccounts.length;
+ }
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ }
+}
+
+export function hashIxName(ixName: string): Buffer {
+ return Buffer.from(sha256(`global:${ixName}`)).slice(0, 8);
+}
+
+/**
+ * Takes a serialized Anchor Instruction
+ * And executes a preflight instruction to get the remaining accounts
+ * @param program
+ * @param instruction
+ * @param verbose
+ * @returns
+ */
+export async function additionalAccountsRequest(
+ connection: anchor.web3.Connection,
+ payer: PublicKey,
+ instruction: anchor.web3.TransactionInstruction,
+ methodName: string,
+ verbose = false,
+ slut = false
+): Promise<{
+ ix: anchor.web3.TransactionInstruction;
+ lookupTable?: anchor.web3.PublicKey;
+}> {
+ // NOTE: LOL we have to do this because slicing only generates a view
+ // so we need to copy it to a new buffer
+ // const originalData = Buffer.from(instruction.data);
+ const originalKeys: AccountMeta[] = ([] as AccountMeta[]).concat(instruction.keys);
+
+ // Overwrite the discriminator
+ const currentBuffer = Buffer.from(instruction.data);
+
+ const newIxDisc = hashIxName(`preflight_${methodName}`);
+ currentBuffer.set(newIxDisc, 0);
+
+ let additionalAccounts: anchor.web3.AccountMeta[] = [];
+ let hasMore = true;
+ let i = 0;
+ let lookupTable: anchor.web3.PublicKey | undefined;
+ let lastSize = 0;
+ while (hasMore) {
+ if (verbose) {
+ console.log(`Iteration: ${i} | additionalAccounts: ${additionalAccounts.length}`);
+ }
+
+ // Write the current page number at the end of the instruction data
+ instruction.data = currentBuffer;
+
+ // Add found accounts to instruction
+ instruction.keys = originalKeys.concat(additionalAccounts.flat());
+
+ const result = await resolveRemainingAccounts(connection, payer, [instruction], verbose, lookupTable);
+
+ if (verbose) {
+ console.log(`Iteration: ${i} | requested: ${result.accounts.length}`);
+ }
+ hasMore = result.hasMore;
+ additionalAccounts = additionalAccounts.concat(result.accounts);
+
+ if (additionalAccounts.length >= 10 && slut) {
+ if (!lookupTable) {
+ const [ix, tableAddr] = anchor.web3.AddressLookupTableProgram.createLookupTable({
+ authority: payer,
+ payer: payer,
+ recentSlot: await connection.getSlot(),
+ });
+
+ await sendTransaction(connection, payer, [ix]);
+ lookupTable = tableAddr;
+ }
+
+ // We want to minimize the number of non-transactional
+ // txs we have to send on-chain. So we maximize # of accounts
+ // to extend the lookup table by.
+ // In practice, we can probably mix accounts from different resolutions
+ // into the same extend LUT tx.
+ if (additionalAccounts.length - lastSize >= 10) {
+ if (verbose) {
+ console.log('Extending lookup table...');
+ }
+ lastSize = await extendLookupTable(additionalAccounts, payer, lastSize, connection, lookupTable);
+ await pollForActiveLookupTable(additionalAccounts, connection, lookupTable);
+ if (verbose) {
+ console.log('...extended!');
+ }
+ }
+ }
+
+ i++;
+ if (i >= 32) {
+ throw new Error(`Too many iterations ${i}`);
+ }
+ }
+
+ if (slut && lookupTable) {
+ await extendLookupTable(additionalAccounts, payer, lastSize, connection, lookupTable);
+ await pollForActiveLookupTable(additionalAccounts, connection, lookupTable);
+ }
+
+ instruction.keys = originalKeys.concat(additionalAccounts);
+
+ // Reset original data
+ instruction.data.set(hashIxName(`${methodName}`), 0);
+
+ return { ix: instruction, lookupTable };
+}
diff --git a/app/api/program-interface/route.ts b/app/api/program-interface/route.ts
new file mode 100644
index 00000000..75920bb2
--- /dev/null
+++ b/app/api/program-interface/route.ts
@@ -0,0 +1,62 @@
+import { Connection, PublicKey, TransactionInstruction } from '@solana/web3.js';
+import { NextResponse } from 'next/server';
+
+import { additionalAccountsRequest } from './resolution';
+
+type Params = {
+ accounts: Record;
+ arguments: Record;
+ txIx: any;
+ programId: string;
+ payer: string;
+ endpointUrl: string;
+ instructionName: string;
+};
+
+// export type FetchedDomainInfo = Awaited>;
+
+export async function POST(request: Request) {
+ console.log('Intercepted post!');
+ const text = await request.text();
+ console.log({ text });
+ const body: Params = JSON.parse(text);
+
+ const connection = new Connection(body.endpointUrl);
+
+ const camelToSnakeCase = (str: string) => str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
+
+ console.log(Buffer.from(body.txIx.data));
+ console.log(
+ body.txIx.keys.map((meta: { pubkey: string; isSigner: boolean; isWritable: boolean }) => ({
+ ...meta,
+ pubkey: new PublicKey(meta.pubkey),
+ }))
+ );
+ console.log(new PublicKey(body.txIx.programId));
+ const txIx: TransactionInstruction = {
+ data: Buffer.from(body.txIx.data),
+ keys: body.txIx.keys.map((meta: { pubkey: string; isSigner: boolean; isWritable: boolean }) => ({
+ ...meta,
+ pubkey: new PublicKey(meta.pubkey),
+ })),
+ programId: new PublicKey(body.txIx.programId),
+ };
+
+ const { ix } = await additionalAccountsRequest(
+ connection,
+ new PublicKey(body.payer),
+ txIx,
+ camelToSnakeCase(body.instructionName),
+ true,
+ false
+ );
+
+ const response = { ix };
+
+ return NextResponse.json(response, {
+ headers: {
+ // 24 hours
+ 'Cache-Control': 'max-age=0',
+ },
+ });
+}
diff --git a/app/api/program-interface/sendTransaction.ts b/app/api/program-interface/sendTransaction.ts
new file mode 100644
index 00000000..74bae503
--- /dev/null
+++ b/app/api/program-interface/sendTransaction.ts
@@ -0,0 +1,73 @@
+import * as anchor from '@project-serum/anchor';
+import { PublicKey } from '@solana/web3.js';
+
+type Opts = {
+ logs?: boolean;
+ simulate?: boolean;
+ verbose?: boolean;
+ signers?: anchor.web3.Keypair[];
+ lookupTableAddress?: anchor.web3.PublicKey;
+};
+
+export const PRE_INSTRUCTIONS = [
+ anchor.web3.ComputeBudgetProgram.setComputeUnitLimit({
+ units: 1_400_000,
+ }),
+ // Only need this is we consume too much heap while resolving / identifying accounts
+ anchor.web3.ComputeBudgetProgram.requestHeapFrame({
+ bytes: 1024 * 32 * 8,
+ }),
+];
+
+export async function sendTransaction(
+ connection: anchor.web3.Connection,
+ payer: PublicKey,
+ ixs: anchor.web3.TransactionInstruction[],
+ opts: Opts = {
+ lookupTableAddress: undefined,
+ simulate: false,
+ verbose: false,
+ }
+): Promise<{ computeUnits: number }> {
+ let lookupTable: anchor.web3.AddressLookupTableAccount | null = null;
+ if (opts.lookupTableAddress) {
+ lookupTable = (await connection.getAddressLookupTable(opts.lookupTableAddress, { commitment: 'finalized' }))
+ .value;
+ }
+
+ let numReplays = 0;
+ while (numReplays < 3) {
+ try {
+ const message = anchor.web3.MessageV0.compile({
+ addressLookupTableAccounts: lookupTable ? [lookupTable] : undefined,
+ instructions: PRE_INSTRUCTIONS.concat(ixs),
+ payerKey: payer,
+ recentBlockhash: (await connection.getLatestBlockhash()).blockhash,
+ });
+ const transaction = new anchor.web3.VersionedTransaction(message);
+
+ if (opts.simulate) {
+ const simulationResult = await connection.simulateTransaction(transaction, {
+ commitment: 'confirmed',
+ });
+
+ if (opts.logs && simulationResult.value.logs) {
+ console.log(simulationResult.value.logs.join('\n'));
+ }
+
+ return { computeUnits: simulationResult.value.unitsConsumed ?? -1 };
+ } else {
+ throw new Error("Don't use this function for real transactions");
+ }
+ } catch (e) {
+ if (e instanceof anchor.web3.TransactionExpiredTimeoutError) {
+ console.log('Retrying transaction');
+ numReplays += 1;
+ } else {
+ throw e;
+ }
+ }
+ }
+
+ return { computeUnits: -1 };
+}
diff --git a/app/components/ClusterModal.tsx b/app/components/ClusterModal.tsx
index 8160d757..f23c66e5 100644
--- a/app/components/ClusterModal.tsx
+++ b/app/components/ClusterModal.tsx
@@ -107,6 +107,8 @@ function ClusterToggle() {
return (
{CLUSTERS.map((net, index) => {
+ if (net === Cluster.MainnetBeta && process.env.NEXT_PUBLIC_MAINNET_ENABLED !== 'true') return null;
+
const active = net === cluster;
if (net === Cluster.Custom)
return
;
diff --git a/app/components/Token22MetadataHeader.tsx b/app/components/Token22MetadataHeader.tsx
new file mode 100644
index 00000000..70075aa7
--- /dev/null
+++ b/app/components/Token22MetadataHeader.tsx
@@ -0,0 +1,84 @@
+import { ArtContent } from '@components/common/NFTArt';
+import React, { useMemo } from 'react';
+import useAsyncEffect from 'use-async-effect';
+
+import { LoadingState as TokenMetadataLoadingState, useTokenMetadata } from './account/SplTokenMetadataInterfaceCard';
+
+enum LoadingState {
+ PreconditionFailed,
+ Started,
+ Succeeded,
+ Failed,
+}
+
+function useTokenMetadataWithUri(mint: string) {
+ const { loading, metadata } = useTokenMetadata(mint);
+
+ const initialLoadingState =
+ metadata && loading === TokenMetadataLoadingState.MetadataFound
+ ? LoadingState.Started
+ : LoadingState.PreconditionFailed;
+ const [jsonLoading, setJsonLoading] = React.useState
(initialLoadingState);
+ const [metadataJson, setMetadataJson] = React.useState