apps/keeper/src/nft/sources.ts

HowRare.is, Magic Eden, Helius DAS and on-chain reads. Shown whole, as it was in the repository when this site was built. Line numbers link: add #L12 to the address.

apps/keeper/src/nft/sources.ts311 lines
1// Where real NFT data comes from. All read-only.2//   HowRare.is   : the collection catalogue, every NFT's name, image and traits (public, no key)3//   Magic Eden   : floor, listings, volume; per-token owner as a slow fallback (public, rate-limited)4//   Helius DAS   : every NFT's current owner, burns, compression, verified grouping (needs HELIUS_API_KEY)5//   Solana RPC   : Metaplex metadata accounts (the verified collection key), wallet existence6import { decodeCoreAsset, decodeMetadata, MPL_CORE_PROGRAM, TOKEN_METADATA_PROGRAM, type CoreAsset, type MetadataAccount } from '@solary/core/metaplex';7import { findPda, isOnCurve, pk } from '../solana/keys';8import { SolRpc } from '../solana/rpc';9import { sleep } from '../ctx';10 11const UA = { 'user-agent': 'solary-keeper/0.1 (+https://solary.fun)', accept: 'application/json' };12 13/** One request at a time with a minimum gap; waits and retries on rate limits. */14class Throttle {15  private tail: Promise<unknown> = Promise.resolve();16  private last = 0;17  constructor(private readonly gapMs: number) {}18  run<T>(fn: () => Promise<T>): Promise<T> {19    const next = this.tail.then(async () => {20      const wait = this.last + this.gapMs - Date.now();21      if (wait > 0) await sleep(wait);22      try {23        return await fn();24      } finally {25        this.last = Date.now();26      }27    });28    this.tail = next.catch(() => undefined);29    return next;30  }31}32 33async function getJson<T>(url: string, throttle: Throttle, opts: { retries?: number; timeoutMs?: number } = {}): Promise<T> {34  const retries = opts.retries ?? 3;35  for (let attempt = 0; ; attempt++) {36    const res = await throttle.run(async () => {37      const r = await fetch(url, { headers: UA, signal: AbortSignal.timeout(opts.timeoutMs ?? 30_000) });38      const text = await r.text();39      return { status: r.status, text };40    });41    const limited = res.status === 429 || /exceeded the requests/i.test(res.text);42    if (res.status === 200 && !limited) {43      try {44        return JSON.parse(res.text) as T;45      } catch {46        throw new Error(`${new URL(url).host}: bad JSON`);47      }48    }49    if (res.status === 404) throw new NotFound(url);50    if (attempt >= retries) throw new Error(`${new URL(url).host}: HTTP ${res.status}${limited ? ' (rate limited)' : ''}`);51    await sleep(limited ? 20_000 * (attempt + 1) : 2_000 * (attempt + 1));52  }53}54 55export class NotFound extends Error {56  constructor(url: string) {57    super(`not found: ${url}`);58  }59}60 61// ---------------------------------------------------------------- HowRare62 63export interface HowRareListing {64  slug: string;65  name: string;66  logo: string | null;67  meSymbol: string | null;68  holders: number;69  items: number;70}71 72export interface HowRareItem {73  mint: string;74  name: string;75  image: string | null;76  attributes: Array<{ trait_type: string; value: string }>;77}78 79export interface HowRareCollection {80  name: string;81  description: string;82  logo: string | null;83  twitter: string | null;84  website: string | null;85  items: HowRareItem[];86}87 88const cleanUrl = (u: unknown) => (typeof u === 'string' && /^https?:\/\//.test(u) ? u.replace(/^http:/, 'https:').slice(0, 300) : null);89 90export class HowRare {91  private readonly t = new Throttle(1_500);92  constructor(private readonly base = 'https://api.howrare.is/v0.1') {}93 94  async list(): Promise<HowRareListing[]> {95    type Row = { name: string; url: string; logo?: string; me_key?: string; holders?: number; items?: number };96    const j = await getJson<{ result: { data: Row[] } }>(`${this.base}/collections`, this.t);97    return j.result.data98      .filter((r) => typeof r.url === 'string' && r.url.startsWith('/'))99      .map((r) => ({100        slug: r.url.slice(1),101        name: r.name,102        logo: cleanUrl(r.logo),103        meSymbol: r.me_key || null,104        holders: Number(r.holders ?? 0),105        items: Number(r.items ?? 0),106      }));107  }108 109  async collection(slug: string): Promise<HowRareCollection> {110    type Item = { mint: string; name: string; image?: string; attributes?: Array<{ name: string; value: string }> };111    type Data = { collection: string; description?: string; logo?: string; twitter?: string; website?: string; items: Item[] };112    const j = await getJson<{ result: { data: Data } }>(`${this.base}/collections/${encodeURIComponent(slug)}`, this.t, { timeoutMs: 90_000 });113    const d = j.result.data;114    return {115      name: d.collection,116      description: (d.description ?? '').slice(0, 1000),117      logo: cleanUrl(d.logo),118      twitter: cleanUrl(d.twitter),119      website: cleanUrl(d.website),120      items: (d.items ?? [])121        .filter((i) => typeof i.mint === 'string')122        .map((i) => ({123          mint: i.mint,124          name: String(i.name ?? '').slice(0, 64),125          image: cleanUrl(i.image),126          // HowRare adds its own "Attribute count"; the rarity code counts traits itself127          attributes: (i.attributes ?? [])128            .filter((a) => a && a.name && a.name.toLowerCase() !== 'attribute count')129            .map((a) => ({ trait_type: String(a.name), value: String(a.value) })),130        })),131    };132  }133}134 135// ---------------------------------------------------------------- Magic Eden136 137export interface MeStats {138  floorLamports: bigint;139  listedCount: number;140  volume7dLamports: bigint;141  volumeAllLamports: bigint;142  avgPrice24hLamports: bigint;143}144 145const lamports = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? BigInt(Math.round(v)) : 0n);146 147export class MagicEden {148  // the public API allows a couple of requests per second at best; stay well under it149  private readonly t = new Throttle(1_300);150  constructor(private readonly base = 'https://api-mainnet.magiceden.dev/v2') {}151 152  async stats(symbol: string): Promise<MeStats> {153    const j = await getJson<Record<string, unknown>>(`${this.base}/collections/${encodeURIComponent(symbol)}/stats`, this.t);154    return {155      floorLamports: lamports(j.floorPrice),156      listedCount: Number(j.listedCount ?? 0) || 0,157      volume7dLamports: lamports(j.volume7d),158      // Magic Eden's stats have no all-time volume (2026-09): 0 = unknown, never the 7-day figure159      volumeAllLamports: lamports(j.volumeAll),160      avgPrice24hLamports: lamports(j.avgPrice24hr),161    };162  }163 164  /** The current owner of one NFT (slow: one request per NFT). */165  async owner(mint: string): Promise<{ owner: string | null; listed: boolean }> {166    const j = await getJson<{ owner?: string; listStatus?: string }>(`${this.base}/tokens/${mint}`, this.t, { retries: 2 });167    return { owner: typeof j.owner === 'string' ? j.owner : null, listed: j.listStatus === 'listed' };168  }169}170 171// ---------------------------------------------------------------- Helius DAS172 173export interface DasAsset {174  id: string;175  owner: string | null;176  burnt: boolean;177  compressed: boolean;178  /** V1_NFT | ProgrammableNFT | MplCoreAsset | ... */179  interface: string;180  name: string;181  image: string | null;182  attributes: Array<{ trait_type: string; value: string }>;183}184 185export class HeliusDas {186  private readonly t = new Throttle(120);187  constructor(private readonly apiKey: string) {}188 189  private async call<T>(method: string, params: unknown): Promise<T> {190    for (let attempt = 0; ; attempt++) {191      const r = await this.t.run(() =>192        fetch(`https://mainnet.helius-rpc.com/?api-key=${this.apiKey}`, {193          method: 'POST',194          headers: { 'content-type': 'application/json' },195          body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),196          signal: AbortSignal.timeout(60_000),197        }),198      );199      if (r.status === 429 && attempt < 4) {200        await sleep(2_000 * (attempt + 1));201        continue;202      }203      if (!r.ok) throw new Error(`helius ${method}: HTTP ${r.status}`); // never print the URL: it holds the key204      const j = (await r.json()) as { result?: T; error?: { message: string } };205      if (j.error) throw new Error(`helius ${method}: ${j.error.message}`);206      return j.result as T;207    }208  }209 210  /** Every asset in a verified collection, 1 000 per page. */211  async collectionAssets(collection: string, onPage?: (n: number) => void): Promise<DasAsset[]> {212    type Raw = {213      id: string;214      interface: string;215      burnt?: boolean;216      ownership?: { owner?: string };217      compression?: { compressed?: boolean };218      content?: { metadata?: { name?: string; attributes?: Array<{ trait_type?: string; value?: unknown }> }; links?: { image?: string } };219    };220    const out: DasAsset[] = [];221    for (let page = 1; page <= 50; page++) {222      const r = await this.call<{ items: Raw[]; total: number }>('getAssetsByGroup', { groupKey: 'collection', groupValue: collection, page, limit: 1000 });223      for (const a of r.items) {224        out.push({225          id: a.id,226          owner: a.ownership?.owner ?? null,227          burnt: !!a.burnt,228          compressed: !!a.compression?.compressed,229          interface: a.interface,230          name: String(a.content?.metadata?.name ?? '').slice(0, 64),231          image: cleanUrl(a.content?.links?.image),232          // some metadata carries attributes as an object, not a list233          attributes: (Array.isArray(a.content?.metadata?.attributes) ? a.content!.metadata!.attributes! : [])234            .filter((x) => x && typeof x.trait_type === 'string')235            .map((x) => ({ trait_type: String(x.trait_type), value: String(x.value) })),236        });237      }238      onPage?.(out.length);239      if (r.items.length < 1000) break;240    }241    return out;242  }243}244 245// ---------------------------------------------------------------- chain reads246 247export function metadataPda(mint: string): string {248  return findPda(['metadata', pk(TOKEN_METADATA_PROGRAM), pk(mint)], TOKEN_METADATA_PROGRAM);249}250 251/** A wallet (on the ed25519 curve) rather than a program address (escrow, pool, PDA). */252export function isWalletAddress(address: string): boolean {253  try {254    return isOnCurve(pk(address));255  } catch {256    return false;257  }258}259 260export class ChainReads {261  private readonly rpc: SolRpc;262  private readonly t = new Throttle(250);263  constructor(rpcUrl: string) {264    this.rpc = new SolRpc(rpcUrl);265  }266 267  /** Metaplex metadata for up to 100 mints (null where missing or unreadable). */268  async metadata(mints: readonly string[]): Promise<Array<MetadataAccount | null>> {269    const out: Array<MetadataAccount | null> = [];270    for (let i = 0; i < mints.length; i += 100) {271      const chunk = mints.slice(i, i + 100).map(metadataPda);272      const accts = await this.t.run(() => this.rpc.getAccounts(chunk));273      out.push(...accts.map((a) => (a ? decodeMetadata(a.data) : null)));274    }275    return out;276  }277 278  /** Metaplex Core assets (owner + collection straight from the asset account), null where not a Core asset. */279  async coreAssets(ids: readonly string[]): Promise<Array<CoreAsset | null>> {280    const out: Array<CoreAsset | null> = [];281    for (let i = 0; i < ids.length; i += 100) {282      const accts = await this.t.run(() => this.rpc.getAccounts(ids.slice(i, i + 100)));283      out.push(...accts.map((a) => (a && a.owner === MPL_CORE_PROGRAM ? decodeCoreAsset(a.data) : null)));284    }285    return out;286  }287 288  /** Which of these addresses exist on-chain (hold lamports). */289  async existing(addresses: readonly string[]): Promise<Set<string>> {290    const found = new Set<string>();291    for (let i = 0; i < addresses.length; i += 100) {292      const chunk = addresses.slice(i, i + 100);293      const accts = await this.t.run(() => this.rpc.getAccounts(chunk));294      accts.forEach((a, j) => {295        if (a && a.lamports > 0) found.add(chunk[j]!);296      });297    }298    return found;299  }300}301 302export interface NftSources {303  howrare: HowRare;304  me: MagicEden;305  das: HeliusDas | null;306  chain: ChainReads;307}308 309export function nftSources(env: { heliusKey?: string; publicRpcUrl: string }): NftSources {310  return { howrare: new HowRare(), me: new MagicEden(), das: env.heliusKey ? new HeliusDas(env.heliusKey) : null, chain: new ChainReads(env.publicRpcUrl) };311}