diff --git a/landing/src/app/token/page.tsx b/landing/src/app/token/page.tsx index 989a9624..f32c4773 100644 --- a/landing/src/app/token/page.tsx +++ b/landing/src/app/token/page.tsx @@ -12,6 +12,7 @@ import type {Metadata} from "next"; import {Footer} from "@/components/Footer"; import {Navbar} from "@/components/Navbar"; import {TokenSection} from "@/components/TokenSection"; +import {TokenStatsSection} from "@/components/TokenStats"; import { TOKEN_PROOFS, TOKEN_SOLSCAN_URL, @@ -30,6 +31,10 @@ export const metadata: Metadata = { }, }; +// Re-fetch live on-chain stats at most every 10 minutes (matches the server +// cache in token-stats.ts). Keeps the page static-fast while staying fresh. +export const revalidate = 600; + const USE_OF_FUNDS = [ { icon: Rocket, @@ -77,6 +82,9 @@ export default function TokenPage() {
+ {/* ── Live on-chain stats ──────────────────────────────────── */} + + {/* ── Why a token ──────────────────────────────────────────── */}
diff --git a/landing/src/components/TokenStats.tsx b/landing/src/components/TokenStats.tsx new file mode 100644 index 00000000..11921fb5 --- /dev/null +++ b/landing/src/components/TokenStats.tsx @@ -0,0 +1,310 @@ +import {ArrowUpRight, Coins, Flame, Lock, Users, Wallet} from "lucide-react"; +import { + TOKEN_CONTRACT_ADDRESS, + TOKEN_CREATOR_ADDRESS, + TOKEN_SOLSCAN_URL, + TOKEN_TICKER, +} from "@/lib/constants"; +import {getTokenStats, type TokenStats} from "@/lib/token-stats"; + +// ── formatters ─────────────────────────────────────────────────────────────── +function compact(n: number | null): string { + if (n == null) return "—"; + const abs = Math.abs(n); + if (abs >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`; + if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`; + if (abs >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toLocaleString("en-US", {maximumFractionDigits: 0}); +} + +function pct(n: number | null): string { + if (n == null) return "—"; + if (n > 0 && n < 0.01) return "<0.01%"; + return `${n.toFixed(2)}%`; +} + +function usdPrice(n: number | null): string { + if (n == null) return "—"; + if (n < 0.000001) return `$${n.toExponential(2)}`; + if (n < 1) return `$${n.toPrecision(3)}`; + return `$${n.toLocaleString("en-US", {maximumFractionDigits: 2})}`; +} + +function usdBig(n: number | null): string { + if (n == null) return "—"; + if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`; + if (n >= 1_000) return `$${(n / 1_000).toFixed(1)}K`; + return `$${n.toLocaleString("en-US", {maximumFractionDigits: 0})}`; +} + +function sol(n: number | null): string { + if (n == null) return "—"; + return `${n.toLocaleString("en-US", {maximumFractionDigits: 2})} SOL`; +} + +function shortAddr(a: string): string { + return a.length > 12 ? `${a.slice(0, 4)}…${a.slice(-4)}` : a; +} + +function solscanAccount(a: string): string { + return `https://solscan.io/account/${a}`; +} + +function timeAgo(ts: number): string { + const secs = Math.max(0, Math.round((Date.now() - ts) / 1000)); + if (secs < 60) return "just now"; + const mins = Math.round(secs / 60); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.round(mins / 60); + return `${hrs}h ago`; +} + +export async function TokenStatsSection() { + const stats = await getTokenStats(); + return ; +} + +// Hidden for now — flip to true to bring the "fees earned for development" +// card back. The data is still fetched; it's just not rendered. +const SHOW_CREATOR_REWARDS = false; + +function TokenStatsView({stats}: {stats: TokenStats}) { + const cards = [ + { + icon: Flame, + label: "Burned", + value: compact(stats.burned), + sub: stats.burnedPct != null ? `${pct(stats.burnedPct)} of initial supply` : "Removed from supply forever", + }, + { + icon: Lock, + label: "Locked", + value: stats.locked != null ? compact(stats.locked) : "Not configured", + sub: stats.lockedPct != null ? `${pct(stats.lockedPct)} of supply` : "Liquidity & vesting locks", + }, + { + icon: Wallet, + label: "Dev / treasury", + value: stats.devBalance != null ? compact(stats.devBalance) : "Not configured", + sub: stats.devPct != null ? `${pct(stats.devPct)} of supply` : "Team-held tokens", + }, + { + icon: Users, + label: "Holders", + value: stats.holders != null ? `${stats.holdersCapped ? "" : ""}${stats.holders.toLocaleString("en-US")}` : "—", + sub: stats.holdersCapped ? "counted (capped)" : "unique wallets", + }, + ]; + + return ( +
+
+ {/* Header */} +
+
+ Live on-chain stats +
+

