From 0e5d2e12310ecbaef0fcd20b92200207afeed993 Mon Sep 17 00:00:00 2001 From: satyakwok Date: Tue, 28 Apr 2026 23:20:25 +0200 Subject: [PATCH 1/2] feat(scan): Sourcify verification badge on address page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add lib/sourcify.ts hook + components/common/SourcifyBadge.tsx that queries https://verify.sentrixchain.com/files/any// to detect contract source verification status: - "perfect" → green "verified" badge linking to Sourcify - "partial" → amber "partial match" badge - "none" → hidden by default (no noise on EOAs/unverified contracts) Wired into [locale]/address/[addr]/page.tsx PageHeader actions next to the existing label kind chip. Self-hosted Sourcify deployed on vps4 supports both mainnet (7119) and testnet (7120). All 8 canonical contract deployments (WSRX, Multicall3, TokenFactory, SentrixSafe across both chains) verified with perfect bytecode match. Tested: typecheck clean, build OK, scan service restarted. --- .../scan/app/[locale]/address/[addr]/page.tsx | 23 +++--- apps/scan/components/common/SourcifyBadge.tsx | 70 ++++++++++++++++++ apps/scan/lib/sourcify.ts | 71 +++++++++++++++++++ 3 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 apps/scan/components/common/SourcifyBadge.tsx create mode 100644 apps/scan/lib/sourcify.ts diff --git a/apps/scan/app/[locale]/address/[addr]/page.tsx b/apps/scan/app/[locale]/address/[addr]/page.tsx index bb0725c..b6045c2 100644 --- a/apps/scan/app/[locale]/address/[addr]/page.tsx +++ b/apps/scan/app/[locale]/address/[addr]/page.tsx @@ -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"; @@ -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 ( - - {label.kind} - - ); - })() : undefined} + actions={ +
+ {label && (() => { + const tone = toneForKind(label.kind); + return ( + + {label.kind} + + ); + })()} + {/* Sourcify verification badge — only meaningful for contract addresses, but harmless on EOAs (returns "unverified" badge) */} + +
+ } /> {/* Address bar */} diff --git a/apps/scan/components/common/SourcifyBadge.tsx b/apps/scan/components/common/SourcifyBadge.tsx new file mode 100644 index 0000000..662d343 --- /dev/null +++ b/apps/scan/components/common/SourcifyBadge.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { ShieldCheck, ShieldAlert } from "lucide-react"; +import { useSourcifyStatus, sourcifyContractUrl } from "@/lib/sourcify"; +import type { NetworkId } from "@/lib/chain"; + +interface SourcifyBadgeProps { + network: NetworkId; + address: string; + /** If false, the component renders nothing. Default true. */ + show?: boolean; + /** If true, also renders a quiet "unverified" badge when not verified. Default false (hides on EOAs / unverified contracts to avoid noise). */ + showUnverified?: boolean; +} + +/** Sourcify verification badge. Reads /files/any from the self-hosted Sourcify server. + * - perfect: bytecode + metadata match + * - partial: bytecode matches but metadata differs (e.g. compiler optimization) + * - none: not verified + */ +export function SourcifyBadge({ network, address, show = true, showUnverified = false }: SourcifyBadgeProps) { + const { match, loading } = useSourcifyStatus(network, address); + + if (!show) return null; + if (loading) return null; // hide silently while loading; render once we know + + // Hide entirely on EOAs / unverified contracts to avoid noise. Caller can opt into showing. + if (match === "none" && !showUnverified) return null; + + if (match === "perfect") { + return ( + + + verified + + ); + } + + if (match === "partial") { + return ( + + + partial match + + ); + } + + // not verified — quiet badge + return ( + + + unverified + + ); +} diff --git a/apps/scan/lib/sourcify.ts b/apps/scan/lib/sourcify.ts new file mode 100644 index 0000000..00fefcc --- /dev/null +++ b/apps/scan/lib/sourcify.ts @@ -0,0 +1,71 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { NetworkId } from "./chain"; + +export type SourcifyMatch = "perfect" | "partial" | "none"; + +const SOURCIFY_URL = "https://verify.sentrixchain.com"; + +const CHAIN_FOR_NETWORK: Record = { + mainnet: "7119", + testnet: "7120", +}; + +/** Hook: query Sourcify for verification status of a contract. + * Returns "perfect" if bytecode matches source 1:1 + metadata, + * "partial" if bytecode matches but metadata differs, + * "none" if not verified or unreachable. + */ +export function useSourcifyStatus(network: NetworkId, address: string | undefined): { + match: SourcifyMatch; + loading: boolean; +} { + const [match, setMatch] = useState("none"); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!address) { + setLoading(false); + return; + } + let cancelled = false; + const chain = CHAIN_FOR_NETWORK[network]; + + (async () => { + try { + // /files/any returns "full" / "partial" / 404 + const res = await fetch(`${SOURCIFY_URL}/files/any/${chain}/${address}`, { + signal: AbortSignal.timeout(5000), + }); + if (cancelled) return; + + if (res.ok) { + const body = await res.json(); + const status = body?.status; + if (status === "full") setMatch("perfect"); + else if (status === "partial") setMatch("partial"); + else setMatch("none"); + } else { + setMatch("none"); + } + } catch { + if (!cancelled) setMatch("none"); + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [network, address]); + + return { match, loading }; +} + +/** External link to Sourcify verification page for a contract */ +export function sourcifyContractUrl(network: NetworkId, address: string): string { + const chain = CHAIN_FOR_NETWORK[network]; + return `${SOURCIFY_URL}/files/any/${chain}/${address}`; +} From 08a15ae9518d1d61d0052f3cdbac74e398513bdc Mon Sep 17 00:00:00 2001 From: satyakwok Date: Thu, 30 Apr 2026 02:05:55 +0200 Subject: [PATCH 2/2] feat(scan): supply / forks / epochs pages + finality + rail badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new top-level pages aimed at "explorer that listing teams + delegators + technical reviewers can read top-to-bottom and understand". Same data sources the rest of the explorer already uses (no new backend endpoints required for Phase 1): - /supply — single canonical SRX breakdown URL. Headline stat row, donut distribution (circulating / premine / bonded / remaining-to-mint / burnt), per-wallet premine breakdown with addresses pulled from the existing canonical-addresses registry, protocol-sentinel addresses called out. Pulls live values via useStats + useValidators so it reflects real chain state without baking magic numbers into the page. - /forks — fork-activation timeline. Source of truth in lib/forks/registry.ts, hand-synced against U64_MAX_FORK_GATES.md. Each fork gets a card with state (active / scheduled / dormant) + activation height + a non-technical "what changed when" description. Cross-network comparison table at the bottom so drift between mainnet + testnet is visible at a glance. Includes the 2026-04-30 JAIL_CONSENSUS activation (mainnet h=950400, testnet h=1030500) and the deliberately-dormant NFT_TOKENOP_HEIGHT (DO NOT activate marker). - /epochs — current-epoch detail with progress bar (blocks-into-epoch %), total bonded, rewards accrued. Past 12 epochs derived locally from the current epoch_number + EPOCH_LENGTH=28800; entries deep-link to the first / last block of each epoch. Footnote explains what an epoch does (validator-set rotation, reward settlement, slashing window, jail evidence boundary). Two new badge components for the deeper tx/block detail pages we'll wire in next: - RailBadge — distinguishes Sentrix's four transaction rails (EVM call / native SRX transfer / SRC-20 native TokenOp / native StakingOp). Sentrix is one of the only chains where SRC-20 lives at the protocol level alongside ERC-20, so users need to be told which rail they're looking at; classifyRail(tx) helper centralises the heuristic. - FinalityBadge — Sentrix BFT finality ladder (Pending → Included → Justified → Finalized). The "Justified" / "Finalized" distinction is what exchanges and bridges should pin, and no other EVM explorer surfaces it because no other EVM chain has 2/3+1 stake-weighted precommit-supermajority finality at 1s blocks. classifyFinality() helper computes the state from txBlockHeight + latestHeight + hasJustification. Nav: /supply added to the top header ("Supply"); /epochs + /forks linked from the footer Explorer column. i18n nav.supply added in en/id. `pnpm typecheck` clean across the workspace; lint reports only existing warnings unrelated to this PR. --- apps/scan/app/[locale]/epochs/page.tsx | 212 ++++++++++++++ apps/scan/app/[locale]/forks/page.tsx | 148 ++++++++++ apps/scan/app/[locale]/supply/donut.tsx | 58 ++++ apps/scan/app/[locale]/supply/page.tsx | 269 ++++++++++++++++++ apps/scan/components/common/FinalityBadge.tsx | 109 +++++++ apps/scan/components/common/RailBadge.tsx | 101 +++++++ apps/scan/components/layout/footer.tsx | 3 + apps/scan/components/layout/header.tsx | 3 +- apps/scan/lib/forks/registry.ts | 155 ++++++++++ apps/scan/messages/en.json | 5 +- apps/scan/messages/id.json | 5 +- 11 files changed, 1063 insertions(+), 5 deletions(-) create mode 100644 apps/scan/app/[locale]/epochs/page.tsx create mode 100644 apps/scan/app/[locale]/forks/page.tsx create mode 100644 apps/scan/app/[locale]/supply/donut.tsx create mode 100644 apps/scan/app/[locale]/supply/page.tsx create mode 100644 apps/scan/components/common/FinalityBadge.tsx create mode 100644 apps/scan/components/common/RailBadge.tsx create mode 100644 apps/scan/lib/forks/registry.ts diff --git a/apps/scan/app/[locale]/epochs/page.tsx b/apps/scan/app/[locale]/epochs/page.tsx new file mode 100644 index 0000000..7e5a39b --- /dev/null +++ b/apps/scan/app/[locale]/epochs/page.tsx @@ -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>(() => { + 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 ( +
+ + + {/* ── Headline stats ─────────────────────────── */} +
+ + + + +
+ + {/* ── Current-epoch detail ──────────────────── */} + + {epoch && stats ? ( + <> + + + {epoch.start_height.toLocaleString()} →{" "} + {epoch.end_height.toLocaleString()} + + } + hint={`${currentBlocksIn.toLocaleString()} of ${EPOCH_LENGTH.toLocaleString()} blocks produced — ${currentProgressPct.toFixed(1)}% complete.`} + /> +
+
+
+
+
+ + + + + ) : ( +
Loading…
+ )} + + + {/* ── Past epochs ──────────────────────────── */} + + Past {PAST_TO_SHOW} epochs + + } + > +
+ + + + + + + + + + + {past.map((e) => ( + + + + + + + ))} + {past.length === 0 && ( + + )} + +
EpochFirst blockLast blockBlocks
#{e.n} + + {e.start.toLocaleString()} + + + + {e.end.toLocaleString()} + + {EPOCH_LENGTH.toLocaleString()}
No past epochs yet.
+
+
+ + {/* ── Footnote ──────────────────────────────── */} + +
+

