From 3badebec0140ba951aecaf5edbe4225e902d51b7 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Thu, 21 May 2026 16:00:58 -0300 Subject: [PATCH 1/5] use serde json, add missing metadata field in faucet response, add explorer data --- Cargo.lock | 1 + bin/network-monitor/Cargo.toml | 49 +-- bin/network-monitor/src/explorer.rs | 351 +++++++++--------- bin/network-monitor/src/faucet.rs | 30 +- bin/network-monitor/src/service_status.rs | 12 +- .../src/view/cards/explorer.rs | 12 +- bin/network-monitor/src/view/cards/faucet.rs | 8 + bin/network-monitor/src/view/mod.rs | 9 +- 8 files changed, 249 insertions(+), 223 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e980114ea4..ea3570685a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3248,6 +3248,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "serde_path_to_error", "sha2", "time", "tokio", diff --git a/bin/network-monitor/Cargo.toml b/bin/network-monitor/Cargo.toml index 45944f0c4e..99ee4e4842 100644 --- a/bin/network-monitor/Cargo.toml +++ b/bin/network-monitor/Cargo.toml @@ -15,27 +15,28 @@ version.workspace = true workspace = true [dependencies] -anyhow = { workspace = true } -axum = { version = "0.8" } -clap = { features = ["env"], workspace = true } -hex = { workspace = true } -humantime = { workspace = true } -maud = { features = ["axum"], version = "0.27" } -miden-node-proto = { workspace = true } -miden-node-utils = { workspace = true } -miden-protocol = { features = ["std", "testing"], workspace = true } -miden-standards = { workspace = true } -miden-testing = { workspace = true } -miden-tx = { features = ["concurrent", "std"], workspace = true } -rand = { workspace = true } -rand_chacha = { workspace = true } -reqwest = { features = ["json", "query"], workspace = true } -serde = { workspace = true } -serde_json = { version = "1.0" } -sha2 = { workspace = true } -time = { features = ["formatting", "macros"], version = "0.3" } -tokio = { features = ["full"], workspace = true } -tonic = { features = ["codegen", "tls-native-roots", "transport"], workspace = true } -tonic-health = { workspace = true } -tracing = { workspace = true } -url = { features = ["serde"], workspace = true } +anyhow = { workspace = true } +axum = { version = "0.8" } +clap = { features = ["env"], workspace = true } +hex = { workspace = true } +humantime = { workspace = true } +maud = { features = ["axum"], version = "0.27" } +miden-node-proto = { workspace = true } +miden-node-utils = { workspace = true } +miden-protocol = { features = ["std", "testing"], workspace = true } +miden-standards = { workspace = true } +miden-testing = { workspace = true } +miden-tx = { features = ["concurrent", "std"], workspace = true } +rand = { workspace = true } +rand_chacha = { workspace = true } +reqwest = { features = ["json", "query"], workspace = true } +serde = { workspace = true } +serde_json = { version = "1.0" } +serde_path_to_error = { version = "0.1" } +sha2 = { workspace = true } +time = { features = ["formatting", "macros"], version = "0.3" } +tokio = { features = ["full"], workspace = true } +tonic = { features = ["codegen", "tls-native-roots", "transport"], workspace = true } +tonic-health = { workspace = true } +tracing = { workspace = true } +url = { features = ["serde"], workspace = true } diff --git a/bin/network-monitor/src/explorer.rs b/bin/network-monitor/src/explorer.rs index d0fcd8f9d8..d8e9827e79 100644 --- a/bin/network-monitor/src/explorer.rs +++ b/bin/network-monitor/src/explorer.rs @@ -1,11 +1,11 @@ // EXPLORER STATUS CHECKER // ================================================================================================ -use std::fmt::{self, Display}; use std::time::Duration; +use anyhow::Context; use reqwest::Client; -use serde::Serialize; +use serde::{Deserialize, Deserializer, Serialize}; use tracing::instrument; use url::Url; @@ -13,20 +13,25 @@ use crate::COMPONENT; use crate::service::Service; use crate::status::{ExplorerStatusDetails, ServiceDetails, ServiceStatus}; -const LATEST_BLOCK_QUERY: &str = " -query LatestBlock { +/// Fetches network-wide totals from `overviewStats` together with the latest block header +/// (number + timestamp + commitments). The latest block is still needed for tip-drift detection +/// against the RPC. +const NETWORK_OVERVIEW_QUERY: &str = " +query NetworkOverview { + overviewStats { + total_count_transactions + total_count_nullifiers + total_count_notes + total_count_account_updates + } blocks(input: { sort_by: timestamp, order_by: desc }, first: 1) { edges { node { block_number timestamp - number_of_transactions - number_of_nullifiers - number_of_notes block_commitment chain_commitment proof_commitment - number_of_account_updates } } } @@ -42,8 +47,8 @@ struct GraphqlRequest { variables: V, } -const LATEST_BLOCK_REQUEST: GraphqlRequest = GraphqlRequest { - query: LATEST_BLOCK_QUERY, +const NETWORK_OVERVIEW_REQUEST: GraphqlRequest = GraphqlRequest { + query: NETWORK_OVERVIEW_QUERY, variables: EmptyVariables, }; @@ -86,7 +91,7 @@ impl Service for ExplorerService { let resp = self .client .post(self.url.clone()) - .json(&LATEST_BLOCK_REQUEST) + .json(&NETWORK_OVERVIEW_REQUEST) .timeout(self.request_timeout) .send() .await; @@ -99,12 +104,7 @@ impl Service for ExplorerService { Err(e) => return ServiceStatus::error(self.name(), e), }; - let value: serde_json::Value = match serde_json::from_str(&body) { - Ok(value) => value, - Err(e) => return ServiceStatus::error(self.name(), format!("{e}: {body}")), - }; - - match ExplorerStatusDetails::try_from(value) { + match parse_response(&body) { Ok(details) => { ServiceStatus::healthy(self.name(), ServiceDetails::ExplorerStatus(details)) }, @@ -113,103 +113,97 @@ impl Service for ExplorerService { } } -#[derive(Debug)] -pub enum ExplorerStatusError { - /// A required field was not present in the response. - NotPresent { field: String, response: String }, - /// A field was present but had an unexpected type. - TypeMismatch { - field: String, - expected: &'static str, - got: String, - }, +/// Deserialize the GraphQL response and project it onto [`ExplorerStatusDetails`]. +/// +/// Uses [`serde_path_to_error`] so that failures point at the specific JSON path that no longer +/// matches the expected shape (e.g. `data.overviewStats.total_count_transactions`). The structs +/// use `#[serde(deny_unknown_fields)]` so that unexpected additions surface in the same way. +fn parse_response(body: &str) -> anyhow::Result { + let mut de = serde_json::Deserializer::from_str(body); + let response: GraphqlResponse = serde_path_to_error::deserialize(&mut de) + .with_context(|| format!("failed to parse explorer response: {body}"))?; + + let block = response + .data + .blocks + .edges + .into_iter() + .next() + .context("explorer returned no blocks")? + .node; + + Ok(ExplorerStatusDetails { + block_number: block.block_number, + timestamp: block.timestamp, + total_transactions: response.data.overview_stats.transactions, + total_nullifiers: response.data.overview_stats.nullifiers, + total_notes: response.data.overview_stats.notes, + total_account_updates: response.data.overview_stats.account_updates, + block_commitment: block.block_commitment, + chain_commitment: block.chain_commitment, + proof_commitment: block.proof_commitment, + }) } -impl Display for ExplorerStatusError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ExplorerStatusError::NotPresent { field, response } => { - write!(f, "field '{field}': not present in response (got: {response})") - }, - ExplorerStatusError::TypeMismatch { field, expected, got } => { - write!(f, "field '{field}': expected {expected}, got {got}") - }, - } - } +// RESPONSE TYPES +// ================================================================================================ + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct GraphqlResponse { + data: T, } -/// Extracts a u64 from a named field. -/// -/// Accepts both numeric values and string-encoded numbers (as returned by the Explorer's -/// GraphQL API). -fn require_u64(node: &serde_json::Value, field: &str) -> Result { - let value = node.get(field).ok_or_else(|| ExplorerStatusError::NotPresent { - field: field.into(), - response: truncate_json(node), - })?; - - value - .as_u64() - .or_else(|| value.as_str().and_then(|s| s.parse().ok())) - .ok_or_else(|| ExplorerStatusError::TypeMismatch { - field: field.into(), - expected: "u64-compatible value", - got: truncate_json(value), - }) +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NetworkOverviewData { + #[serde(rename = "overviewStats")] + overview_stats: OverviewStats, + blocks: BlockConnection, } -/// Extracts a string from a named field. -fn require_str(node: &serde_json::Value, field: &str) -> Result { - let value = node.get(field).ok_or_else(|| ExplorerStatusError::NotPresent { - field: field.into(), - response: truncate_json(node), - })?; - - value - .as_str() - .map(String::from) - .ok_or_else(|| ExplorerStatusError::TypeMismatch { - field: field.into(), - expected: "string", - got: truncate_json(value), - }) +/// The explorer's `BigIntStringScalar` fields are wire-encoded as strings, so we parse them via +/// [`u64_from_str`] rather than relying on serde's numeric deserialization. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct OverviewStats { + #[serde(rename = "total_count_transactions", deserialize_with = "u64_from_str")] + transactions: u64, + #[serde(rename = "total_count_nullifiers", deserialize_with = "u64_from_str")] + nullifiers: u64, + #[serde(rename = "total_count_notes", deserialize_with = "u64_from_str")] + notes: u64, + #[serde(rename = "total_count_account_updates", deserialize_with = "u64_from_str")] + account_updates: u64, } -/// Returns a short string representation of a JSON value for error messages. -/// -/// Truncates the JSON string to at most 60 characters, appending "..." if truncated. -/// Truncation is done at a character boundary to avoid panicking on multi-byte characters. -fn truncate_json(value: &serde_json::Value) -> String { - let s = value.to_string(); - match s.char_indices().nth(60) { - Some((idx, _)) => format!("{}...", &s[..idx]), - None => s, - } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BlockConnection { + edges: Vec, } -impl TryFrom for ExplorerStatusDetails { - type Error = ExplorerStatusError; +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BlockEdge { + node: BlockNode, +} - fn try_from(value: serde_json::Value) -> Result { - let node = value.pointer("/data/blocks/edges/0/node").ok_or_else(|| { - ExplorerStatusError::NotPresent { - field: "data.blocks.edges[0].node".to_string(), - response: truncate_json(&value), - } - })?; - - Ok(Self { - block_number: require_u64(node, "block_number")?, - timestamp: require_u64(node, "timestamp")?, - number_of_transactions: require_u64(node, "number_of_transactions")?, - number_of_nullifiers: require_u64(node, "number_of_nullifiers")?, - number_of_notes: require_u64(node, "number_of_notes")?, - number_of_account_updates: require_u64(node, "number_of_account_updates")?, - block_commitment: require_str(node, "block_commitment")?, - chain_commitment: require_str(node, "chain_commitment")?, - proof_commitment: require_str(node, "proof_commitment")?, - }) - } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BlockNode { + #[serde(deserialize_with = "u64_from_str")] + block_number: u64, + #[serde(deserialize_with = "u64_from_str")] + timestamp: u64, + block_commitment: String, + chain_commitment: String, + proof_commitment: String, +} + +fn u64_from_str<'de, D: Deserializer<'de>>(d: D) -> Result { + let s = String::deserialize(d)?; + s.parse().map_err(serde::de::Error::custom) } // TESTS @@ -217,105 +211,98 @@ impl TryFrom for ExplorerStatusDetails { #[cfg(test)] mod tests { - use serde_json::json; - use super::*; - // truncate_json tests - // -------------------------------------------------------------------------------------------- - - #[test] - fn truncate_json_short_value_is_not_truncated() { - let value = json!({"key": "short"}); - let result = truncate_json(&value); - assert_eq!(result, value.to_string()); - assert!(!result.ends_with("...")); - } - - #[test] - fn truncate_json_long_value_is_truncated() { - let long_string = "a".repeat(100); - let value = json!(long_string); - let result = truncate_json(&value); - assert!(result.ends_with("...")); - // 60 chars + "..." - assert_eq!(result.chars().count(), 63); - } - - #[test] - fn truncate_json_multibyte_chars_are_handled() { - // Each 'é' is 2 bytes in UTF-8. Build a string whose serialized JSON form exceeds 60 - // characters, ensuring truncation lands on a char boundary. - let multibyte_string = "é".repeat(80); - let value = json!(multibyte_string); - // Should not panic and should still truncate correctly. - let result = truncate_json(&value); - assert!(result.ends_with("...")); - } - - #[test] - fn truncate_json_exactly_60_chars_is_not_truncated() { - // Build a JSON string whose serialized form is exactly 60 characters. json!("x".repeat(58)) - // serializes as `"xxx...xxx"` (58 chars + 2 quotes = 60). - let value = json!("x".repeat(58)); - let result = truncate_json(&value); - assert_eq!(result.chars().count(), 60); - assert!(!result.ends_with("...")); - } - - // require_u64 tests - // -------------------------------------------------------------------------------------------- - - #[test] - fn require_u64_from_number() { - let node = json!({"block_number": 42}); - assert_eq!(require_u64(&node, "block_number").unwrap(), 42); + fn sample_response() -> String { + r#"{ + "data": { + "overviewStats": { + "total_count_transactions": "241820", + "total_count_nullifiers": "53060", + "total_count_notes": "125701", + "total_count_account_updates": "241776" + }, + "blocks": { + "edges": [{ + "node": { + "block_number": "1211961", + "timestamp": "1779374922", + "block_commitment": "0x7306", + "chain_commitment": "0x11844a", + "proof_commitment": "0xc2014763" + } + }] + } + } + }"# + .to_string() } #[test] - fn require_u64_from_string() { - let node = json!({"block_number": "42"}); - assert_eq!(require_u64(&node, "block_number").unwrap(), 42); + fn parse_full_response() { + let details = parse_response(&sample_response()).unwrap(); + assert_eq!(details.block_number, 1_211_961); + assert_eq!(details.timestamp, 1_779_374_922); + assert_eq!(details.total_transactions, 241_820); + assert_eq!(details.total_nullifiers, 53_060); + assert_eq!(details.total_notes, 125_701); + assert_eq!(details.total_account_updates, 241_776); + assert_eq!(details.block_commitment, "0x7306"); + assert_eq!(details.chain_commitment, "0x11844a"); + assert_eq!(details.proof_commitment, "0xc2014763"); } #[test] - fn require_u64_missing_field() { - let node = json!({}); - let err = require_u64(&node, "block_number").unwrap_err(); + fn missing_field_error_includes_path() { + let body = sample_response().replace("total_count_transactions", "txn_count"); + let err = parse_response(&body).unwrap_err(); + let msg = format!("{err:#}"); assert!( - matches!(err, ExplorerStatusError::NotPresent { field, .. } if field == "block_number") + msg.contains("data.overviewStats"), + "error should locate the failing path, got: {msg}", ); } #[test] - fn require_u64_wrong_type() { - let node = json!({"block_number": [1, 2, 3]}); - let err = require_u64(&node, "block_number").unwrap_err(); + fn unknown_field_is_rejected() { + let body = sample_response().replace( + "\"total_count_transactions\": \"241820\",", + "\"total_count_transactions\": \"241820\", \"unexpected_field\": 1,", + ); + let err = parse_response(&body).unwrap_err(); + let msg = format!("{err:#}"); assert!( - matches!(err, ExplorerStatusError::TypeMismatch { field, .. } if field == "block_number") + msg.contains("unexpected_field"), + "error should name the unknown field, got: {msg}", ); } - // require_str tests - // -------------------------------------------------------------------------------------------- - - #[test] - fn require_str_valid() { - let node = json!({"name": "hello"}); - assert_eq!(require_str(&node, "name").unwrap(), "hello"); - } - #[test] - fn require_str_missing_field() { - let node = json!({}); - let err = require_str(&node, "name").unwrap_err(); - assert!(matches!(err, ExplorerStatusError::NotPresent { field, .. } if field == "name")); + fn empty_blocks_array_is_an_error() { + let body = r#"{ + "data": { + "overviewStats": { + "total_count_transactions": "1", + "total_count_nullifiers": "1", + "total_count_notes": "1", + "total_count_account_updates": "1" + }, + "blocks": { "edges": [] } + } + }"#; + let err = parse_response(body).unwrap_err(); + let msg = format!("{err:#}"); + assert!(msg.contains("no blocks"), "got: {msg}"); } #[test] - fn require_str_wrong_type() { - let node = json!({"name": 123}); - let err = require_str(&node, "name").unwrap_err(); - assert!(matches!(err, ExplorerStatusError::TypeMismatch { field, .. } if field == "name")); + fn non_numeric_string_is_rejected() { + let body = sample_response().replace("\"241820\"", "\"not-a-number\""); + let err = parse_response(&body).unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains("total_count_transactions"), + "error should point at the failing field, got: {msg}", + ); } } diff --git a/bin/network-monitor/src/faucet.rs b/bin/network-monitor/src/faucet.rs index f1ec3c2a28..9a59237ee0 100644 --- a/bin/network-monitor/src/faucet.rs +++ b/bin/network-monitor/src/faucet.rs @@ -42,7 +42,12 @@ pub struct FaucetTestDetails { } /// Response from the faucet's `/pow` endpoint. +/// +/// `deny_unknown_fields` makes the monitor flag schema drift loudly — a new field on the faucet +/// response will fail deserialization and surface in the error message, instead of being +/// silently dropped. #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct PowChallengeResponse { challenge: String, target: u64, @@ -52,6 +57,7 @@ struct PowChallengeResponse { /// Response from the faucet's `/get_tokens` endpoint. #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct GetTokensResponse { tx_id: String, #[expect(dead_code)] // Note ID is part of API response but not used in monitoring @@ -59,7 +65,12 @@ struct GetTokensResponse { } /// Response from the faucet's `/get_metadata` endpoint. +/// +/// Field set mirrors the faucet's `GetMetadataResponse` in +/// `bin/faucet/src/api/get_metadata.rs` on the `next` branch. Keep these in sync; the +/// `deny_unknown_fields` attribute will surface any drift loudly. #[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct GetMetadataResponse { pub version: String, pub id: String, @@ -68,6 +79,7 @@ pub struct GetMetadataResponse { pub explorer_url: Option, pub pow_load_difficulty: u64, pub base_amount: u64, + pub note_transport_url: Option, } // FAUCET TEST TASK @@ -201,7 +213,7 @@ async fn perform_faucet_test( debug!("Faucet PoW response: {}", response_text); let challenge_response: PowChallengeResponse = - serde_json::from_str(&response_text).context("unexpected response from /pow")?; + parse_faucet_response(&response_text).context("unexpected response from /pow")?; debug!( "Received PoW challenge: target={}, challenge={}...", @@ -231,7 +243,7 @@ async fn perform_faucet_test( debug!("Faucet /get_tokens response: {}", response_text); let tokens_response: GetTokensResponse = - serde_json::from_str(&response_text).context("unexpected response from /get_tokens")?; + parse_faucet_response(&response_text).context("unexpected response from /get_tokens")?; // Step 4: Get faucet metadata let metadata_url = faucet_url.join("/get_metadata")?; @@ -242,11 +254,23 @@ async fn perform_faucet_test( debug!("Faucet /get_metadata response: {}", response_text); let metadata: GetMetadataResponse = - serde_json::from_str(&response_text).context("unexpected response from /get_metadata")?; + parse_faucet_response(&response_text).context("unexpected response from /get_metadata")?; Ok((tokens_response, metadata)) } +/// Deserialize a faucet response using [`serde_path_to_error`] so that the failing JSON path +/// (e.g. `max_supply`, `explorer_url`) is included in the error message. Combined with +/// `#[serde(deny_unknown_fields)]` on each response type, this means renamed, removed, or newly +/// added fields all surface a precise field name rather than a generic "unexpected response". +fn parse_faucet_response(body: &str) -> anyhow::Result +where + T: for<'de> Deserialize<'de>, +{ + let mut de = serde_json::Deserializer::from_str(body); + serde_path_to_error::deserialize(&mut de).with_context(|| format!("response body: {body}")) +} + /// Solves a proof-of-work challenge using SHA-256 hashing. /// /// # Arguments diff --git a/bin/network-monitor/src/service_status.rs b/bin/network-monitor/src/service_status.rs index 4863b857c0..0f45600ccf 100644 --- a/bin/network-monitor/src/service_status.rs +++ b/bin/network-monitor/src/service_status.rs @@ -191,14 +191,18 @@ pub struct CounterTrackingDetails { } /// Details of the explorer service. +/// +/// The `total_*` counters are network-wide totals sourced from the explorer's `overviewStats` +/// query, not per-block counts. `block_number`, `timestamp` and the commitments are still +/// taken from the latest block so that tip-drift against the RPC can be detected. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ExplorerStatusDetails { pub block_number: u64, pub timestamp: u64, - pub number_of_transactions: u64, - pub number_of_nullifiers: u64, - pub number_of_notes: u64, - pub number_of_account_updates: u64, + pub total_transactions: u64, + pub total_nullifiers: u64, + pub total_notes: u64, + pub total_account_updates: u64, pub block_commitment: String, pub chain_commitment: String, pub proof_commitment: String, diff --git a/bin/network-monitor/src/view/cards/explorer.rs b/bin/network-monitor/src/view/cards/explorer.rs index 51051488a9..8395e11fdf 100644 --- a/bin/network-monitor/src/view/cards/explorer.rs +++ b/bin/network-monitor/src/view/cards/explorer.rs @@ -70,14 +70,14 @@ pub(in crate::view) fn render_explorer( } } (metric_row( - "Transactions:", - &num_or_dash(stats.number_of_transactions, healthy), + "Total Transactions:", + &num_or_dash(stats.total_transactions, healthy), )) - (metric_row("Nullifiers:", &num_or_dash(stats.number_of_nullifiers, healthy))) - (metric_row("Notes:", &num_or_dash(stats.number_of_notes, healthy))) + (metric_row("Total Nullifiers:", &num_or_dash(stats.total_nullifiers, healthy))) + (metric_row("Total Notes:", &num_or_dash(stats.total_notes, healthy))) (metric_row( - "Account Updates:", - &num_or_dash(stats.number_of_account_updates, healthy), + "Total Account Updates:", + &num_or_dash(stats.total_account_updates, healthy), )) } } diff --git a/bin/network-monitor/src/view/cards/faucet.rs b/bin/network-monitor/src/view/cards/faucet.rs index 24494ab119..11522eec0a 100644 --- a/bin/network-monitor/src/view/cards/faucet.rs +++ b/bin/network-monitor/src/view/cards/faucet.rs @@ -79,6 +79,14 @@ fn render_faucet_metadata(metadata: &GetMetadataResponse, healthy: bool) -> Mark } } } + @if let Some(url) = &metadata.note_transport_url { + div class="metric-row" { + span class="metric-label" { "Note Transport URL:" } + span class="metric-value" { + a href=(url) target="_blank" rel="noopener noreferrer" { (url) } + } + } + } } } } diff --git a/bin/network-monitor/src/view/mod.rs b/bin/network-monitor/src/view/mod.rs index a166d64c17..a2292fe66e 100644 --- a/bin/network-monitor/src/view/mod.rs +++ b/bin/network-monitor/src/view/mod.rs @@ -394,6 +394,7 @@ mod tests { explorer_url: Some("https://explorer.example".to_string()), pow_load_difficulty: 4, base_amount: 100, + note_transport_url: Some("https://note-transport.example".to_string()), }), }; let html = render(vec![healthy("faucet", ServiceDetails::FaucetTest(details))]); @@ -433,10 +434,10 @@ mod tests { let details = ExplorerStatusDetails { block_number: 100, timestamp: 1_609_459_200, - number_of_transactions: 1, - number_of_nullifiers: 1, - number_of_notes: 1, - number_of_account_updates: 1, + total_transactions: 1, + total_nullifiers: 1, + total_notes: 1, + total_account_updates: 1, block_commitment: "0x".repeat(20), chain_commitment: "0x".repeat(20), proof_commitment: "0x".repeat(20), From 584bcb869ec81a19743696a2c92e0d4c2b5dd338 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Thu, 21 May 2026 16:05:54 -0300 Subject: [PATCH 2/5] prevent copy buttom from wrapping --- bin/network-monitor/assets/index.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bin/network-monitor/assets/index.css b/bin/network-monitor/assets/index.css index a4662ffa9f..30275f5b29 100644 --- a/bin/network-monitor/assets/index.css +++ b/bin/network-monitor/assets/index.css @@ -427,6 +427,16 @@ body { font-weight: 500; } +/* When the value carries an inline action (currently only the copy button), keep them on the + same line. Without this `.metric-value` is inline, and the whitespace between the value text + and the button is a wrap opportunity — so long values (URLs in particular) push the button + onto its own row below. */ +.metric-value:has(.copy-button) { + display: inline-flex; + align-items: center; + gap: 4px; +} + .metric-value.warning-delta, .warning-text { color: var(--color-warning); From 73c0853607471696dab933504da01a74e519e315 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Thu, 21 May 2026 16:47:45 -0300 Subject: [PATCH 3/5] in-memory account for ntxs checks --- bin/network-monitor/.env | 2 - bin/network-monitor/README.md | 68 +++++-------- bin/network-monitor/src/cli/commands.rs | 2 +- bin/network-monitor/src/config.rs | 19 ---- bin/network-monitor/src/counter.rs | 110 +++++++++++----------- bin/network-monitor/src/deploy/counter.rs | 10 -- bin/network-monitor/src/deploy/mod.rs | 73 +++----------- bin/network-monitor/src/deploy/wallet.rs | 18 +--- bin/network-monitor/src/explorer.rs | 6 +- bin/network-monitor/src/faucet.rs | 4 +- bin/network-monitor/src/main.rs | 8 +- bin/network-monitor/src/monitor/tasks.rs | 20 +++- 12 files changed, 113 insertions(+), 227 deletions(-) diff --git a/bin/network-monitor/.env b/bin/network-monitor/.env index 8b2e04c849..3c92248811 100644 --- a/bin/network-monitor/.env +++ b/bin/network-monitor/.env @@ -16,8 +16,6 @@ MIDEN_MONITOR_FAUCET_URL=https://faucet-api.devnet.miden.io/ MIDEN_MONITOR_FAUCET_TEST_INTERVAL=2m # network transaction checks MIDEN_MONITOR_DISABLE_NTX_SERVICE=false -MIDEN_MONITOR_COUNTER_FILEPATH=counter_account.mac -MIDEN_MONITOR_WALLET_FILEPATH=wallet_account.mac MIDEN_MONITOR_COUNTER_INCREMENT_INTERVAL=30s MIDEN_MONITOR_COUNTER_LATENCY_TIMEOUT=2m # explorer checks diff --git a/bin/network-monitor/README.md b/bin/network-monitor/README.md index 257ab473bb..91a0aa2b0b 100644 --- a/bin/network-monitor/README.md +++ b/bin/network-monitor/README.md @@ -42,8 +42,6 @@ miden-network-monitor start --faucet-url http://localhost:8080 --enable-otel - `--stale-chain-tip-threshold`: Maximum time without a chain tip update before marking RPC as unhealthy (default: `1m`) - `--port, -p`: Web server port (default: `3000`) - `--enable-otel`: Enable OpenTelemetry tracing -- `--wallet-filepath`: Path where the wallet account is located (default: `wallet_account.mac`) -- `--counter-filepath`: Path where the network account is located (default: `counter_program.mac`) - `--counter-increment-interval`: Interval at which to send the increment counter transaction (default: `30s`) - `--counter-latency-timeout`: Maximum time to wait for a counter update after submitting a transaction (default: `2m`) - `--help, -h`: Show help information @@ -68,8 +66,6 @@ If command-line arguments are not provided, the application falls back to enviro - `MIDEN_MONITOR_STALE_CHAIN_TIP_THRESHOLD`: Maximum time without a chain tip update before marking RPC as unhealthy - `MIDEN_MONITOR_PORT`: Web server port - `MIDEN_MONITOR_ENABLE_OTEL`: Enable OpenTelemetry tracing -- `MIDEN_MONITOR_WALLET_FILEPATH`: Path where the wallet account is located -- `MIDEN_MONITOR_COUNTER_FILEPATH`: Path where the network account is located - `MIDEN_MONITOR_COUNTER_INCREMENT_INTERVAL`: Interval at which to send the increment counter transaction - `MIDEN_MONITOR_COUNTER_LATENCY_TIMEOUT`: Maximum time to wait for a counter update after submitting a transaction @@ -96,20 +92,17 @@ miden-network-monitor start --port 8080 \ --rpc.url "http://localhost:50051" -# Enable network transaction service (both increment and tracking) with custom account file paths +# Enable network transaction service (both increment and tracking) miden-network-monitor start \ - --wallet-filepath my_wallet.mac \ - --counter-filepath my_network_account.mac \ --rpc-url https://testnet.miden.io:443 ``` -**Optional Counter Account Management (only when counter is enabled):** -When `--disable-ntx-service=false` or unset, the monitor ensures required network transaction service account exists before starting the task: -1. If file is missing, creates new counter account: - - Network account with the increment procedure -2. Saves network account to the specified file using the Miden `AccountFile` format -3. Deploys accounts to the network via RPC (if not already deployed) -4. The network account contract authorizes increments only from a whitelisted wallet account +**Counter Account Management (only when counter is enabled):** +When `--disable-ntx-service=false` or unset, the monitor creates a fresh wallet and counter +account in memory on every startup, deploys the counter to the network, and holds both +accounts entirely in memory. The accounts are never persisted to disk; restarting the monitor +always provisions new ones, and the counter value on the dashboard restarts from zero. The +counter contract authorizes increments only from the wallet that owns it. ## Usage @@ -128,8 +121,6 @@ miden-network-monitor start \ --faucet-test-interval 2m \ --status-check-interval 3s \ --port 8080 \ - --wallet-filepath my_wallet.mac \ - --counter-filepath my_counter.mac \ --enable-otel # Get help @@ -145,8 +136,6 @@ MIDEN_MONITOR_REMOTE_PROVER_URLS="http://localhost:50052" miden-network-monitor # Multiple remote provers, faucet testing, and network transaction service MIDEN_MONITOR_REMOTE_PROVER_URLS="http://localhost:50052,http://localhost:50053,http://localhost:50054" \ MIDEN_MONITOR_FAUCET_URL="http://localhost:8080" \ -MIDEN_MONITOR_WALLET_FILEPATH="my_wallet.mac" \ -MIDEN_MONITOR_COUNTER_FILEPATH="my_counter.mac" \ MIDEN_MONITOR_DISABLE_NTX_SERVICE=false \ miden-network-monitor start ``` @@ -269,46 +258,31 @@ The dashboard automatically probes RPC and Remote Prover services every 30 secon ## Account Management -When the network transaction service is enabled, the monitor manages the necessary Miden accounts: +When the network transaction service is enabled, the monitor provisions the necessary Miden +accounts entirely in memory. ### Created Accounts **Network Account:** - Implements a simple counter with increment functionality -- Includes authentication logic that restricts access to the network account -- Uses custom MASM script with account ID-based authorization -- Automatically created if not present +- Authorizes increments only from the wallet account that owns it (account ID-based check) +- Uses a custom MASM script **Wallet Account:** - Uses RpoFalcon512 authentication scheme -- Contains authentication keys for transaction signing -- Automatically created if not present +- Holds the authentication keys for signing increment transactions -### Account File Management +### Lifecycle -The monitor automatically: -1. Checks for existing account files on startup -2. Creates new accounts if files don't exist -3. Deploys accounts to the network via RPC -4. Saves the wallet and counter contract account in the specified file paths (default: `wallet_account.mac` and `counter_program.mac`) +On every startup the monitor: +1. Generates a fresh wallet/counter account pair in memory. +2. Deploys the counter to the network via RPC. +3. Holds both accounts in memory; they are never written to disk. -### Example Usage - -```bash -# Start monitor with counter task and default account files -miden-network-monitor start --rpc-url https://testnet.miden.io:443 - -# Start monitor with custom account file paths -miden-network-monitor start \ - --disable-ntx-service=false \ - --rpc-url https://testnet.miden.io:443 \ - --wallet-filepath my_wallet.mac \ - --counter-filepath my_counter.mac - -# The generated files can be loaded in Miden applications: -# - wallet_account.mac: Contains the wallet account with authentication keys -# - counter_program.mac: Contains the counter program account -``` +If increment transactions fail repeatedly, the monitor regenerates both accounts in memory +(same flow as startup) and the tracker switches to the new counter automatically. Restarting +the monitor always provisions fresh accounts, so the dashboard's counter value starts at +zero after each restart. ## Future Monitor Items diff --git a/bin/network-monitor/src/cli/commands.rs b/bin/network-monitor/src/cli/commands.rs index 50bfeaf405..abc67d78b7 100644 --- a/bin/network-monitor/src/cli/commands.rs +++ b/bin/network-monitor/src/cli/commands.rs @@ -24,7 +24,7 @@ impl Cli { /// Execute the parsed command. pub async fn execute(self) -> anyhow::Result<()> { match self.command { - Command::Start(config) => start_monitor(config).await, + Command::Start(config) => Box::pin(start_monitor(config)).await, } } } diff --git a/bin/network-monitor/src/config.rs b/bin/network-monitor/src/config.rs index 7739f33ccf..4db46ab3c7 100644 --- a/bin/network-monitor/src/config.rs +++ b/bin/network-monitor/src/config.rs @@ -3,7 +3,6 @@ //! This module contains the configuration structures and constants for the network monitor. //! Configuration for the monitor. -use std::path::PathBuf; use std::time::Duration; use clap::Parser; @@ -119,24 +118,6 @@ pub struct MonitorConfig { )] pub disable_ntx_service: bool, - /// Path for the counter program network account file. - #[arg( - long = "counter-filepath", - env = "MIDEN_MONITOR_COUNTER_FILEPATH", - default_value = "counter_program.mac", - help = "Path where the counter account is located" - )] - pub counter_filepath: PathBuf, - - /// Path for the wallet account file. - #[arg( - long = "wallet-filepath", - env = "MIDEN_MONITOR_WALLET_FILEPATH", - default_value = "wallet_account.mac", - help = "Path where the wallet account is located" - )] - pub wallet_filepath: PathBuf, - /// The interval at which to send the increment counter transaction. #[arg( long = "counter-increment-interval", diff --git a/bin/network-monitor/src/counter.rs b/bin/network-monitor/src/counter.rs index 2d829235ba..eaa1a0b317 100644 --- a/bin/network-monitor/src/counter.rs +++ b/bin/network-monitor/src/counter.rs @@ -3,7 +3,6 @@ //! This module contains the implementation for periodically incrementing the counter //! of the network account deployed at startup by creating and submitting network notes. -use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; @@ -13,7 +12,7 @@ use miden_node_proto::clients::RpcClient; use miden_node_proto::generated::rpc::BlockHeaderByNumberRequest; use miden_node_proto::generated::transaction::ProvenTransaction; use miden_protocol::account::auth::AuthSecretKey; -use miden_protocol::account::{Account, AccountCode, AccountFile, AccountHeader, AccountId}; +use miden_protocol::account::{Account, AccountCode, AccountHeader, AccountId}; use miden_protocol::asset::AssetVault; use miden_protocol::block::{BlockHeader, BlockNumber}; use miden_protocol::crypto::dsa::falcon512_poseidon2::SecretKey; @@ -38,12 +37,16 @@ use miden_tx::auth::BasicAuthenticator; use miden_tx::{LocalTransactionProver, TransactionExecutor}; use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha20Rng; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, watch}; use tracing::{error, info, instrument, warn}; use crate::config::MonitorConfig; use crate::deploy::counter::COUNTER_SLOT_NAME; -use crate::deploy::{MonitorDataStore, create_genesis_aware_rpc_client}; +use crate::deploy::{ + MonitorDataStore, + create_and_deploy_accounts, + create_genesis_aware_rpc_client, +}; use crate::service::Service; use crate::status::{ CounterTrackingDetails, @@ -134,17 +137,27 @@ pub struct IncrementService { details: IncrementDetails, expected_counter_value: Arc, latency_state: Arc>, + /// Publishes the current counter account to [`CounterTrackingService`]. A new value is sent + /// whenever the increment task regenerates accounts after persistent failures, so the tracker + /// can switch to the new account ID without polling disk. + counter_sender: watch::Sender, } impl IncrementService { pub async fn new( config: MonitorConfig, + wallet_account: Account, + secret_key: SecretKey, + counter_account: Account, + counter_sender: watch::Sender, expected_counter_value: Arc, latency_state: Arc>, ) -> Result { let mut rpc_client = create_genesis_aware_rpc_client(&config.rpc_url, config.request_timeout).await?; - let (tx, details) = setup_increment_task(config.clone(), &mut rpc_client).await?; + let (tx, details) = + setup_increment_task(wallet_account, secret_key, counter_account, &mut rpc_client) + .await?; Ok(Self { config, rpc_client, @@ -153,6 +166,7 @@ impl IncrementService { details, expected_counter_value, latency_state, + counter_sender, }) } @@ -205,6 +219,10 @@ impl IncrementService { } /// Regenerate accounts from scratch when re-sync is ineffective. + /// + /// Builds a fresh wallet/counter pair in memory, deploys the counter to the network, swaps + /// the local [`TxBuilder`] state, and publishes the new counter on [`Self::counter_sender`] + /// so the tracker switches over without polling disk. #[instrument( parent = None, target = COMPONENT, @@ -214,18 +232,25 @@ impl IncrementService { err, )] async fn try_regenerate_accounts(&mut self) -> Result<()> { - crate::deploy::force_recreate_accounts( - &self.config.wallet_filepath, - &self.config.counter_filepath, - &self.config.rpc_url, + let (wallet_account, secret_key, counter_account) = + create_and_deploy_accounts(&self.config.rpc_url) + .await + .context("failed to regenerate accounts")?; + + let (tx, details) = setup_increment_task( + wallet_account, + secret_key, + counter_account.clone(), + &mut self.rpc_client, ) - .await - .context("failed to regenerate accounts")?; - - let (tx, details) = setup_increment_task(self.config.clone(), &mut self.rpc_client).await?; + .await?; self.tx = tx; self.details = details; + self.counter_sender + .send(counter_account) + .context("counter tracker dropped before regeneration completed")?; + info!("account regeneration completed, increment task re-initialized"); Ok(()) } @@ -372,6 +397,8 @@ pub struct CounterTrackingService { config: MonitorConfig, rpc_client: RpcClient, counter_account: Account, + /// Observes regenerations of the counter account from [`IncrementService`]. + counter_receiver: watch::Receiver, details: CounterTrackingDetails, expected_counter_value: Arc, latency_state: Arc>, @@ -380,13 +407,13 @@ pub struct CounterTrackingService { impl CounterTrackingService { pub async fn new( config: MonitorConfig, + counter_receiver: watch::Receiver, expected_counter_value: Arc, latency_state: Arc>, ) -> Result { let mut rpc_client = create_genesis_aware_rpc_client(&config.rpc_url, config.request_timeout).await?; - let counter_account = load_counter_account(&config.counter_filepath) - .context("Failed to load counter account")?; + let counter_account = counter_receiver.borrow().clone(); let mut details = CounterTrackingDetails::default(); initialize_tracking_state( @@ -401,24 +428,20 @@ impl CounterTrackingService { config, rpc_client, counter_account, + counter_receiver, details, expected_counter_value, latency_state, }) } - /// The increment service regenerates accounts on persistent failure and rewrites the counter - /// account file. If the file's account ID has changed, switch to the new account and reset + /// If [`IncrementService`] regenerated accounts and published a new counter, adopt it and reset /// tracking state. async fn reload_counter_account_if_changed(&mut self) { - let reloaded = match load_counter_account(&self.config.counter_filepath) { - Ok(account) => account, - Err(e) => { - warn!(err = ?e, "failed to reload counter account file"); - return; - }, - }; - + if !self.counter_receiver.has_changed().unwrap_or(false) { + return; + } + let reloaded = self.counter_receiver.borrow_and_update().clone(); if reloaded.id() == self.counter_account.id() { return; } @@ -426,7 +449,7 @@ impl CounterTrackingService { info!( old.id = %self.counter_account.id(), new.id = %reloaded.id(), - "counter account file changed, resetting tracking state", + "counter account changed, resetting tracking state", ); self.counter_account = reloaded; self.details = CounterTrackingDetails::default(); @@ -542,30 +565,15 @@ impl Service for CounterTrackingService { // SETUP // ================================================================================================ -/// Load wallet + counter accounts, fetch the genesis block header, and build the data store and -/// increment script needed to produce network notes. +/// Fetch the genesis block header and build the data store + increment script needed to produce +/// network notes from a freshly-created wallet/counter pair. The accounts are passed in already +/// constructed by [`create_and_deploy_accounts`]; there is no file I/O. async fn setup_increment_task( - config: MonitorConfig, + wallet_account: Account, + secret_key: SecretKey, + counter_account: Account, rpc_client: &mut RpcClient, ) -> Result<(TxBuilder, IncrementDetails)> { - let wallet_account_file = - AccountFile::read(config.wallet_filepath).context("Failed to read wallet account file")?; - let wallet_account = fetch_wallet_account(rpc_client, wallet_account_file.account.id()) - .await? - .unwrap_or(wallet_account_file.account.clone()); - - let AuthSecretKey::Falcon512Poseidon2(secret_key) = wallet_account_file - .auth_secret_keys - .first() - .expect("wallet account file should have one auth secret key") - .clone() - else { - anyhow::bail!("Failed to load wallet account, auth secret key not found") - }; - - let counter_account = load_counter_account(&config.counter_filepath) - .inspect_err(|e| error!("Failed to load counter account: {:?}", e))?; - let block_header = get_genesis_block_header(rpc_client).await?; let increment_script = create_increment_script()?; @@ -910,14 +918,6 @@ fn build_account_storage( AccountStorage::new(slots).context("failed to create account storage") } -/// Load counter account from file. -fn load_counter_account(file_path: &Path) -> Result { - let account_file = - AccountFile::read(file_path).context("Failed to read counter account file")?; - - Ok(account_file.account.clone()) -} - /// Create the increment procedure script. fn create_increment_script() -> Result { let script = diff --git a/bin/network-monitor/src/deploy/counter.rs b/bin/network-monitor/src/deploy/counter.rs index 5479f15898..9578c5afdc 100644 --- a/bin/network-monitor/src/deploy/counter.rs +++ b/bin/network-monitor/src/deploy/counter.rs @@ -1,14 +1,11 @@ //! Counter program account creation functionality. -use std::path::Path; - use anyhow::Result; use miden_protocol::account::component::AccountComponentMetadata; use miden_protocol::account::{ Account, AccountBuilder, AccountComponent, - AccountFile, AccountId, AccountStorageMode, AccountType, @@ -71,10 +68,3 @@ pub fn create_counter_account(owner_account_id: AccountId) -> Result { Ok(counter_account) } - -/// Save counter program account to disk without extra auth material. -pub fn save_counter_account(account: &Account, file_path: &Path) -> Result<()> { - let account_file = AccountFile::new(account.clone(), vec![]); - account_file.write(file_path)?; - Ok(()) -} diff --git a/bin/network-monitor/src/deploy/mod.rs b/bin/network-monitor/src/deploy/mod.rs index 06963c1e55..e4c8c60a86 100644 --- a/bin/network-monitor/src/deploy/mod.rs +++ b/bin/network-monitor/src/deploy/mod.rs @@ -3,7 +3,6 @@ //! This module contains functionality for deploying Miden accounts to the network. use std::collections::{BTreeSet, HashMap}; -use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -14,6 +13,7 @@ use miden_node_proto::generated::transaction::ProvenTransaction; use miden_protocol::account::{Account, AccountId, PartialAccount, StorageMapKey}; use miden_protocol::asset::{AssetVaultKey, AssetWitness}; use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::crypto::dsa::falcon512_poseidon2::SecretKey; use miden_protocol::crypto::merkle::mmr::{MmrPeaks, PartialMmr}; use miden_protocol::note::{NoteScript, NoteScriptRoot}; use miden_protocol::transaction::{AccountInputs, InputNotes, PartialBlockchain, TransactionArgs}; @@ -32,8 +32,8 @@ use tracing::instrument; use url::Url; use crate::COMPONENT; -use crate::deploy::counter::{create_counter_account, save_counter_account}; -use crate::deploy::wallet::{create_wallet_account, save_wallet_account}; +use crate::deploy::counter::create_counter_account; +use crate::deploy::wallet::create_wallet_account; pub mod counter; pub mod wallet; @@ -93,75 +93,24 @@ pub async fn create_genesis_aware_rpc_client( Ok(rpc_client) } -/// Ensure accounts exist, creating them if they don't. +/// Create a fresh wallet + counter pair in memory and deploy the counter to the network. /// -/// This function checks if the wallet and counter account files exist. -/// If they don't exist, it creates new accounts and saves them to the specified files. -/// If they do exist, it does nothing. -/// -/// # Arguments -/// -/// * `wallet_file` - Path to the wallet account file. -/// * `counter_file` - Path to the counter program account file. -/// -/// # Returns -/// -/// `Ok(())` if the accounts exist or were successfully created, or an error if creation fails. -pub async fn ensure_accounts_exist( - wallet_filepath: &Path, - counter_filepath: &Path, - rpc_url: &Url, -) -> Result<()> { - let wallet_exists = wallet_filepath.exists(); - let counter_exists = counter_filepath.exists(); +/// Used both at startup and by the increment task when accounts are fundamentally outdated +/// (e.g., after a network reset) and re-syncing from the RPC is not sufficient. The accounts +/// are never persisted to disk; the monitor re-creates them on every restart. +pub async fn create_and_deploy_accounts(rpc_url: &Url) -> Result<(Account, SecretKey, Account)> { + tracing::info!("Creating fresh monitor accounts"); - if wallet_exists && counter_exists { - tracing::info!("Account files already exist, skipping account creation"); - return Ok(()); - } - - tracing::info!("Account files not found, creating new accounts"); - - // Create wallet account let (wallet_account, secret_key) = create_wallet_account()?; - - // Create counter program account let counter_account = create_counter_account(wallet_account.id())?; deploy_counter_account(&counter_account, rpc_url).await?; tracing::info!("Successfully created and deployed accounts"); - // Save accounts to files - save_wallet_account(&wallet_account, &secret_key, wallet_filepath)?; - save_counter_account(&counter_account, counter_filepath) -} - -/// Unconditionally creates fresh wallet and counter accounts, deploys the counter, and saves both -/// to disk. Unlike [`ensure_accounts_exist`], this always replaces existing account files. -/// -/// Used by the increment task when accounts are fundamentally outdated (e.g., after a network -/// reset) and re-syncing from the RPC is not sufficient. -pub async fn force_recreate_accounts( - wallet_filepath: &Path, - counter_filepath: &Path, - rpc_url: &Url, -) -> Result<()> { - tracing::warn!("Regenerating monitor accounts (force recreate)"); - - let (wallet_account, secret_key) = create_wallet_account()?; - let counter_account = create_counter_account(wallet_account.id())?; - - deploy_counter_account(&counter_account, rpc_url).await?; - tracing::info!("Successfully recreated and deployed accounts"); - - save_wallet_account(&wallet_account, &secret_key, wallet_filepath)?; - save_counter_account(&counter_account, counter_filepath) + Ok((wallet_account, secret_key, counter_account)) } -/// Deploy counter account to the network. -/// -/// This function creates a counter program account, -/// then saves it to the specified file. +/// Deploy a counter account to the network by submitting its genesis transaction via RPC. #[instrument(target = COMPONENT, name = "deploy-counter-account", skip_all, ret(level = "debug"))] pub async fn deploy_counter_account(counter_account: &Account, rpc_url: &Url) -> Result<()> { // Deploy counter account to the network using a genesis-aware RPC client. diff --git a/bin/network-monitor/src/deploy/wallet.rs b/bin/network-monitor/src/deploy/wallet.rs index 9b9274440d..8ec2eabab0 100644 --- a/bin/network-monitor/src/deploy/wallet.rs +++ b/bin/network-monitor/src/deploy/wallet.rs @@ -1,11 +1,9 @@ //! Wallet account creation functionality. -use std::path::Path; - use anyhow::Result; use miden_node_utils::crypto::get_rpo_random_coin; -use miden_protocol::account::auth::{AuthScheme, AuthSecretKey}; -use miden_protocol::account::{Account, AccountFile, AccountStorageMode, AccountType}; +use miden_protocol::account::auth::AuthScheme; +use miden_protocol::account::{Account, AccountStorageMode, AccountType}; use miden_protocol::crypto::dsa::falcon512_poseidon2::SecretKey; use miden_standards::AuthMethod; use miden_standards::account::wallets::create_basic_wallet; @@ -36,15 +34,3 @@ pub fn create_wallet_account() -> Result<(Account, SecretKey)> { Ok((wallet_account, secret_key)) } - -/// Save wallet account to disk together with its authentication key. -pub fn save_wallet_account( - account: &Account, - secret_key: &SecretKey, - file_path: &Path, -) -> Result<()> { - let auth_secret_key = AuthSecretKey::Falcon512Poseidon2(secret_key.clone()); - let account_file = AccountFile::new(account.clone(), vec![auth_secret_key]); - account_file.write(file_path)?; - Ok(()) -} diff --git a/bin/network-monitor/src/explorer.rs b/bin/network-monitor/src/explorer.rs index d8e9827e79..42e06bda71 100644 --- a/bin/network-monitor/src/explorer.rs +++ b/bin/network-monitor/src/explorer.rs @@ -13,9 +13,9 @@ use crate::COMPONENT; use crate::service::Service; use crate::status::{ExplorerStatusDetails, ServiceDetails, ServiceStatus}; -/// Fetches network-wide totals from `overviewStats` together with the latest block header -/// (number + timestamp + commitments). The latest block is still needed for tip-drift detection -/// against the RPC. +/// Fetches network-wide totals from `overviewStats` together with the latest block header (number + +/// timestamp + commitments). The latest block is still needed for tip-drift detection against the +/// RPC. const NETWORK_OVERVIEW_QUERY: &str = " query NetworkOverview { overviewStats { diff --git a/bin/network-monitor/src/faucet.rs b/bin/network-monitor/src/faucet.rs index 9a59237ee0..6fb723d079 100644 --- a/bin/network-monitor/src/faucet.rs +++ b/bin/network-monitor/src/faucet.rs @@ -259,8 +259,8 @@ async fn perform_faucet_test( Ok((tokens_response, metadata)) } -/// Deserialize a faucet response using [`serde_path_to_error`] so that the failing JSON path -/// (e.g. `max_supply`, `explorer_url`) is included in the error message. Combined with +/// Deserialize a faucet response using [`serde_path_to_error`] so that the failing JSON path (e.g. +/// `max_supply`, `explorer_url`) is included in the error message. Combined with /// `#[serde(deny_unknown_fields)]` on each response type, this means renamed, removed, or newly /// added fields all surface a precise field name rather than a generic "unexpected response". fn parse_faucet_response(body: &str) -> anyhow::Result diff --git a/bin/network-monitor/src/main.rs b/bin/network-monitor/src/main.rs index eb2ea84d81..68edaea13e 100644 --- a/bin/network-monitor/src/main.rs +++ b/bin/network-monitor/src/main.rs @@ -1,7 +1,7 @@ //! Miden Network Monitor //! //! A monitor application for Miden network infrastructure that provides real-time status -//! monitoring and account deployment capabilities. +//! monitoring across the RPC, provers, faucet, explorer, and network transaction services. use anyhow::Result; use clap::Parser; @@ -34,10 +34,8 @@ pub const COMPONENT: &str = "miden-network-monitor"; /// Network Monitor main function. /// -/// This function parses command-line arguments and delegates to the appropriate -/// command handler. The monitor supports two main commands: -/// - `start`: Runs the network monitoring service with web dashboard -/// - `deploy-account`: Creates and deploys Miden accounts to the network +/// Parses command-line arguments and runs the `start` subcommand, which launches the network +/// monitoring service with its web dashboard. #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); diff --git a/bin/network-monitor/src/monitor/tasks.rs b/bin/network-monitor/src/monitor/tasks.rs index f69713b924..13d25413ef 100644 --- a/bin/network-monitor/src/monitor/tasks.rs +++ b/bin/network-monitor/src/monitor/tasks.rs @@ -14,7 +14,7 @@ use tracing::debug; use crate::COMPONENT; use crate::config::MonitorConfig; use crate::counter::{CounterTrackingService, IncrementService, LatencyState}; -use crate::deploy::ensure_accounts_exist; +use crate::deploy::create_and_deploy_accounts; use crate::explorer::ExplorerService; use crate::faucet::FaucetService; use crate::frontend::{ServerState, serve}; @@ -130,26 +130,36 @@ impl Tasks { } /// Spawn the network transaction service checker task. + /// + /// Creates a fresh wallet/counter pair in memory, deploys the counter to the network, and + /// hands the same counter account to both services via a [`watch::channel`]. The increment + /// service publishes new counters on the channel when it regenerates accounts after + /// persistent failures; the tracking service observes the channel to switch over. pub async fn spawn_ntx_service( &mut self, config: &MonitorConfig, ) -> Result<(Receiver, Receiver)> { - // Ensure accounts exist before starting monitoring tasks - ensure_accounts_exist(&config.wallet_filepath, &config.counter_filepath, &config.rpc_url) - .await?; + let (wallet_account, secret_key, counter_account) = + create_and_deploy_accounts(&config.rpc_url).await?; + + let (counter_tx, counter_rx) = watch::channel(counter_account.clone()); - // Create shared atomic counter for tracking expected counter value let expected_counter_value = Arc::new(AtomicU64::new(0)); let latency_state = Arc::new(Mutex::new(LatencyState::default())); let increment_svc = IncrementService::new( config.clone(), + wallet_account, + secret_key, + counter_account, + counter_tx, Arc::clone(&expected_counter_value), latency_state.clone(), ) .await?; let tracking_svc = CounterTrackingService::new( config.clone(), + counter_rx, Arc::clone(&expected_counter_value), latency_state, ) From 2db647d5ec01003279f3f9720af4b902c3705a26 Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Thu, 21 May 2026 17:19:48 -0300 Subject: [PATCH 4/5] add threshold for network transactions --- CHANGELOG.md | 3 + bin/network-monitor/README.md | 2 + bin/network-monitor/src/config.rs | 12 +++ bin/network-monitor/src/counter.rs | 140 ++++++++++++++++++++++++++++- 4 files changed, 154 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fa7eb6230..eea22bf1cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ - [BREAKING] Renamed `SubmitProvenBatch` RPC endpoint to `SubmitProvenTxBatch` ([#2094](https://github.com/0xMiden/node/pull/2094)). - Fixed block producer mempool panic when selecting transactions that depend on notes created by pruned committed transactions ([#2097](https://github.com/0xMiden/node/pull/2097)). +- [BREAKING] Renamed `ExplorerStatusDetails` fields in the network monitor's `/status` payload from `number_of_*` to `total_*` (`total_transactions`, `total_nullifiers`, `total_notes`, `total_account_updates`). The values now represent network-wide cumulative totals from the explorer's `overviewStats` query instead of last-block counts. +- [BREAKING] Removed `--wallet-filepath` / `--counter-filepath` flags and the `MIDEN_MONITOR_WALLET_FILEPATH` / `MIDEN_MONITOR_COUNTER_FILEPATH` env vars from the network monitor. The monitor now keeps wallet and counter accounts fully in memory and regenerates them on every startup; the dashboard's counter value resets to zero on restart. +- Added `--counter-pending-unhealthy-threshold` (env `MIDEN_MONITOR_COUNTER_PENDING_UNHEALTHY_THRESHOLD`, default `5`) to the network monitor: the Network Transactions card now flips unhealthy when the gap between expected and observed counter values stays above the threshold for three consecutive polls. ## v0.14.11 (TBD) - Replaced blocking-in-async operations in the validator, remote prover, and ntx-builder with `spawn_blocking` to avoid starving the Tokio runtime ([#2041](https://github.com/0xMiden/node/pull/2041)). diff --git a/bin/network-monitor/README.md b/bin/network-monitor/README.md index 91a0aa2b0b..51c87ce680 100644 --- a/bin/network-monitor/README.md +++ b/bin/network-monitor/README.md @@ -43,6 +43,7 @@ miden-network-monitor start --faucet-url http://localhost:8080 --enable-otel - `--port, -p`: Web server port (default: `3000`) - `--enable-otel`: Enable OpenTelemetry tracing - `--counter-increment-interval`: Interval at which to send the increment counter transaction (default: `30s`) +- `--counter-pending-unhealthy-threshold`: Mark the Network Transactions card unhealthy when the gap between expected and observed counter values stays above this for several consecutive polls (default: `5`) - `--counter-latency-timeout`: Maximum time to wait for a counter update after submitting a transaction (default: `2m`) - `--help, -h`: Show help information - `--version, -V`: Show version information @@ -67,6 +68,7 @@ If command-line arguments are not provided, the application falls back to enviro - `MIDEN_MONITOR_PORT`: Web server port - `MIDEN_MONITOR_ENABLE_OTEL`: Enable OpenTelemetry tracing - `MIDEN_MONITOR_COUNTER_INCREMENT_INTERVAL`: Interval at which to send the increment counter transaction +- `MIDEN_MONITOR_COUNTER_PENDING_UNHEALTHY_THRESHOLD`: Mark the Network Transactions card unhealthy when the gap between expected and observed counter values stays above this for several consecutive polls - `MIDEN_MONITOR_COUNTER_LATENCY_TIMEOUT`: Maximum time to wait for a counter update after submitting a transaction ## Commands diff --git a/bin/network-monitor/src/config.rs b/bin/network-monitor/src/config.rs index 4db46ab3c7..d569247b3f 100644 --- a/bin/network-monitor/src/config.rs +++ b/bin/network-monitor/src/config.rs @@ -138,6 +138,18 @@ pub struct MonitorConfig { )] pub counter_latency_timeout: Duration, + /// Maximum allowed gap between the expected and observed counter values before the Network + /// Transactions card is flipped to unhealthy. A small backlog while transactions are in flight + /// is expected; this threshold guards against the network silently dropping notes. + #[arg( + long = "counter-pending-unhealthy-threshold", + env = "MIDEN_MONITOR_COUNTER_PENDING_UNHEALTHY_THRESHOLD", + default_value_t = 5, + help = "Mark the counter card unhealthy when the gap between expected and observed values \ + stays above this threshold for several consecutive polls" + )] + pub counter_pending_unhealthy_threshold: u64, + /// The timeout for the outgoing requests. #[arg( long = "request-timeout", diff --git a/bin/network-monitor/src/counter.rs b/bin/network-monitor/src/counter.rs index eaa1a0b317..acfb1d6d71 100644 --- a/bin/network-monitor/src/counter.rs +++ b/bin/network-monitor/src/counter.rs @@ -66,6 +66,11 @@ const REGENERATE_FAILURE_THRESHOLD: usize = 10; /// Minimum time between account regeneration attempts. const REGENERATE_COOLDOWN: Duration = Duration::from_secs(3600); +/// Number of consecutive polls observing the pending-increments gap above +/// [`MonitorConfig::counter_pending_unhealthy_threshold`] before flipping the Network Transactions +/// card to unhealthy. Buffers against a single in-flight batch of notes flapping the card. +const PENDING_UNHEALTHY_CONFIRMATION_POLLS: u32 = 3; + // SHARED STATE // ================================================================================================ @@ -402,6 +407,9 @@ pub struct CounterTrackingService { details: CounterTrackingDetails, expected_counter_value: Arc, latency_state: Arc>, + /// Consecutive polls that observed `pending_increments > counter_pending_unhealthy_threshold`. + /// Used to confirm a real backlog before flipping the card to unhealthy. + over_threshold_streak: u32, } impl CounterTrackingService { @@ -432,6 +440,7 @@ impl CounterTrackingService { details, expected_counter_value, latency_state, + over_threshold_streak: 0, }) } @@ -453,6 +462,7 @@ impl CounterTrackingService { ); self.counter_account = reloaded; self.details = CounterTrackingDetails::default(); + self.over_threshold_streak = 0; initialize_tracking_state( &mut self.rpc_client, &self.counter_account, @@ -558,7 +568,32 @@ impl Service for CounterTrackingService { async fn check(&mut self) -> ServiceStatus { self.reload_counter_account_if_changed().await; let last_error = self.poll_counter_once().await; - build_tracking_status(&self.details, last_error) + self.update_over_threshold_streak(); + build_tracking_status( + &self.details, + last_error, + self.over_threshold_streak, + self.config.counter_pending_unhealthy_threshold, + ) + } +} + +impl CounterTrackingService { + /// Update the over-threshold streak using the most recent pending-increments observation. + /// + /// - A fresh observation strictly above the threshold extends the streak. + /// - A fresh observation at or below the threshold resets it. + /// - No fresh observation (RPC error, counter not yet observed) leaves the streak unchanged + /// so a single missing tick doesn't paper over a real backlog. + fn update_over_threshold_streak(&mut self) { + let Some(pending) = self.details.pending_increments else { + return; + }; + if pending > self.config.counter_pending_unhealthy_threshold { + self.over_threshold_streak = self.over_threshold_streak.saturating_add(1); + } else { + self.over_threshold_streak = 0; + } } } @@ -642,15 +677,36 @@ fn build_increment_status(details: &IncrementDetails, last_error: Option } /// Build a `ServiceStatus` snapshot from the current tracking details and last error. +/// +/// Health priority: +/// 1. Explicit RPC errors from this poll flip the card to unhealthy immediately. +/// 2. A sustained backlog (the pending-increments gap exceeded the configured threshold for at +/// least [`PENDING_UNHEALTHY_CONFIRMATION_POLLS`] polls in a row) flips the card to +/// unhealthy. A single in-flight batch of notes won't hit this; a network silently dropping +/// notes will. +/// 3. Otherwise healthy if we have observed a counter value, unknown if we haven't yet. fn build_tracking_status( details: &CounterTrackingDetails, last_error: Option, + over_threshold_streak: u32, + threshold: u64, ) -> ServiceStatus { let service_details = ServiceDetails::NtxTracking(details.clone()); if let Some(err) = last_error { - ServiceStatus::unhealthy("Network Transactions", err, service_details) - } else if details.current_value.is_some() { + return ServiceStatus::unhealthy("Network Transactions", err, service_details); + } + + if over_threshold_streak >= PENDING_UNHEALTHY_CONFIRMATION_POLLS { + let pending = details.pending_increments.unwrap_or(0); + let err = format!( + "counter trailing expected by {pending} (> {threshold}) for {over_threshold_streak} \ + consecutive polls", + ); + return ServiceStatus::unhealthy("Network Transactions", err, service_details); + } + + if details.current_value.is_some() { ServiceStatus::healthy("Network Transactions", service_details) } else { ServiceStatus::unknown("Network Transactions", service_details) @@ -981,3 +1037,81 @@ async fn fetch_chain_tip(rpc_client: &mut RpcClient) -> Result { anyhow::bail!("RPC status response did not include a chain tip") } } + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use crate::counter::{PENDING_UNHEALTHY_CONFIRMATION_POLLS, build_tracking_status}; + use crate::status::{CounterTrackingDetails, Status}; + + const THRESHOLD: u64 = 5; + + fn details(current: u64, expected: u64) -> CounterTrackingDetails { + let pending = expected.saturating_sub(current); + CounterTrackingDetails { + current_value: Some(current), + expected_value: Some(expected), + last_updated: Some(1), + pending_increments: Some(pending), + } + } + + #[test] + fn healthy_when_pending_under_threshold() { + // When pending sits at or below the threshold, `update_over_threshold_streak` keeps the + // streak at zero, so the card stays green regardless of how long we have been polling. + let status = build_tracking_status(&details(100, 102), None, 0, THRESHOLD); + assert_eq!(status.status, Status::Healthy); + assert!(status.error.is_none()); + } + + #[test] + fn healthy_while_streak_below_confirmation_window() { + // Pending is over threshold this tick (8 > 5) but the streak hasn't crossed the window yet, + // so we keep the card green until we've confirmed sustained backlog. + let streak = PENDING_UNHEALTHY_CONFIRMATION_POLLS - 1; + let status = build_tracking_status(&details(10, 18), None, streak, THRESHOLD); + assert_eq!(status.status, Status::Healthy); + } + + #[test] + fn unhealthy_when_streak_reaches_window() { + let status = build_tracking_status( + &details(10, 20), + None, + PENDING_UNHEALTHY_CONFIRMATION_POLLS, + THRESHOLD, + ); + assert_eq!(status.status, Status::Unhealthy); + let err = status.error.expect("error message should be set"); + assert!(err.contains("10"), "should mention pending count, got: {err}"); + assert!(err.contains('5'), "should mention threshold, got: {err}"); + } + + #[test] + fn rpc_error_wins_over_streak() { + let status = build_tracking_status( + &details(10, 20), + Some("fetch counter value failed".to_string()), + PENDING_UNHEALTHY_CONFIRMATION_POLLS, + THRESHOLD, + ); + assert_eq!(status.status, Status::Unhealthy); + let err = status.error.expect("error message should be set"); + assert!(err.contains("fetch counter value failed")); + } + + #[test] + fn unknown_when_no_observation_yet() { + let blank = CounterTrackingDetails { + current_value: None, + expected_value: None, + last_updated: None, + pending_increments: None, + }; + let status = build_tracking_status(&blank, None, 0, THRESHOLD); + assert_eq!(status.status, Status::Unknown); + } +} From e6e6297d3ecab933b39a920fd6aabb004ce28d8e Mon Sep 17 00:00:00 2001 From: SantiagoPittella Date: Fri, 22 May 2026 16:13:50 -0300 Subject: [PATCH 5/5] chore: use NetworkAuth component in network monitor --- bin/network-monitor/src/counter.rs | 2 +- bin/network-monitor/src/deploy/counter.rs | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/bin/network-monitor/src/counter.rs b/bin/network-monitor/src/counter.rs index acfb1d6d71..68ae3ff71c 100644 --- a/bin/network-monitor/src/counter.rs +++ b/bin/network-monitor/src/counter.rs @@ -975,7 +975,7 @@ fn build_account_storage( } /// Create the increment procedure script. -fn create_increment_script() -> Result { +pub(crate) fn create_increment_script() -> Result { let script = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/assets/counter_program.masm")); diff --git a/bin/network-monitor/src/deploy/counter.rs b/bin/network-monitor/src/deploy/counter.rs index 9578c5afdc..d8025787e5 100644 --- a/bin/network-monitor/src/deploy/counter.rs +++ b/bin/network-monitor/src/deploy/counter.rs @@ -1,5 +1,7 @@ //! Counter program account creation functionality. +use std::collections::BTreeSet; + use anyhow::Result; use miden_protocol::account::component::AccountComponentMetadata; use miden_protocol::account::{ @@ -14,11 +16,12 @@ use miden_protocol::account::{ }; use miden_protocol::utils::sync::LazyLock; use miden_protocol::{Felt, Word}; +use miden_standards::account::auth::AuthNetworkAccount; use miden_standards::code_builder::CodeBuilder; -use miden_standards::testing::account_component::IncrNonceAuthComponent; use tracing::instrument; use crate::COMPONENT; +use crate::counter::create_increment_script; pub static OWNER_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("miden::monitor::counter_contract::owner") @@ -55,7 +58,16 @@ pub fn create_counter_account(owner_account_id: AccountId) -> Result { let account_code = AccountComponent::new(component_code, vec![counter_slot, owner_id_slot], metadata)?; - let incr_nonce_auth: AccountComponent = IncrNonceAuthComponent.into(); + let mut allowed_scripts = BTreeSet::new(); + + let increment_script = create_increment_script().expect("is valid note script"); + + allowed_scripts.insert(increment_script.root()); + + let network_account_auth: AccountComponent = + AuthNetworkAccount::with_allowlist(allowed_scripts) + .expect("list is not empty") + .into(); // Create the counter program account let init_seed: [u8; 32] = rand::random(); @@ -63,7 +75,7 @@ pub fn create_counter_account(owner_account_id: AccountId) -> Result { .account_type(AccountType::RegularAccountUpdatableCode) .storage_mode(AccountStorageMode::Network) .with_component(account_code) - .with_auth_component(incr_nonce_auth) + .with_auth_component(network_account_auth) .build()?; Ok(counter_account)