diff --git a/app/api/fetch-idl/route.ts b/app/api/fetch-idl/route.ts index c505c71..9f92693 100644 --- a/app/api/fetch-idl/route.ts +++ b/app/api/fetch-idl/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { fetchIdl, DEFAULT_RPC_URL } from "@/lib/fetch-idl"; +import { isIdlSource } from "@/lib/idl-source"; import { rateLimit, getIp } from "@/lib/rate-limit"; export const runtime = "nodejs"; @@ -22,7 +23,7 @@ export async function POST(req: NextRequest) { ); } - let body: { programId?: string }; + let body: { idlSource?: string; programId?: string }; try { body = await req.json(); } catch { @@ -30,6 +31,7 @@ export async function POST(req: NextRequest) { } const { programId } = body; + const idlSource = body.idlSource == null ? "auto" : body.idlSource; if (!programId || typeof programId !== "string") { return NextResponse.json( @@ -38,10 +40,17 @@ export async function POST(req: NextRequest) { ); } + if (!isIdlSource(idlSource)) { + return NextResponse.json( + { error: "Missing or invalid `idlSource` field" }, + { status: 400 } + ); + } + const resolvedRpcUrl = process.env.SOLANA_RPC_URL || DEFAULT_RPC_URL; try { - const idl = await fetchIdl(programId.trim(), resolvedRpcUrl); + const idl = await fetchIdl(programId.trim(), resolvedRpcUrl, idlSource); return NextResponse.json( { idl }, { headers: { "X-RateLimit-Remaining": String(remaining) } } diff --git a/app/page.tsx b/app/page.tsx index a907b68..179c4cf 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { fetchIdl, DEFAULT_RPC_URL } from "@/lib/fetch-idl"; +import { getIdlSourceFromSearchParams, type IdlSource } from "@/lib/idl-source"; import { ProgramIdInput } from "@/components/ProgramIdInput"; import { PresetSelector } from "@/components/PresetSelector"; import { IdlViewer } from "@/components/IdlViewer"; @@ -10,6 +11,7 @@ import { BrandIcon } from "@/components/BrandIcon"; export default function Home() { const [programId, setProgramId] = useState(""); + const [idlSource, setIdlSource] = useState("auto"); const [rpcUrl, setRpcUrl] = useState(DEFAULT_RPC_URL); const [showRpc, setShowRpc] = useState(false); const [loading, setLoading] = useState(false); @@ -18,18 +20,26 @@ export default function Home() { // Accept an optional explicit ID so we can call this from the URL-state // effect before React has flushed the programId state update. - async function handleFetch(explicitId?: string) { + async function handleFetch(explicitId?: string, explicitSource?: IdlSource) { const nextProgramId = typeof explicitId === "string" ? explicitId : programId; + const nextIdlSource = + typeof explicitSource === "string" ? explicitSource : idlSource; const id = nextProgramId.trim(); if (!id) return; // Sync input state if we were called with an explicit ID (e.g. from URL) if (typeof explicitId === "string") setProgramId(explicitId); + if (typeof explicitSource === "string") setIdlSource(explicitSource); // Persist the program ID in the URL so the result is shareable const url = new URL(window.location.href); url.searchParams.set("program", id); + if (nextIdlSource === "auto") { + url.searchParams.delete("idlSource"); + } else { + url.searchParams.set("idlSource", nextIdlSource); + } window.history.replaceState(null, "", url.toString()); setLoading(true); @@ -41,13 +51,13 @@ export default function Home() { if (rpcUrl !== DEFAULT_RPC_URL) { // Custom RPC: call directly from the browser so the URL never leaves the client - result = await fetchIdl(id, rpcUrl); + result = await fetchIdl(id, rpcUrl, nextIdlSource); } else { // Default: let the server use its configured RPC const res = await fetch("/api/fetch-idl", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ programId: id }), + body: JSON.stringify({ programId: id, idlSource: nextIdlSource }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "Failed to fetch IDL"); @@ -66,8 +76,9 @@ export default function Home() { useEffect(() => { const params = new URLSearchParams(window.location.search); const program = params.get("program"); + const nextIdlSource = getIdlSourceFromSearchParams(params); if (program?.trim()) { - handleFetch(program.trim()); + handleFetch(program.trim(), nextIdlSource); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/lib/fetch-idl.ts b/lib/fetch-idl.ts index 49977b8..ecbfd2a 100644 --- a/lib/fetch-idl.ts +++ b/lib/fetch-idl.ts @@ -1,21 +1,82 @@ import { - createSolanaRpc, address, createAddressWithSeed, - getProgramDerivedAddress, + createSolanaRpc, fetchEncodedAccount, + getProgramDerivedAddress, } from "@solana/kit"; +import { + fetchAndParseMetadataContent, + findMetadataPda, +} from "@solana-program/program-metadata"; import { inflate } from "pako"; +import type { ExplicitIdlSource, IdlSource } from "./idl-source"; export const DEFAULT_RPC_URL = "https://api.mainnet-beta.solana.com"; +const NO_COMPATIBLE_IDL_MESSAGE = + "No compatible on-chain IDL was found for this program. The IDL may not have been uploaded, or the program may use an unsupported IDL layout."; + +class MissingIdlError extends Error { + readonly source: ExplicitIdlSource; + + constructor(source: ExplicitIdlSource) { + super(NO_COMPATIBLE_IDL_MESSAGE); + this.name = "MissingIdlError"; + this.source = source; + } +} + +type SolanaRpc = ReturnType; + +type AnchorIdlAccount = { + accountAddress: string; + data: Uint8Array; +}; + +type ProgramMetadataIdl = { + accountAddress: string; + idl: unknown; +}; + +export type FetchIdlDeps = { + createRpc: (rpcUrl: string) => SolanaRpc; + fetchAnchorIdlAccount: ( + rpc: SolanaRpc, + programId: string + ) => Promise; + fetchProgramMetadataIdl: ( + rpc: SolanaRpc, + programId: string + ) => Promise; + getLatestUpdateTimestamp: ( + rpc: SolanaRpc, + accountAddress: string + ) => Promise; +}; + +type SuccessfulCandidate = { + accountAddress: string; + idl: unknown; + source: ExplicitIdlSource; + updatedAt: number | null; +}; + +type FailedCandidate = { + error: Error; + source: ExplicitIdlSource; +}; + +type IdlCandidate = SuccessfulCandidate | FailedCandidate; + // Anchor stores IDLs at: createWithSeed(findProgramAddress([], programId), "anchor:idl", programId) -async function getIdlAddress(programId: string): Promise { +async function getAnchorIdlAddress(programId: string): Promise { const programAddress = address(programId); const [base] = await getProgramDerivedAddress({ programAddress, seeds: [], }); + return createAddressWithSeed({ baseAddress: base, programAddress, @@ -23,6 +84,18 @@ async function getIdlAddress(programId: string): Promise { }); } +async function getProgramMetadataIdlAddress( + programId: string +): Promise { + const [metadataAddress] = await findMetadataPda({ + authority: null, + program: address(programId), + seed: "idl", + }); + + return metadataAddress; +} + // IDL account layout (after 8-byte discriminator): // 32 bytes: authority (Pubkey) // 4 bytes: data_len (u32 LE) @@ -44,26 +117,237 @@ export function decodeIdlAccountData(raw: Uint8Array): unknown { return JSON.parse(new TextDecoder().decode(inflated)); } +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function normalizeProgramMetadataIdl(idl: unknown): unknown { + if (typeof idl !== "string") { + return idl; + } + + const trimmed = idl.trim(); + if ( + !(trimmed.startsWith("{") && trimmed.endsWith("}")) && + !(trimmed.startsWith("[") && trimmed.endsWith("]")) + ) { + return idl; + } + + try { + return JSON.parse(trimmed); + } catch { + return idl; + } +} + +function isMissingIdlError(error: Error): boolean { + return ( + error instanceof MissingIdlError || + error.message === NO_COMPATIBLE_IDL_MESSAGE + ); +} + +function isSuccessfulCandidate( + candidate: IdlCandidate +): candidate is SuccessfulCandidate { + return "idl" in candidate; +} + +function pickAutoCandidate( + anchorCandidate: SuccessfulCandidate, + programMetadataCandidate: SuccessfulCandidate +): SuccessfulCandidate { + const anchorTimestamp = anchorCandidate.updatedAt ?? Number.NEGATIVE_INFINITY; + const programMetadataTimestamp = + programMetadataCandidate.updatedAt ?? Number.NEGATIVE_INFINITY; + + if (programMetadataTimestamp >= anchorTimestamp) { + return programMetadataCandidate; + } + + return anchorCandidate; +} + +async function defaultFetchAnchorIdlAccount( + rpc: SolanaRpc, + programId: string +): Promise { + const accountAddress = await getAnchorIdlAddress(programId); + const account = await fetchEncodedAccount(rpc, address(accountAddress)); + + if (!account.exists) { + throw new MissingIdlError("anchor"); + } + + return { + accountAddress, + data: account.data, + }; +} + +async function defaultFetchProgramMetadataIdl( + rpc: SolanaRpc, + programId: string +): Promise { + const accountAddress = await getProgramMetadataIdlAddress(programId); + const metadataAccount = await fetchEncodedAccount( + rpc, + address(accountAddress) + ); + + if (!metadataAccount.exists) { + throw new MissingIdlError("program-metadata"); + } + + return { + accountAddress, + idl: normalizeProgramMetadataIdl( + await fetchAndParseMetadataContent(rpc, address(programId), "idl") + ), + }; +} + +async function defaultGetLatestUpdateTimestamp( + rpc: SolanaRpc, + accountAddress: string +): Promise { + const signatures = await rpc + .getSignaturesForAddress(address(accountAddress), { limit: 1 }) + .send(); + + const latest = signatures[0]; + return latest?.blockTime == null ? null : Number(latest.blockTime); +} + +function getFetchIdlDeps(overrides: Partial = {}): FetchIdlDeps { + return { + createRpc: createSolanaRpc, + fetchAnchorIdlAccount: defaultFetchAnchorIdlAccount, + fetchProgramMetadataIdl: defaultFetchProgramMetadataIdl, + getLatestUpdateTimestamp: defaultGetLatestUpdateTimestamp, + ...overrides, + }; +} + +async function loadAnchorCandidate( + rpc: SolanaRpc, + programId: string, + deps: FetchIdlDeps +): Promise { + try { + const account = await deps.fetchAnchorIdlAccount(rpc, programId); + const [updatedAt, idl] = await Promise.all([ + deps.getLatestUpdateTimestamp(rpc, account.accountAddress), + Promise.resolve(decodeIdlAccountData(account.data)), + ]); + + return { + accountAddress: account.accountAddress, + idl, + source: "anchor", + updatedAt, + }; + } catch (error) { + return { error: asError(error), source: "anchor" }; + } +} + +async function loadProgramMetadataCandidate( + rpc: SolanaRpc, + programId: string, + deps: FetchIdlDeps +): Promise { + try { + const result = await deps.fetchProgramMetadataIdl(rpc, programId); + const updatedAt = await deps.getLatestUpdateTimestamp( + rpc, + result.accountAddress + ); + + return { + accountAddress: result.accountAddress, + idl: normalizeProgramMetadataIdl(result.idl), + source: "program-metadata", + updatedAt, + }; + } catch (error) { + return { error: asError(error), source: "program-metadata" }; + } +} + +async function fetchFromExplicitSource( + rpc: SolanaRpc, + programId: string, + idlSource: ExplicitIdlSource, + deps: FetchIdlDeps +): Promise { + if (idlSource === "anchor") { + const account = await deps.fetchAnchorIdlAccount(rpc, programId); + return decodeIdlAccountData(account.data); + } + + const result = await deps.fetchProgramMetadataIdl(rpc, programId); + return normalizeProgramMetadataIdl(result.idl); +} + +function getAutoFetchError( + anchorCandidate: FailedCandidate, + programMetadataCandidate: FailedCandidate +): Error { + const preferredError = [programMetadataCandidate.error, anchorCandidate.error] + .filter((error) => !isMissingIdlError(error)) + .at(0); + + return preferredError ?? new Error(NO_COMPATIBLE_IDL_MESSAGE); +} + export async function fetchIdl( programId: string, - rpcUrl = DEFAULT_RPC_URL + rpcUrl = DEFAULT_RPC_URL, + idlSource: IdlSource = "auto" +): Promise { + return fetchIdlWithDeps(programId, rpcUrl, idlSource); +} + +export async function fetchIdlWithDeps( + programId: string, + rpcUrl = DEFAULT_RPC_URL, + idlSource: IdlSource = "auto", + overrides: Partial = {} ): Promise { - // Validate it looks like a base58 address if (!programId || programId.length < 32 || programId.length > 44) { throw new Error( "Invalid program ID. Must be a valid Solana base58 address." ); } - const rpc = createSolanaRpc(rpcUrl); - const idlAddr = await getIdlAddress(programId); - const account = await fetchEncodedAccount(rpc, address(idlAddr)); + const deps = getFetchIdlDeps(overrides); + const rpc = deps.createRpc(rpcUrl); - if (!account.exists) { - throw new Error( - "No compatible on-chain IDL was found for this program. The IDL may not have been uploaded, or the program may use an unsupported IDL layout." - ); + if (idlSource !== "auto") { + return fetchFromExplicitSource(rpc, programId, idlSource, deps); + } + + const [anchorCandidate, programMetadataCandidate] = await Promise.all([ + loadAnchorCandidate(rpc, programId, deps), + loadProgramMetadataCandidate(rpc, programId, deps), + ]); + + if ( + isSuccessfulCandidate(anchorCandidate) && + isSuccessfulCandidate(programMetadataCandidate) + ) { + return pickAutoCandidate(anchorCandidate, programMetadataCandidate).idl; + } + + if (isSuccessfulCandidate(programMetadataCandidate)) { + return programMetadataCandidate.idl; + } + + if (isSuccessfulCandidate(anchorCandidate)) { + return anchorCandidate.idl; } - return decodeIdlAccountData(account.data); + throw getAutoFetchError(anchorCandidate, programMetadataCandidate); } diff --git a/lib/idl-source.ts b/lib/idl-source.ts new file mode 100644 index 0000000..d297bbf --- /dev/null +++ b/lib/idl-source.ts @@ -0,0 +1,18 @@ +export const IDL_SOURCES = ["auto", "anchor", "program-metadata"] as const; + +export type IdlSource = (typeof IDL_SOURCES)[number]; +export type ExplicitIdlSource = Exclude; + +export function isIdlSource(value: unknown): value is IdlSource { + return typeof value === "string" && IDL_SOURCES.includes(value as IdlSource); +} + +export function normalizeIdlSource(value: unknown): IdlSource { + return isIdlSource(value) ? value : "auto"; +} + +export function getIdlSourceFromSearchParams( + params: URLSearchParams +): IdlSource { + return normalizeIdlSource(params.get("idlSource")); +} diff --git a/package-lock.json b/package-lock.json index f18ced1..c5e008c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@codama/renderers-js-umi": "^1.1.24", "@codama/renderers-rust": "^3.0.0", "@limechain/codama-dart": "^0.1.1", + "@solana-program/program-metadata": "^0.5.1", "@solana/kit": "^6.3.1", "@vercel/analytics": "^2.0.1", "codama": "^1.5.1", @@ -1875,6 +1876,54 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@solana-program/compute-budget": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@solana-program/compute-budget/-/compute-budget-0.13.0.tgz", + "integrity": "sha512-jdiiWaxFG3kEf6bYPNo2mwz2jNxaj7sF+gZIb8wHw9zK3ZILmpkg4sUeChb1BnH2UGf+HgYb9L/lMdqOTqUoWA==", + "license": "Apache-2.0", + "peerDependencies": { + "@solana/kit": "^6.0.0" + } + }, + "node_modules/@solana-program/program-metadata": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@solana-program/program-metadata/-/program-metadata-0.5.1.tgz", + "integrity": "sha512-KORPOJI4+/kdL5BsSub4cU/F84dhM9tEl74J4t+OvobCHcE4SqpPBHFG4VoR/5eRUWr59MfdeTzlFVdYJiCNhw==", + "license": "MIT", + "dependencies": { + "@iarna/toml": "^2.2.5", + "@solana-program/compute-budget": "^0.13.0", + "@solana-program/system": "^0.11.0", + "commander": "^13.0.0", + "pako": "^2.1.0", + "picocolors": "^1.1.1", + "yaml": "^2.7.0" + }, + "bin": { + "program-metadata": "bin/cli.cjs" + }, + "peerDependencies": { + "@solana/kit": "^6.0.0" + } + }, + "node_modules/@solana-program/program-metadata/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@solana-program/system": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@solana-program/system/-/system-0.11.0.tgz", + "integrity": "sha512-SJeQVTkqGZzIXd7XHlCxnfpKpvPZghB1IFwddPPG04ydVXtDLRWp9wLoTR5Prkl9FIWRe/c5VgT4nxyzW1cAuQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@solana/kit": "^6.0.0" + } + }, "node_modules/@solana/accounts": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/@solana/accounts/-/accounts-6.3.1.tgz", @@ -8938,6 +8987,21 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 43c7ac2..6aac28d 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@codama/renderers-js-umi": "^1.1.24", "@codama/renderers-rust": "^3.0.0", "@limechain/codama-dart": "^0.1.1", + "@solana-program/program-metadata": "^0.5.1", "@solana/kit": "^6.3.1", "@vercel/analytics": "^2.0.1", "codama": "^1.5.1", diff --git a/test/fetch-idl.test.ts b/test/fetch-idl.test.ts index f49ae1e..8bc0757 100644 --- a/test/fetch-idl.test.ts +++ b/test/fetch-idl.test.ts @@ -1,17 +1,16 @@ import test from "node:test"; import assert from "node:assert/strict"; import { deflate } from "pako"; -import { decodeIdlAccountData } from "../lib/fetch-idl"; +import { + decodeIdlAccountData, + fetchIdlWithDeps, + type FetchIdlDeps, +} from "../lib/fetch-idl"; +import type { IdlSource } from "../lib/idl-source"; -test("decodeIdlAccountData inflates and parses compatible Solana IDL account data", () => { - const idl = { - address: "11111111111111111111111111111111", - metadata: { name: "counter", version: "0.1.0", spec: "0.1.0" }, - instructions: [], - accounts: [], - types: [], - }; +const PROGRAM_ID = "11111111111111111111111111111111"; +function encodeAnchorIdlAccountData(idl: unknown): Uint8Array { const compressed = deflate(JSON.stringify(idl)); const raw = new Uint8Array(8 + 32 + 4 + compressed.length); const view = new DataView(raw.buffer); @@ -19,7 +18,67 @@ test("decodeIdlAccountData inflates and parses compatible Solana IDL account dat view.setUint32(40, compressed.length, true); raw.set(compressed, 44); - assert.deepEqual(decodeIdlAccountData(raw), idl); + return raw; +} + +function createFetchIdlDeps(input: { + anchorData?: Uint8Array; + anchorError?: Error; + anchorUpdatedAt?: number | null; + programMetadataError?: Error; + programMetadataIdl?: unknown; + programMetadataUpdatedAt?: number | null; +}) { + const calls: IdlSource[] = []; + + return { + calls, + deps: { + createRpc: (() => ({})) as unknown as FetchIdlDeps["createRpc"], + fetchAnchorIdlAccount: async () => { + calls.push("anchor"); + if (input.anchorError) throw input.anchorError; + if (!input.anchorData) throw new Error("Missing anchor test data"); + return { + accountAddress: "Anchor111111111111111111111111111111111", + data: input.anchorData, + }; + }, + fetchProgramMetadataIdl: async () => { + calls.push("program-metadata"); + if (input.programMetadataError) throw input.programMetadataError; + if (input.programMetadataIdl === undefined) { + throw new Error("Missing program metadata test data"); + } + return { + accountAddress: "Meta11111111111111111111111111111111111", + idl: input.programMetadataIdl, + }; + }, + getLatestUpdateTimestamp: async ( + _rpc: unknown, + accountAddress: string + ) => { + if (accountAddress.startsWith("Anchor")) { + return input.anchorUpdatedAt ?? null; + } + + return input.programMetadataUpdatedAt ?? null; + }, + }, + }; +} + +test("decodeIdlAccountData inflates and parses compatible Solana IDL account data", () => { + const idl = { + accounts: [], + address: PROGRAM_ID, + instructions: [], + metadata: { name: "counter", spec: "0.1.0", version: "0.1.0" }, + types: [], + }; + + assert.deepEqual(decodeIdlAccountData(encodeAnchorIdlAccountData(idl)), idl); }); test("decodeIdlAccountData rejects account data that is too short", () => { @@ -28,3 +87,147 @@ test("decodeIdlAccountData rejects account data that is too short", () => { /too short to be valid/i ); }); + +test("fetchIdlWithDeps uses the Anchor path when idlSource=anchor", async () => { + const anchorIdl = { metadata: { name: "anchor" } }; + const { calls, deps } = createFetchIdlDeps({ + anchorData: encodeAnchorIdlAccountData(anchorIdl), + }); + + const idl = await fetchIdlWithDeps(PROGRAM_ID, undefined, "anchor", deps); + + assert.deepEqual(idl, anchorIdl); + assert.deepEqual(calls, ["anchor"]); +}); + +test("fetchIdlWithDeps uses the program metadata path when idlSource=program-metadata", async () => { + const programMetadataIdl = { metadata: { name: "pmp" } }; + const { calls, deps } = createFetchIdlDeps({ + programMetadataIdl, + }); + + const idl = await fetchIdlWithDeps( + PROGRAM_ID, + undefined, + "program-metadata", + deps + ); + + assert.deepEqual(idl, programMetadataIdl); + assert.deepEqual(calls, ["program-metadata"]); +}); + +test("fetchIdlWithDeps parses JSON-string program metadata IDLs", async () => { + const programMetadataIdl = JSON.stringify({ + metadata: { name: "pmp-json" }, + instructions: [], + }); + const { deps } = createFetchIdlDeps({ + programMetadataIdl, + }); + + const idl = await fetchIdlWithDeps( + PROGRAM_ID, + undefined, + "program-metadata", + deps + ); + + assert.deepEqual(idl, { + metadata: { name: "pmp-json" }, + instructions: [], + }); +}); + +test("fetchIdlWithDeps leaves non-JSON program metadata strings unchanged", async () => { + const programMetadataIdl = "plain-text-idl"; + const { deps } = createFetchIdlDeps({ + programMetadataIdl, + }); + + const idl = await fetchIdlWithDeps( + PROGRAM_ID, + undefined, + "program-metadata", + deps + ); + + assert.equal(idl, programMetadataIdl); +}); + +test("fetchIdlWithDeps prefers the newer source in auto mode", async () => { + const anchorIdl = { metadata: { name: "anchor" } }; + const programMetadataIdl = { metadata: { name: "pmp" } }; + const { deps } = createFetchIdlDeps({ + anchorData: encodeAnchorIdlAccountData(anchorIdl), + anchorUpdatedAt: 100, + programMetadataIdl, + programMetadataUpdatedAt: 200, + }); + + const idl = await fetchIdlWithDeps(PROGRAM_ID, undefined, "auto", deps); + + assert.deepEqual(idl, programMetadataIdl); +}); + +test("fetchIdlWithDeps prefers program metadata on timestamp ties in auto mode", async () => { + const anchorIdl = { metadata: { name: "anchor" } }; + const programMetadataIdl = { metadata: { name: "pmp" } }; + const { deps } = createFetchIdlDeps({ + anchorData: encodeAnchorIdlAccountData(anchorIdl), + anchorUpdatedAt: 100, + programMetadataIdl, + programMetadataUpdatedAt: 100, + }); + + const idl = await fetchIdlWithDeps(PROGRAM_ID, undefined, "auto", deps); + + assert.deepEqual(idl, programMetadataIdl); +}); + +test("fetchIdlWithDeps falls back to Anchor when program metadata is missing in auto mode", async () => { + const anchorIdl = { metadata: { name: "anchor" } }; + const { deps } = createFetchIdlDeps({ + anchorData: encodeAnchorIdlAccountData(anchorIdl), + anchorUpdatedAt: 100, + programMetadataError: new Error( + "No compatible on-chain IDL was found for this program." + ), + }); + + const idl = await fetchIdlWithDeps(PROGRAM_ID, undefined, "auto", deps); + + assert.deepEqual(idl, anchorIdl); +}); + +test("fetchIdlWithDeps propagates a real error when auto mode has no usable IDL", async () => { + const { deps } = createFetchIdlDeps({ + anchorError: new Error( + "No compatible on-chain IDL was found for this program." + ), + programMetadataError: new Error( + "Program metadata account exists but could not be parsed." + ), + }); + + await assert.rejects( + () => fetchIdlWithDeps(PROGRAM_ID, undefined, "auto", deps), + /could not be parsed/i + ); +}); + +test("fetchIdlWithDeps returns the generic not-found error when neither source exists in auto mode", async () => { + const { deps } = createFetchIdlDeps({ + anchorError: new Error( + "No compatible on-chain IDL was found for this program." + ), + programMetadataError: new Error( + "No compatible on-chain IDL was found for this program." + ), + }); + + await assert.rejects( + () => fetchIdlWithDeps(PROGRAM_ID, undefined, "auto", deps), + /no compatible on-chain idl was found/i + ); +}); diff --git a/test/idl-source.test.ts b/test/idl-source.test.ts new file mode 100644 index 0000000..4904af3 --- /dev/null +++ b/test/idl-source.test.ts @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + getIdlSourceFromSearchParams, + isIdlSource, + normalizeIdlSource, +} from "../lib/idl-source"; + +test("isIdlSource accepts supported values", () => { + assert.equal(isIdlSource("auto"), true); + assert.equal(isIdlSource("anchor"), true); + assert.equal(isIdlSource("program-metadata"), true); + assert.equal(isIdlSource("invalid"), false); +}); + +test("normalizeIdlSource falls back to auto for invalid values", () => { + assert.equal(normalizeIdlSource("anchor"), "anchor"); + assert.equal(normalizeIdlSource("invalid"), "auto"); + assert.equal(normalizeIdlSource(null), "auto"); +}); + +test("getIdlSourceFromSearchParams reads and normalizes idlSource from the URL", () => { + assert.equal( + getIdlSourceFromSearchParams( + new URLSearchParams("program=abc&idlSource=program-metadata") + ), + "program-metadata" + ); + assert.equal( + getIdlSourceFromSearchParams( + new URLSearchParams("program=abc&idlSource=invalid") + ), + "auto" + ); + assert.equal( + getIdlSourceFromSearchParams(new URLSearchParams("program=abc")), + "auto" + ); +});