+ 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' claim balances, and + jail evidence (if any) is dispatched as a system transaction. +

+

+ Length is fixed at 28,800 blocks (≈ 8 hours at + our 1-second block target), defined in{" "} + crates/sentrix-staking/src/epoch.rs. +

+
+
+
+ ); +} diff --git a/apps/scan/app/[locale]/forks/page.tsx b/apps/scan/app/[locale]/forks/page.tsx new file mode 100644 index 0000000..0c44b90 --- /dev/null +++ b/apps/scan/app/[locale]/forks/page.tsx @@ -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 ( +
+ + + +

+ 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's currently dormant. +

+
+ + {/* ── Per-fork cards (chronological on this network) ── */} +
+ {sorted.map((f) => { + const state = forkStateAt(f, network, height); + return ; + })} +
+ + {/* ── Cross-network comparison ────────────────── */} + +
+ + + + + + + + + + {FORKS.map((f) => ( + + + + + + ))} + +
ForkMainnetTestnet
{f.title} + {f.heights.mainnet == null + ? dormant + : formatNumber(f.heights.mainnet)} + + {f.heights.testnet == null + ? dormant + : formatNumber(f.heights.testnet)} +
+
+
+
+ ); +} + +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 ( +
+
+
+
{fork.title}
+
{fork.summary}
+
+ + + {stateLabel} + +
+

{fork.description}

+ {fh != null && ( +
+ Activation height ({network}): {fh.toLocaleString()} +
+ )} +
+ ); +} diff --git a/apps/scan/app/[locale]/supply/donut.tsx b/apps/scan/app/[locale]/supply/donut.tsx new file mode 100644 index 0000000..7345275 --- /dev/null +++ b/apps/scan/app/[locale]/supply/donut.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; + +interface Segment { + label: string; + value: number; + color: string; +} + +interface SupplyDonutProps { + segments: Segment[]; +} + +export function SupplyDonut({ segments }: SupplyDonutProps) { + const total = segments.reduce((s, x) => s + x.value, 0) || 1; + const data = segments.map((s) => ({ + name: s.label, + value: s.value, + pct: ((s.value / total) * 100).toFixed(1), + })); + + return ( + + + + {segments.map((s, i) => ( + + ))} + + { + const num = typeof value === "number" ? value : Number(value ?? 0); + const payload = item as { payload?: { pct?: string; name?: string } }; + const pct = payload.payload?.pct ?? "0"; + const name = payload.payload?.name ?? ""; + return [`${num.toLocaleString()} SRX (${pct}%)`, name]; + }} + /> + + + ); +} diff --git a/apps/scan/app/[locale]/supply/page.tsx b/apps/scan/app/[locale]/supply/page.tsx new file mode 100644 index 0000000..6e10584 --- /dev/null +++ b/apps/scan/app/[locale]/supply/page.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { useMemo } from "react"; +import { Coins, Flame, Lock, PieChart, Wallet, ShieldCheck } from "lucide-react"; +import dynamic from "next/dynamic"; +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 { Address } from "@/components/common/Address"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useNetwork } from "@/lib/network-context"; +import { useStats, useValidators } from "@/lib/hooks"; +import { formatSRX, formatNumber } from "@/lib/format"; + +// DECISION: dedicated /supply page so listing platforms (CG / CMC / DefiLlama) +// + due-diligence reviewers can deep-link to a single canonical breakdown URL +// instead of fishing across 3 different docs pages. Mirrors the layout +// Etherscan exposes at /stat/supply but Sentrix-aware: +// - Max supply is the post-tokenomics-v2 cap (315M) once the fork is past; +// - "Locked / Premine" surface the four canonical premine wallets so the +// 20% disclosed premine is publicly auditable; +// - "Bonded" surfaces the live total stake from the validator endpoint; +// - "Burnt" pulls from the chain.info supply tracker. + +const SupplyDonut = dynamic(() => import("./donut").then((m) => m.SupplyDonut), { + ssr: false, + loading: () => , +}); + +interface PremineEntry { + label: string; + address: string; + /** Disclosed premine size in SRX. Pulled from `founder-private/CANONICAL_ADDRESSES.md`. */ + amount: number; +} + +const PREMINE_WALLETS: PremineEntry[] = [ + { + label: "Founder (vesting 1y cliff + 4y linear)", + address: "0x5b5b06688dcdbe532353ac610aaff41af825279d", + amount: 21_000_000, + }, + { + label: "Sentrix Ecosystem Fund", + address: "0xeb70fdefd00fdb768dec06c478f450c351499f14", + amount: 21_000_000, + }, + { + label: "Validator Incentive Pool", + address: "0x328d56b8174697ef6c9e40e19b7663797e16fa47", + amount: 10_500_000, + }, + { + label: "Strategic Reserve", + address: "0x2578cad17e3e56c2970a5b5eab45952439f5ba97", + amount: 10_500_000, + }, +]; + +const PREMINE_TOTAL = PREMINE_WALLETS.reduce((s, e) => s + e.amount, 0); + +export default function SupplyPage() { + const { network } = useNetwork(); + const { data: stats, loading: statsLoading } = useStats(network); + const { data: validators } = useValidators(network); + + // Bonded = sum of all validator stake (self_stake + total_delegated). The + // /validators endpoint returns these per-validator, so the dashboard sums. + const bonded = useMemo(() => { + if (!validators) return 0; + // ValidatorData.stake is total bonded (self + delegations) per the + // /validators endpoint shape — see `lib/api.ts` interface ValidatorData. + return validators.reduce((sum, v) => sum + (v.stake ?? 0), 0); + }, [validators]); + + const max = stats?.max_supply_srx ?? 315_000_000; + const minted = stats?.total_minted_srx ?? 0; + const burnt = stats?.total_burned_srx ?? 0; + // Circulating excludes burnt + premine that is still locked. Until vesting + // contracts go on-chain we approximate "locked" as the static premine total + // (~63M); once the founder vesting contract deploys and we can read the + // vested-vs-unvested split on-chain, this should switch to that. + const circulatingApprox = Math.max(0, minted - burnt - PREMINE_TOTAL); + const remainingToMint = Math.max(0, max - minted); + + const segments = [ + { label: "Circulating (approx)", value: circulatingApprox, color: "var(--green)" }, + { label: "Premine / Locked", value: PREMINE_TOTAL, color: "var(--gold)" }, + { label: "Bonded (Staked)", value: bonded, color: "var(--blue)" }, + { label: "Remaining to Mint", value: remainingToMint, color: "var(--tx-d)" }, + { label: "Burnt", value: burnt, color: "var(--pink)" }, + ]; + + return ( +
+ + + {/* ── Headline stat row ─────────────────────────── */} +
+ + + + +
+ + {/* ── Donut + breakdown ─────────────────────────── */} +
+ +
+ +
    + {segments.map((s) => ( +
  • + + + {s.label} + + {formatSRX(s.value)} +
  • + ))} +
