Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions apps/api/src/queries/stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { one, q } from "#api/db";

export const STATS_BUCKETS = 90;
export const STATS_BLOCKS_PER_BUCKET = 144;

export async function statsHeight(db: D1Database): Promise<number> {
const row = await one<{ height: number }>(db,
"SELECT CAST(value AS INTEGER) AS height FROM chain_state WHERE key = 'block_height'");
return row?.height ?? 0;
}

export interface TradeActivityBucket {
bucket: number;
n: number;
xcp: string;
}

/** Use the existing partial block index; the maker's mirror row must never
* double a book fill. Both buys and sells contribute positive gross volume. */
export function tradeActivityByBucket(db: D1Database, startBlock: number, endBlock: number) {
return q<TradeActivityBucket>(db,
`SELECT e.block_index / 144 AS bucket, COUNT(*) AS n,
CAST(SUM(ABS(CAST(e.xcp_delta AS INTEGER))) AS TEXT) AS xcp
FROM asset_events e
WHERE e.primary_actor = 1 AND e.block_index BETWEEN ?1 AND ?2
AND EXISTS (SELECT 1 FROM launches l WHERE l.asset = e.asset AND l.conforming = 1)
GROUP BY bucket ORDER BY bucket`, startBlock, endBlock);
}

export interface BurnActivityBucket {
bucket: number;
asset: string;
divisible: number;
n: number;
quantity: string;
}