+ Every number, straight from the chain. +

+

+ Supply, holders, burns, locks and team holdings for {TOKEN_TICKER}, + read live from Solana. Nothing here is hand-entered — cross-check any + figure on Solscan. +

+
+ + {/* Supply + market headline */} +
+ + + +
+ + {/* Stat cards */} +
+ {cards.map((c) => { + const Icon = c.icon; + return ( +
+ +
+ {c.label} +
+
+ {c.value} +
+
{c.sub}
+
+ ); + })} +
+ + {/* Creator rewards — pump.fun creator fees, the funding story */} + {SHOW_CREATOR_REWARDS && stats.creatorRewardsSol != null && ( +
+
+ +
+
+ Fees earned for development +
+
+ {sol(stats.creatorRewardsSol)} +
+ {stats.creatorRewardsUsd != null && ( +
+ ≈ {usdBig(stats.creatorRewardsUsd)} +
+ )} +

+ Lifetime {TOKEN_TICKER} trading fees — the funding that pays for + full-time work on Voicebox.{" "} + + Verify + +

+
+ )} + + {/* Locked breakdown (only if any configured) */} + {stats.lockedBreakdown.length > 0 && ( +
+
+ Locked & vesting +
+
    + {stats.lockedBreakdown.map((l) => ( +
  • + + {l.label} + {l.unlocksAt && ( + · {l.unlocksAt} + )} + + {compact(l.amount)} + + + + +
  • + ))} +
+
+ )} + + {/* Top holders */} + {stats.topHolders.length > 0 && ( +
+
+
+ Top holders +
+ + All holders + +
+
    + {stats.topHolders.map((h, i) => ( +
  • + + {i + 1} + + + {shortAddr(h.owner)} + + + {compact(h.amount)} + + + {pct(h.pct)} + +
  • + ))} +
+
+ )} + + {/* Footer: provenance + freshness */} +
+ + {stats.live ? ( + <>Updated {timeAgo(stats.updatedAt)} · data via Helius & Jupiter + ) : ( + <>Live stats unavailable right now — verify on Solscan. + )} + + + {shortAddr(TOKEN_CONTRACT_ADDRESS)} + Inspect on Solscan + +
+
+
+ ); +} + +function HeadlineStat({ + label, + value, + sub, +}: { + label: string; + value: string; + sub?: string; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+ {sub &&
{sub}
} +
+ ); +} diff --git a/landing/src/lib/constants.ts b/landing/src/lib/constants.ts index bc86339b..e94894ba 100644 --- a/landing/src/lib/constants.ts +++ b/landing/src/lib/constants.ts @@ -16,6 +16,111 @@ export const TOKEN_PUMP_URL = `https://pump.fun/coin/${TOKEN_CONTRACT_ADDRESS}`; export const TOKEN_SOLSCAN_URL = `https://solscan.io/token/${TOKEN_CONTRACT_ADDRESS}`; export const TOKEN_TOTAL_SUPPLY = '1B'; +// ── Live on-chain tracking config ─────────────────────────────────────────── +// Powers the transparency dashboard on /token. Reads are done server-side via +// Helius (HELIUS_API_KEY). Every value below has a safe default so the page +// still renders if something is unset — sections you haven't configured just +// show as "not configured" rather than breaking the build. + +/** Mint supply at launch, used to derive burned = initial − current supply. */ +export const TOKEN_INITIAL_SUPPLY = 1_000_000_000; + +/** + * pump.fun creator wallet — the address that launched the coin and earns creator + * fees. Lifetime creator rewards (in SOL) are read from pump.fun's swap-api for + * this wallet. Defaults to the dev wallet (they're the same here). + */ +export const TOKEN_CREATOR_ADDRESS = envStr( + 'TOKEN_CREATOR_ADDRESS', + 'BSn573bjkQa5iffMg6zA8eb9mHyzewu2ps9R85qNKXC5', +); + +/** + * Dev / treasury wallets to surface as "team holdings". List every address you + * want counted; balances are summed. Public, read-only — these are already + * visible on-chain. Override at deploy time with TOKEN_DEV_WALLETS (comma list). + */ +export const TOKEN_DEV_WALLETS: string[] = envList('TOKEN_DEV_WALLETS', [ + 'BSn573bjkQa5iffMg6zA8eb9mHyzewu2ps9R85qNKXC5', // Jamie's dev/treasury wallet +]); + +/** + * Locked supply: token accounts whose $VOICEBOX is locked (liquidity lockers, + * vesting escrows). Each entry is summed into "locked"; unlocksAt is optional + * copy for the card. Override with TOKEN_LOCKED_ACCOUNTS as a JSON array. + */ +export interface LockedAccount { + label: string; + /** The token account or owner address holding the locked $VOICEBOX. */ + account: string; + /** Human-readable unlock date, e.g. "Unlocks Jun 2027" (optional). */ + unlocksAt?: string; + /** Optional Solscan/locker link proving the lock. */ + url?: string; +} +export const TOKEN_LOCKED_ACCOUNTS: LockedAccount[] = envJson( + 'TOKEN_LOCKED_ACCOUNTS', + [ + // Streamflow locks. `account` is each lock's escrow token account (read for + // the live balance, so it ticks down only when actually unlocked/withdrawn); + // `url` is the public Streamflow contract page for verification. + { + label: 'Streamflow lock #1', + account: 'EaPun3ZUk5XiKft2tbvVRXgq8HyXjTmg77kUYYe7Q5HM', + unlocksAt: 'Unlocks Jun 2027', + url: 'https://app.streamflow.finance/contract/solana/mainnet/AmzHaDAZWWZPkvN5zC78mQ3QAedH7hHSCEWeYSbSWXu5', + }, + { + label: 'Streamflow lock #2', + account: 'FGK5G4CbtryRdoubPN7u4y3WTYS4vqoepPLpppba92cp', + unlocksAt: 'Unlocks Jun 2027', + url: 'https://app.streamflow.finance/contract/solana/mainnet/GfBjWriW8mcJWS9njC2gBBJoJuGRQQzJLRFNg6a12bW8', + }, + { + label: 'Streamflow lock #3', + account: 'ELKMRnDin7w6ht4MkQ6FnDU3LvkDtoYbtR9y3P51pf1N', + unlocksAt: 'Unlocks Jun 2027', + url: 'https://app.streamflow.finance/contract/solana/mainnet/3xa49K6b8ChsL5SoPYrAWigwKmoXAge6YCAmUJWM6Ncw', + }, + ], +); + +/** + * Burn / dead address. The standard SPL incinerator by default. Buyback+burns + * that reduce mint supply are already captured by initial − current; this is + * only used to additionally surface anything parked at a dead address. + */ +export const TOKEN_BURN_ADDRESS = envStr( + 'TOKEN_BURN_ADDRESS', + '1nc1nerator11111111111111111111111111111111', +); + +/** How long stats are cached server-side (ms). Keeps us off rate limits. */ +export const TOKEN_STATS_CACHE_MS = 1000 * 60 * 10; // 10 minutes + +// ── tiny env helpers (server-only; safe in this module, no secrets exposed) ── +function envStr(key: string, fallback: string): string { + const v = process.env[key]; + return v && v.trim() ? v.trim() : fallback; +} +function envList(key: string, fallback: string[]): string[] { + const v = process.env[key]; + if (!v || !v.trim()) return fallback; + return v + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} +function envJson(key: string, fallback: T): T { + const v = process.env[key]; + if (!v || !v.trim()) return fallback; + try { + return JSON.parse(v) as T; + } catch { + return fallback; + } +} + // On-chain transparency log — locks and buyback+burns. // Add a new entry every time a lock or burn happens; set `txUrl` to its Solscan // link to make the card a live, verifiable proof. Entries without a txUrl render diff --git a/landing/src/lib/token-stats.ts b/landing/src/lib/token-stats.ts new file mode 100644 index 00000000..234b6165 --- /dev/null +++ b/landing/src/lib/token-stats.ts @@ -0,0 +1,418 @@ +// Live on-chain stats for $VOICEBOX, fetched server-side via Helius. +// +// Design goals: +// • Never throws. Every sub-fetch is isolated; a failure degrades that one +// metric to `null` and is recorded in `warnings`, so the page always renders. +// • Cheap. Results are cached in-memory for TOKEN_STATS_CACHE_MS, and holder +// enumeration is page-capped so a viral token can't blow up a request. +// • Honest. Numbers come straight from chain reads (Helius RPC) and Jupiter +// for price — nothing is asserted that can't be verified on Solscan. + +import { + TOKEN_BURN_ADDRESS, + TOKEN_CONTRACT_ADDRESS, + TOKEN_CREATOR_ADDRESS, + TOKEN_DEV_WALLETS, + TOKEN_INITIAL_SUPPLY, + TOKEN_LOCKED_ACCOUNTS, + TOKEN_STATS_CACHE_MS, +} from './constants'; + +const MINT = TOKEN_CONTRACT_ADDRESS; +const WSOL_MINT = 'So11111111111111111111111111111111111111112'; + +// Let Next cache the underlying network reads and revalidate them on the same +// cadence as the page (ISR). Keeps /token static-fast and CDN-cacheable while +// staying fresh, instead of forcing the route fully dynamic with `no-store`. +const REVALIDATE_S = Math.round(TOKEN_STATS_CACHE_MS / 1000); + +// Public Solana mainnet RPC — used as a fallback so supply/balances/locks work +// without any API key. Rate-limited, but our 10-minute cache keeps us under it. +const PUBLIC_RPC = 'https://api.mainnet-beta.solana.com'; + +function heliusRpcUrl(): string | null { + const key = process.env.HELIUS_API_KEY?.trim(); + if (!key) return null; + return `https://mainnet.helius-rpc.com/?api-key=${key}`; +} + +// Standard JSON-RPC reads (supply, balances) — Helius if configured, else public. +function standardRpcUrl(): string { + return heliusRpcUrl() ?? PUBLIC_RPC; +} + +// Holder enumeration needs Helius' DAS `getTokenAccounts` extension; public RPC +// can't do it efficiently. Null when no key — holders degrade to "—". +function dasRpcUrl(): string | null { + return heliusRpcUrl(); +} + +export interface TopHolder { + owner: string; + amount: number; + pct: number; // share of current supply, 0–100 +} + +export interface LockedEntry { + label: string; + account: string; + amount: number | null; + unlocksAt?: string; + url?: string; +} + +export interface TokenStats { + /** True only if Helius is configured and the core supply read succeeded. */ + live: boolean; + decimals: number; + initialSupply: number; + /** Current on-chain mint supply (UI amount). */ + totalSupply: number | null; + /** initialSupply − totalSupply: tokens permanently removed by burns. */ + burned: number | null; + burnedPct: number | null; + /** Sum of configured locked accounts. */ + locked: number | null; + lockedPct: number | null; + lockedBreakdown: LockedEntry[]; + /** Sum of configured dev/treasury wallets. */ + devBalance: number | null; + devPct: number | null; + /** Unique-owner holder count (page-capped; see holdersCapped). */ + holders: number | null; + holdersCapped: boolean; + topHolders: TopHolder[]; + /** Spot price in USD (Jupiter). */ + priceUsd: number | null; + /** priceUsd × totalSupply. */ + marketCapUsd: number | null; + /** Lifetime pump.fun creator fees earned by the creator wallet, in SOL. */ + creatorRewardsSol: number | null; + /** creatorRewardsSol × SOL/USD price. */ + creatorRewardsUsd: number | null; + /** Float supply = total − locked − dev − burned-at-dead-address. */ + circulating: number | null; + updatedAt: number; + /** Human-readable notes about anything unconfigured or failed. */ + warnings: string[]; +} + +// Caching is handled by Next's fetch cache (revalidate per request below), so +// this just aggregates the reads. Never throws — degrades to emptyStats. +export async function getTokenStats(): Promise { + try { + return await buildTokenStats(); + } catch (err) { + console.error('getTokenStats failed:', err); + return emptyStats(['Live stats are temporarily unavailable.']); + } +} + +function emptyStats(warnings: string[]): TokenStats { + return { + live: false, + decimals: 6, + initialSupply: TOKEN_INITIAL_SUPPLY, + totalSupply: null, + burned: null, + burnedPct: null, + locked: null, + lockedPct: null, + lockedBreakdown: TOKEN_LOCKED_ACCOUNTS.map((l) => ({ ...l, amount: null })), + devBalance: null, + devPct: null, + holders: null, + holdersCapped: false, + topHolders: [], + priceUsd: null, + marketCapUsd: null, + creatorRewardsSol: null, + creatorRewardsUsd: null, + circulating: null, + updatedAt: Date.now(), + warnings, + }; +} + +async function buildTokenStats(): Promise { + const warnings: string[] = []; + const rpc = standardRpcUrl(); // supply/balances/locks — public RPC if no key + const das = dasRpcUrl(); // holder enumeration — Helius only + + if (!das) { + warnings.push('Set HELIUS_API_KEY to enable the holder count & top holders.'); + } + + // Core supply first — everything downstream is a percentage of it. + const supply = await getTokenSupply(rpc).catch((e) => { + warnings.push('Could not read token supply.'); + console.error('getTokenSupply:', e); + return null; + }); + + const decimals = supply?.decimals ?? 6; + const totalSupply = supply?.uiAmount ?? null; + + // Run the independent reads concurrently. + const [holderData, devBalance, lockedAmounts, priceUsd, creatorRewardsSol, solPrice] = + await Promise.all([ + das + ? getHolders(das).catch((e) => { + warnings.push('Could not enumerate holders.'); + console.error('getHolders:', e); + return null; + }) + : Promise.resolve(null), + TOKEN_DEV_WALLETS.length + ? getOwnersBalance(rpc, TOKEN_DEV_WALLETS).catch((e) => { + warnings.push('Could not read dev wallet balance.'); + console.error('getOwnersBalance(dev):', e); + return null; + }) + : Promise.resolve(null), + TOKEN_LOCKED_ACCOUNTS.length + ? Promise.all( + TOKEN_LOCKED_ACCOUNTS.map((l) => + getAddressBalance(rpc, l.account) + .catch(() => null) + .then((amount) => ({ ...l, amount })), + ), + ) + : Promise.resolve( + [] as Array<(typeof TOKEN_LOCKED_ACCOUNTS)[number] & { amount: number | null }>, + ), + getJupiterPrice(MINT).catch(() => { + warnings.push('Could not read price from Jupiter.'); + return null; + }), + getCreatorRewardsSol().catch((e) => { + warnings.push('Could not read creator rewards.'); + console.error('getCreatorRewardsSol:', e); + return null; + }), + getJupiterPrice(WSOL_MINT).catch(() => null), + ]); + + if (!TOKEN_DEV_WALLETS.length) warnings.push('No dev/treasury wallet configured.'); + if (!TOKEN_LOCKED_ACCOUNTS.length) warnings.push('No locked accounts configured.'); + + const locked = + lockedAmounts.length && lockedAmounts.some((l) => l.amount != null) + ? lockedAmounts.reduce((sum, l) => sum + (l.amount ?? 0), 0) + : lockedAmounts.length + ? null + : null; + + const burned = + totalSupply != null ? Math.max(0, TOKEN_INITIAL_SUPPLY - totalSupply) : null; + + const pct = (n: number | null): number | null => + n != null && totalSupply ? (n / totalSupply) * 100 : null; + const pctOfInitial = (n: number | null): number | null => + n != null ? (n / TOKEN_INITIAL_SUPPLY) * 100 : null; + + const marketCapUsd = + priceUsd != null && totalSupply != null ? priceUsd * totalSupply : null; + + const creatorRewardsUsd = + creatorRewardsSol != null && solPrice != null + ? creatorRewardsSol * solPrice + : null; + + const circulating = + totalSupply != null + ? Math.max(0, totalSupply - (locked ?? 0) - (devBalance ?? 0)) + : null; + + const topHolders: TopHolder[] = (holderData?.top ?? []).map((h) => ({ + owner: h.owner, + amount: h.amount, + pct: totalSupply ? (h.amount / totalSupply) * 100 : 0, + })); + + return { + live: totalSupply != null, + decimals, + initialSupply: TOKEN_INITIAL_SUPPLY, + totalSupply, + burned, + burnedPct: pctOfInitial(burned), + locked, + lockedPct: pct(locked), + lockedBreakdown: lockedAmounts.length + ? lockedAmounts + : TOKEN_LOCKED_ACCOUNTS.map((l) => ({ ...l, amount: null })), + devBalance, + devPct: pct(devBalance), + holders: holderData?.count ?? null, + holdersCapped: holderData?.capped ?? false, + topHolders, + priceUsd, + marketCapUsd, + creatorRewardsSol, + creatorRewardsUsd, + circulating, + updatedAt: Date.now(), + warnings, + }; +} + +// ── Solana / Helius RPC primitives ─────────────────────────────────────────── + +async function rpcCall(rpc: string, method: string, params: unknown): Promise { + const res = await fetch(rpc, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + next: { revalidate: REVALIDATE_S }, + body: JSON.stringify({ jsonrpc: '2.0', id: 'voicebox', method, params }), + }); + if (!res.ok) throw new Error(`RPC ${method} HTTP ${res.status}`); + const json = (await res.json()) as { result?: T; error?: { message: string } }; + if (json.error) throw new Error(`RPC ${method}: ${json.error.message}`); + if (json.result === undefined) throw new Error(`RPC ${method}: empty result`); + return json.result; +} + +interface SupplyResult { + value: { amount: string; decimals: number; uiAmount: number | null }; +} +async function getTokenSupply( + rpc: string, +): Promise<{ uiAmount: number; decimals: number }> { + const r = await rpcCall(rpc, 'getTokenSupply', [MINT]); + const decimals = r.value.decimals; + const uiAmount = + r.value.uiAmount ?? Number(r.value.amount) / 10 ** decimals; + return { uiAmount, decimals }; +} + +// Sum a single owner's balance of the mint across all their token accounts. +async function getOwnerBalance(rpc: string, owner: string): Promise { + const r = await rpcCall<{ + value: Array<{ + account: { data: { parsed: { info: { tokenAmount: { uiAmount: number | null } } } } }; + }>; + }>(rpc, 'getTokenAccountsByOwner', [ + owner, + { mint: MINT }, + { encoding: 'jsonParsed' }, + ]); + return r.value.reduce( + (sum, a) => sum + (a.account.data.parsed.info.tokenAmount.uiAmount ?? 0), + 0, + ); +} + +async function getOwnersBalance(rpc: string, owners: string[]): Promise { + const balances = await Promise.all(owners.map((o) => getOwnerBalance(rpc, o))); + return balances.reduce((a, b) => a + b, 0); +} + +// Balance for a configured "account" that may be either a token-account address +// or an owner address — try token account first, fall back to owner. +async function getAddressBalance(rpc: string, address: string): Promise { + try { + const r = await rpcCall<{ + value: { amount: string; decimals: number; uiAmount: number | null }; + }>(rpc, 'getTokenAccountBalance', [address]); + return r.value.uiAmount ?? Number(r.value.amount) / 10 ** r.value.decimals; + } catch { + // Not a token account — treat it as an owner. + return getOwnerBalance(rpc, address); + } +} + +// Holder enumeration via Helius DAS getTokenAccounts. Dedupes by owner (one +// owner can hold many token accounts) and ranks the top holders. Page-capped. +const HOLDER_PAGE_LIMIT = 1000; +const HOLDER_MAX_PAGES = 25; // up to 25k accounts before we stop and flag it +const TOP_HOLDERS = 12; + +interface HeliusTokenAccount { + owner: string; + amount: number; // raw, needs / 10**decimals +} +interface HeliusTokenAccountsPage { + total: number; + limit: number; + page: number; + token_accounts: HeliusTokenAccount[]; +} + +async function getHolders( + rpc: string, +): Promise<{ count: number; capped: boolean; top: Array<{ owner: string; amount: number }> }> { + const balances = new Map(); // owner -> raw amount + let page = 1; + let capped = false; + let decimals = 6; + + // Grab decimals once so we can return UI amounts for the top holders. + try { + decimals = (await getTokenSupply(rpc)).decimals; + } catch { + /* fall back to 6 */ + } + + for (;;) { + const res = await rpcCall(rpc, 'getTokenAccounts', { + mint: MINT, + page, + limit: HOLDER_PAGE_LIMIT, + options: { showZeroBalance: false }, + }); + const accounts = res.token_accounts ?? []; + for (const a of accounts) { + if (!a.owner || !a.amount) continue; + balances.set(a.owner, (balances.get(a.owner) ?? 0) + a.amount); + } + if (accounts.length < HOLDER_PAGE_LIMIT) break; + page += 1; + if (page > HOLDER_MAX_PAGES) { + capped = true; + break; + } + } + + const top = [...balances.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, TOP_HOLDERS) + .map(([owner, raw]) => ({ owner, amount: raw / 10 ** decimals })); + + return { count: balances.size, capped, top }; +} + +// ── Price (Jupiter, no key required) ───────────────────────────────────────── +async function getJupiterPrice(mint: string): Promise { + const res = await fetch(`https://lite-api.jup.ag/price/v3?ids=${mint}`, { + next: { revalidate: REVALIDATE_S }, + }); + if (!res.ok) throw new Error(`Jupiter HTTP ${res.status}`); + const json = (await res.json()) as Record; + const price = json[mint]?.usdPrice; + return typeof price === 'number' ? price : null; +} + +// ── Creator rewards (pump.fun swap-api) ────────────────────────────────────── +// Lifetime creator fees earned by the creator wallet, in SOL. The swap-api +// returns a daily series with a running `cumulativeCreatorFeeSOL`; the latest +// (max) bucket is the lifetime total. The per-coin endpoint is unreliable, so +// we use the per-creator one. +interface CreatorFeeBucket { + cumulativeCreatorFeeSOL: string; +} +async function getCreatorRewardsSol(): Promise { + const res = await fetch( + `https://swap-api.pump.fun/v1/creators/${TOKEN_CREATOR_ADDRESS}/fees?interval=1d`, + { next: { revalidate: REVALIDATE_S }, headers: { 'User-Agent': 'voicebox.sh' } }, + ); + if (!res.ok) throw new Error(`pump.fun swap-api HTTP ${res.status}`); + const buckets = (await res.json()) as CreatorFeeBucket[]; + if (!Array.isArray(buckets) || buckets.length === 0) return null; + // Cumulative is monotonic, but take the max defensively. + const max = buckets.reduce((m, b) => { + const v = Number.parseFloat(b.cumulativeCreatorFeeSOL); + return Number.isFinite(v) && v > m ? v : m; + }, 0); + return max; +}