apps/keeper/src/jobs/owners.ts
Reads who holds each NFT. 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/owners.ts109 lines
1// owners: who holds each NFT of a listed collection right now. Payouts go to whoever owns an NFT when they run.2// - With HELIUS_API_KEY: Helius DAS getAssetsByGroup reads every NFT's owner and burn state (1,000 per call), its3// on-chain image (arweave; public IPFS gateways rate-limit) and its name when HowRare had none.4// Collections paired with a coin refresh every `owners.paired_every_s` (10 min); the rest every 6 h.5// - Without it: Magic Eden's per-token endpoint, a bounded number per run (slow; enough to demo, not to pay a whole6// collection). NFTs of paired collections first, rarest first, never-seen owners before stale ones.7// A program-owned NFT (escrow, pool, PDA) is marked owner_is_wallet = false: its rewards wait for the next wallet.8import { and, asc, desc, eq, inArray, isNotNull, isNull, lt, or, sql } from 'drizzle-orm';9import { collections, coins, nfts } from '@solary/db';10import { getSetting, type JobCtx, type Stats } from '../ctx';11import { isWalletAddress } from '../nft/sources';12 13interface OwnerRow {14 id: string;15 owner: string | null;16 burnt: boolean;17 /** the on-chain image (DAS content.links.image, usually arweave): replaces HowRare's IPFS mirror when present */18 image?: string | null;19 /** fills the name only when HowRare had none */20 name?: string | null;21}22 23async function writeOwners(ctx: Pick<JobCtx, 'db'>, rows: readonly OwnerRow[]): Promise<void> {24 for (let i = 0; i < rows.length; i += 1000) {25 const chunk = rows.slice(i, i + 1000);26 const values = sql.join(27 chunk.map((r) => sql`(${r.id}, ${r.owner}, ${r.owner ? isWalletAddress(r.owner) : false}, ${r.burnt}, ${r.image || null}, ${r.name || null})`),28 sql`, `,29 );30 await ctx.db.execute(sql`31 UPDATE nfts SET owner = v.owner, owner_is_wallet = v.wallet, burnt = v.burnt,32 image = COALESCE(v.image::text, nfts.image),33 name = CASE WHEN nfts.name = '' THEN COALESCE(v.name::text, '') ELSE nfts.name END,34 updated_at = now()35 FROM (VALUES ${values}) AS v(id, owner, wallet, burnt, image, name)36 WHERE nfts.id = v.id`);37 }38}39 40async function refreshTotals(ctx: Pick<JobCtx, 'db'>, collection: string): Promise<void> {41 await ctx.db.execute(sql`42 UPDATE collections SET43 total_shares = COALESCE((SELECT SUM(multiplier) FROM nfts WHERE collection = ${collection} AND NOT burnt), 0),44 owners = GREATEST((SELECT COUNT(DISTINCT owner) FROM nfts WHERE collection = ${collection} AND owner IS NOT NULL AND NOT burnt), owners),45 owners_at = now()46 WHERE address = ${collection}`);47}48 49/** Every owner and burn of one collection from Helius DAS (needs HELIUS_API_KEY); also used by the demo seed. */50export async function refreshCollectionOwners(ctx: Pick<JobCtx, 'db' | 'nft'>, address: string): Promise<{ nfts: number; unranked: number }> {51 if (!ctx.nft.das) throw new Error('HELIUS_API_KEY is not set');52 const assets = await ctx.nft.das.collectionAssets(address);53 const knownIds = new Set((await ctx.db.select({ id: nfts.id }).from(nfts).where(eq(nfts.collection, address))).map((r) => r.id));54 const rows = assets.filter((a) => knownIds.has(a.id)).map((a) => ({ id: a.id, owner: a.owner, burnt: a.burnt, image: a.image, name: a.name }));55 await writeOwners(ctx, rows);56 await refreshTotals(ctx, address);57 return { nfts: rows.length, unranked: assets.length - rows.length };58}59 60export async function ownersJob(ctx: JobCtx): Promise<Stats> {61 const { db, nft } = ctx;62 const pairedRows = await db.selectDistinct({ c: coins.collection }).from(coins).where(isNotNull(coins.collection));63 const paired = new Set(pairedRows.map((r) => r.c!));64 const listed = await db65 .select({ address: collections.address, name: collections.name, ownersAt: collections.ownersAt })66 .from(collections)67 .where(inArray(collections.status, ['listed', 'disabled']));68 69 if (nft.das) {70 const pairedEvery = (await getSetting<number>(db, 'owners.paired_every_s', 600)) * 1000;71 const otherEvery = (await getSetting<number>(db, 'owners.other_every_s', 6 * 3600)) * 1000;72 const due = listed73 .filter((c) => Date.now() - (c.ownersAt?.getTime() ?? 0) > (paired.has(c.address) ? pairedEvery : otherEvery))74 .sort((a, b) => Number(paired.has(b.address)) - Number(paired.has(a.address)) || (a.ownersAt?.getTime() ?? 0) - (b.ownersAt?.getTime() ?? 0))75 .slice(0, 4);76 let updated = 0;77 for (const c of due) {78 const r = await refreshCollectionOwners(ctx, c.address);79 updated += r.nfts;80 ctx.log(`${c.name}: ${r.nfts} owners from Helius (${r.unranked} assets not in the ranked list)`);81 }82 return { source: 'helius', collections: due.length, nfts: updated };83 }84 85 // Magic Eden fallback, bounded86 const budget = await getSetting<number>(db, 'owners.me_per_run', ctx.env.fast ? 30 : 40);87 const pairedList = [...paired];88 if (!pairedList.length) return { source: 'magic-eden', skipped: 'no paired collections yet' };89 const staleBefore = new Date(Date.now() - 24 * 3600_000);90 const todo = await db91 .select({ id: nfts.id, collection: nfts.collection })92 .from(nfts)93 .where(and(inArray(nfts.collection, pairedList), eq(nfts.burnt, false), or(isNull(nfts.owner), lt(nfts.updatedAt, staleBefore))))94 .orderBy(sql`${nfts.owner} IS NOT NULL`, desc(nfts.multiplier), asc(nfts.rank))95 .limit(budget);96 const rows: OwnerRow[] = [];97 let failed = 0;98 for (const t of todo) {99 try {100 const o = await nft.me.owner(t.id);101 rows.push({ id: t.id, owner: o.owner, burnt: false });102 } catch {103 failed++;104 }105 }106 await writeOwners(ctx, rows);107 for (const c of new Set(todo.map((t) => t.collection))) await refreshTotals(ctx, c);108 return { source: 'magic-eden', looked_up: rows.length, failed };109}