+
+
+ + +
+

+ Max supply is fixed by the on-chain tokenomics-v2 fork at{" "} + 315,000,000 SRX. Anything above + that cannot be minted by any path on the protocol — it's enforced in{" "} + crates/sentrix-core/src/blockchain.rs. +

+

+ Total minted is what the chain has issued so far across genesis + + every block reward. Burnt is the running fee-burn counter — half + of every native transaction fee is destroyed instead of credited to the validator. +

+

+ Bonded sums every active validator's self-stake + delegations + from the staking registry. It's the live number any delegator-facing + calculator (APR, slashing exposure) should pin against, not a snapshot. +

+

+ Premine below is the disclosed 20% allocation set in genesis. The + founder slot is on a 1-year cliff + 4-year linear vest; the others were liquid at + genesis. We surface the wallet addresses so the schedule is auditable on-chain. +

+
+
+
+ + {/* ── Premine breakdown ─────────────────────────── */} + + Premine wallets ({formatSRX(PREMINE_TOTAL)}) + + } + > +
+ {PREMINE_WALLETS.map((w) => ( + +
+ {formatSRX(w.amount)} +
+ } + /> + ))} +
+
+ + {/* ── Sentinels (no private key) ────────────────── */} + + Protocol sentinels + + } + > +
+

