From c0f4244a64cb44d4a647b5de1a442a71d0ddcdc1 Mon Sep 17 00:00:00 2001 From: Noah Gundotra Date: Mon, 17 Jun 2024 14:29:22 -0400 Subject: [PATCH 1/2] Fix compressed nft hooks (#349) previous cnft pr did not properly make hooks, causing perf issues. this PR fixes it by using useSWRImmutable hooks to manage fetching & caching --- app/components/account/CompressedNftCard.tsx | 1 - app/providers/compressed-nft.tsx | 80 +++++--------------- 2 files changed, 17 insertions(+), 64 deletions(-) diff --git a/app/components/account/CompressedNftCard.tsx b/app/components/account/CompressedNftCard.tsx index 6ff1678d..ba110c99 100644 --- a/app/components/account/CompressedNftCard.tsx +++ b/app/components/account/CompressedNftCard.tsx @@ -72,7 +72,6 @@ export function CompressedNftCard({ account }: { account: Account }) { export function CompressedNftAccountHeader({ account }: { account: Account }) { const { url } = useCluster(); const compressedNft = useCompressedNft({ address: account.pubkey.toString(), url }); - if (!compressedNft) throw new Error('Compressed NFT not found'); if (compressedNft) { return ( diff --git a/app/providers/compressed-nft.tsx b/app/providers/compressed-nft.tsx index db584847..7405926f 100644 --- a/app/providers/compressed-nft.tsx +++ b/app/providers/compressed-nft.tsx @@ -1,60 +1,14 @@ -type CacheType

= Record< - string, - void | { __type: 'promise'; promise: Promise } | { __type: 'result'; result: P } ->; +import useSWRImmutable from 'swr/immutable'; -const cachedNftPromises: CacheType = {}; - -const cachedPromises = { - compressedNft: {} as CacheType, - compressedNftProof: {} as CacheType, - nftMetadataJson: {} as CacheType, -}; - -function makeCache( - cacheName: keyof typeof cachedPromises, - keygen: (params: Params) => string, - action: (params: Params) => Promise -): (params: Params) => CacheValueType { - return (params: Params) => { - const key = keygen(params); - const cacheEntry = cachedPromises[cacheName][key]; - - if (cacheEntry === undefined) { - const promise = action(params) - .then((value: CacheValueType) => { - cachedPromises[cacheName][key] = { - __type: 'result', - result: value, - }; - }) - .catch(_ => { - cachedNftPromises[key] = { __type: 'result', result: null }; - }); - cachedPromises[cacheName][key] = { - __type: 'promise', - promise, - }; - throw promise; - } else if (cacheEntry.__type === 'promise') { - throw cacheEntry.promise; - } - return cacheEntry.result; - }; -} - -export const useMetadataJsonLink = makeCache( - 'nftMetadataJson', - (url: string) => url, - async (url: string) => { +export function useMetadataJsonLink(url: string) { + const { data, error } = useSWRImmutable(url, async (url: string) => { return fetch(url).then(response => response.json()); - } -); + }); + return error ? null : data; +} -export const useCompressedNft = makeCache<{ address: string; url: string }, CompressedNft | null>( - 'compressedNft', - ({ address, url }) => `${address}-${url}`, - async ({ address, url }) => { +export function useCompressedNft({ address, url }: { address: string; url: string }): CompressedNft | null { + const { data, error } = useSWRImmutable([address, url], async ([address, url]): Promise => { return fetch(`${url}`, { body: JSON.stringify({ id: address, @@ -72,18 +26,17 @@ export const useCompressedNft = makeCache<{ address: string; url: string }, Comp .then(response => response.json()) .then((response: DasApiResponse) => { if ('error' in response) { - throw new Error(response.error.message); + return null; } return response.result; }); - } -); + }); + return error ? null : data ?? null; +} -export const useCompressedNftProof = makeCache<{ address: string; url: string }, CompressedNftProof | null>( - 'compressedNftProof', - ({ address, url }) => `proof-${address}-${url}`, - async ({ address, url }) => { +export function useCompressedNftProof({ address, url }: { address: string; url: string }): CompressedNftProof | null { + const { data, error } = useSWRImmutable([address, url], async ([address, url]) => { return fetch(`${url}`, { body: JSON.stringify({ id: address, @@ -103,8 +56,9 @@ export const useCompressedNftProof = makeCache<{ address: string; url: string }, return response.result; }); - } -); + }); + return error ? null : data ?? null; +} type DasResponseTypes = CompressedNft | CompressedNftProof; export type DasApiResponse = From 8f9939d6ebb3957e8fbd23179564327f70ba74c2 Mon Sep 17 00:00:00 2001 From: Noah Gundotra Date: Mon, 17 Jun 2024 14:29:48 -0400 Subject: [PATCH 2/2] Add support for parsing IDLs stored in `.solana.idl` section of ELF (#348) Allow developers to store Zlib-compressed IDLs in `.solana.idl`. Inspect `http://localhost:3000/address/AssetGtQBTSgm5s91d1RAQod5JmaZiJDxqsgtqrZud73?cluster=devnet` --- app/address/[address]/layout.tsx | 23 ++-- app/components/account/AnchorAccountCard.tsx | 2 +- app/components/account/AnchorProgramCard.tsx | 6 +- .../transaction/InstructionsSection.tsx | 2 +- app/providers/anchor.tsx | 107 ++++++++++++++++-- app/utils/anchor.tsx | 2 +- app/utils/types/elfy.d.ts | 4 + package.json | 3 + pnpm-lock.yaml | 23 ++++ 9 files changed, 147 insertions(+), 25 deletions(-) create mode 100644 app/utils/types/elfy.d.ts diff --git a/app/address/[address]/layout.tsx b/app/address/[address]/layout.tsx index 0a8f2930..2b149099 100644 --- a/app/address/[address]/layout.tsx +++ b/app/address/[address]/layout.tsx @@ -275,9 +275,12 @@ function AccountHeader({ let token: { logoURI?: string; name?: string } = {}; let unverified = false; - const metadataExtension = mintInfo?.extensions?.find(({ extension }: { extension: string }) => extension === 'tokenMetadata'); - const metadataPointerExtension = mintInfo?.extensions?.find(({ extension }: { extension: string }) => extension === 'metadataPointer'); - + const metadataExtension = mintInfo?.extensions?.find( + ({ extension }: { extension: string }) => extension === 'tokenMetadata' + ); + const metadataPointerExtension = mintInfo?.extensions?.find( + ({ extension }: { extension: string }) => extension === 'metadataPointer' + ); if (metadataPointerExtension && metadataExtension) { const tokenMetadata = create(metadataExtension.state, TokenMetadata); @@ -286,9 +289,9 @@ function AccountHeader({ // Handles the basic case where MetadataPointer is reference the Token Metadata extension directly // Does not handle the case where MetadataPointer is pointing at a separate account. if (metadataAddress?.toString() === address) { - token.name = tokenMetadata.name + token.name = tokenMetadata.name; } - } + } // Fall back to legacy token list when there is stub metadata (blank uri), updatable by default by the mint authority else if (!parsedData?.nftData?.metadata.data.uri && tokenInfo) { token = tokenInfo; @@ -630,7 +633,7 @@ function getAnchorTabs(pubkey: PublicKey, account: Account) { tabComponents.push({ component: ( }> - + ), tab: anchorProgramTab, @@ -653,13 +656,13 @@ function getAnchorTabs(pubkey: PublicKey, account: Account) { return tabComponents; } -function AnchorProgramLink({ tab, address, pubkey }: { tab: Tab; address: string; pubkey: PublicKey }) { +function AnchorProgramIdlLink({ tab, address, pubkey }: { tab: Tab; address: string; pubkey: PublicKey }) { const { url } = useCluster(); - const anchorProgram = useAnchorProgram(pubkey.toString(), url); + const { idl } = useAnchorProgram(pubkey.toString(), url); const anchorProgramPath = useClusterPath({ pathname: `/address/${address}/${tab.path}` }); const selectedLayoutSegment = useSelectedLayoutSegment(); const isActive = selectedLayoutSegment === tab.path; - if (!anchorProgram) { + if (!idl) { return null; } @@ -674,7 +677,7 @@ function AnchorProgramLink({ tab, address, pubkey }: { tab: Tab; address: string function AccountDataLink({ address, tab, programId }: { address: string; tab: Tab; programId: PublicKey }) { const { url } = useCluster(); - const accountAnchorProgram = useAnchorProgram(programId.toString(), url); + const { program: accountAnchorProgram } = useAnchorProgram(programId.toString(), url); const accountDataPath = useClusterPath({ pathname: `/address/${address}/${tab.path}` }); const selectedLayoutSegment = useSelectedLayoutSegment(); const isActive = selectedLayoutSegment === tab.path; diff --git a/app/components/account/AnchorAccountCard.tsx b/app/components/account/AnchorAccountCard.tsx index 2970e448..6eac8345 100644 --- a/app/components/account/AnchorAccountCard.tsx +++ b/app/components/account/AnchorAccountCard.tsx @@ -10,7 +10,7 @@ import React, { useMemo } from 'react'; export function AnchorAccountCard({ account }: { account: Account }) { const { lamports } = account; const { url } = useCluster(); - const anchorProgram = useAnchorProgram(account.owner.toString(), url); + const { program: anchorProgram } = useAnchorProgram(account.owner.toString(), url); const rawData = account.data.raw; const programName = getAnchorProgramName(anchorProgram) || 'Unknown Program'; diff --git a/app/components/account/AnchorProgramCard.tsx b/app/components/account/AnchorProgramCard.tsx index 20eedefd..d8bdd24e 100644 --- a/app/components/account/AnchorProgramCard.tsx +++ b/app/components/account/AnchorProgramCard.tsx @@ -6,9 +6,9 @@ import ReactJson from 'react-json-view'; export function AnchorProgramCard({ programId }: { programId: string }) { const { url } = useCluster(); - const program = useAnchorProgram(programId, url); + const { idl } = useAnchorProgram(programId, url); - if (!program) { + if (!idl) { return null; } @@ -24,7 +24,7 @@ export function AnchorProgramCard({ programId }: { programId: string }) {

- +
diff --git a/app/components/transaction/InstructionsSection.tsx b/app/components/transaction/InstructionsSection.tsx index 680f69c0..88488c6d 100644 --- a/app/components/transaction/InstructionsSection.tsx +++ b/app/components/transaction/InstructionsSection.tsx @@ -162,7 +162,7 @@ function InstructionCard({ url: string; }) { const key = `${index}-${childIndex}`; - const anchorProgram = useAnchorProgram(ix.programId.toString(), url); + const { program: anchorProgram } = useAnchorProgram(ix.programId.toString(), url); if ('parsed' in ix) { const props = { diff --git a/app/providers/anchor.tsx b/app/providers/anchor.tsx index 949b517e..f74735a7 100644 --- a/app/providers/anchor.tsx +++ b/app/providers/anchor.tsx @@ -1,25 +1,99 @@ import { NodeWallet } from '@metaplex/js'; import { Idl, Program, Provider } from '@project-serum/anchor'; -import { Connection, Keypair } from '@solana/web3.js'; +import { Connection, Keypair, PublicKey } from '@solana/web3.js'; +import * as elfy from 'elfy'; +import pako from 'pako'; +import { useEffect, useMemo } from 'react'; + +import { useAccountInfo, useFetchAccountInfo } from './accounts'; const cachedAnchorProgramPromises: Record< string, - void | { __type: 'promise'; promise: Promise } | { __type: 'result'; result: Program | null } + void | { __type: 'promise'; promise: Promise } | { __type: 'result'; result: Idl | null } > = {}; -export function useAnchorProgram(programAddress: string, url: string): Program | null { +function useIdlFromSolanaProgramBinary(programAddress: string): Idl | null { + const fetchAccountInfo = useFetchAccountInfo(); + const programInfo = useAccountInfo(programAddress); + const programDataAddress: string | undefined = programInfo?.data?.data.parsed?.parsed.info['programData']; + const programDataInfo = useAccountInfo(programDataAddress); + + useEffect(() => { + if (!programInfo) { + fetchAccountInfo(new PublicKey(programAddress), 'parsed'); + } + }, [programAddress, fetchAccountInfo, programInfo]); + + useEffect(() => { + if (programDataAddress && !programDataInfo) { + fetchAccountInfo(new PublicKey(programDataAddress), 'raw'); + } + }, [programDataAddress, fetchAccountInfo, programDataInfo]); + + const param = useMemo(() => { + if (programDataInfo && programDataInfo.data && programDataInfo.data.data.raw) { + const offset = + (programInfo?.data?.owner.toString() ?? '') === 'BPFLoaderUpgradeab1e11111111111111111111111' ? 45 : 0; + const raw = Buffer.from(programDataInfo.data.data.raw.slice(offset)); + + try { + return parseIdlFromElf(raw); + } catch (e) { + return null; + } + } + return null; + }, [programDataInfo, programInfo]); + return param; +} + +function parseIdlFromElf(elfBuffer: any) { + const elf = elfy.parse(elfBuffer); + const solanaIdlSection = elf.body.sections.find((section: any) => section.name === '.solana.idl'); + if (!solanaIdlSection) { + throw new Error('.solana.idl section not found'); + } + + // Extract the section data + const solanaIdlData = solanaIdlSection.data; + + // Parse the section data + solanaIdlData.readUInt32LE(4); + const ptr = solanaIdlData.readUInt32LE(4); + const size = solanaIdlData.readBigUInt64LE(8); + + // Get the compressed bytes + const byteRange = elfBuffer.slice(ptr, ptr + Number(size)); + + // Decompress the IDL + try { + const inflatedIdl = JSON.parse(new TextDecoder().decode(pako.inflate(byteRange))); + return inflatedIdl; + } catch (err) { + console.error('Failed to decompress data:', err); + return null; + } +} + +function getProvider(url: string) { + return new Provider(new Connection(url), new NodeWallet(Keypair.generate()), {}); +} + +function useIdlFromAnchorProgramSeed(programAddress: string, url: string): Idl | null { const key = `${programAddress}-${url}`; const cacheEntry = cachedAnchorProgramPromises[key]; if (cacheEntry === undefined) { - const promise = Program.at( - programAddress, - new Provider(new Connection(url), new NodeWallet(Keypair.generate()), {}) - ) - .then(program => { + const programId = new PublicKey(programAddress); + const promise = Program.fetchIdl(programId, getProvider(url)) + .then(idl => { + if (!idl) { + throw new Error(`IDL not found for program: ${programAddress.toString()}`); + } + cachedAnchorProgramPromises[key] = { __type: 'result', - result: program, + result: idl, }; }) .catch(_ => { @@ -36,6 +110,21 @@ export function useAnchorProgram(programAddress: string, url: string): Program | return cacheEntry.result; } +export function useAnchorProgram(programAddress: string, url: string): { program: Program | null; idl: Idl | null } { + const idlFromBinary = useIdlFromSolanaProgramBinary(programAddress); + const idlFromAnchorProgram = useIdlFromAnchorProgramSeed(programAddress, url); + const idl = idlFromBinary ?? idlFromAnchorProgram; + const program: Program | null = useMemo(() => { + if (!idl) return null; + try { + return new Program(idl, new PublicKey(programAddress), getProvider(url)); + } catch (e) { + return null; + } + }, [idl, programAddress, url]); + return { idl, program }; +} + export type AnchorAccount = { layout: string; account: object; diff --git a/app/utils/anchor.tsx b/app/utils/anchor.tsx index ecb692fb..5d3e54f2 100644 --- a/app/utils/anchor.tsx +++ b/app/utils/anchor.tsx @@ -23,7 +23,7 @@ export function AnchorProgramName({ url: string; defaultName?: string; }) { - const program = useAnchorProgram(programId.toString(), url); + const { program } = useAnchorProgram(programId.toString(), url); const programName = getAnchorProgramName(program) || defaultName; return <>{programName}; } diff --git a/app/utils/types/elfy.d.ts b/app/utils/types/elfy.d.ts new file mode 100644 index 00000000..c09b228c --- /dev/null +++ b/app/utils/types/elfy.d.ts @@ -0,0 +1,4 @@ +declare module 'elfy' { + const elfy: any; + export = elfy; +} diff --git a/package.json b/package.json index 37a75fac..2850db7e 100644 --- a/package.json +++ b/package.json @@ -34,12 +34,14 @@ "chart.js": "^4.3.0", "classnames": "^2.3.1", "cross-fetch": "^3.1.5", + "elfy": "^1.0.0", "eslint": "8.39.0", "eslint-config-next": "13.4.0", "humanize-duration-ts": "^2.1.1", "moment": "^2.29.4", "next": "13.4.0", "p-limit": "^3.1.0", + "pako": "^2.1.0", "react": "18.2.0", "react-chartjs-2": "^5.2.0", "react-content-loader": "^6.1.0", @@ -68,6 +70,7 @@ "@types/bs58": "4.0.1", "@types/chart.js": "^2.9.34", "@types/node": "18.16.3", + "@types/pako": "^2.0.3", "@types/react": "18.2.0", "@types/react-dom": "18.2.1", "@types/react-select": "3.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ab74795..cbb36c7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ dependencies: cross-fetch: specifier: ^3.1.5 version: 3.1.5 + elfy: + specifier: ^1.0.0 + version: 1.0.0 eslint: specifier: 8.39.0 version: 8.39.0 @@ -98,6 +101,9 @@ dependencies: p-limit: specifier: ^3.1.0 version: 3.1.0 + pako: + specifier: ^2.1.0 + version: 2.1.0 react: specifier: 18.2.0 version: 18.2.0 @@ -178,6 +184,9 @@ devDependencies: '@types/node': specifier: 18.16.3 version: 18.16.3 + '@types/pako': + specifier: ^2.0.3 + version: 2.0.3 '@types/react': specifier: 18.2.0 version: 18.2.0 @@ -3537,6 +3546,10 @@ packages: /@types/node@18.16.3: resolution: {integrity: sha512-OPs5WnnT1xkCBiuQrZA4+YAV4HEJejmHneyraIaxsbev5yCEr6KMwINNFP9wQeFIw8FWcoTqF3vQsa5CDaI+8Q==} + /@types/pako@2.0.3: + resolution: {integrity: sha512-bq0hMV9opAcrmE0Byyo0fY3Ew4tgOevJmQ9grUhpXQhYfyLJ1Kqg3P33JT5fdbT2AjeAjR51zqqVjAL/HMkx7Q==} + dev: true + /@types/parse-json@4.0.0: resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} dev: false @@ -4880,6 +4893,12 @@ packages: /electron-to-chromium@1.4.382: resolution: {integrity: sha512-czMavlW52VIPgutbVL9JnZIZuFijzsG1ww/1z2Otu1r1q+9Qe2bTsH3My3sZarlvwyqHM6+mnZfEnt2Vr4dsIg==} + /elfy@1.0.0: + resolution: {integrity: sha512-4Kp3AA94jC085IJox+qnvrZ3PudqTi4gQNvIoTZfJJ9IqkRuCoqP60vCVYlIg00c5aYusi5Wjh2bf0cHYt+6gQ==} + dependencies: + endian-reader: 0.3.0 + dev: false + /elliptic@6.5.4: resolution: {integrity: sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==} dependencies: @@ -4904,6 +4923,10 @@ packages: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} dev: false + /endian-reader@0.3.0: + resolution: {integrity: sha512-zPlHN59VLEjeJtpEU41ti/i7ZvTbwclvUN2M8anCsI3tOC/3mq6WNTJEKi49A5eLGvDkA0975LZb67Xwp7u4xQ==} + dev: false + /enhanced-resolve@5.13.0: resolution: {integrity: sha512-eyV8f0y1+bzyfh8xAwW/WTSZpLbjhqc4ne9eGSH4Zo2ejdyiNG9pU6mf9DG8a7+Auk6MFTlNOT4Y2y/9k8GKVg==} engines: {node: '>=10.13.0'}