import { TOKEN_PROGRAMS, type ParsedTokenAccountItem } from "./snapshot.js";
export type FetchLike = (url: string, init: { method: string; headers: Record<string, string>; body: string; signal?: AbortSignal }) => Promise<{
ok: boolean;
status: number;
json(): Promise<unknown>;
}>;
export interface FetchAccountsInput {
rpcUrl: string;
mint: string;
fetch?: FetchLike;
/** Passed to the RPC as minContextSlot. */
minContextSlot?: number;
timeoutMs?: number;
}
export interface RecordedAccounts {
slot: number;
accounts: ParsedTokenAccountItem[];
}
export class RpcError extends Error {
constructor(message: string) {
super(message);
this.name = "RpcError";
}
}
/** The JSON-RPC request body for one token program. Exported so it can be checked in tests. */
export function programAccountsRequest(programId: string, mint: string, id: number, minContextSlot?: number) {
const config: Record<string, unknown> = {
encoding: "jsonParsed",
withContext: true,
filters: [{ memcmp: { offset: 0, bytes: mint } }],
};
if (minContextSlot !== undefined) config.minContextSlot = minContextSlot;
return { jsonrpc: "2.0", id, method: "getProgramAccounts", params: [programId, config] };
}
/**
* One getProgramAccounts call per token program (SPL Token and Token 2022), jsonParsed, withContext,
* memcmp on the mint at offset 0. slot is the lowest context slot of the answers. The RPC URL is
* never included in an error message.
*/
export async function fetchAccounts(input: FetchAccountsInput): Promise<RecordedAccounts> {
if (!input || typeof input.rpcUrl !== "string" || !/^https?:\/\//.test(input.rpcUrl)) throw new RpcError("an http or https RPC URL is required");
if (typeof input.mint !== "string" || input.mint.length === 0) throw new RpcError("mint is required");
const doFetch: FetchLike = input.fetch ?? (globalThis.fetch as unknown as FetchLike);
const timeoutMs = input.timeoutMs ?? 120_000;
const results = await Promise.all(
TOKEN_PROGRAMS.map(async (program, i) => {
const body = JSON.stringify(programAccountsRequest(program, input.mint, i + 1, input.minContextSlot));
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeoutMs);
let res;
try {
res = await doFetch(input.rpcUrl, { method: "POST", headers: { "content-type": "application/json" }, body, signal: ctl.signal });
} catch (e) {
throw new RpcError(`RPC request failed for ${program}: ${(e as Error)?.name === "AbortError" ? "timeout" : "network error"}`);
} finally {
clearTimeout(timer);
}
if (!res.ok) throw new RpcError(`RPC answered HTTP ${res.status} for ${program}`);
const json = (await res.json()) as any;
if (json?.error) throw new RpcError(`RPC error for ${program}: ${String(json.error.message ?? json.error.code ?? "unknown")}`);
const slot = json?.result?.context?.slot;
const value = json?.result?.value;
if (typeof slot !== "number" || !Array.isArray(value)) throw new RpcError(`RPC answer for ${program} is not a context and value list`);
return { slot, value: value as ParsedTokenAccountItem[] };
}),
);
return { slot: Math.min(...results.map((r) => r.slot)), accounts: results.flatMap((r) => r.value) };
}
agentgate-source: code mirror on Cloudflare Pages. All repos