From 48c52361b25a825f61b3574a55b60250427bc704 Mon Sep 17 00:00:00 2001 From: satyakwok Date: Tue, 5 May 2026 12:52:51 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(stats):=20swap=20REST=20/sentrix=5Fsta?= =?UTF-8?q?tus=5Fextended=20=E2=86=92=20pure=20gRPC=20v0.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README's "zero JSON-RPC, zero JS glue" claim was aspirational — StatsDashboard's validator/mempool fields and SupplyBar's minted total were both bridged through REST `/sentrix_status_extended` because proto v0.1 didn't ship the corresponding RPCs. Chain v2.1.72 (sentrix-labs/sentrix#472) lands `GetValidatorSet`, `GetSupply`, `GetMempool` as pure-read gRPC methods. This commit swaps the explorer's stats hot path onto them — no more REST in StatsDashboard or StatsPanel. Block height + avg block time still ride EVM `eth_blockNumber` / `eth_getBlockByNumber`. Both are 100% in proto territory via `GetBlock(latest)` but the existing JSON-RPC path works and the follow-up to swap them is a separate commit (would touch `compute_avg_block_time_ms` + the EvmProvider trait). Proto in `proto/sentrix.proto` synced from chain. tonic-build regenerates `pb::{GetValidatorSet, GetSupply, GetMempool, ValidatorSet, Supply, Mempool, ValidatorEntry, MempoolEntry, ...}` — see grpc/client.rs for the new wrappers. Bundle size impact: marginal — same tonic infra, three more proto types. **Stays on a feature branch** until chain v2.1.72 ships to prod (grpc.sentrixchain.com:443). Merging earlier would 502 the stats cards because the gRPC endpoint would respond `unimplemented`. --- proto/sentrix.proto | 73 +++++++++++++++++++++++++++++++ src/components/stats.rs | 24 ++-------- src/components/stats_dashboard.rs | 65 +++++++++------------------ src/grpc/client.rs | 27 +++++++++++- 4 files changed, 124 insertions(+), 65 deletions(-) diff --git a/proto/sentrix.proto b/proto/sentrix.proto index 44bffb5..f6c33d4 100644 --- a/proto/sentrix.proto +++ b/proto/sentrix.proto @@ -134,6 +134,18 @@ service Sentrix { // the pending-aware nonce behaviour from chain v2.1.57). rpc GetBalance(GetBalanceRequest) returns (Account); + // v0.4 read-only state queries — drop the REST `/sentrix_status_extended` + // bridge from the explorer's stats hot path. All three are pure reads + // off `state.read()` snapshots; same lock contention profile as + // GetBlock/GetBalance. + // + // Active validator set + per-validator stake/active/jailed flags. + rpc GetValidatorSet(GetValidatorSetRequest) returns (ValidatorSet); + // Native-token supply snapshot (minted, burned, circulating). + rpc GetSupply(GetSupplyRequest) returns (Supply); + // Pending-tx count + a capped header window for UI display. + rpc GetMempool(GetMempoolRequest) returns (Mempool); + // Server-streaming chain events. Subscribe once, receive every event type // until cancel or server restart. Replaces N separate eth_subscribe calls. // Backpressure: server bounded channel per stream (capacity 4096 — same as @@ -182,3 +194,64 @@ enum EventFilter { EVENT_FILTER_VALIDATOR_SET = 3; EVENT_FILTER_LOG = 4; } + +// ──────────────────────────────────────────────────────────── +// v0.4 read-only state-query messages +// ──────────────────────────────────────────────────────────── + +message GetValidatorSetRequest { + // Reserved for at_height historical reads — gated on MDBX snapshot + // isolation (Refactor 5). v0.4 returns the latest finalized set. + optional BlockHeight at_height = 1; +} + +message ValidatorSet { + uint32 epoch = 1; + uint32 active_count = 2; + uint32 total_count = 3; + // Total stake across the active set, in sentri (10^-8 SRX). + uint64 total_active_stake_sentri = 4; + repeated ValidatorEntry validators = 5; +} + +message ValidatorEntry { + Address address = 1; + uint64 stake_sentri = 2; + bool active = 3; + bool jailed = 4; +} + +message GetSupplyRequest { + optional BlockHeight at_height = 1; +} + +message Supply { + // sentri = 10^-8 SRX. Cap is fork-gated (210M pre-fork, 315M post- + // tokenomics-v2 at h=640800); leave conversion to display layer. + uint64 minted_sentri = 1; + uint64 burned_sentri = 2; + uint64 circulating_sentri = 3; // = minted − burned +} + +message GetMempoolRequest { + // Cap returned tx headers; default 100, max 500. Larger windows need + // pagination (deferred). 0 = use server default (100). + uint32 limit = 1; +} + +message Mempool { + // Authoritative pending-tx count. `entries` is capped by `limit`; + // size always reflects the full mempool depth. + uint32 size = 1; + repeated MempoolEntry entries = 2; +} + +message MempoolEntry { + Hash txid = 1; + Address from_address = 2; + Address to_address = 3; + Amount amount = 4; + Amount fee = 5; + uint64 nonce = 6; + uint32 tx_type = 7; +} diff --git a/src/components/stats.rs b/src/components/stats.rs index f6d9011..2c4b6d4 100644 --- a/src/components/stats.rs +++ b/src/components/stats.rs @@ -134,28 +134,12 @@ fn SupplyBar() -> impl IntoView { #[cfg(target_arch = "wasm32")] { + use crate::grpc::client::SentrixGrpcClient; leptos::task::spawn_local(async move { - let endpoint = format!( - "{}/sentrix_status_extended", - crate::state::network::Network::from_host( - &web_sys::window() - .and_then(|w| w.location().host().ok()) - .unwrap_or_default() - ) - .rpc_url() - ); + let mut client = SentrixGrpcClient::new(crate::GRPC_ENDPOINT); loop { - if let Ok(resp) = gloo_net::http::Request::get(&endpoint).send().await { - if resp.ok() { - if let Ok(body) = resp.json::().await { - if let Some(v) = body - .pointer("/supply/minted_sentri") - .and_then(serde_json::Value::as_u64) - { - minted_srx.set(v / 100_000_000); - } - } - } + if let Ok(supply) = client.get_supply().await { + minted_srx.set(supply.minted_sentri / 100_000_000); } crate::util::sleep_ms(5_000).await; } diff --git a/src/components/stats_dashboard.rs b/src/components/stats_dashboard.rs index db8eef3..919ddb1 100644 --- a/src/components/stats_dashboard.rs +++ b/src/components/stats_dashboard.rs @@ -88,9 +88,10 @@ async fn fetch_chain_stats(network: Network) -> Result { }) } -/// Subset of `/sentrix_status_extended` the dashboard cares about. -/// `Default` returns zero-valued fields so a partial-failure path can -/// keep rendering the JSON-RPC-backed cards without short-circuiting. +/// Subset of fields the dashboard cares about. Backed by gRPC v0.4 +/// `GetValidatorSet` + `GetMempool` — no JSON / REST involvement. +/// `Default` keeps the UI rendering zeros rather than blank cards if +/// any single call fails. #[cfg(target_arch = "wasm32")] #[derive(Default)] struct SentrixStatusSubset { @@ -100,52 +101,28 @@ struct SentrixStatusSubset { } #[cfg(target_arch = "wasm32")] -async fn fetch_sentrix_status(network: Network) -> Result { - use gloo_net::http::Request; - use serde_json::Value; +async fn fetch_sentrix_status(_network: Network) -> Result { + use crate::grpc::client::SentrixGrpcClient; - let url = format!("{}/sentrix_status_extended", network.rpc_url()); - let resp = Request::get(&url) - .send() + let mut client = SentrixGrpcClient::new(crate::GRPC_ENDPOINT); + + // Two parallel single-method round-trips beat one bigger query — + // each handler is a single `state.read()` snapshot on the chain + // side, contending for the same lock anyway. Sequential keeps + // futures::join macros out of the bundle. + let validators = client + .get_validator_set() .await - .map_err(|e| FetchError::Rpc(format!("status: {e}")))?; - if !resp.ok() { - return Err(FetchError::Rpc(format!("status http {}", resp.status()))); - } - let body: Value = resp - .json() + .map_err(|s| FetchError::Rpc(format!("validator_set: {}", s.message())))?; + let mempool = client + .get_mempool(0) .await - .map_err(|e| FetchError::Rpc(format!("status decode: {e}")))?; - - // Pull fields defensively — every one of these has a chain-side - // type guarantee, but a future field rename shouldn't crash the - // dashboard. Missing field == 0; UI shows "0 / 0" which is - // recognisable as "endpoint changed shape" without a panic. - let active_validators = body - .pointer("/validators/active_count") - .and_then(Value::as_u64) - .map(|n| u32::try_from(n).unwrap_or(u32::MAX)) - .unwrap_or(0); - - // `top` is capped at 7 by stake-rank server-side; for mainnet's - // current 4-validator set this equals the registered count. Once - // external validators push registered > 7, swap to a dedicated - // count field on the endpoint or a /staking/validators call. - let total_validators = body - .pointer("/validators/top") - .and_then(Value::as_array) - .map(|a| u32::try_from(a.len()).unwrap_or(u32::MAX)) - .unwrap_or(0); - - let mempool_pending = body - .pointer("/mempool/size") - .and_then(Value::as_u64) - .unwrap_or(0); + .map_err(|s| FetchError::Rpc(format!("mempool: {}", s.message())))?; Ok(SentrixStatusSubset { - active_validators, - total_validators, - mempool_pending, + active_validators: validators.active_count, + total_validators: validators.total_count, + mempool_pending: u64::from(mempool.size), }) } diff --git a/src/grpc/client.rs b/src/grpc/client.rs index 492b332..e9d1fc7 100644 --- a/src/grpc/client.rs +++ b/src/grpc/client.rs @@ -11,7 +11,8 @@ use tonic_web_wasm_client::Client as WebClient; use super::pb::{ get_block_request::Selector, sentrix_client::SentrixClient, BlockHeight, EventFilter, - GetBalanceRequest, GetBlockRequest, StreamEventsRequest, + GetBalanceRequest, GetBlockRequest, GetMempoolRequest, GetSupplyRequest, + GetValidatorSetRequest, Mempool, StreamEventsRequest, Supply, ValidatorSet, }; /// The concrete client type after we've wired the wasm transport. @@ -67,6 +68,30 @@ impl SentrixGrpcClient { Ok(resp.into_inner()) } + /// v0.4 — full active set + jail/active flags + per-validator stake. + pub async fn get_validator_set(&mut self) -> Result { + let req = GetValidatorSetRequest { at_height: None }; + let resp = self.inner.get_validator_set(req).await?; + Ok(resp.into_inner()) + } + + /// v0.4 — minted/burned/circulating supply snapshot. + pub async fn get_supply(&mut self) -> Result { + let req = GetSupplyRequest { at_height: None }; + let resp = self.inner.get_supply(req).await?; + Ok(resp.into_inner()) + } + + /// v0.4 — pending-tx size + capped header window. `limit = 0` ⇒ + /// server default (100). Pass a smaller limit for the dashboard + /// header card (just need `size`); pass max 500 for the mempool + /// panel that lists actual entries. + pub async fn get_mempool(&mut self, limit: u32) -> Result { + let req = GetMempoolRequest { limit }; + let resp = self.inner.get_mempool(req).await?; + Ok(resp.into_inner()) + } + /// Server-streaming events. Returns a `Streaming` that the /// caller drains with `.message().await`. Filter list is sent verbatim; /// empty = subscribe-all (server-side filter). From a4fb506dee64b73e13a4f5d25a20da5413b2ad2c Mon Sep 17 00:00:00 2001 From: satyakwok Date: Tue, 5 May 2026 13:01:48 +0200 Subject: [PATCH 2/2] fix: cargo fmt + clippy clean (manual_is_multiple_of in Gregorian conv) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI flagged six `manual_is_multiple_of` lints in `format_unix_ts`'s hand-rolled leap-year calculation — `% N == 0` → `.is_multiple_of(N)` across the year/century/quad-century guards. Plus `cargo fmt` over the new MobileNavLink component signature wraps the args one per line. --- src/components/navbar.rs | 6 +- src/components/stats_dashboard.rs | 7 +- src/labels.rs | 145 ++++++++++++++++++++++-------- src/screens/block_detail.rs | 5 +- 4 files changed, 116 insertions(+), 47 deletions(-) diff --git a/src/components/navbar.rs b/src/components/navbar.rs index de28721..759fed5 100644 --- a/src/components/navbar.rs +++ b/src/components/navbar.rs @@ -122,7 +122,11 @@ pub fn Navbar() -> impl IntoView { } #[component] -fn MobileNavLink(href: &'static str, label_key: &'static str, close: RwSignal) -> impl IntoView { +fn MobileNavLink( + href: &'static str, + label_key: &'static str, + close: RwSignal, +) -> impl IntoView { let lang = use_lang(); view! { impl IntoView { +fn StatCard(label: &'static str, value: String, accent: bool, icon: Icon) -> impl IntoView { let value_class = if accent { "mt-2 font-mono text-2xl font-bold tabular-nums text-sentrix-gold" } else { diff --git a/src/labels.rs b/src/labels.rs index 4c28ccc..0279e94 100644 --- a/src/labels.rs +++ b/src/labels.rs @@ -39,12 +39,8 @@ impl Label { /// dashboard so labels feel native, not bolted-on. pub const fn pill_classes(&self) -> &'static str { match self.kind { - LabelKind::Validator => { - "border-emerald-500/30 bg-emerald-500/10 text-emerald-300" - } - LabelKind::Treasury => { - "border-amber-500/30 bg-amber-500/10 text-amber-300" - } + LabelKind::Validator => "border-emerald-500/30 bg-emerald-500/10 text-emerald-300", + LabelKind::Treasury => "border-amber-500/30 bg-amber-500/10 text-amber-300", LabelKind::Token => "border-violet-500/30 bg-violet-500/10 text-violet-300", LabelKind::Account => "border-sky-500/30 bg-sky-500/10 text-sky-300", } @@ -57,55 +53,91 @@ const SHARED: &[(&str, Label)] = &[ // Premine wallets (v3 — post 2026-04-24 rotation) ( "0x5b5b06688dcdbe532353ac610aaff41af825279d", - Label { name: "Founder v3", kind: LabelKind::Treasury }, + Label { + name: "Founder v3", + kind: LabelKind::Treasury, + }, ), ( "0xeb70fdefd00fdb768dec06c478f450c351499f14", - Label { name: "Ecosystem Fund", kind: LabelKind::Treasury }, + Label { + name: "Ecosystem Fund", + kind: LabelKind::Treasury, + }, ), ( "0x328d56b8174697ef6c9e40e19b7663797e16fa47", - Label { name: "Validator Incentive Pool", kind: LabelKind::Treasury }, + Label { + name: "Validator Incentive Pool", + kind: LabelKind::Treasury, + }, ), ( "0x2578cad17e3e56c2970a5b5eab45952439f5ba97", - Label { name: "Strategic Reserve", kind: LabelKind::Treasury }, + Label { + name: "Strategic Reserve", + kind: LabelKind::Treasury, + }, ), // Governance signing wallet (1-of-1 SentrixSafe owner) ( "0xa25236925bc10954e0519731cc7ba97f4bb5714b", - Label { name: "Authority Wallet", kind: LabelKind::Treasury }, + Label { + name: "Authority Wallet", + kind: LabelKind::Treasury, + }, ), // Mainnet validator operators — names match the systemd unit // identities so logs and dashboards line up. ( "0x753f2f68829fbe76a0132295624f48b27ce2e2d9", - Label { name: "Sentrix Foundation (Validator)", kind: LabelKind::Validator }, + Label { + name: "Sentrix Foundation (Validator)", + kind: LabelKind::Validator, + }, ), ( "0x0804a00f53fde72d46abd1db7ee3e97cbfd0a107", - Label { name: "Sentrix Treasury (Validator)", kind: LabelKind::Validator }, + Label { + name: "Sentrix Treasury (Validator)", + kind: LabelKind::Validator, + }, ), ( "0x87c9976d4b2e360b9fbb87e4bd5442edce2a7511", - Label { name: "Sentrix Core (Validator)", kind: LabelKind::Validator }, + Label { + name: "Sentrix Core (Validator)", + kind: LabelKind::Validator, + }, ), ( "0x4cad4793b25b6bb2c927eddfe911996070c7ce68", - Label { name: "Sentrix Beacon (Validator)", kind: LabelKind::Validator }, + Label { + name: "Sentrix Beacon (Validator)", + kind: LabelKind::Validator, + }, ), // Protocol-reserved sentinels — no private key, consensus-level only. ( "0x0000000000000000000000000000000000000000", - Label { name: "Sentrix Token Op (sentinel)", kind: LabelKind::Treasury }, + Label { + name: "Sentrix Token Op (sentinel)", + kind: LabelKind::Treasury, + }, ), ( "0x0000000000000000000000000000000000000002", - Label { name: "Protocol Treasury (Reward Escrow)", kind: LabelKind::Treasury }, + Label { + name: "Protocol Treasury (Reward Escrow)", + kind: LabelKind::Treasury, + }, ), ( "0x0000000000000000000000000000000000000100", - Label { name: "Sentrix Staking (sentinel)", kind: LabelKind::Treasury }, + Label { + name: "Sentrix Staking (sentinel)", + kind: LabelKind::Treasury, + }, ), ]; @@ -114,23 +146,38 @@ const SHARED: &[(&str, Label)] = &[ const MAINNET_ONLY: &[(&str, Label)] = &[ ( "0x6272dc0c842f05542f9ff7b5443e93c0642a3b26", - Label { name: "SentrixSafe", kind: LabelKind::Treasury }, + Label { + name: "SentrixSafe", + kind: LabelKind::Treasury, + }, ), ( "0xab67e171c0de0cd6dd6fe87e5e399c091f9c9de8", - Label { name: "Sentrix DEX Router", kind: LabelKind::Token }, + Label { + name: "Sentrix DEX Router", + kind: LabelKind::Token, + }, ), ( "0xc5344f0dde0b9916217449ad9222e446475ad936", - Label { name: "Sentrix DEX Factory", kind: LabelKind::Token }, + Label { + name: "Sentrix DEX Factory", + kind: LabelKind::Token, + }, ), ( "0x4693b113e523a196d9579333c4ab8358e2656553", - Label { name: "WSRX", kind: LabelKind::Token }, + Label { + name: "WSRX", + kind: LabelKind::Token, + }, ), ( "0xa79fc9015ae30766ab4d24a5d4d3a0c66f371504", - Label { name: "SGC", kind: LabelKind::Token }, + Label { + name: "SGC", + kind: LabelKind::Token, + }, ), ]; @@ -139,23 +186,38 @@ const MAINNET_ONLY: &[(&str, Label)] = &[ const TESTNET_ONLY: &[(&str, Label)] = &[ ( "0xc9d7a61d7c2f428f6a055916488041fd00532110", - Label { name: "SentrixSafe (Testnet)", kind: LabelKind::Treasury }, + Label { + name: "SentrixSafe (Testnet)", + kind: LabelKind::Treasury, + }, ), ( "0x2bf73491733c3b87d72b16d4f7151da294b55cb0", - Label { name: "Sentrix DEX Router (Testnet)", kind: LabelKind::Token }, + Label { + name: "Sentrix DEX Router (Testnet)", + kind: LabelKind::Token, + }, ), ( "0x8565392086cba8d39cbba1f6f60ad1f1a17651c7", - Label { name: "Sentrix DEX Factory (Testnet)", kind: LabelKind::Token }, + Label { + name: "Sentrix DEX Factory (Testnet)", + kind: LabelKind::Token, + }, ), ( "0x85d5e7694af31c2edd0a7e66b7c6c92c59ff949a", - Label { name: "WtSRX (Testnet)", kind: LabelKind::Token }, + Label { + name: "WtSRX (Testnet)", + kind: LabelKind::Token, + }, ), ( "0x72730453f4080c6ad8def96c06f6074818fb95b5", - Label { name: "SGC (Testnet)", kind: LabelKind::Token }, + Label { + name: "SGC (Testnet)", + kind: LabelKind::Token, + }, ), ]; @@ -239,12 +301,19 @@ mod tests { #[test] fn resolves_per_network() { - let mainnet = - label_for("0xab67e171c0de0cd6dd6fe87e5e399c091f9c9de8", Network::Mainnet); - let testnet = - label_for("0xab67e171c0de0cd6dd6fe87e5e399c091f9c9de8", Network::Testnet); + let mainnet = label_for( + "0xab67e171c0de0cd6dd6fe87e5e399c091f9c9de8", + Network::Mainnet, + ); + let testnet = label_for( + "0xab67e171c0de0cd6dd6fe87e5e399c091f9c9de8", + Network::Testnet, + ); assert!(mainnet.is_some(), "mainnet router should resolve"); - assert!(testnet.is_none(), "mainnet router should not resolve on testnet"); + assert!( + testnet.is_none(), + "mainnet router should not resolve on testnet" + ); } #[test] @@ -253,17 +322,17 @@ mod tests { "0x5B5B06688DCDBE532353AC610AAFF41AF825279D", Network::Mainnet, ); - let bare = label_for( - "5b5b06688dcdbe532353ac610aaff41af825279d", - Network::Mainnet, - ); + let bare = label_for("5b5b06688dcdbe532353ac610aaff41af825279d", Network::Mainnet); assert!(with_prefix.is_some()); assert!(bare.is_some()); } #[test] fn unknown_returns_none() { - let l = label_for("0xdeadbeef0000000000000000000000000000beef", Network::Mainnet); + let l = label_for( + "0xdeadbeef0000000000000000000000000000beef", + Network::Mainnet, + ); assert!(l.is_none()); } diff --git a/src/screens/block_detail.rs b/src/screens/block_detail.rs index da67d6a..47e7b9c 100644 --- a/src/screens/block_detail.rs +++ b/src/screens/block_detail.rs @@ -292,7 +292,8 @@ fn format_unix_ts(ts: u64) -> String { let mut year: u64 = 1970; loop { - let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; + let leap = + (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400); let year_days = if leap { 366 } else { 365 }; if days < year_days { break; @@ -301,7 +302,7 @@ fn format_unix_ts(ts: u64) -> String { year += 1; } - let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; + let leap = (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400); let month_lengths = [ 31u64, if leap { 29 } else { 28 },