From d5d8e76a7a0acd2527013273e49b3bf171e86be3 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:23:56 +0000 Subject: [PATCH] Finality pause and /api/live pinned to the node's landed shape (ca3-v4-0316 b2e21447, eec34ac3): finalityActive decides paused when present, the node's "paused: " prefix dropped from the sentence, heldBy not repeated when the reason names the table, timestamp_source header | chain_block, a null vote_key_hash reads as empty; tests on the node's exact words Co-Authored-By: Claude Fable 5.1 (cherry picked from commit b43d329d105868bc604241012932fce047089ad6) --- app/igneum-app/src/ember.rs | 28 +++++++++++++++++++++------- app/igneum-app/src/engine.rs | 8 +++++++- app/igneum-app/src/state.rs | 6 +++++- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index c3353ccd6..0bb4a93c6 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -929,6 +929,8 @@ pub fn finality_paused_line(since_unix: f64, reason: &str, held_by: &str) -> Str /// (empty when live), "finalityProvisional", "heldBy" {tableIndex, stayersShareBps, expiresDaa} or null, /// "pausedSinceMs" or null. Returns (reason, held-by words, provisional, paused_since unix seconds). pub struct FinalityStatus { + /// the node's own word on whether finality is live (b2e21447: finalityActive); None on an older node + pub active: Option, pub reason: String, pub held_by: String, pub provisional: bool, @@ -936,7 +938,10 @@ pub struct FinalityStatus { } pub fn parse_finality_status(v: &serde_json::Value) -> Option { - let reason = v.get("finalityReason")?.as_str().unwrap_or("").trim().to_string(); + // the node's words begin "paused: ..." (b2e21447); the app's sentence already says "Finality paused since", so + // that prefix goes; a reason that already names who holds it ("held by weight table 7, ...") is not repeated + let reason = v.get("finalityReason")?.as_str().unwrap_or("").trim().trim_start_matches("paused:").trim().to_string(); + let active = v.get("finalityActive").and_then(|b| b.as_bool()); let provisional = v.get("finalityProvisional").and_then(|b| b.as_bool()).unwrap_or(false); let held_by = match v.get("heldBy") { Some(h) if h.is_object() => { @@ -947,8 +952,9 @@ pub fn parse_finality_status(v: &serde_json::Value) -> Option { } _ => String::new(), }; + let held_by = if reason.contains("held by") { String::new() } else { held_by }; let paused_since = v.get("pausedSinceMs").and_then(|x| x.as_u64()).filter(|ms| *ms > 0).map(|ms| ms as f64 / 1000.0); - Some(FinalityStatus { reason, held_by, provisional, paused_since }) + Some(FinalityStatus { active, reason, held_by, provisional, paused_since }) } /// The node's pause line, when it carries one: `... finality_reason= held_by=`. @@ -1137,15 +1143,23 @@ mod tests { assert_eq!(parse_finality_line("x finality_reason=under_two_thirds"), Some(("under two thirds".into(), String::new()))); assert_eq!(parse_finality_line("status: accepted 3 blocks"), None); // the structured carrier on igneum_getProvingStatus (the node lane's field names) - let v: serde_json::Value = serde_json::from_str(r#"{"v1":{"active":true},"finalityReason":"under two thirds of the weight is signing","finalityProvisional":true,"heldBy":{"tableIndex":7,"stayersShareBps":6150,"expiresDaa":205000},"pausedSinceMs":1791311940000}"#).unwrap(); + // the node's own words (b2e21447): "paused: under two thirds ... ({p}%; the table frozen at lock {j} has {q}% signing)" + let v: serde_json::Value = serde_json::from_str(r#"{"v1":{"active":true},"finalityActive":false,"finalityReason":"paused: under two thirds of the weight is signing (61%; the table frozen at lock 7 has 58% signing)","finalityProvisional":false,"heldBy":null,"pausedSinceMs":1791311940000}"#).unwrap(); let f = parse_finality_status(&v).unwrap(); - assert_eq!((f.reason.as_str(), f.provisional, f.paused_since), ("under two thirds of the weight is signing", true, Some(1_791_311_940.0))); - assert_eq!(f.held_by, "weight table 7, 61.50% still signing, expires at DAA 205000"); - assert_eq!(finality_paused_line(f.paused_since.unwrap(), &f.reason, &f.held_by), "Finality paused since 18:39 UTC: under two thirds of the weight is signing (held by weight table 7, 61.50% still signing, expires at DAA 205000)"); + assert_eq!((f.active, f.provisional, f.paused_since), (Some(false), false, Some(1_791_311_940.0))); + assert_eq!(finality_paused_line(f.paused_since.unwrap(), &f.reason, &f.held_by), "Finality paused since 18:39 UTC: under two thirds of the weight is signing (61%; the table frozen at lock 7 has 58% signing)"); + // the held-by form: the reason names the table itself, so heldBy is not repeated + let v2: serde_json::Value = serde_json::from_str(r#"{"finalityActive":false,"finalityReason":"paused: held by weight table 7, 61% of it signing, expires at DAA 205000","finalityProvisional":true,"heldBy":{"tableIndex":7,"stayersShareBps":6150,"expiresDaa":205000},"pausedSinceMs":1791311940000}"#).unwrap(); + let g = parse_finality_status(&v2).unwrap(); + assert!(g.provisional && g.held_by.is_empty()); + assert_eq!(finality_paused_line(g.paused_since.unwrap(), &g.reason, &g.held_by), "Finality paused since 18:39 UTC: held by weight table 7, 61% of it signing, expires at DAA 205000"); + // a reason without the table words keeps the heldBy suffix + let v3: serde_json::Value = serde_json::from_str(r#"{"finalityActive":false,"finalityReason":"paused: no checkpoint determined above the latest lock","heldBy":{"tableIndex":7,"stayersShareBps":6150,"expiresDaa":205000}}"#).unwrap(); + assert_eq!(parse_finality_status(&v3).unwrap().held_by, "weight table 7, 61.50% still signing, expires at DAA 205000"); // live finality: an empty reason, null heldBy, null pausedSinceMs let live: serde_json::Value = serde_json::from_str(r#"{"finalityReason":"","finalityProvisional":false,"heldBy":null,"pausedSinceMs":null}"#).unwrap(); let l = parse_finality_status(&live).unwrap(); - assert!(l.reason.is_empty() && l.held_by.is_empty() && !l.provisional && l.paused_since.is_none()); + assert!(l.reason.is_empty() && l.held_by.is_empty() && !l.provisional && l.paused_since.is_none() && l.active.is_none()); // a node before 0.3.16 carries none of it assert!(parse_finality_status(&serde_json::json!({"v1":{"active":true}})).is_none()); } diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 22540a7da..b94715b41 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -1168,6 +1168,7 @@ impl Engine { let mut st = self.st(); match f { Some(f) => { + st.finality.node_active = f.active; st.finality.reason = f.reason; st.finality.held_by = f.held_by; st.finality.provisional = f.provisional; @@ -1178,6 +1179,7 @@ impl Engine { } None => { // a node without the fields: the log line (parse_finality_line) may still have set a cause + st.finality.node_active = None; if st.finality.cause_source != "node-line" { st.finality.cause_source.clear(); } @@ -3674,7 +3676,11 @@ impl Engine { // Horizon polish Q83/Q84: finality paused = a synced node with no checkpoint lock for FINALITY_PAUSE_S (the // last lock's age, else the engine's uptime); since the last lock (else the start); one sentence everywhere let gap = if st.finality.last_lock > 0 { st.finality.age_s } else { st.uptime_s as f64 }; - let paused = st.node.synced && gap > crate::manifest::FINALITY_PAUSE_S; + // the node's own word wins when it carries one (0.3.16, finalityActive); else the engine's 15-minute rule + let paused = match st.finality.node_active { + Some(active) => !active, + None => st.node.synced && gap > crate::manifest::FINALITY_PAUSE_S, + }; if paused && !st.finality.paused && st.finality.cause_source != "node" { st.finality.paused_since = if st.finality.last_lock > 0 { st.finality.last_lock_at } else { unix - st.uptime_s as f64 }; } diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 3ff5d3e58..966eddd1b 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -268,8 +268,12 @@ pub struct FinalityState { pub line: String, /// the node's word (igneum_getProvingStatus finalityProvisional, 0.3.16): locks are provisional right now pub provisional: bool, - /// where the cause came from: "node" (the RPC's structured fields or its log line) or "" (the engine's own rule) + /// where the cause came from: "node" (the RPC's structured fields), "node-line" (its log line) or "" (the + /// engine's own rule) pub cause_source: String, + /// the node's own finalityActive (b2e21447), when it carries it: then the node decides `paused`, not the + /// engine's 15-minute rule + pub node_active: Option, } /// Clock skew against the network. skew_s = local time minus network time (negative = this machine is behind).