mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-19 23:00:45 -07:00
Add transparency stats to landing page
This commit is contained in:
@@ -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<LockedAccount[]>(
|
||||
'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<T>(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
|
||||
|
||||
@@ -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<TokenStats> {
|
||||
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<TokenStats> {
|
||||
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<T>(rpc: string, method: string, params: unknown): Promise<T> {
|
||||
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<SupplyResult>(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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<string, number>(); // 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<HeliusTokenAccountsPage>(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<number | null> {
|
||||
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<string, { usdPrice?: number } | undefined>;
|
||||
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<number | null> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user