diff --git a/app/api/anchor/__tests__/route.spec.ts b/app/api/anchor/__tests__/route.spec.ts index 6394946af..49b2af601 100644 --- a/app/api/anchor/__tests__/route.spec.ts +++ b/app/api/anchor/__tests__/route.spec.ts @@ -1,27 +1,49 @@ -import { Idl, Program } from '@coral-xyz/anchor'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Idl } from '@coral-xyz/anchor'; +import { encodeIdlAccount as encodeIdlAccountBorsh } from '@coral-xyz/anchor/dist/cjs/idl'; +import { + getBase64Decoder, + SOLANA_ERROR__JSON_RPC__INTERNAL_ERROR, + SOLANA_ERROR__JSON_RPC__METHOD_NOT_FOUND, + SOLANA_ERROR__RPC__API_PLAN_MISSING_FOR_RPC_METHOD, + SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, + SOLANA_ERROR__RPC__TRANSPORT_HTTP_HEADER_FORBIDDEN, + SolanaError, +} from '@solana/kit'; +import { SYSTEM_PROGRAM_ADDRESS } from '@solana-program/system'; +import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { deflateSync } from 'zlib'; +import { Logger } from '@/app/shared/lib/logger'; +import { toLegacyPublicKey } from '@/app/shared/lib/web3js-compat'; import { Cluster } from '@/app/utils/cluster'; -vi.mock('@coral-xyz/anchor', async () => { - const actual = await vi.importActual('@coral-xyz/anchor'); +// A non-denylisted base58 address — random valid pubkey for tests that exercise RPC. +const ANCHOR_PROGRAM_ADDRESS = 'C7QLEmDz81Usvy2sYa4xZSdA8EwEcYvZo8iuYZMaqXmj'; + +const mocks = vi.hoisted(() => ({ + sendGetAccountInfo: vi.fn(), +})); + +vi.mock('@solana/kit', async () => { + const actual = await vi.importActual('@solana/kit'); return { ...actual, - Program: { - fetchIdl: vi.fn(), - }, + createSolanaRpc: vi.fn(() => ({ + getAccountInfo: () => ({ send: mocks.sendGetAccountInfo }), + })), }; }); -vi.mock('@coral-xyz/anchor/dist/cjs/nodewallet', () => ({ - default: vi.fn().mockImplementation(() => ({})), -})); - -const VALID_ADDRESS = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; - describe('GET /api/anchor', () => { beforeEach(() => { - vi.clearAllMocks(); + mocks.sendGetAccountInfo.mockReset(); + vi.spyOn(Logger, 'warn').mockImplementation(() => {}); + vi.spyOn(Logger, 'panic').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it('should return 400 when programAddress is missing', async () => { @@ -33,14 +55,14 @@ describe('GET /api/anchor', () => { it('should return 400 when cluster is missing', async () => { const { GET } = await importRoute(); - const res = await GET(createRequest({ programAddress: VALID_ADDRESS })); + const res = await GET(createRequest({ programAddress: ANCHOR_PROGRAM_ADDRESS })); expect(res.status).toBe(400); expect(await res.json()).toEqual({ error: 'Invalid query params' }); }); it('should return 400 for an invalid cluster value', async () => { const { GET } = await importRoute(); - const res = await GET(createRequest({ cluster: '999', programAddress: VALID_ADDRESS })); + const res = await GET(createRequest({ cluster: '999', programAddress: ANCHOR_PROGRAM_ADDRESS })); expect(res.status).toBe(400); expect(await res.json()).toEqual({ error: 'Invalid cluster' }); }); @@ -52,60 +74,238 @@ describe('GET /api/anchor', () => { expect(await res.json()).toEqual({ error: 'Invalid program address' }); }); - it('should return IDL on success', async () => { + it('should short-circuit known non-Anchor programs without an RPC call', async () => { + for (const address of [TOKEN_PROGRAM_ADDRESS, SYSTEM_PROGRAM_ADDRESS]) { + const { GET } = await importRoute(); + const res = await GET(createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: address })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ idl: null }); + expect(res.headers.get('Cache-Control')).toContain('max-age='); + } + expect(mocks.sendGetAccountInfo).not.toHaveBeenCalled(); + }); + + it('should return null IDL with 200 when no IDL account exists', async () => { + mocks.sendGetAccountInfo.mockResolvedValueOnce({ context: { slot: 0n }, value: null }); + + const { GET } = await importRoute(); + const res = await GET( + createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: ANCHOR_PROGRAM_ADDRESS }), + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ idl: null }); + expect(res.headers.get('Cache-Control')).toContain('max-age='); + }); + + it('should return parsed IDL when the account holds valid Anchor data', async () => { const fakeIdl: Idl = { - address: VALID_ADDRESS, + address: ANCHOR_PROGRAM_ADDRESS, instructions: [], metadata: { name: 'test', spec: '0.1.0', version: '0.1.0' }, }; - vi.mocked(Program.fetchIdl).mockResolvedValueOnce(fakeIdl); + mocks.sendGetAccountInfo.mockResolvedValueOnce({ + context: { slot: 0n }, + value: accountValue(encodeIdlAccount(fakeIdl)), + }); const { GET } = await importRoute(); - const res = await GET(createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: VALID_ADDRESS })); + const res = await GET( + createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: ANCHOR_PROGRAM_ADDRESS }), + ); + expect(res.status).toBe(200); expect(await res.json()).toEqual({ idl: fakeIdl }); expect(res.headers.get('Cache-Control')).toContain('max-age='); }); - it('should return null IDL with 200 when program has non-IDL data at PDA', async () => { - const bufferError = Object.assign(new RangeError('Attempt to access memory outside buffer bounds'), { - code: 'ERR_BUFFER_OUT_OF_BOUNDS', + it('should return null IDL with 200 when account data is undecodable', async () => { + // Garbage bytes — fails inflate / JSON.parse rather than the truncated-buffer path. + const garbage = new Uint8Array(8 + 32 + 4 + 16).fill(0xff); + new DataView(garbage.buffer).setUint32(8 + 32, 16, true); + mocks.sendGetAccountInfo.mockResolvedValueOnce({ + context: { slot: 0n }, + value: accountValue(garbage), }); - vi.mocked(Program.fetchIdl).mockRejectedValueOnce(bufferError); const { GET } = await importRoute(); - const res = await GET(createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: VALID_ADDRESS })); + const res = await GET( + createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: ANCHOR_PROGRAM_ADDRESS }), + ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ idl: null }); - expect(res.headers.get('Cache-Control')).toContain('max-age='); + expect(Logger.warn).toHaveBeenCalled(); + expect(Logger.panic).not.toHaveBeenCalled(); }); - it('should return 502 when RangeError has no ERR_BUFFER_OUT_OF_BOUNDS code', async () => { - vi.mocked(Program.fetchIdl).mockRejectedValueOnce(new RangeError('index out of range')); + it('should return null IDL with 200 when the decoded JSON is not Idl-shaped', async () => { + // Well-formed JSON object at the IDL PDA, but missing `instructions: []`. The shape + // guard inside decodeIdl rejects it so we don't cache garbage as a valid IDL. + mocks.sendGetAccountInfo.mockResolvedValueOnce({ + context: { slot: 0n }, + value: accountValue(encodeIdlAccount({ hello: 'world' })), + }); + + const { GET } = await importRoute(); + const res = await GET( + createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: ANCHOR_PROGRAM_ADDRESS }), + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ idl: null }); + expect(Logger.warn).toHaveBeenCalled(); + expect(Logger.panic).not.toHaveBeenCalled(); + }); + + it('should return null IDL with 200 when the account is shorter than the Anchor discriminator', async () => { + // 4 bytes < 8-byte discriminator → Buffer.from(buf, 8, -4) throws RangeError + // before borsh ever sees the bytes. Caught by the route's outer try/catch. + mocks.sendGetAccountInfo.mockResolvedValueOnce({ + context: { slot: 0n }, + value: accountValue(new Uint8Array(4)), + }); + + const { GET } = await importRoute(); + const res = await GET( + createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: ANCHOR_PROGRAM_ADDRESS }), + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ idl: null }); + expect(Logger.warn).toHaveBeenCalled(); + expect(Logger.panic).not.toHaveBeenCalled(); + }); + + it('should return 502 without escalating on a transient JSON-RPC error (Internal error)', async () => { + const rpcError = new SolanaError(SOLANA_ERROR__JSON_RPC__INTERNAL_ERROR, { + __serverMessage: 'Internal error', + }); + mocks.sendGetAccountInfo.mockRejectedValueOnce(rpcError); const { GET } = await importRoute(); - const res = await GET(createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: VALID_ADDRESS })); + const res = await GET( + createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: ANCHOR_PROGRAM_ADDRESS }), + ); + + expect(res.status).toBe(502); + expect(await res.json()).toEqual({ error: 'Upstream RPC error' }); + expect(Logger.warn).toHaveBeenCalled(); + expect(Logger.panic).not.toHaveBeenCalled(); + }); + + it.each([ + ['HTTP 500 upstream sick', 500], + ['HTTP 502 bad gateway', 502], + ['HTTP 503 service unavailable', 503], + ['HTTP 429 rate-limited backpressure', 429], + ])('should treat %s as transient and warn without escalating', async (_label, statusCode) => { + const rpcError = new SolanaError(SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, { + headers: new Headers(), + message: 'upstream', + statusCode, + }); + mocks.sendGetAccountInfo.mockRejectedValueOnce(rpcError); + + const { GET } = await importRoute(); + const res = await GET( + createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: ANCHOR_PROGRAM_ADDRESS }), + ); + + expect(res.status).toBe(502); + expect(await res.json()).toEqual({ error: 'Upstream RPC error' }); + expect(Logger.warn).toHaveBeenCalled(); + expect(Logger.panic).not.toHaveBeenCalled(); + }); + + it.each([ + ['HTTP 401 wrong RPC token', 401], + ['HTTP 403 forbidden', 403], + ['HTTP 404 wrong endpoint', 404], + ])('should escalate %s as misconfiguration', async (_label, statusCode) => { + const rpcError = new SolanaError(SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, { + headers: new Headers(), + message: 'unauthorized', + statusCode, + }); + mocks.sendGetAccountInfo.mockRejectedValueOnce(rpcError); + + const { GET } = await importRoute(); + const res = await GET( + createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: ANCHOR_PROGRAM_ADDRESS }), + ); + + expect(res.status).toBe(502); + expect(await res.json()).toEqual({ error: 'Failed to fetch IDL' }); + expect(Logger.panic).toHaveBeenCalled(); + expect(Logger.warn).not.toHaveBeenCalled(); + }); + + it('should escalate when the RPC reports a missing API plan', async () => { + const rpcError = new SolanaError(SOLANA_ERROR__RPC__API_PLAN_MISSING_FOR_RPC_METHOD, { + method: 'getAccountInfo', + params: [], + }); + mocks.sendGetAccountInfo.mockRejectedValueOnce(rpcError); + + const { GET } = await importRoute(); + const res = await GET( + createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: ANCHOR_PROGRAM_ADDRESS }), + ); expect(res.status).toBe(502); expect(await res.json()).toEqual({ error: 'Failed to fetch IDL' }); + expect(Logger.panic).toHaveBeenCalled(); + }); + + it('should escalate when a proxy strips required RPC headers', async () => { + const rpcError = new SolanaError(SOLANA_ERROR__RPC__TRANSPORT_HTTP_HEADER_FORBIDDEN, { + headers: ['Solana-Client'], + }); + mocks.sendGetAccountInfo.mockRejectedValueOnce(rpcError); + + const { GET } = await importRoute(); + const res = await GET( + createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: ANCHOR_PROGRAM_ADDRESS }), + ); + + expect(res.status).toBe(502); + expect(Logger.panic).toHaveBeenCalled(); + }); + + it('should escalate when the RPC reports an unknown method', async () => { + const rpcError = new SolanaError(SOLANA_ERROR__JSON_RPC__METHOD_NOT_FOUND, { + __serverMessage: 'Method not found', + }); + mocks.sendGetAccountInfo.mockRejectedValueOnce(rpcError); + + const { GET } = await importRoute(); + const res = await GET( + createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: ANCHOR_PROGRAM_ADDRESS }), + ); + + expect(res.status).toBe(502); + expect(Logger.panic).toHaveBeenCalled(); }); - it('should return 502 with a generic message when fetchIdl throws', async () => { + it('should return 502 and escalate to Sentry on truly unexpected errors', async () => { const internalError = Object.assign(new Error('AccountNotFoundError'), { context: { rpcUrl: 'https://internal-rpc.company.com:8899' }, logs: ['Program log: secret stuff'], }); - vi.mocked(Program.fetchIdl).mockRejectedValueOnce(internalError); + mocks.sendGetAccountInfo.mockRejectedValueOnce(internalError); const { GET } = await importRoute(); - const res = await GET(createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: VALID_ADDRESS })); + const res = await GET( + createRequest({ cluster: String(Cluster.MainnetBeta), programAddress: ANCHOR_PROGRAM_ADDRESS }), + ); expect(res.status).toBe(502); const body = await res.json(); expect(body).toEqual({ error: 'Failed to fetch IDL' }); + expect(Logger.panic).toHaveBeenCalled(); - // Verify no internal details leaked + // Verify no internal details leaked into the response body. const bodyStr = JSON.stringify(body); expect(bodyStr).not.toContain('internal-rpc.company.com'); expect(bodyStr).not.toContain('secret stuff'); @@ -120,3 +320,30 @@ function createRequest(params: Record = {}) { async function importRoute() { return await import('../route'); } + +// Build a full IDL account: [8-byte Anchor discriminator] + borsh-encoded { authority, data } +// where data is zlib(JSON-stringified payload). The discriminator bytes are arbitrary — the +// route slices them off before decoding. Accepts arbitrary payloads so callers can also +// build accounts whose JSON is well-formed but not Idl-shaped. +function encodeIdlAccount(payload: unknown): Uint8Array { + const compressed = deflateSync(new TextEncoder().encode(JSON.stringify(payload))); + const body = encodeIdlAccountBorsh({ + authority: toLegacyPublicKey(SYSTEM_PROGRAM_ADDRESS), + data: compressed, + }); + const buf = new Uint8Array(8 + body.length); + buf.set(body, 8); + return buf; +} + +// Shape the JSON-RPC `getAccountInfo` value with `encoding: 'base64'` returns. +function accountValue(data: Uint8Array) { + return { + data: [getBase64Decoder().decode(data), 'base64'] as const, + executable: false, + lamports: 0n, + owner: SYSTEM_PROGRAM_ADDRESS, + rentEpoch: 0n, + space: BigInt(data.length), + }; +} diff --git a/app/api/anchor/config.ts b/app/api/anchor/config.ts new file mode 100644 index 000000000..31375457f --- /dev/null +++ b/app/api/anchor/config.ts @@ -0,0 +1,81 @@ +import { + SOLANA_ERROR__JSON_RPC__INTERNAL_ERROR, + SOLANA_ERROR__JSON_RPC__PARSE_ERROR, + SOLANA_ERROR__JSON_RPC__SCAN_ERROR, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_BLOCK_CLEANED_UP, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_BLOCK_NOT_AVAILABLE, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_BLOCK_STATUS_NOT_AVAILABLE_YET, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_KEY_EXCLUDED_FROM_SECONDARY_INDEX, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_LONG_TERM_STORAGE_SLOT_SKIPPED, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_MIN_CONTEXT_SLOT_NOT_REACHED, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_NO_SNAPSHOT, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_NODE_UNHEALTHY, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SLOT_SKIPPED, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_TRANSACTION_HISTORY_NOT_AVAILABLE, + type SolanaErrorCode, +} from '@solana/kit'; +import { COMPUTE_BUDGET_PROGRAM_ADDRESS } from '@solana-program/compute-budget'; +import { MEMO_PROGRAM_ADDRESS } from '@solana-program/memo'; +import { SYSTEM_PROGRAM_ADDRESS } from '@solana-program/system'; +import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; +import { TOKEN_2022_PROGRAM_ADDRESS } from '@solana-program/token-2022'; + +import { LOADER_IDS } from '@/app/utils/programs'; + +export const CACHE_MAX_AGE = 60 * 60; // 60 minutes + +export const CACHE_HEADERS = { + 'Cache-Control': `public, max-age=${CACHE_MAX_AGE}, s-maxage=${CACHE_MAX_AGE}, stale-while-revalidate=60`, +}; + +// Anchor's IDL account is a PDA derived from the program id with a fixed seed. +// This is part of Anchor's on-chain protocol contract, stable across versions. +export const IDL_ACCOUNT_SEED = 'anchor:idl'; + +// Programs that cannot have an Anchor IDL by definition (native runtime / well-known +// non-Anchor programs). Short-circuit before any RPC call so we never depend on the +// upstream returning `null` for the derived IDL PDA. Some RPCs have been observed to +// return transient JSON-RPC errors instead of `null` (notably the SIMD-296 testnet), +// which previously surfaced as Sentry panics for every transaction touching System, +// Token, or other builtins. +export const NON_ANCHOR_PROGRAMS = new Set([ + SYSTEM_PROGRAM_ADDRESS, + TOKEN_PROGRAM_ADDRESS, + TOKEN_2022_PROGRAM_ADDRESS, + COMPUTE_BUDGET_PROGRAM_ADDRESS, + MEMO_PROGRAM_ADDRESS, // v2 + ...Object.keys(LOADER_IDS), // BPF loaders, Move loader, Native loader + // No library exports for these: + 'AddressLookupTab1e1111111111111111111111111', + 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', // SPL Associated Token Account + 'Config1111111111111111111111111111111111111', + 'Ed25519SigVerify111111111111111111111111111', + 'KeccakSecp256k11111111111111111111111111111', + 'Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo', // SPL Memo v1 + 'Stake11111111111111111111111111111111111111', + 'Vote111111111111111111111111111111111111111', + 'ZkE1Gama1Proof11111111111111111111111111111', + 'ZkTokenProof1111111111111111111111111111111', +]); + +// SolanaError codes that represent ephemeral upstream-state issues for a `getAccountInfo` +// call: the node is briefly unhealthy, the slot was skipped, the snapshot/block isn't +// available yet, the JSON-RPC server returned a generic "Internal error", etc. They are +// not actionable at the app layer and recur naturally — warn and let the client retry. +// Everything outside this set (auth, plan, malformed responses, programmer-bug-class +// codes) falls through to `Logger.panic` so misconfiguration surfaces in Sentry. +export const TRANSIENT_RPC_ERROR_CODES = new Set([ + SOLANA_ERROR__JSON_RPC__INTERNAL_ERROR, + SOLANA_ERROR__JSON_RPC__PARSE_ERROR, + SOLANA_ERROR__JSON_RPC__SCAN_ERROR, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_BLOCK_CLEANED_UP, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_BLOCK_NOT_AVAILABLE, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_BLOCK_STATUS_NOT_AVAILABLE_YET, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_KEY_EXCLUDED_FROM_SECONDARY_INDEX, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_LONG_TERM_STORAGE_SLOT_SKIPPED, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_MIN_CONTEXT_SLOT_NOT_REACHED, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_NO_SNAPSHOT, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_NODE_UNHEALTHY, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SLOT_SKIPPED, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_TRANSACTION_HISTORY_NOT_AVAILABLE, +]); diff --git a/app/api/anchor/route.ts b/app/api/anchor/route.ts index 203498007..fb76693b8 100644 --- a/app/api/anchor/route.ts +++ b/app/api/anchor/route.ts @@ -1,20 +1,27 @@ -import { AnchorProvider, Idl, Program } from '@coral-xyz/anchor'; -import NodeWallet from '@coral-xyz/anchor/dist/cjs/nodewallet'; -import { Connection, Keypair, PublicKey } from '@solana/web3.js'; +import type { Idl } from '@coral-xyz/anchor'; +import { decodeIdlAccount } from '@coral-xyz/anchor/dist/cjs/idl'; +import { clusterFromParam } from '@entities/cluster/server'; +import { + type Address, + address, + createAddressWithSeed, + createSolanaRpc, + getBase64Encoder, + getProgramDerivedAddress, + isSolanaError, + type ReadonlyUint8Array, + type Rpc, + SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, + type SolanaError, + type SolanaRpcApi, +} from '@solana/kit'; import { NextResponse } from 'next/server'; +import { inflateSync } from 'zlib'; import { Logger } from '@/app/shared/lib/logger'; -import { Cluster, serverClusterUrl } from '@/app/utils/cluster'; +import { serverClusterUrl } from '@/app/utils/cluster'; -const CACHE_DURATION = 60 * 60; // 60 minutes - -const CACHE_HEADERS = { - 'Cache-Control': `public, max-age=${CACHE_DURATION}, s-maxage=${CACHE_DURATION}, stale-while-revalidate=60`, -}; - -function isBufferOutOfBounds(error: unknown): boolean { - return error instanceof RangeError && (error as NodeJS.ErrnoException).code === 'ERR_BUFFER_OUT_OF_BOUNDS'; -} +import { CACHE_HEADERS, IDL_ACCOUNT_SEED, NON_ANCHOR_PROGRAMS, TRANSIENT_RPC_ERROR_CODES } from './config'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); @@ -25,39 +32,102 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Invalid query params' }, { status: 400 }); } - const url = Number(clusterProp) in Cluster && serverClusterUrl(Number(clusterProp) as Cluster, ''); - + const cluster = clusterFromParam(clusterProp); + const url = cluster !== undefined ? serverClusterUrl(cluster, '') : undefined; if (!url) { return NextResponse.json({ error: 'Invalid cluster' }, { status: 400 }); } - let programId: PublicKey; + let programId: Address; try { - programId = new PublicKey(programAddress); + programId = address(programAddress); } catch { return NextResponse.json({ error: 'Invalid program address' }, { status: 400 }); } + if (NON_ANCHOR_PROGRAMS.has(programId)) { + return NextResponse.json({ idl: null }, { headers: CACHE_HEADERS, status: 200 }); + } + + let accountData: ReadonlyUint8Array | undefined; try { - const provider = new AnchorProvider(new Connection(url), new NodeWallet(Keypair.generate()), {}); - const idl = await Program.fetchIdl(programId, provider); - return NextResponse.json( - { idl }, - { - headers: CACHE_HEADERS, - status: 200, - }, - ); + accountData = await fetchIdlAccountData(createSolanaRpc(url), programId); } catch (error) { - if (isBufferOutOfBounds(error)) { - Logger.warn(`[api:anchor] Program ${programAddress} is not an Anchor program`); - return NextResponse.json({ idl: null }, { headers: CACHE_HEADERS, status: 200 }); + if (isSolanaError(error) && classifySolanaError(error) === 'transient') { + // Ephemeral upstream issue (node unhealthy, slot skipped, JSON-RPC "Internal error", + // 5xx/429, ...). Not actionable at the app layer; return 502 (uncached) for retry. + Logger.warn('[api:anchor] RPC error fetching IDL account', { + cluster: clusterProp, + programAddress, + rpcError: error.message, + }); + return NextResponse.json({ error: 'Upstream RPC error' }, { status: 502 }); } - // RPC failure means the request fundamentally failed — escalate to Sentry. - Logger.panic(new Error('[api:anchor] Failed to fetch IDL', { cause: error }), { + // Misconfiguration (wrong RPC token, missing API plan, method not supported, ...) or + // any other unexpected throwable. Escalate so Sentry pages us. + Logger.panic(new Error('[api:anchor] Failed to fetch IDL account', { cause: error }), { sentryExtras: { cluster: clusterProp, programAddress }, }); return NextResponse.json({ error: 'Failed to fetch IDL' }, { status: 502 }); } + + if (!accountData) { + // No IDL account at the derived PDA — not an Anchor program. Cache the negative result. + return NextResponse.json({ idl: null }, { headers: CACHE_HEADERS, status: 200 }); + } + + try { + return NextResponse.json({ idl: decodeIdl(accountData) }, { headers: CACHE_HEADERS, status: 200 }); + } catch (error) { + // Account exists but its bytes don't decode as a valid Anchor IDL (malformed + // borsh, bad zlib payload, or invalid JSON). Treat as non-Anchor and cache. + Logger.warn('[api:anchor] IDL account present but undecodable', { + cluster: clusterProp, + decodeError: error instanceof Error ? error.message : String(error), + programAddress, + }); + return NextResponse.json({ idl: null }, { headers: CACHE_HEADERS, status: 200 }); + } +} + +function classifySolanaError(error: SolanaError): 'transient' | 'misconfig' { + if (TRANSIENT_RPC_ERROR_CODES.has(error.context.__code)) return 'transient'; + if (isSolanaError(error, SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR)) { + // 5xx = upstream is sick, retryable. 429 = backpressure, also retryable. Everything + // else in the 4xx range (401/403 wrong token, 404 wrong URL, 410, ...) is persistent + // misconfig and should page. + const { statusCode } = error.context; + return statusCode >= 500 || statusCode === 429 ? 'transient' : 'misconfig'; + } + return 'misconfig'; +} + +async function fetchIdlAccountData( + rpc: Rpc, + programId: Address, +): Promise { + const idlAddr = await deriveIdlAddress(programId); + const { value } = await rpc.getAccountInfo(idlAddr, { encoding: 'base64' }).send(); + if (!value) return undefined; + const [base64Data] = value.data; + return getBase64Encoder().encode(base64Data); +} + +function decodeIdl(accountData: ReadonlyUint8Array): Idl { + // 8-byte Anchor discriminator + borsh-encoded { authority, data }; data is zlib(JSON). + // decodeIdlAccount's signature is typed with Buffer, so wrap the slice as a zero-copy + // Buffer view of the same memory. + const body = Buffer.from(accountData.buffer, accountData.byteOffset + 8, accountData.byteLength - 8); + const { data } = decodeIdlAccount(body); + const idl = JSON.parse(new TextDecoder().decode(inflateSync(data))); + if (!idl || typeof idl !== 'object' || !Array.isArray(idl.instructions)) { + throw new Error('Decoded IDL has unexpected shape'); + } + return idl; +} + +async function deriveIdlAddress(programId: Address): Promise
{ + const [base] = await getProgramDerivedAddress({ programAddress: programId, seeds: [] }); + return createAddressWithSeed({ baseAddress: base, programAddress: programId, seed: IDL_ACCOUNT_SEED }); } diff --git a/app/components/Header.tsx b/app/components/Header.tsx index ad92a236d..101996a1c 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -8,7 +8,7 @@ import { useCoinGeckoVerification, type VerificationTarget, } from '@/app/features/token-verification-badge'; -import { toAddress } from '@/app/shared/model/address'; +import { toKitAddress } from '@/app/shared/lib/web3js-compat'; import { isNativeMint, isTokenMintByOwner } from '@/app/shared/model/token-program'; type HeaderProps = ComponentProps; @@ -21,7 +21,7 @@ export function Header({ address, account, tokenInfo, isTokenInfoLoading }: Head parsedData && isTokenProgramData(parsedData) && parsedData?.parsed.type === 'mint' && - isTokenMintByOwner(toAddress(account.owner), account.data.raw); + isTokenMintByOwner(toKitAddress(account.owner), account.data.raw); const coinInfo = useCoinGeckoVerification(address, !!isTokenMint); diff --git a/app/entities/cluster/lib/__tests__/cluster-from-param.spec.ts b/app/entities/cluster/lib/__tests__/cluster-from-param.spec.ts new file mode 100644 index 000000000..5da8ad32d --- /dev/null +++ b/app/entities/cluster/lib/__tests__/cluster-from-param.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { Cluster } from '@/app/utils/cluster'; + +import { clusterFromParam } from '../cluster-from-param'; + +describe('clusterFromParam', () => { + it('should parse each known cluster value', () => { + expect(clusterFromParam('0')).toBe(Cluster.MainnetBeta); + expect(clusterFromParam('1')).toBe(Cluster.Testnet); + expect(clusterFromParam('2')).toBe(Cluster.Devnet); + expect(clusterFromParam('3')).toBe(Cluster.Simd296); + expect(clusterFromParam('4')).toBe(Cluster.Custom); + }); + + it('should return undefined for out-of-range numbers', () => { + expect(clusterFromParam('5')).toBeUndefined(); + expect(clusterFromParam('-1')).toBeUndefined(); + expect(clusterFromParam('999')).toBeUndefined(); + }); + + it('should return undefined for non-numeric strings', () => { + expect(clusterFromParam('mainnet-beta')).toBeUndefined(); + expect(clusterFromParam('')).toBeUndefined(); + expect(clusterFromParam('NaN')).toBeUndefined(); + }); +}); diff --git a/app/entities/cluster/lib/cluster-from-param.ts b/app/entities/cluster/lib/cluster-from-param.ts new file mode 100644 index 000000000..47264675b --- /dev/null +++ b/app/entities/cluster/lib/cluster-from-param.ts @@ -0,0 +1,11 @@ +import { Cluster, CLUSTERS } from '@/app/utils/cluster'; + +// Parse a cluster from a numeric query-param string (e.g. "0" → Cluster.MainnetBeta). +// Avoids `n in Cluster` checks, which depend on the enum's reverse mapping and behave +// inconsistently across build targets. The round-trip equality guards against `Number()` +// silently coercing empty / whitespace / leading-zero inputs (e.g. "" → 0). +export function clusterFromParam(value: string): Cluster | undefined { + const n = Number(value); + if (!Number.isInteger(n) || String(n) !== value) return undefined; + return CLUSTERS.find(c => c === n); +} diff --git a/app/entities/cluster/server.ts b/app/entities/cluster/server.ts new file mode 100644 index 000000000..631d5ce61 --- /dev/null +++ b/app/entities/cluster/server.ts @@ -0,0 +1 @@ +export { clusterFromParam } from './lib/cluster-from-param'; diff --git a/app/entities/idl/model/__tests__/use-anchor-program.spec.ts b/app/entities/idl/model/__tests__/use-anchor-program.spec.ts index 8a4c872f3..cc1542fc0 100644 --- a/app/entities/idl/model/__tests__/use-anchor-program.spec.ts +++ b/app/entities/idl/model/__tests__/use-anchor-program.spec.ts @@ -95,7 +95,7 @@ describe('Allow for useAnchorProgram to create program instance', () => { ])('should create %s program instance via hook', (fallbackId: string, idl: any) => { const programId = idl.metadata?.address ?? fallbackId; - vi.mocked(useIdlFromAnchorProgramSeed).mockReturnValue(idl); + vi.mocked(useIdlFromAnchorProgramSeed).mockReturnValue({ idl, isLoading: false }); vi.mocked(getProvider).mockReturnValue(createMockProvider(url, programId) as unknown as AnchorProvider); const { result } = renderHook(() => useAnchorProgram(programId, url, 2)); diff --git a/app/entities/idl/model/__tests__/use-idl-from-anchor-program-seed.test.ts b/app/entities/idl/model/__tests__/use-idl-from-anchor-program-seed.test.ts deleted file mode 100644 index 2cfdcb7f1..000000000 --- a/app/entities/idl/model/__tests__/use-idl-from-anchor-program-seed.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Program } from '@coral-xyz/anchor'; -import { PublicKey } from '@solana/web3.js'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { Cluster } from '@/app/utils/cluster'; - -import { resetCacheForTesting, useIdlFromAnchorProgramSeed } from '../use-idl-from-anchor-program-seed'; - -describe('useIdlFromAnchorProgramSeed', () => { - const url = 'https://any.rpc.address'; - - afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - resetCacheForTesting(); - }); - - it('should not fire duplicate /api/anchor requests for the same key while the request is in flight', () => { - const fetchMock = vi.fn(() => new Promise(() => {})); - vi.stubGlobal('fetch', fetchMock); - - const programAddress = PublicKey.unique().toBase58(); - - let firstThrown: unknown; - let secondThrown: unknown; - - try { - useIdlFromAnchorProgramSeed(programAddress, url, Cluster.MainnetBeta); - } catch (e) { - firstThrown = e; - } - try { - useIdlFromAnchorProgramSeed(programAddress, url, Cluster.MainnetBeta); - } catch (e) { - secondThrown = e; - } - - expect(firstThrown).toBeInstanceOf(Promise); - expect(secondThrown).toBe(firstThrown); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it('should not call Program.fetchIdl twice for the same key while the request is in flight on a Custom cluster', () => { - const fetchIdlSpy = vi.spyOn(Program, 'fetchIdl').mockReturnValue(new Promise(() => {})); - - const programAddress = PublicKey.unique().toBase58(); - - let firstThrown: unknown; - let secondThrown: unknown; - - try { - useIdlFromAnchorProgramSeed(programAddress, url, Cluster.Custom); - } catch (e) { - firstThrown = e; - } - try { - useIdlFromAnchorProgramSeed(programAddress, url, Cluster.Custom); - } catch (e) { - secondThrown = e; - } - - expect(firstThrown).toBeInstanceOf(Promise); - expect(secondThrown).toBe(firstThrown); - expect(fetchIdlSpy).toHaveBeenCalledTimes(1); - }); -}); diff --git a/app/entities/idl/model/use-anchor-program.ts b/app/entities/idl/model/use-anchor-program.ts index ab4ce444e..426308596 100644 --- a/app/entities/idl/model/use-anchor-program.ts +++ b/app/entities/idl/model/use-anchor-program.ts @@ -13,11 +13,10 @@ export function useAnchorProgram( programAddress: string, url: string, cluster?: Cluster, -): { program: Program | null; idl: Idl | null } { +): { program: Program | null; idl: Idl | null; isLoading: boolean } { // TODO(ngundotra): Rewrite this to be more efficient // const idlFromBinary = useIdlFromSolanaProgramBinary(programAddress); - const idlFromAnchorProgram = useIdlFromAnchorProgramSeed(programAddress, url, cluster); - const idl = idlFromAnchorProgram; + const { idl, isLoading } = useIdlFromAnchorProgramSeed(programAddress, url, cluster); const program: Program | null = useMemo(() => { if (!idl) return null; @@ -30,7 +29,7 @@ export function useAnchorProgram( } }, [idl, programAddress, url]); - return { idl, program }; + return { idl, isLoading, program }; } export type AnchorAccount = { diff --git a/app/entities/idl/model/use-idl-from-anchor-program-seed.ts b/app/entities/idl/model/use-idl-from-anchor-program-seed.ts index 734bcde43..5f4c831bc 100644 --- a/app/entities/idl/model/use-idl-from-anchor-program-seed.ts +++ b/app/entities/idl/model/use-idl-from-anchor-program-seed.ts @@ -1,80 +1,51 @@ 'use client'; -import { AnchorProvider, Idl, Program } from '@coral-xyz/anchor'; +import { AnchorProvider, type Idl, Program } from '@coral-xyz/anchor'; import NodeWallet from '@coral-xyz/anchor/dist/cjs/nodewallet'; import { Connection, Keypair, PublicKey } from '@solana/web3.js'; +import useSWRImmutable from 'swr/immutable'; +import { Logger } from '@/app/shared/lib/logger'; import { Cluster } from '@/app/utils/cluster'; -const cachedAnchorProgramPromises: Record< - string, - void | { __type: 'promise'; promise: Promise } | { __type: 'result'; result: Idl | null } -> = {}; - -export function getProvider(url: string) { - return new AnchorProvider(new Connection(url), new NodeWallet(Keypair.generate()), {}); -} - -function recordInFlight(key: string, promise: Promise) { - cachedAnchorProgramPromises[key] = { __type: 'promise', promise }; +type IdlSwrKey = readonly ['idl-anchor', string, Cluster, string]; + +export function useIdlFromAnchorProgramSeed( + programAddress: string, + url: string, + cluster?: Cluster, +): { idl: Idl | null; isLoading: boolean } { + const resolvedCluster = cluster ?? Cluster.MainnetBeta; + const swrKey: IdlSwrKey = ['idl-anchor', programAddress, resolvedCluster, url]; + // No suspense: it's rendered without a boundary in the transaction inspector, + // where a thrown promise rolls back the render tree. Callers use `isLoading` instead. + const { data, isLoading } = useSWRImmutable(swrKey, fetchIdlForProgram); + return { idl: data ?? null, isLoading }; } -export function resetCacheForTesting() { - for (const k of Object.keys(cachedAnchorProgramPromises)) { - delete cachedAnchorProgramPromises[k]; - } +export function getProvider(url: string): AnchorProvider { + return new AnchorProvider(new Connection(url), new NodeWallet(Keypair.generate()), {}); } -export function useIdlFromAnchorProgramSeed(programAddress: string, url: string, cluster?: Cluster): Idl | null { - const key = `${programAddress}-${url}`; - const cacheEntry = cachedAnchorProgramPromises[key]; - - if (cacheEntry === undefined) { - let promise; - cluster = cluster || Cluster.MainnetBeta; - if (cluster !== undefined && cluster !== Cluster.Custom) { - promise = fetch(`/api/anchor?programAddress=${programAddress}&cluster=${cluster}`) - .then(async result => { - return result - .json() - .then(({ idl, error }) => { - if (!idl) { - throw new Error(error || `IDL not found for program: ${programAddress.toString()}`); - } - cachedAnchorProgramPromises[key] = { - __type: 'result', - result: idl, - }; - }) - .catch(_ => { - cachedAnchorProgramPromises[key] = { __type: 'result', result: null }; - }); - }) - .catch(_ => { - cachedAnchorProgramPromises[key] = { __type: 'result', result: null }; - }); - recordInFlight(key, promise); - } else { - const programId = new PublicKey(programAddress); - promise = Program.fetchIdl(programId, getProvider(url)) - .then(idl => { - if (!idl) { - throw new Error(`IDL not found for program: ${programAddress.toString()}`); - } +async function fetchIdlForProgram([, programAddress, cluster, url]: IdlSwrKey): Promise { + try { + if (cluster === Cluster.Custom) { + return await Program.fetchIdl(new PublicKey(programAddress), getProvider(url)); + } - cachedAnchorProgramPromises[key] = { - __type: 'result', - result: idl, - }; - }) - .catch(_ => { - cachedAnchorProgramPromises[key] = { __type: 'result', result: null }; - }); - recordInFlight(key, promise); + const response = await fetch(`/api/anchor?programAddress=${programAddress}&cluster=${cluster}`); + if (!response.ok) { + Logger.warn('[idl] /api/anchor returned non-OK status', { + cluster, + programAddress, + status: response.status, + }); + return null; } - throw promise; - } else if (cacheEntry.__type === 'promise') { - throw cacheEntry.promise; + const { idl } = await response.json(); + return idl ?? null; + } catch (error) { + Logger.error(new Error('[idl] Error fetching Anchor IDL', { cause: error }), { cluster, programAddress }); + return null; } - return cacheEntry.result; } diff --git a/app/entities/program-metadata/model/useProgramCanonicalMetadata.tsx b/app/entities/program-metadata/model/useProgramCanonicalMetadata.tsx index f5a61fcd7..9b7ac1d1b 100644 --- a/app/entities/program-metadata/model/useProgramCanonicalMetadata.tsx +++ b/app/entities/program-metadata/model/useProgramCanonicalMetadata.tsx @@ -48,7 +48,7 @@ export function useProgramCanonicalMetadata( enabled: boolean, useSuspense = false, ) { - const { data } = useSWRImmutable( + const { data, isLoading } = useSWRImmutable( `program-metadata-${programAddress}-${url}-${seed}`, async () => { if (!enabled) { @@ -84,5 +84,5 @@ export function useProgramCanonicalMetadata( }, { suspense: useSuspense }, ); - return { programMetadata: data }; + return { isLoading, programMetadata: data }; } diff --git a/app/entities/program-metadata/model/useProgramMetadataCodamaIdl.tsx b/app/entities/program-metadata/model/useProgramMetadataCodamaIdl.tsx index 12d164e87..2a2f6510d 100644 --- a/app/entities/program-metadata/model/useProgramMetadataCodamaIdl.tsx +++ b/app/entities/program-metadata/model/useProgramMetadataCodamaIdl.tsx @@ -14,7 +14,7 @@ export function useProgramMetadataCodamaIdl( cluster: Cluster, useSuspense = false, ) { - const { programMetadata } = useProgramCanonicalMetadata( + const { isLoading, programMetadata } = useProgramCanonicalMetadata( programAddress, CODAMA_IDL_SEED, url, @@ -22,5 +22,5 @@ export function useProgramMetadataCodamaIdl( PMP_IDL_ENABLED, useSuspense, ); - return { codamaIdl: programMetadata as CodamaIdl | undefined }; + return { codamaIdl: programMetadata as CodamaIdl | undefined, isLoading }; } diff --git a/app/entities/program-metadata/model/useProgramMetadataIdl.tsx b/app/entities/program-metadata/model/useProgramMetadataIdl.tsx index f41d40c42..3b2dac9da 100644 --- a/app/entities/program-metadata/model/useProgramMetadataIdl.tsx +++ b/app/entities/program-metadata/model/useProgramMetadataIdl.tsx @@ -7,7 +7,7 @@ import { useProgramCanonicalMetadata } from './useProgramCanonicalMetadata'; const PMP_IDL_ENABLED = isEnvEnabled(process.env.NEXT_PUBLIC_PMP_IDL_ENABLED); export function useProgramMetadataIdl(programAddress: string, url: string, cluster: Cluster, useSuspense = false) { - const { programMetadata } = useProgramCanonicalMetadata( + const { isLoading, programMetadata } = useProgramCanonicalMetadata( programAddress, IDL_SEED, url, @@ -15,5 +15,5 @@ export function useProgramMetadataIdl(programAddress: string, url: string, clust PMP_IDL_ENABLED, useSuspense, ); - return { programMetadataIdl: programMetadata }; + return { isLoading, programMetadataIdl: programMetadata }; } diff --git a/app/features/idl/ui/IdlCard.tsx b/app/features/idl/ui/IdlCard.tsx index 4cad0a690..1d0164cc8 100644 --- a/app/features/idl/ui/IdlCard.tsx +++ b/app/features/idl/ui/IdlCard.tsx @@ -1,4 +1,5 @@ 'use client'; +import { LoadingCard } from '@components/common/LoadingCard'; import { getIdlVersion, isIdlProgramIdMismatch, type SupportedIdl, useAnchorProgram } from '@entities/idl'; import { useProgramMetadataCodamaIdl, useProgramMetadataIdl } from '@entities/program-metadata'; import { useCluster } from '@providers/cluster'; @@ -24,9 +25,14 @@ type IdlTab = { export function IdlCard({ programId }: { programId: string }) { const { url, cluster } = useCluster(); const network = clusterSlug(cluster); - const { idl } = useAnchorProgram(programId, url, cluster); - const { programMetadataIdl } = useProgramMetadataIdl(programId, url, cluster); - const { codamaIdl } = useProgramMetadataCodamaIdl(programId, url, cluster); + const { idl, isLoading: isAnchorIdlLoading } = useAnchorProgram(programId, url, cluster); + const { programMetadataIdl, isLoading: isProgramMetadataIdlLoading } = useProgramMetadataIdl( + programId, + url, + cluster, + ); + const { codamaIdl, isLoading: isCodamaIdlLoading } = useProgramMetadataCodamaIdl(programId, url, cluster); + const isAnyIdlLoading = isAnchorIdlLoading || isProgramMetadataIdlLoading || isCodamaIdlLoading; const [activeTabIndex, setActiveTabIndex] = useState(); const [searchStr, setSearchStr] = useState(''); @@ -82,6 +88,9 @@ export function IdlCard({ programId }: { programId: string }) { }, [tabs, activeTabIndex]); if (tabs.length === 0 || activeTabIndex === undefined) { + if (isAnyIdlLoading || tabs.length > 0) { + return ; + } return (
diff --git a/app/features/idl/ui/__tests__/IdlCard.spec.tsx b/app/features/idl/ui/__tests__/IdlCard.spec.tsx index 52785d492..704fead79 100644 --- a/app/features/idl/ui/__tests__/IdlCard.spec.tsx +++ b/app/features/idl/ui/__tests__/IdlCard.spec.tsx @@ -109,10 +109,12 @@ describe('IdlCard', () => { test('should render IdlCard with PMP IDL when programMetadataIdl exists', async () => { vi.spyOn(anchorModule, 'useAnchorProgram').mockReturnValue({ idl: null, + isLoading: false, program: null, }); vi.spyOn(programMetadataIdlModule, 'useProgramMetadataIdl').mockReturnValue({ + isLoading: false, programMetadataIdl: createMockProgramMetadataIdl(), }); @@ -157,10 +159,12 @@ describe('IdlCard', () => { test('should render IdlCard with Anchor IDL when anchorIdl exists', async () => { vi.spyOn(anchorModule, 'useAnchorProgram').mockReturnValue({ idl: createMockAnchorIdl(), + isLoading: false, program: null, }); vi.spyOn(programMetadataIdlModule, 'useProgramMetadataIdl').mockReturnValue({ + isLoading: false, programMetadataIdl: null, }); @@ -205,10 +209,12 @@ describe('IdlCard', () => { test('should render IdlCard tabs when both IDLs exist', async () => { vi.spyOn(anchorModule, 'useAnchorProgram').mockReturnValue({ idl: createMockAnchorIdl(), + isLoading: false, program: null, }); vi.spyOn(programMetadataIdlModule, 'useProgramMetadataIdl').mockReturnValue({ + isLoading: false, programMetadataIdl: createMockProgramMetadataIdl(), }); @@ -230,10 +236,12 @@ describe('IdlCard', () => { test('should render BaseWarningCard when Anchor IDL address mismatches programId', async () => { vi.spyOn(anchorModule, 'useAnchorProgram').mockReturnValue({ idl: createMockAnchorIdl(Keypair.generate().publicKey.toBase58()), // imitate malicious IDL + isLoading: false, program: null, }); vi.spyOn(programMetadataIdlModule, 'useProgramMetadataIdl').mockReturnValue({ + isLoading: false, programMetadataIdl: createMockAnchorIdl(), // but use normal one for PMP program }); @@ -265,10 +273,12 @@ describe('IdlCard', () => { test('should not render IdlCard when both IDLs are null', async () => { vi.spyOn(anchorModule, 'useAnchorProgram').mockReturnValue({ idl: null, + isLoading: false, program: null, }); vi.spyOn(programMetadataIdlModule, 'useProgramMetadataIdl').mockReturnValue({ + isLoading: false, programMetadataIdl: null, }); diff --git a/app/shared/model/address.ts b/app/shared/model/address.ts deleted file mode 100644 index c1dc1b485..000000000 --- a/app/shared/model/address.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { type Address, address } from '@solana/kit'; -import type { PublicKey } from '@solana/web3.js'; - -export function toAddress(publicKey: PublicKey): Address { - return address(publicKey.toBase58()); -} diff --git a/bench/BUILD.md b/bench/BUILD.md index b2f6636ec..9ccc20558 100644 --- a/bench/BUILD.md +++ b/bench/BUILD.md @@ -22,10 +22,10 @@ | Dynamic | `/address/[address]/program-multisig` | 10 kB | 1.26 MB | | Dynamic | `/address/[address]/rewards` | 10 kB | 1.20 MB | | Dynamic | `/address/[address]/security` | 10 kB | 1.32 MB | -| Dynamic | `/address/[address]/slot-hashes` | 890 B | 1.20 MB | +| Dynamic | `/address/[address]/slot-hashes` | 880 B | 1.20 MB | | Dynamic | `/address/[address]/stake-history` | 440 B | 1.20 MB | | Dynamic | `/address/[address]/token-extensions` | 10 kB | 1.23 MB | -| Dynamic | `/address/[address]/tokens` | 10 kB | 1.38 MB | +| Dynamic | `/address/[address]/tokens` | 10 kB | 1.37 MB | | Dynamic | `/address/[address]/transfers` | 10 kB | 1.28 MB | | Dynamic | `/address/[address]/verified-build` | 10 kB | 1.26 MB | | Dynamic | `/address/[address]/vote-history` | 920 B | 1.20 MB | @@ -56,4 +56,4 @@ | Static | `/tos` | 180 B | 190 kB | | Dynamic | `/tx/[signature]` | 70 kB | 1.66 MB | | Dynamic | `/tx/[signature]/inspect` | 430 B | 1.44 MB | -| Static | `/tx/inspector` | 410 B | 1.44 MB | \ No newline at end of file +| Static | `/tx/inspector` | 420 B | 1.44 MB | \ No newline at end of file