+ These addresses hold balance for protocol-level book-keeping and have no private key. + They appear in tx history when a TokenOp / Stake op routes through them. +

+
    +
  • +
    + Sentrix Token Op (sentinel) +
  • +
  • +
    + Protocol Treasury (Reward Escrow) +
  • +
  • +
    + Sentrix Staking (sentinel) +
  • +
+
+
+ + {/* ── Numerical reference ────────────────────────── */} + + + {(BigInt(max) * 100_000_000n).toString()} sentri + + } + mono + hint="1 SRX = 100,000,000 sentri (8-decimal native ledger)." + /> + + + + 0 + ? `${((bonded / minted) * 100).toFixed(2)}%` + : "—" + } + last + hint="Bonded ÷ Total minted. Higher = more SRX securing the chain." + /> + +
+ ); +} diff --git a/apps/scan/components/common/FinalityBadge.tsx b/apps/scan/components/common/FinalityBadge.tsx new file mode 100644 index 0000000..e42c767 --- /dev/null +++ b/apps/scan/components/common/FinalityBadge.tsx @@ -0,0 +1,109 @@ +import { CheckCircle2, Clock, Layers, Shield } from "lucide-react"; +import { cn } from "@/lib/utils"; + +// Sentrix BFT finality has four observable states, modelled after the +// way Tendermint-derived chains expose them but renamed to plain English so +// non-technical users get it on first read: +// +// pending — tx is in the mempool, no block has included it +// included — tx is inside a block, the block is at the chain head +// justified — block has 2/3+1 stake-weighted precommit signatures +// attached (fast finality threshold met for this height) +// finalized — at least one descendant block has been justified, so a +// conflicting block at this height can no longer reach +// supermajority without slashing → safe to consider settled +// +// "Finalized" is what exchanges + bridges should wait for. We surface the +// distinction prominently because it's a Sentrix-specific value-prop other +// EVM chains don't expose. Keeping the colours separate from `StatusBadge` +// (success/failed) so they can be shown together — a tx can be "Success + +// Finalized" or "Success + Justified" or "Failed + Included" etc. + +export type Finality = "pending" | "included" | "justified" | "finalized"; + +const STYLES: Record = { + pending: { + bg: "bg-yellow-500/10", + text: "text-yellow-500", + border: "border-yellow-500/20", + icon: Clock, + label: "Pending", + tooltip: "In the mempool — not yet included in a block.", + }, + included: { + bg: "bg-[color-mix(in_oklab,var(--blue)_12%,transparent)]", + text: "text-[var(--blue)]", + border: "border-[color-mix(in_oklab,var(--blue)_25%,transparent)]", + icon: Layers, + label: "Included", + tooltip: "Sealed inside a block at the chain head. Wait for justification before treating as settled.", + }, + justified: { + bg: "bg-[color-mix(in_oklab,var(--gold)_12%,transparent)]", + text: "text-[var(--gold)]", + border: "border-[color-mix(in_oklab,var(--gold)_25%,transparent)]", + icon: Shield, + label: "Justified", + tooltip: "Block has the 2/3+1 stake-weighted precommit supermajority. Reverting requires slashing.", + }, + finalized: { + bg: "bg-green-500/10", + text: "text-green-500", + border: "border-green-500/20", + icon: CheckCircle2, + label: "Finalized", + tooltip: "A descendant block has been justified — this height is settled. Safe for exchanges + bridges.", + }, +}; + +interface FinalityBadgeProps { + finality: Finality; + size?: "sm" | "md"; + className?: string; +} + +export function FinalityBadge({ finality, size = "sm", className }: FinalityBadgeProps) { + const cfg = STYLES[finality]; + const Icon = cfg.icon; + return ( + + + {cfg.label} + + ); +} + +/** Compute the BFT finality state for a tx from the chain head + the block + * the tx was included in. Caller passes: + * - `txBlockHeight` — null if the tx is still pending in the mempool. + * - `latestHeight` — current chain head as the explorer sees it. + * - `hasJustification` — true if the tx's block carries a precommit- + * supermajority justification (every block produced + * under Voyager ships one, but historical pre-fork + * blocks don't, so the caller should consult the + * block payload, not assume). + * + * We call a height "finalized" if at least one descendant block has its own + * justification — keeping the rule simple instead of walking the precommit + * graph, since under Voyager every block is justified at production time. + */ +export function classifyFinality(opts: { + txBlockHeight: number | null; + latestHeight: number | null; + hasJustification: boolean; +}): Finality { + if (opts.txBlockHeight == null) return "pending"; + if (!opts.hasJustification) return "included"; + if (opts.latestHeight != null && opts.latestHeight > opts.txBlockHeight) return "finalized"; + return "justified"; +} diff --git a/apps/scan/components/common/RailBadge.tsx b/apps/scan/components/common/RailBadge.tsx new file mode 100644 index 0000000..d636177 --- /dev/null +++ b/apps/scan/components/common/RailBadge.tsx @@ -0,0 +1,101 @@ +import { Boxes, Coins, Cpu, Landmark } from "lucide-react"; +import { cn } from "@/lib/utils"; + +// DECISION: Sentrix is one of the only chains where SRC-20 (native TokenOps) +// and ERC-20 (EVM) live side-by-side at the protocol level. A user staring +// at a tx page has to be told which rail this tx travelled on — otherwise the +// "I sent SRX but Etherscan-style decoded view shows nothing" confusion is +// the dominant support ticket. Four rails: +// +// - "evm" — eth_sendRawTransaction call against an EVM contract +// - "native" — basic native SRX transfer (no token op encoded) +// - "token" — native SRC-20 op (Mint / Burn / Transfer / Approve / Deploy) +// - "stake" — native StakingOp (Delegate / Undelegate / ClaimRewards / +// RegisterValidator / AddSelfStake / Unjail) +// +// Colours pull from the chain's existing accent palette so the badge feels +// native to the explorer, not a slap-on add-on. + +export type Rail = "evm" | "native" | "token" | "stake"; + +const STYLES: Record = { + evm: { + bg: "bg-[color-mix(in_oklab,var(--blue)_12%,transparent)]", + text: "text-[var(--blue)]", + border: "border-[color-mix(in_oklab,var(--blue)_25%,transparent)]", + icon: Cpu, + label: "EVM", + tooltip: "EVM transaction — executed by the embedded revm runtime against a Solidity / Vyper contract.", + }, + native: { + bg: "bg-muted", + text: "text-muted-foreground", + border: "border-[var(--brd)]", + icon: Boxes, + label: "Native", + tooltip: "Native Sentrix transfer — moves SRX between two accounts without invoking a contract.", + }, + token: { + bg: "bg-[color-mix(in_oklab,var(--gold)_12%,transparent)]", + text: "text-[var(--gold)]", + border: "border-[color-mix(in_oklab,var(--gold)_25%,transparent)]", + icon: Coins, + label: "SRC-20", + tooltip: "Native SRC-20 token operation (Mint / Burn / Transfer / Approve / Deploy) — runs at the protocol level, not via revm.", + }, + stake: { + bg: "bg-[color-mix(in_oklab,var(--green)_12%,transparent)]", + text: "text-[var(--green)]", + border: "border-[color-mix(in_oklab,var(--green)_25%,transparent)]", + icon: Landmark, + label: "Staking", + tooltip: "Native staking operation (Delegate / Undelegate / Claim / RegisterValidator / AddSelfStake / Unjail) — applied directly against the stake registry.", + }, +}; + +interface RailBadgeProps { + rail: Rail; + size?: "sm" | "md"; + className?: string; + /** Override the default label (e.g. "Delegate" instead of generic "Staking"). */ + label?: string; +} + +export function RailBadge({ rail, size = "sm", className, label }: RailBadgeProps) { + const cfg = STYLES[rail]; + const Icon = cfg.icon; + const displayLabel = label ?? cfg.label; + return ( + + + {displayLabel} + + ); +} + +/** Heuristic: classify a tx into a rail from the data + to_address fields the + * REST API returns. Centralised so every page that renders a rail badge + * agrees on the answer. + * + * - to_address == 0x0000…0000 sentinel → SRC-20 TokenOp + * - to_address == 0x0000…0100 sentinel → Native StakingOp + * - data starts with "EVM:" prefix → EVM call + * - everything else → Native SRX transfer + */ +export function classifyRail(tx: { to_address?: string | null; data?: string | null }): Rail { + const to = (tx.to_address ?? "").toLowerCase(); + if (to === "0x0000000000000000000000000000000000000000") return "token"; + if (to === "0x0000000000000000000000000000000000000100") return "stake"; + if (tx.data && tx.data.startsWith("EVM:")) return "evm"; + return "native"; +} diff --git a/apps/scan/components/layout/footer.tsx b/apps/scan/components/layout/footer.tsx index 58e300f..5b37265 100644 --- a/apps/scan/components/layout/footer.tsx +++ b/apps/scan/components/layout/footer.tsx @@ -32,6 +32,9 @@ export function Footer() { Validators Tokens Leaderboard + Supply + Epochs + Fork History diff --git a/apps/scan/components/layout/header.tsx b/apps/scan/components/layout/header.tsx index 1b00441..022ab31 100644 --- a/apps/scan/components/layout/header.tsx +++ b/apps/scan/components/layout/header.tsx @@ -138,11 +138,12 @@ export function Header() { setLangOpen(false); } - const NAV_LINKS: { href: "/blocks" | "/validators" | "/tokens" | "/" | "/analytics"; key: keyof IntlMessages["nav"] }[] = [ + const NAV_LINKS: { href: "/blocks" | "/validators" | "/tokens" | "/" | "/analytics" | "/supply"; key: keyof IntlMessages["nav"] }[] = [ { href: "/", key: "home" }, { href: "/blocks", key: "blocks" }, { href: "/validators", key: "validators" }, { href: "/tokens", key: "tokens" }, + { href: "/supply", key: "supply" }, { href: "/analytics", key: "analytics" }, ]; diff --git a/apps/scan/lib/forks/registry.ts b/apps/scan/lib/forks/registry.ts new file mode 100644 index 0000000..d7376e5 --- /dev/null +++ b/apps/scan/lib/forks/registry.ts @@ -0,0 +1,155 @@ +// Authoritative fork-activation registry. Same source-of-truth that +// `founder-private/U64_MAX_FORK_GATES.md` documents — kept in sync by hand +// for now (any time we ship a new fork const in `crates/sentrix-core`, we +// add an entry here too). +// +// Each entry pairs an env-var name with the height the fork activated on +// each network. `null` for a network means "still parked at u64::MAX (not +// activated)" — the UI renders that as "Dormant" in the timeline. +// +// Sources for the heights below: BIBLE.md hardfork table + jail-consensus +// activation note in `U64_MAX_FORK_GATES.md`. + +import type { NetworkId } from "../chain"; + +export interface ForkEntry { + /** Stable identifier — used as the React key + URL fragment. */ + id: string; + /** Display name (the env-var name, with dashes for readability). */ + title: string; + /** Short one-line description aimed at non-technical users. */ + summary: string; + /** Fully expanded description for the detail panel. */ + description: string; + /** Per-network activation height; null means dormant on that network. */ + heights: Record; + /** Risk class — "shipped", "dormant", "danger" (do-not-activate). */ + state: "shipped" | "dormant" | "danger"; +} + +export const FORKS: ForkEntry[] = [ + { + id: "state-root", + title: "STATE_ROOT_FORK_HEIGHT", + summary: "Block hash starts committing to the post-block account state root.", + description: + "Before this fork the block hash was computed without including the trie state root, " + + "so two validators could disagree on state and still produce identical block hashes — " + + "any disagreement only surfaced via balance queries. After activation the trie root is " + + "part of the block hash, so any state divergence triggers an immediate `previous_hash` " + + "mismatch on the next block and prevents silent forks.", + heights: { mainnet: 100_000, testnet: 100_000 }, + state: "shipped", + }, + { + id: "legacy-validation", + title: "SENTRIX_LEGACY_VALIDATION_HEIGHT", + summary: "Closes the txid_index backfill startup hang (#268).", + description: + "Below this height the node skips strict re-validation of historical blocks during MDBX " + + "warm-up, because the pre-cutover history was produced under looser invariants. From this " + + "height onward every block goes through the strict validator at boot.", + heights: { mainnet: 557_144, testnet: 0 }, + state: "shipped", + }, + { + id: "voyager", + title: "VOYAGER_FORK_HEIGHT", + summary: "Activates DPoS proposer rotation + 3-phase BFT finality.", + description: + "Replaces the bootstrap Pioneer round-robin proposer with Voyager — Tendermint-style " + + "Propose → Prevote → Precommit → Finalize over a DPoS-elected validator set. From this " + + "height onward every block carries a `BlockJustification` with the precommits that " + + "finalised it, and finality at 2/3+1 stake weight is observable on-chain.", + heights: { mainnet: 579_047, testnet: 10 }, + state: "shipped", + }, + { + id: "voyager-evm", + title: "VOYAGER_EVM_HEIGHT", + summary: "Turns on the embedded revm runtime for `eth_sendRawTransaction`.", + description: + "Before this fork the chain ran native Sentrix transactions only. After activation the " + + "node embeds a revm interpreter and accepts standard EVM transactions, so Hardhat / Foundry " + + "/ ethers.js / viem dApps work without any Sentrix-specific tooling.", + heights: { mainnet: 579_060, testnet: 752 }, + state: "shipped", + }, + { + id: "reward-v2", + title: "VOYAGER_REWARD_V2_HEIGHT", + summary: "Coinbase routes to the protocol treasury; rewards are claimed.", + description: + "Pre-fork the block reward was credited directly to the proposer. Post-fork the coinbase " + + "deposits 1 SRX into the protocol treasury at `0x0000…0002`, and validators + delegators " + + "claim their accrued share via `StakingOp::ClaimRewards`. Keeps stake registry rewards " + + "and account balances in lock-step without per-block payouts.", + heights: { mainnet: 590_100, testnet: 100 }, + state: "shipped", + }, + { + id: "tokenomics-v2", + title: "TOKENOMICS_V2_HEIGHT", + summary: "Max supply moves to 315M; halving aligns with BTC-parity 4y schedule.", + description: + "Pre-fork the cap was 210M with 42M-block halvings. Post-fork: 315M cap, 126M-block " + + "halving (~4 years at 1s blocks). Tightens the issuance curve to BTC-parity for the long " + + "tail and restores the 20% premine ratio.", + heights: { mainnet: 640_800, testnet: 381_651 }, + state: "shipped", + }, + { + id: "bft-gate-relax", + title: "BFT_GATE_RELAX_HEIGHT", + summary: "BFT can run with ⌈2/3 × N⌉ active validators instead of full mesh.", + description: + "Originally BFT activation required every active validator to be connected at startup. " + + "Post-fork the gate relaxes to ⌈2/3 × N⌉, so the chain can recover from a single-validator " + + "outage without a coordinated halt-and-restart.", + heights: { mainnet: 692_700, testnet: 551_500 }, + state: "shipped", + }, + { + id: "add-self-stake", + title: "ADD_SELF_STAKE_HEIGHT", + summary: "Validators can top up `self_stake` from their wallet without a phantom mint.", + description: + "Recovery path for validators that fall under `MIN_SELF_STAKE`: bond real SRX into " + + "`self_stake` directly via `StakingOp::AddSelfStake`. Pre-fork the only path was the " + + "`force-unjail` CLI, which mutated the registry without a corresponding balance debit and " + + "left the supply invariant slightly off.", + heights: { mainnet: 731_245, testnet: 0 }, + state: "shipped", + }, + { + id: "jail-consensus", + title: "JAIL_CONSENSUS_HEIGHT", + summary: "Jail decisions become consensus state via `JailEvidenceBundle` system txs.", + description: + "Replaces the legacy local-only `check_liveness` jail trigger with an epoch-boundary " + + "system transaction (`StakingOp::JailEvidenceBundle`, sender `PROTOCOL_TREASURY`, " + + "dispatch recompute-and-compare for auth). Every node applies identical jail state " + + "post-fork — closes the divergence class observed when LivenessTracker was per-node " + + "in-memory.", + heights: { mainnet: 950_400, testnet: 1_030_500 }, + state: "shipped", + }, + { + id: "nft-tokenop", + title: "NFT_TOKENOP_HEIGHT", + summary: "SRC-721 + SRC-1155 dispatch (DO NOT activate yet).", + description: + "Wire format + Pass-1 gate shipped; Pass-2 dispatch + storage layer is still " + + "`unreachable!()`, so any non-`u64::MAX` value here would crash every validator at apply " + + "time. Stays dormant until the follow-up PR ships the apply path.", + heights: { mainnet: null, testnet: null }, + state: "danger", + }, +]; + +export function forkStateAt(fork: ForkEntry, network: NetworkId, height: number): "active" | "scheduled" | "dormant" { + const fh = fork.heights[network]; + if (fh == null) return "dormant"; + if (height >= fh) return "active"; + return "scheduled"; +} diff --git a/apps/scan/messages/en.json b/apps/scan/messages/en.json index 3a640fa..c354d95 100644 --- a/apps/scan/messages/en.json +++ b/apps/scan/messages/en.json @@ -6,7 +6,8 @@ "tokens": "Tokens", "leaderboard": "Leaderboard", "analytics": "Analytics", - "search_placeholder": "Search by block / tx / address..." + "search_placeholder": "Search by block / tx / address...", + "supply": "Supply" }, "leaderboard": { "eyebrow": "Accounts", @@ -195,4 +196,4 @@ "github": "GitHub", "chain": "Sentrix Chain" } -} +} \ No newline at end of file diff --git a/apps/scan/messages/id.json b/apps/scan/messages/id.json index b5a6540..62d18f0 100644 --- a/apps/scan/messages/id.json +++ b/apps/scan/messages/id.json @@ -6,7 +6,8 @@ "tokens": "Token", "leaderboard": "Leaderboard", "analytics": "Analitik", - "search_placeholder": "Cari berdasarkan blok / tx / alamat..." + "search_placeholder": "Cari berdasarkan blok / tx / alamat...", + "supply": "Suplai" }, "leaderboard": { "eyebrow": "Akun", @@ -195,4 +196,4 @@ "github": "GitHub", "chain": "Sentrix Chain" } -} +} \ No newline at end of file