kog-holder-snapshot / tree / src/fetch.ts ยท raw (this view has old GitHub links rewritten for the mirror; raw is byte-exact)
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