apps/keeper/src/jobs/collections.ts
Finds, verifies and ranks collections. Shown whole, as it was in the repository when this site was built. Line numbers link: add #L12 to the address.
apps/keeper/src/jobs/collections.ts254 lines
1// collections: find real Solana NFT collections that can be paired with a coin, and keep their data fresh.2// 1. Catalogue (daily): HowRare.is lists ~1,900 collections with their Magic Eden symbol, size and holders.3// Candidates: a Magic Eden symbol, 100–20,000 NFTs, the most holders first.4// 2. Market (hourly): Magic Eden floor, listings and 7-day volume per candidate. Traded ones rank by volume.5// 3. Listing (daily per collection): for the top traded candidates, download every NFT with its traits from HowRare,6// read a sample of Metaplex metadata accounts on-chain for the verified collection key (the collection's address),7// rank rarity, run every check in core/collections.ts and upsert collections + nfts.8// Collections that already have coins are never dropped from the refresh, whatever their rank.9import { and, eq, inArray, isNotNull, sql } from 'drizzle-orm';10import { createHash } from 'node:crypto';11import { collections, coins, nfts } from '@solary/db';12import { MAX_COLLECTION_SIZE, MIN_COLLECTION_SIZE, checkCollection, rankRarity, slugify } from '@solary/core';13import { event, getSetting, setSetting, type JobCtx, type Stats } from '../ctx';14import type { HowRareListing, MeStats } from '../nft/sources';15 16interface CatalogRow extends HowRareListing {17 floor: string;18 vol7d: string;19 volAll: string;20 listed: number;21 statsAt: number;22 /** last time listing was attempted and the collection could not be listed */23 skipAt?: number;24}25 26const DAY = 86_400_000;27const HOUR = 3_600_000;28 29export async function collectionsJob(ctx: JobCtx): Promise<Stats> {30 const { db, nft } = ctx;31 const scanTop = await getSetting<number>(db, 'collections.scan_top', 60);32 const maxListed = await getSetting<number>(db, 'collections.max_listed', 30);33 const perRun = await getSetting<number>(db, 'collections.list_per_run', 6);34 35 // 1. catalogue36 let catalog = await getSetting<CatalogRow[]>(db, 'collections.catalog', []);37 const catalogAt = Number(await getSetting<string>(db, 'collections.catalog_at', '0'));38 if (!catalog.length || Date.now() - catalogAt > DAY) {39 const list = await nft.howrare.list();40 const prev = new Map(catalog.map((c) => [c.slug, c]));41 catalog = list42 .filter((c) => c.meSymbol && c.items >= MIN_COLLECTION_SIZE && c.items <= MAX_COLLECTION_SIZE && c.holders >= 50)43 .sort((a, b) => b.holders - a.holders)44 .slice(0, scanTop)45 .map((c) => ({ floor: '0', vol7d: '0', volAll: '0', listed: 0, statsAt: 0, ...prev.get(c.slug), ...c }));46 await setSetting(db, 'collections.catalog', catalog);47 await setSetting(db, 'collections.catalog_at', String(Date.now()));48 ctx.log(`catalogue: ${list.length} collections on HowRare, ${catalog.length} candidates`);49 }50 51 // collections that must stay fresh: listed, or paired with a coin52 const paired = new Set(53 (await db.selectDistinct({ c: coins.collection }).from(coins).where(isNotNull(coins.collection))).map((r) => r.c!),54 );55 const known = await db.select({ address: collections.address, howrare: collections.howrare, status: collections.status, checkedAt: collections.checkedAt }).from(collections);56 const keepSlugs = new Set(known.filter((k) => k.status === 'listed' || paired.has(k.address)).map((k) => k.howrare));57 58 // 2. market data, stalest first, a bounded number per run (Magic Eden is slow)59 const statsBudget = await getSetting<number>(db, 'collections.stats_per_run', ctx.env.fast ? 60 : 25);60 const stale = catalog.filter((c) => Date.now() - c.statsAt > HOUR).sort((a, b) => a.statsAt - b.statsAt).slice(0, statsBudget);61 let statsOk = 0;62 for (const c of stale) {63 let s: MeStats;64 try {65 s = await nft.me.stats(c.meSymbol!);66 } catch (e) {67 ctx.log(`${c.name}: Magic Eden stats failed: ${(e as Error).message}`);68 continue;69 }70 Object.assign(c, { floor: s.floorLamports.toString(), vol7d: s.volume7dLamports.toString(), volAll: s.volumeAllLamports.toString(), listed: s.listedCount, statsAt: Date.now() });71 statsOk++;72 // keep listed rows in step73 await db74 .update(collections)75 .set({ floorLamports: s.floorLamports, volume7dLamports: s.volume7dLamports, volumeAllLamports: s.volumeAllLamports, listedCount: s.listedCount, marketAt: new Date() })76 .where(eq(collections.howrare, c.slug));77 }78 if (statsOk) await setSetting(db, 'collections.catalog', catalog);79 80 // 3. listing: the top traded candidates + everything we must keep, oldest check first81 const traded = catalog.filter((c) => BigInt(c.floor) > 0n && BigInt(c.vol7d) > 0n).sort((a, b) => (BigInt(b.vol7d) > BigInt(a.vol7d) ? 1 : -1));82 const want = [...new Set([...traded.slice(0, maxListed).map((c) => c.slug), ...keepSlugs].filter((s): s is string => !!s))];83 const checkedAt = new Map(known.map((k) => [k.howrare, k.checkedAt?.getTime() ?? 0]));84 // collections that can't be listed (no verified key, empty) are retried weekly, not every run85 for (const c of catalog) if (c.skipAt && !checkedAt.has(c.slug)) checkedAt.set(c.slug, c.skipAt + 6 * DAY);86 const due = want.filter((s) => Date.now() - (checkedAt.get(s) ?? 0) > DAY).sort((a, b) => (checkedAt.get(a) ?? 0) - (checkedAt.get(b) ?? 0)).slice(0, perRun);87 88 let listed = 0;89 let rejected = 0;90 let skipped = 0;91 for (const slug of due) {92 const cat = catalog.find((c) => c.slug === slug);93 try {94 const r = await listCollection(ctx, slug, cat);95 if (r === 'listed') listed++;96 else if (r === 'rejected') rejected++;97 else if (cat) {98 skipped++;99 cat.skipAt = Date.now();100 }101 } catch (e) {102 ctx.log(`${cat?.name ?? slug}: listing failed: ${(e as Error).message}`);103 }104 }105 if (skipped) await setSetting(db, 'collections.catalog', catalog);106 return { candidates: catalog.length, stats: statsOk, traded: traded.length, due: due.length, listed, rejected, skipped };107}108 109/** Download, verify, rank and upsert one collection. */110async function listCollection(ctx: JobCtx, slug: string, cat: CatalogRow | undefined): Promise<'listed' | 'rejected' | 'skipped'> {111 const { db, nft } = ctx;112 const hr = await nft.howrare.collection(slug);113 if (hr.items.length === 0) return 'skipped';114 115 // the verified collection key, from a spread of on-chain metadata accounts116 const step = Math.max(1, Math.floor(hr.items.length / 12));117 const sample = hr.items.filter((_, i) => i % step === 0).slice(0, 12).map((i) => i.mint);118 const md = (await nft.chain.metadata(sample)).filter((m): m is NonNullable<typeof m> => !!m);119 const keys = new Map<string, number>();120 let verifiedCount = 0;121 let programmable = 0;122 let checked = md.length;123 for (const m of md) {124 if (m.collection?.verified) {125 verifiedCount++;126 keys.set(m.collection.key, (keys.get(m.collection.key) ?? 0) + 1);127 }128 if (m.tokenStandard === 4) programmable++;129 }130 let standard = md.length > 0 ? (programmable === md.length ? 'pnft' : programmable === 0 ? 'nft' : 'mixed') : 'nft';131 if (md.length === 0) {132 // Metaplex Core: no metadata account; the asset account itself names its collection133 const core = (await nft.chain.coreAssets(sample)).filter((a): a is NonNullable<typeof a> => !!a);134 checked = core.length;135 for (const a of core) {136 if (!a.collection) continue;137 verifiedCount++;138 keys.set(a.collection, (keys.get(a.collection) ?? 0) + 1);139 }140 if (core.length) standard = 'core';141 }142 const [address, hits] = [...keys.entries()].sort((a, b) => b[1] - a[1])[0] ?? [null, 0];143 if (!address || hits < Math.max(2, Math.ceil(checked * 0.8))) {144 ctx.log(`${hr.name}: no verified on-chain collection key (${verifiedCount}/${checked} samples verified); not listed`);145 return 'skipped';146 }147 148 // rarity over every NFT HowRare knows149 const ranked = rankRarity(hr.items.map((i) => ({ id: i.mint, attributes: i.attributes })));150 const byId = new Map(ranked.items.map((r) => [r.id, r]));151 const rarityHash = createHash('sha256')152 .update(ranked.items.map((r) => `${r.id}:${r.rank}:${r.tier}`).join('\n'))153 .digest('hex');154 155 const check = checkCollection({156 name: hr.name,157 verified: true, // on-chain verified key found above, and HowRare/Magic Eden list it158 floorLamports: BigInt(cat?.floor ?? '0'),159 volumeAllLamports: BigInt(cat?.volAll ?? '0'),160 standard: true,161 size: hr.items.length,162 lastMintAt: null,163 });164 165 const [existing] = await db.select({ status: collections.status, slug: collections.slug }).from(collections).where(eq(collections.address, address)).limit(1);166 let siteSlug = existing?.slug ?? slugify(hr.name);167 if (!existing) {168 const clash = await db.select({ a: collections.address }).from(collections).where(eq(collections.slug, siteSlug)).limit(1);169 if (clash.length) siteSlug = `${siteSlug}-${address.slice(0, 4).toLowerCase()}`;170 }171 const status = check.ok ? 'listed' : existing?.status === 'listed' ? 'disabled' : 'rejected';172 173 await db.transaction(async (t) => {174 const values = {175 address,176 slug: siteSlug,177 name: hr.name.slice(0, 64),178 meSymbol: cat?.meSymbol ?? null,179 howrare: slug,180 image: hr.logo ?? cat?.logo ?? null,181 description: hr.description,182 twitter: hr.twitter,183 website: hr.website,184 standard,185 size: hr.items.length,186 owners: cat?.holders ?? 0,187 floorLamports: BigInt(cat?.floor ?? '0'),188 listedCount: cat?.listed ?? 0,189 volume7dLamports: BigInt(cat?.vol7d ?? '0'),190 volumeAllLamports: BigInt(cat?.volAll ?? '0'),191 verified: true,192 status: status as 'listed' | 'rejected' | 'disabled',193 failedChecks: check.failed,194 flat: ranked.flat,195 totalShares: ranked.totalWeight,196 traitTypes: ranked.traitTypes,197 rarityHash,198 checkedAt: new Date(),199 marketAt: cat?.statsAt ? new Date(cat.statsAt) : null,200 };201 await t202 .insert(collections)203 .values(values)204 .onConflictDoUpdate({ target: collections.address, set: { ...values, slug: sql`${collections.slug}`, address: sql`${collections.address}` } });205 // NFTs in chunks; owner fields are the owners job's, never touched here206 const rows = hr.items.map((i) => {207 const r = byId.get(i.mint)!;208 return { id: i.mint, collection: address, name: i.name, image: i.image, rank: r.rank, tier: r.tier, multiplier: r.multiplier, score: r.score, attributes: i.attributes };209 });210 for (let k = 0; k < rows.length; k += 1000) {211 await t212 .insert(nfts)213 .values(rows.slice(k, k + 1000))214 .onConflictDoUpdate({215 target: nfts.id,216 set: {217 name: sql`excluded.name`,218 // the owners job may have stored the on-chain (DAS) image: keep it over the HowRare mirror219 image: sql`COALESCE(${nfts.image}, excluded.image)`,220 rank: sql`excluded.rank`,221 tier: sql`excluded.tier`,222 multiplier: sql`excluded.multiplier`,223 score: sql`excluded.score`,224 attributes: sql`excluded.attributes`,225 updatedAt: new Date(),226 },227 });228 }229 // live shares = shares of NFTs not known to be burnt230 await t.execute(sql`UPDATE collections SET total_shares = COALESCE((SELECT SUM(multiplier) FROM nfts WHERE collection = ${address} AND NOT burnt), 0) WHERE address = ${address}`);231 });232 233 if (!existing || existing.status !== status) {234 await event(db, {235 collection: address,236 action: status === 'listed' ? 'collection_listed' : 'collection_rejected',237 message:238 status === 'listed'239 ? `${hr.name} is listed: ${hr.items.length.toLocaleString('en-US')} NFTs ranked into rarity tiers`240 : `${hr.name} is not listed (${check.failed.join(', ')})`,241 level: status === 'disabled' ? 'alert' : 'info',242 });243 }244 ctx.log(`${hr.name}: ${status} · ${hr.items.length} NFTs · key ${address.slice(0, 6)}… · ${ranked.flat ? 'flat' : `${ranked.traitTypes.length} traits`}`);245 return status === 'listed' ? 'listed' : 'rejected';246}247 248/** Collections by status, for other jobs. */249export async function listedCollections(ctx: JobCtx): Promise<Array<{ address: string; name: string; meSymbol: string | null; size: number }>> {250 return ctx.db251 .select({ address: collections.address, name: collections.name, meSymbol: collections.meSymbol, size: collections.size })252 .from(collections)253 .where(and(inArray(collections.status, ['listed', 'disabled'])));254}