mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-20 15:20:39 -07:00
Add transparency stats to landing page
This commit is contained in:
@@ -12,6 +12,7 @@ import type {Metadata} from "next";
|
|||||||
import {Footer} from "@/components/Footer";
|
import {Footer} from "@/components/Footer";
|
||||||
import {Navbar} from "@/components/Navbar";
|
import {Navbar} from "@/components/Navbar";
|
||||||
import {TokenSection} from "@/components/TokenSection";
|
import {TokenSection} from "@/components/TokenSection";
|
||||||
|
import {TokenStatsSection} from "@/components/TokenStats";
|
||||||
import {
|
import {
|
||||||
TOKEN_PROOFS,
|
TOKEN_PROOFS,
|
||||||
TOKEN_SOLSCAN_URL,
|
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 = [
|
const USE_OF_FUNDS = [
|
||||||
{
|
{
|
||||||
icon: Rocket,
|
icon: Rocket,
|
||||||
@@ -77,6 +82,9 @@ export default function TokenPage() {
|
|||||||
<main className="pt-16">
|
<main className="pt-16">
|
||||||
<TokenSection />
|
<TokenSection />
|
||||||
|
|
||||||
|
{/* ── Live on-chain stats ──────────────────────────────────── */}
|
||||||
|
<TokenStatsSection />
|
||||||
|
|
||||||
{/* ── Why a token ──────────────────────────────────────────── */}
|
{/* ── Why a token ──────────────────────────────────────────── */}
|
||||||
<section className="border-t border-border py-20">
|
<section className="border-t border-border py-20">
|
||||||
<div className="mx-auto max-w-3xl px-6">
|
<div className="mx-auto max-w-3xl px-6">
|
||||||
|
|||||||
@@ -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 <TokenStatsView stats={stats} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<section className="border-t border-border py-20">
|
||||||
|
<div className="mx-auto max-w-5xl px-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||||
|
Live on-chain stats
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
|
||||||
|
Every number, straight from the chain.
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground max-w-2xl mx-auto mt-4">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Supply + market headline */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3 mb-4">
|
||||||
|
<HeadlineStat
|
||||||
|
label="Circulating supply"
|
||||||
|
value={compact(stats.circulating)}
|
||||||
|
sub={
|
||||||
|
stats.totalSupply != null
|
||||||
|
? `of ${compact(stats.totalSupply)} total`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<HeadlineStat label="Price" value={usdPrice(stats.priceUsd)} sub="via Jupiter" />
|
||||||
|
<HeadlineStat label="Market cap" value={usdBig(stats.marketCapUsd)} sub="price × supply" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stat cards */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{cards.map((c) => {
|
||||||
|
const Icon = c.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.label}
|
||||||
|
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5 text-accent mb-3" />
|
||||||
|
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground mb-1">
|
||||||
|
{c.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-semibold tracking-tight text-foreground tabular-nums">
|
||||||
|
{c.value}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">{c.sub}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Creator rewards — pump.fun creator fees, the funding story */}
|
||||||
|
{SHOW_CREATOR_REWARDS && stats.creatorRewardsSol != null && (
|
||||||
|
<div className="mt-4 rounded-2xl border border-accent/30 bg-gradient-to-b from-accent/[0.08] to-transparent p-8 text-center">
|
||||||
|
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full border border-accent/30 bg-accent/10">
|
||||||
|
<Coins className="h-5 w-5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
|
Fees earned for development
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-4xl font-semibold tracking-tight text-foreground tabular-nums">
|
||||||
|
{sol(stats.creatorRewardsSol)}
|
||||||
|
</div>
|
||||||
|
{stats.creatorRewardsUsd != null && (
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground tabular-nums">
|
||||||
|
≈ {usdBig(stats.creatorRewardsUsd)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="mx-auto mt-4 max-w-md text-sm leading-relaxed text-muted-foreground">
|
||||||
|
Lifetime {TOKEN_TICKER} trading fees — the funding that pays for
|
||||||
|
full-time work on Voicebox.{" "}
|
||||||
|
<a
|
||||||
|
href={`https://solscan.io/account/${TOKEN_CREATOR_ADDRESS}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-0.5 text-foreground/80 hover:text-foreground"
|
||||||
|
>
|
||||||
|
Verify <ArrowUpRight className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Locked breakdown (only if any configured) */}
|
||||||
|
{stats.lockedBreakdown.length > 0 && (
|
||||||
|
<div className="mt-4 rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6">
|
||||||
|
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground mb-4">
|
||||||
|
Locked & vesting
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{stats.lockedBreakdown.map((l) => (
|
||||||
|
<li
|
||||||
|
key={l.account}
|
||||||
|
className="flex items-center gap-3 text-sm"
|
||||||
|
>
|
||||||
|
<Lock className="h-4 w-4 shrink-0 text-accent" />
|
||||||
|
<span className="text-foreground/90">{l.label}</span>
|
||||||
|
{l.unlocksAt && (
|
||||||
|
<span className="text-xs text-muted-foreground">· {l.unlocksAt}</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto font-medium tabular-nums text-foreground">
|
||||||
|
{compact(l.amount)}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={l.url ?? solscanAccount(l.account)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="View on Solscan"
|
||||||
|
>
|
||||||
|
<ArrowUpRight className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top holders */}
|
||||||
|
{stats.topHolders.length > 0 && (
|
||||||
|
<div className="mt-4 rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
Top holders
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`${TOKEN_SOLSCAN_URL}#holders`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
All holders <ArrowUpRight className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y divide-border/60">
|
||||||
|
{stats.topHolders.map((h, i) => (
|
||||||
|
<li
|
||||||
|
key={h.owner}
|
||||||
|
className="flex items-center gap-3 py-2.5 text-sm"
|
||||||
|
>
|
||||||
|
<span className="w-5 text-xs text-muted-foreground tabular-nums">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={solscanAccount(h.owner)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-mono text-foreground/90 hover:text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{shortAddr(h.owner)}
|
||||||
|
</a>
|
||||||
|
<span className="ml-auto tabular-nums text-foreground">
|
||||||
|
{compact(h.amount)}
|
||||||
|
</span>
|
||||||
|
<span className="w-16 text-right tabular-nums text-muted-foreground">
|
||||||
|
{pct(h.pct)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer: provenance + freshness */}
|
||||||
|
<div className="mt-6 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{stats.live ? (
|
||||||
|
<>Updated {timeAgo(stats.updatedAt)} · data via Helius & Jupiter</>
|
||||||
|
) : (
|
||||||
|
<>Live stats unavailable right now — verify on Solscan.</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={TOKEN_SOLSCAN_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<span className="font-mono">{shortAddr(TOKEN_CONTRACT_ADDRESS)}</span>
|
||||||
|
Inspect on Solscan <ArrowUpRight className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeadlineStat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-border bg-card/60 backdrop-blur-sm p-6 text-center">
|
||||||
|
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground mb-2">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-semibold tracking-tight text-foreground tabular-nums">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
{sub && <div className="text-xs text-muted-foreground mt-1">{sub}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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_SOLSCAN_URL = `https://solscan.io/token/${TOKEN_CONTRACT_ADDRESS}`;
|
||||||
export const TOKEN_TOTAL_SUPPLY = '1B';
|
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.
|
// On-chain transparency log — locks and buyback+burns.
|
||||||
// Add a new entry every time a lock or burn happens; set `txUrl` to its Solscan
|
// 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
|
// 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