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
23 changes: 15 additions & 8 deletions apps/scan/app/[locale]/address/[addr]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { formatSRX, formatNumber } from "@/lib/format";
import { Link } from "@/i18n/navigation";
import { useAddressLabel, toneForKind } from "@/lib/labels";
import { AddressNote } from "@/components/common/AddressNote";
import { SourcifyBadge } from "@/components/common/SourcifyBadge";
import { downloadCsv } from "@/lib/csv";
import { toMillis } from "@/lib/format";

Expand Down Expand Up @@ -48,14 +49,20 @@ export default function AddressDetailPage({ params }: { params: Promise<{ addr:
icon={Wallet}
eyebrow="Address"
title={label?.name ?? "Account"}
actions={label ? (() => {
const tone = toneForKind(label.kind);
return (
<span className={`inline-flex items-center text-[10px] font-mono uppercase tracking-[.12em] rounded-md px-2 py-1 border ${tone.bg} ${tone.fg} ${tone.border}`}>
{label.kind}
</span>
);
})() : undefined}
actions={
<div className="flex items-center gap-2">
{label && (() => {
const tone = toneForKind(label.kind);
return (
<span className={`inline-flex items-center text-[10px] font-mono uppercase tracking-[.12em] rounded-md px-2 py-1 border ${tone.bg} ${tone.fg} ${tone.border}`}>
{label.kind}
</span>
);
})()}
{/* Sourcify verification badge — only meaningful for contract addresses, but harmless on EOAs (returns "unverified" badge) */}
<SourcifyBadge network={network} address={addr} />
</div>
}
/>

{/* Address bar */}
Expand Down
212 changes: 212 additions & 0 deletions apps/scan/app/[locale]/epochs/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
"use client";

import { useMemo } from "react";
import { Link } from "@/i18n/navigation";
import { CalendarRange, Layers, Coins, Users } from "lucide-react";
import { PageHeader } from "@/components/common/PageHeader";
import { DetailCard } from "@/components/common/DetailCard";
import { InfoRow } from "@/components/common/InfoRow";
import { StatCard } from "@/components/common/StatCard";
import { useNetwork } from "@/lib/network-context";
import { useStats, useCurrentEpoch, useValidators } from "@/lib/hooks";
import { formatNumber, formatSRX } from "@/lib/format";

// Same constant as `crates/sentrix-staking/src/epoch.rs`. Epochs are 28,800
// blocks long — at 1s blocks that's exactly 8 hours — so we can derive every
// past epoch's height range from a single counter without a dedicated API.
const EPOCH_LENGTH = 28_800;
const PAST_TO_SHOW = 12;

// DECISION: Epoch view is the primary unit for staking analytics — rewards
// are settled at boundaries, validator set rotates at boundaries, slashing
// looks at the last full epoch's missed-blocks counter. This page surfaces:
//
// - the current epoch (live — what's happening right now)
// - a tail of recent past epochs with their height ranges so a user can
// deep-link to "blocks produced in epoch N" via the list page
// - validator set count + total stake at the current epoch boundary
//
// No new backend endpoint required: `/epoch/current` already exists and
// /validators carries enough to render the active set count. Past epochs
// are derived locally from epoch_number — historical epoch payouts will
// require an indexer-side endpoint we haven't built yet (the indexer
// scaffold has the schema; renderer wires up once that ships).

export default function EpochsPage() {
const { network } = useNetwork();
const { data: stats } = useStats(network);
const { data: epoch } = useCurrentEpoch(network);
const { data: validators } = useValidators(network);

const totalBonded = useMemo(() => {
if (!validators) return 0;
return validators.reduce((s, v) => s + (v.stake ?? 0), 0);
}, [validators]);

const past = useMemo<Array<{ n: number; start: number; end: number }>>(() => {
if (!epoch) return [];
const out: Array<{ n: number; start: number; end: number }> = [];
for (let i = 1; i <= PAST_TO_SHOW; i++) {
const n = epoch.epoch_number - i;
if (n < 0) break;
const start = n * EPOCH_LENGTH;
const end = start + EPOCH_LENGTH - 1;
out.push({ n, start, end });
}
return out;
}, [epoch]);

const currentBlocksIn = stats && epoch ? stats.height - epoch.start_height + 1 : 0;
const currentProgressPct = epoch
? Math.min(100, Math.max(0, (currentBlocksIn / EPOCH_LENGTH) * 100))
: 0;

return (
<div className="space-y-6">
<PageHeader
icon={CalendarRange}
eyebrow="EPOCHS"
title={epoch ? `Epoch ${epoch.epoch_number} (current)` : "Epochs"}
/>

{/* ── Headline stats ─────────────────────────── */}
<div className="grid gap-3 sm:gap-4 grid-cols-2 lg:grid-cols-4">
<StatCard
label="Current epoch"
value={epoch ? `#${epoch.epoch_number}` : "—"}
accent="var(--gold)"
/>
<StatCard
label="Active validators"
value={
stats?.active_validators != null
? formatNumber(stats.active_validators)
: "—"
}
accent="var(--blue)"
/>
<StatCard
label="Total bonded"
value={formatSRX(totalBonded)}
accent="var(--green)"
/>
<StatCard
label="Epoch length"
value="28,800 blk"
accent="var(--purple)"
/>
</div>

{/* ── Current-epoch detail ──────────────────── */}
<DetailCard title="Current epoch detail">
{epoch && stats ? (
<>
<InfoRow label="Epoch number" value={`#${epoch.epoch_number}`} />
<InfoRow
label="Block range"
value={
<span className="font-mono text-xs">
{epoch.start_height.toLocaleString()} →{" "}
{epoch.end_height.toLocaleString()}
</span>
}
hint={`${currentBlocksIn.toLocaleString()} of ${EPOCH_LENGTH.toLocaleString()} blocks produced — ${currentProgressPct.toFixed(1)}% complete.`}
/>
<div className="py-3 border-b border-border/60">
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-[var(--gold)] transition-all"
style={{ width: `${currentProgressPct}%` }}
/>
</div>
</div>
<InfoRow
label="Total staked at boundary"
value={formatSRX(epoch.total_staked / 100_000_000)}
hint="Sum of self-stake + delegations across the active set when this epoch started."
/>
<InfoRow
label="Rewards this epoch"
value={`${formatSRX(epoch.total_rewards / 100_000_000)}`}
hint="Sentri minted to the protocol treasury since this epoch began."
/>
<InfoRow
label="Blocks produced"
value={formatNumber(epoch.total_blocks_produced)}
last
/>
</>
) : (
<div className="text-sm text-muted-foreground py-4">Loading…</div>
)}
</DetailCard>

{/* ── Past epochs ──────────────────────────── */}
<DetailCard
title={
<span className="inline-flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" /> Past {PAST_TO_SHOW} epochs
</span>
}
>
<div className="overflow-x-auto -mx-6">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground border-b border-border/60">
<th className="px-6 py-2 font-medium">Epoch</th>
<th className="px-6 py-2 font-medium">First block</th>
<th className="px-6 py-2 font-medium">Last block</th>
<th className="px-6 py-2 font-medium text-right">Blocks</th>
</tr>
</thead>
<tbody>
{past.map((e) => (
<tr key={e.n} className="border-b border-border/30 last:border-0">
<td className="px-6 py-2.5 font-mono">#{e.n}</td>
<td className="px-6 py-2.5 font-mono text-xs">
<Link
href={`/blocks/${e.start}`}
className="text-[var(--gold)] hover:underline"
>
{e.start.toLocaleString()}
</Link>
</td>
<td className="px-6 py-2.5 font-mono text-xs">
<Link
href={`/blocks/${e.end}`}
className="text-[var(--gold)] hover:underline"
>
{e.end.toLocaleString()}
</Link>
</td>
<td className="px-6 py-2.5 text-right font-mono text-xs">{EPOCH_LENGTH.toLocaleString()}</td>
</tr>
))}
{past.length === 0 && (
<tr><td colSpan={4} className="px-6 py-6 text-center text-muted-foreground text-sm">No past epochs yet.</td></tr>
)}
</tbody>
</table>
</div>
</DetailCard>

{/* ── Footnote ──────────────────────────────── */}
<DetailCard title="What an epoch does">
<div className="text-sm leading-relaxed text-muted-foreground space-y-2 py-1">
<p>
An epoch is the basic unit Sentrix uses for everything time-bucketed: validator-set
rotation, reward settlement, slashing windows, and the consensus-jail boundary
check (post-fork). At the end of every epoch the active validator set is
recomputed, accrued rewards are pushed to delegators&apos; claim balances, and
jail evidence (if any) is dispatched as a system transaction.
</p>
<p>
Length is fixed at <span className="font-mono">28,800 blocks</span> (≈ 8 hours at
our 1-second block target), defined in{" "}
<span className="font-mono">crates/sentrix-staking/src/epoch.rs</span>.
</p>
</div>
</DetailCard>
</div>
);
}
148 changes: 148 additions & 0 deletions apps/scan/app/[locale]/forks/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"use client";

import { GitFork, CheckCircle2, AlertTriangle, Clock } from "lucide-react";
import { PageHeader } from "@/components/common/PageHeader";
import { DetailCard } from "@/components/common/DetailCard";
import { useNetwork } from "@/lib/network-context";
import { useStats } from "@/lib/hooks";
import { FORKS, forkStateAt, type ForkEntry } from "@/lib/forks/registry";
import { formatNumber } from "@/lib/format";
import { cn } from "@/lib/utils";

// DECISION: a fork-history page that listing teams, security reviewers, and
// curious users can read top-to-bottom and understand "what changed when".
// Two viewing modes share one source of truth (`lib/forks/registry.ts`):
//
// - Top section: a per-fork card on the active network with current status
// (active vs scheduled vs dormant) and the activation height.
// - Bottom section: cross-network comparison table so Satya can spot drift
// between mainnet and testnet at a glance.
//
// Source of truth lives in `founder-private/U64_MAX_FORK_GATES.md`. The
// registry below is hand-synced any time we ship a new fork.

export default function ForksPage() {
const { network } = useNetwork();
const { data: stats } = useStats(network);
const height = stats?.height ?? 0;

const sorted = [...FORKS].sort((a, b) => {
const ah = a.heights[network];
const bh = b.heights[network];
if (ah == null && bh == null) return 0;
if (ah == null) return 1;
if (bh == null) return -1;
return ah - bh;
});

return (
<div className="space-y-6">
<PageHeader
icon={GitFork}
eyebrow="HARD-FORK HISTORY"
title={`Fork timeline — ${network === "mainnet" ? "Mainnet" : "Testnet"} (h=${formatNumber(height)})`}
/>

<DetailCard title="What this is">
<p className="text-sm leading-relaxed text-muted-foreground py-1">
Sentrix ships protocol changes through height-gated forks: every consensus-affecting
change has an activation height, and nodes that reach that height switch to the new
rules. This page lists every fork that has been defined for the chain — when it
activates, what it changes, and whether it&apos;s currently dormant.
</p>
</DetailCard>

{/* ── Per-fork cards (chronological on this network) ── */}
<div className="grid gap-4">
{sorted.map((f) => {
const state = forkStateAt(f, network, height);
return <ForkCard key={f.id} fork={f} state={state} network={network} />;
})}
</div>

{/* ── Cross-network comparison ────────────────── */}
<DetailCard title="Cross-network comparison">
<div className="overflow-x-auto -mx-6">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground border-b border-border/60">
<th className="px-6 py-2 font-medium">Fork</th>
<th className="px-6 py-2 font-medium text-right">Mainnet</th>
<th className="px-6 py-2 font-medium text-right">Testnet</th>
</tr>
</thead>
<tbody>
{FORKS.map((f) => (
<tr key={f.id} className="border-b border-border/30 last:border-0">
<td className="px-6 py-3 font-mono text-xs">{f.title}</td>
<td className="px-6 py-3 text-right font-mono text-xs">
{f.heights.mainnet == null
? <span className="text-muted-foreground">dormant</span>
: formatNumber(f.heights.mainnet)}
</td>
<td className="px-6 py-3 text-right font-mono text-xs">
{f.heights.testnet == null
? <span className="text-muted-foreground">dormant</span>
: formatNumber(f.heights.testnet)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</DetailCard>
</div>
);
}

function ForkCard({
fork,
state,
network,
}: {
fork: ForkEntry;
state: "active" | "scheduled" | "dormant";
network: "mainnet" | "testnet";
}) {
const fh = fork.heights[network];
const Icon =
state === "active" ? CheckCircle2
: state === "scheduled" ? Clock
: AlertTriangle;
const tone =
state === "active" ? "text-green-500"
: state === "scheduled" ? "text-yellow-500"
: fork.state === "danger" ? "text-red-500" : "text-muted-foreground";
const stateLabel =
state === "active" ? "Active"
: state === "scheduled" ? `Scheduled @ h=${fh!.toLocaleString()}`
: fork.state === "danger" ? "Dormant — DO NOT activate"
: "Dormant";

return (
<div className="rounded-xl border border-border bg-card p-5 space-y-3">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
<div className="font-mono text-sm font-semibold">{fork.title}</div>
<div className="text-sm text-muted-foreground mt-0.5">{fork.summary}</div>
</div>
<span
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md border text-xs font-medium",
"border-border bg-muted/40",
tone,
)}
>
<Icon className="h-3.5 w-3.5" />
{stateLabel}
</span>
</div>
<p className="text-sm leading-relaxed">{fork.description}</p>
{fh != null && (
<div className="text-xs text-muted-foreground font-mono">
Activation height ({network}): {fh.toLocaleString()}
</div>
)}
</div>
);
}
Loading