/** The burn index contains holder SENDs and explicit destructions, excluding
* failed-mint cleanup and LP tokens. Keep quantities per asset, as exact raw
* integers: unrelated token units cannot be added into an ecosystem total.
* CROSS JOIN keeps the indexed block range as the outer scan, followed by
* a unique asset lookup, instead of rescanning burns for every launch. */
export function burnActivityByBucket(db: D1Database, startBlock: number, endBlock: number) {
return q<BurnActivityBucket>(db,
`SELECT b.block_index / 144 AS bucket, b.asset, l.divisible,
COUNT(*) AS n, CAST(SUM(CAST(b.quantity AS INTEGER)) AS TEXT) AS quantity
FROM token_burns b
CROSS JOIN launches l ON l.asset = b.asset AND l.conforming = 1
WHERE b.block_index BETWEEN ?1 AND ?2 AND b.tx_index > l.tx_index
GROUP BY bucket, b.asset ORDER BY bucket, b.asset`, startBlock, endBlock);
}
32 changes: 19 additions & 13 deletions apps/api/src/read/launches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
tradeVolumeByDay,
} from "#api/queries/launches";
import { J, router } from "#api/read/respond";
import { burnActivityByBucket, statsHeight, STATS_BLOCKS_PER_BUCKET, STATS_BUCKETS, tradeActivityByBucket } from "#api/queries/stats";
import { getRewardAccount, listRewardBatches } from "#api/queries/rewards";
import { getAddressLaunchpadSummary } from "#api/queries/address-summary";
import {
Expand Down Expand Up @@ -140,10 +141,6 @@ launchesRoute.get("/v2/launches/index", async (c) => {

// Conforming launches per phase. The homepage shows a slice of each section
// and needs the size of the whole; /stats asks the same question directly.
/** ~28 Bitcoin days of history, which is as far back as a "what's happening
* lately" chart stays a chart rather than a timeline. */
const ACTIVITY_BUCKETS = 28;
const BLOCKS_PER_DAY = 144;
/** Edge-cache lifetimes, in seconds, kept together so the site's freshness
* policy reads as one decision rather than as numbers scattered per route.
* STATS_TTL matches the cron interval; nothing it summarises can change
Expand Down Expand Up @@ -209,16 +206,26 @@ launchesRoute.get("/v2/research/behavior", async (c) => {
});

launchesRoute.get("/v2/stats", async (c) => {
const height = Number(c.req.query("height") ?? 0) || 0;
const since = height > 0 ? Math.max(0, height - ACTIVITY_BUCKETS * BLOCKS_PER_DAY) : 0;
const sinceBucket = Math.floor(since / BLOCKS_PER_DAY);
const [rows, totals, buckets, refundBuckets, tradeVolume] = await Promise.all([
const includeActivity = c.req.query("activity") === "1";
// Anchor every chart to our indexed tip, even if the caller's node is ahead.
// The detailed charts are opt-in so launch counts/rewards do not pay for
// extra aggregation. The chart URL is stable across caller block heights.
const height = includeActivity
? await statsHeight(c.env.DB)
: Number(c.req.query("height") ?? 0) || 0;
const sinceBucket = includeActivity
? Math.max(0, Math.floor(height / STATS_BLOCKS_PER_BUCKET) - STATS_BUCKETS + 1)
: Math.floor(Math.max(0, height - 28 * STATS_BLOCKS_PER_BUCKET) / STATS_BLOCKS_PER_BUCKET);
const startBlock = sinceBucket * STATS_BLOCKS_PER_BUCKET;
const [rows, totals, buckets, refundBuckets, tradeVolume, trades, burns] = await Promise.all([
countByPhase(c.env.DB),
getMintTotals(c.env.DB),
// Whole buckets, so the window is expressed in the unit the rollup stores.
listMintBuckets(c.env.DB, sinceBucket),
refundsByBucket(c.env.DB, sinceBucket),
tradeVolumeByDay(c.env.DB),
includeActivity ? tradeActivityByBucket(c.env.DB, startBlock, height) : Promise.resolve([]),
includeActivity ? burnActivityByBucket(c.env.DB, startBlock, height) : Promise.resolve([]),
]);
const counts: Record<string, number> = {
scheduled: 0,
Expand Down Expand Up @@ -256,21 +263,20 @@ launchesRoute.get("/v2/stats", async (c) => {
total,
activity,
markets,
...(includeActivity ? { ecosystem: { height, trades_daily: trades, burns_daily: burns } } : {}),
// Bucket index is `block / 144`; the client turns it back into an
// approximate day using the height it already has.
daily: buckets,
refunds_daily: refundBuckets,
blocks_per_bucket: BLOCKS_PER_DAY,
blocks_per_bucket: STATS_BLOCKS_PER_BUCKET,
},
},
// Matched to the indexer's own cadence rather than the 60s every other
// route uses: the cron writes every 5 minutes, so a shorter TTL cannot
// make this fresher — it can only re-serve the same answer more times.
//
// This used to be the cost control, back when the route ran three full
// aggregates per request. Since migration 0010 those are materialised and
// a miss costs a primary-key lookup plus a short indexed range, so the TTL
// is now about freshness honesty rather than about cost.
// Mint totals are materialised; trading and burns use indexed, bounded
// block ranges. The shared edge cache avoids repeating these per visitor.
STATS_TTL,
);
});
Expand Down
148 changes: 148 additions & 0 deletions apps/web/src/app/[lang]/stats/_components/ecosystem-activity.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"use client";

import { useState, type PointerEvent } from "react";
import type { LaunchStats } from "@/lib/api/launchpad-api";
import { ACTIVITY_WINDOWS, statsActivity, type ActivityWindow } from "@/lib/stats-activity";
import { fromSats, tokenQty } from "@/lib/format";
import { useNumbers } from "@/lib/i18n/numbers";
import { useT } from "@/lib/i18n/client";
import { LazyLink } from "@/components/lazy-link";

export function EcosystemActivity({ stats }: { stats: LaunchStats }) {
const [days, setDays] = useState<ActivityWindow>(28);
const [selected, setSelected] = useState<number | null>(null);
const num = useNumbers();
const t = useT();
const data = statsActivity(stats, days);
if (!data) return <p className="rounded-xl border border-dashed border-gray-300 p-5 text-sm text-gray-500 dark:border-gray-700">{t("Activity charts are unavailable right now.")}</p>;

const { series, totals, assets } = data;
const point = series[selected ?? series.length - 1]!;
const about = (index: number) => index === series.length - 1 ? t("Current day · partial") : t("About {n}d ago", { n: series.length - 1 - index });
const rows = [
{ label: t("Trading"), value: `${num.fixed(fromSats(totals.xcp), 1)} XCP`,
detail: t("{n} fills · pools + order book", { n: num.commas(totals.fills) }),
values: series.map(d => fromSats(d.xcp)), color: "text-emerald-600 dark:text-emerald-400",
format: (value: number) => `${num.compact(value)} XCP` },
{ label: t("Burns & destructions"), value: num.commas(totals.burns),
detail: t("Actions across {n} assets", { n: num.commas(assets.length) }),
values: series.map(d => d.burns), color: "text-orange-600 dark:text-orange-400",
format: (value: number) => num.compact(value) },
{ label: t("Minting"), value: num.commas(totals.mints), detail: t("mint transactions"),
values: series.map(d => d.mints), color: "text-purple-600 dark:text-purple-400",
format: (value: number) => num.compact(value) },
];

return (
<section aria-labelledby="ecosystem-heading" className="overflow-hidden rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-100 px-5 py-4 dark:border-gray-800 sm:px-6">
<div>
<h2 id="ecosystem-heading" className="font-semibold">{t("Activity")}</h2>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{t("Last {n} days · approximate", { n: days })}</p>
</div>
<div className="flex gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-800" role="group" aria-label={t("Time range")}>
{ACTIVITY_WINDOWS.map(window => (
<button key={window} type="button" aria-pressed={days === window}
onClick={() => { setDays(window); setSelected(null); }}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-purple-500 ${days === window ? "bg-white text-gray-950 shadow-sm dark:bg-gray-700 dark:text-white" : "text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"}`}>
{t("{n}d", { n: window })}
</button>
))}
</div>
</div>

<div className="divide-y divide-gray-100 px-5 dark:divide-gray-800 sm:px-6">
{rows.map(row => (
<div key={row.label} className="grid gap-4 py-5 sm:grid-cols-[190px_minmax(0,1fr)] sm:items-center sm:gap-6">
<div>
<h3 className={`text-xs font-semibold ${row.color}`}>{row.label}</h3>
<p className="mt-1 text-2xl font-bold tracking-tight tabular-nums">{row.value}</p>
<p className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">{row.detail}</p>
</div>
<ActivityBars label={row.label} values={row.values} color={row.color}
format={row.format} selected={selected} onSelect={setSelected} about={about} />
</div>
))}
</div>

<div className="border-t border-gray-100 bg-gray-50/70 px-5 py-3 text-xs dark:border-gray-800 dark:bg-gray-950/30 sm:px-6">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 tabular-nums" aria-live="polite" aria-atomic="true">
<span className="font-medium">{about(selected ?? series.length - 1)}</span>
<span className="text-emerald-700 dark:text-emerald-400">{num.fixed(fromSats(point.xcp), 2)} XCP</span>
<span className="text-orange-700 dark:text-orange-400">{t("{n} burn actions", { n: num.commas(point.burns) })}</span>
<span className="text-purple-700 dark:text-purple-400">{t("{n} mints", { n: num.commas(point.mints) })}</span>
</div>
<p className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">{t("144 blocks per day. Separate scales. Tap or hover to inspect.")}</p>
</div>

<div className="border-t border-gray-100 px-5 py-5 dark:border-gray-800 sm:px-6">
<div className="flex items-baseline justify-between gap-3">
<h3 className="text-sm font-semibold">{t("Most active burns")}</h3>
<span className="text-[11px] text-gray-500 dark:text-gray-400">{t("Same period · ranked by actions")}</span>
</div>
{assets.length === 0 ? <p className="mt-3 text-sm text-gray-500">{t("No burns in this window.")}</p> : (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-left text-xs tabular-nums">
<thead className="text-[11px] text-gray-500 dark:text-gray-400">
<tr><th className="pb-2 font-normal">{t("Asset")}</th><th className="pb-2 text-right font-normal">{t("Actions")}</th><th className="pb-2 pl-4 text-right font-normal">{t("Tokens removed")}</th></tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{assets.slice(0, 5).map(asset => (
<tr key={asset.asset}>
<td className="py-2.5 pr-4"><LazyLink href={`/${asset.asset}`} className="font-medium text-purple-600 hover:underline dark:text-purple-400">{asset.asset}</LazyLink></td>
<td className="py-2.5 text-right">{num.commas(asset.n)}</td>
<td className="py-2.5 pl-4 text-right" title={num.commasRaw(asset.quantity, asset.divisible ? 8 : 0)}>{num.compact(tokenQty(asset.quantity, asset.divisible))}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="mt-3 max-w-2xl text-[11px] leading-relaxed text-gray-500 dark:text-gray-400">{t("Holder burns and explicit destructions of launch tokens. Excludes LP tokens and failed-mint cleanup. Token amounts stay separate by asset.")}</p>
</div>
</section>
);
}

function ActivityBars({ label, values, color, selected, onSelect, format, about }: {
label: string; values: number[]; color: string; selected: number | null;
onSelect: (index: number | null) => void; format: (value: number) => string; about: (index: number) => string;
}) {
const t = useT();
const peak = Math.max(...values, 1);
const width = 560;
const step = width / values.length;
const chartHeight = 80;
const selectAt = (event: PointerEvent<SVGSVGElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
onSelect(Math.max(0, Math.min(values.length - 1, Math.floor((event.clientX - rect.left) / rect.width * values.length))));
};
return (
<div className="min-w-0">
<div className="mb-1 flex justify-between text-[10px] text-gray-400 dark:text-gray-500"><span>{format(peak)}</span><span>{t("per day")}</span></div>
<svg viewBox={`0 0 ${width} 88`} preserveAspectRatio="none" role="img" aria-label={t("{label}. Use arrow keys to inspect days.", { label })}
tabIndex={0} className={`h-24 w-full rounded-sm focus-visible:outline-2 focus-visible:outline-purple-500 ${color}`}
onPointerDown={selectAt}
onPointerMove={selectAt}
onPointerLeave={event => { if (event.pointerType === "mouse") onSelect(null); }}
onKeyDown={event => {
const index = selected ?? values.length - 1;
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
onSelect(Math.max(0, Math.min(values.length - 1, index + (event.key === "ArrowLeft" ? -1 : 1))));
} else if (event.key === "Escape") onSelect(null);
}}>
<title>{label}</title>
{[0, 40, 80].map(y => <line key={y} x1="0" x2={width} y1={y + 4} y2={y + 4} className="stroke-gray-100 dark:stroke-gray-800" />)}
{selected !== null && <rect x={selected * step} y="0" width={step} height="88" fill="currentColor" opacity="0.12" />}
{values.map((value, i) => {
const height = value > 0 ? Math.max(1, value / peak * chartHeight) : 0;
return <rect key={i} x={i * step + 1} y={84 - height} width={Math.max(1, step - 2)} height={height} rx="1.5" fill="currentColor" opacity={i === selected ? 1 : i === values.length - 1 ? 0.4 : 0.7}>
<title>{`${about(i)} · ${format(value)}`}</title>
</rect>;
})}
</svg>
<div className="mt-1 flex justify-between text-[10px] text-gray-400 dark:text-gray-500"><span>{about(0)}</span><span>{t("Current day · partial")}</span></div>
</div>
);
}
Loading
Loading