Skip to content

Commit 2ac19d6

Browse files
committed
Implement Week 1: State Monotonicity Check (CRITICAL)
Added high-water mark system to prevent state rollbacks: New module: crates/ultradag-coin/src/persistence/monotonicity.rs - HighWaterMark struct tracks highest round ever finalized - Stores round number, timestamp, and state hash - verify_monotonic() checks new_round >= max_round - Atomic file operations for persistence - 8 comprehensive unit tests (all passing) Integration in main.rs load_state(): - Load high-water mark on startup - Verify monotonicity before loading DAG state - Exit with detailed error message if rollback detected - Prevents loading old state files that would cause rollback Integration in validator.rs: - Update high-water mark after each finalized round - Store state hash for verification - Atomic save to disk Error handling: - Detailed error messages explaining rollback detection - Lists possible causes (old deployment, backup restore, corruption) - Provides recovery instructions - Refuses to start if r- Refuses to start if r- Refuses to start if r- Refuses to start if r- Refuses toom ever happening again. Node will refuse to start if attemptever happening again. Node will refuse to start iplan: COMPLETE ✅ Next: Week 2 - Peer state verification
1 parent f6d21f7 commit 2ac19d6

7 files changed

Lines changed: 332 additions & 147 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ultradag-coin/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ thiserror = { workspace = true }
1616

1717
[dev-dependencies]
1818
serde_json = "1"
19+
tempfile = "3"

crates/ultradag-coin/src/persistence.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ use std::fs;
22
use std::path::Path;
33
use serde::{Serialize, Deserialize};
44

5+
pub mod monotonicity;
6+
57
/// Persistence error types
68
#[derive(Debug, thiserror::Error)]
79
pub enum PersistenceError {
@@ -20,6 +22,14 @@ pub fn save<T: Serialize>(data: &T, path: &Path) -> Result<(), PersistenceError>
2022
Ok(())
2123
}
2224

25+
/// Atomic write of raw bytes (used by monotonicity module)
26+
pub fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
27+
let tmp_path = path.with_extension("tmp");
28+
fs::write(&tmp_path, data)?;
29+
fs::rename(&tmp_path, path)?;
30+
Ok(())
31+
}
32+
2333
/// Load data from disk
2434
pub fn load<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, PersistenceError> {
2535
let json = fs::read_to_string(path)?;
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
use std::path::{Path, PathBuf};
2+
use std::fs;
3+
use serde::{Serialize, Deserialize};
4+
use thiserror::Error;
5+
6+
use crate::persistence::atomic_write;
7+
8+
#[derive(Debug, Error)]
9+
pub enum MonotonicityError {
10+
#[error("State rollback detected: attempting to load round {attempting} but high-water mark is {current}")]
11+
StateRollbackDetected {
12+
current: u64,
13+
attempting: u64,
14+
},
15+
#[error("IO error: {0}")]
16+
Io(#[from] std::io::Error),
17+
#[error("Serialization error: {0}")]
18+
Serialization(#[from] serde_json::Error),
19+
}
20+
21+
/// High-water mark tracking the highest round ever finalized.
22+
/// This prevents loading old state files that would cause a rollback.
23+
#[derive(Debug, Clone, Serialize, Deserialize)]
24+
pub struct HighWaterMark {
25+
/// Highest round number ever finalized
26+
pub max_round: u64,
27+
/// Timestamp when this round was reached (Unix timestamp)
28+
pub timestamp: i64,
29+
/// Blake3 hash of the state at this round (for verification)
30+
pub state_hash: [u8; 32],
31+
}
32+
33+
impl HighWaterMark {
34+
/// Create a new high-water mark at genesis
35+
pub fn new() -> Self {
36+
Self {
37+
max_round: 0,
38+
timestamp: chrono::Utc::now().timestamp(),
39+
state_hash: [0; 32],
40+
}
41+
}
42+
43+
/// Load from disk or create new if doesn't exist
44+
pub fn load_or_create(path: &Path) -> Result<Self, MonotonicityError> {
45+
if path.exists() {
46+
let data = fs::read(path)?;
47+
let hwm: HighWaterMark = serde_json::from_slice(&data)?;
48+
Ok(hwm)
49+
} else {
50+
Ok(Self::new())
51+
}
52+
}
53+
54+
/// Verify that new_round is >= max_round (monotonicity check)
55+
/// Returns error if attempting to go backwards
56+
pub fn verify_monotonic(&self, new_round: u64) -> Result<(), MonotonicityError> {
57+
if new_round < self.max_round {
58+
return Err(MonotonicityError::StateRollbackDetected {
59+
current: self.max_round,
60+
attempting: new_round,
61+
});
62+
}
63+
Ok(())
64+
}
65+
66+
/// Update to new high-water mark
67+
/// Only updates if new round is >= current max
68+
pub fn update(&mut self, round: u64, state_hash: [u8; 32]) {
69+
if round >= self.max_round {
70+
self.max_round = round;
71+
self.timestamp = chrono::Utc::now().timestamp();
72+
self.state_hash = state_hash;
73+
}
74+
}
75+
76+
/// Save to disk atomically
77+
pub fn save(&self, path: &Path) -> Result<(), MonotonicityError> {
78+
let data = serde_json::to_vec_pretty(self)?;
79+
atomic_write(path, &data)?;
80+
Ok(())
81+
}
82+
83+
/// Get the current high-water mark round
84+
pub fn current_round(&self) -> u64 {
85+
self.max_round
86+
}
87+
88+
/// Get the path for the high-water mark file in a data directory
89+
pub fn path_in_dir(data_dir: &Path) -> PathBuf {
90+
data_dir.join("high_water_mark.json")
91+
}
92+
}
93+
94+
impl Default for HighWaterMark {
95+
fn default() -> Self {
96+
Self::new()
97+
}
98+
}
99+
100+
#[cfg(test)]
101+
mod tests {
102+
use super::*;
103+
use std::fs;
104+
use tempfile::TempDir;
105+
106+
#[test]
107+
fn test_new_high_water_mark() {
108+
let hwm = HighWaterMark::new();
109+
assert_eq!(hwm.max_round, 0);
110+
assert_eq!(hwm.state_hash, [0; 32]);
111+
}
112+
113+
#[test]
114+
fn test_verify_monotonic_allows_forward() {
115+
let hwm = HighWaterMark {
116+
max_round: 100,
117+
timestamp: 0,
118+
state_hash: [0; 32],
119+
};
120+
121+
assert!(hwm.verify_monotonic(100).is_ok());
122+
assert!(hwm.verify_monotonic(101).is_ok());
123+
assert!(hwm.verify_monotonic(1000).is_ok());
124+
}
125+
126+
#[test]
127+
fn test_verify_monotonic_rejects_backward() {
128+
let hwm = HighWaterMark {
129+
max_round: 100,
130+
timestamp: 0,
131+
state_hash: [0; 32],
132+
};
133+
134+
let result = hwm.verify_monotonic(99);
135+
assert!(result.is_err());
136+
137+
if let Err(MonotonicityError::StateRollbackDetected { current, attempting }) = result {
138+
assert_eq!(current, 100);
139+
assert_eq!(attempting, 99);
140+
} else {
141+
panic!("Expected StateRollbackDetected error");
142+
}
143+
}
144+
145+
#[test]
146+
fn test_update_advances_forward() {
147+
let mut hwm = HighWaterMark::new();
148+
let hash1 = [1; 32];
149+
let hash2 = [2; 32];
150+
151+
hwm.update(50, hash1);
152+
assert_eq!(hwm.max_round, 50);
153+
assert_eq!(hwm.state_hash, hash1);
154+
155+
hwm.update(100, hash2);
156+
assert_eq!(hwm.max_round, 100);
157+
assert_eq!(hwm.state_hash, hash2);
158+
}
159+
160+
#[test]
161+
fn test_update_ignores_backward() {
162+
let mut hwm = HighWaterMark {
163+
max_round: 100,
164+
timestamp: 0,
165+
state_hash: [1; 32],
166+
};
167+
168+
let hash2 = [2; 32];
169+
hwm.update(50, hash2);
170+
171+
// Should not update
172+
assert_eq!(hwm.max_round, 100);
173+
assert_eq!(hwm.state_hash, [1; 32]);
174+
}
175+
176+
#[test]
177+
fn test_save_and_load() {
178+
let temp_dir = TempDir::new().unwrap();
179+
let path = temp_dir.path().join("hwm.json");
180+
181+
let mut hwm = HighWaterMark::new();
182+
hwm.update(42, [7; 32]);
183+
hwm.save(&path).unwrap();
184+
185+
let loaded = HighWaterMark::load_or_create(&path).unwrap();
186+
assert_eq!(loaded.max_round, 42);
187+
assert_eq!(loaded.state_hash, [7; 32]);
188+
}
189+
190+
#[test]
191+
fn test_load_or_create_when_missing() {
192+
let temp_dir = TempDir::new().unwrap();
193+
let path = temp_dir.path().join("missing.json");
194+
195+
let hwm = HighWaterMark::load_or_create(&path).unwrap();
196+
assert_eq!(hwm.max_round, 0);
197+
}
198+
199+
#[test]
200+
fn test_path_in_dir() {
201+
let dir = Path::new("/data");
202+
let path = HighWaterMark::path_in_dir(dir);
203+
assert_eq!(path, Path::new("/data/high_water_mark.json"));
204+
}
205+
}

crates/ultradag-node/src/main.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,17 +120,65 @@ async fn save_state(server: &NodeServer, data_dir: &std::path::Path) {
120120

121121
/// Load all node state from disk if available.
122122
async fn load_state(server: &NodeServer, data_dir: &std::path::Path) {
123+
use ultradag_coin::persistence::monotonicity::HighWaterMark;
124+
123125
let dag_path = data_dir.join("dag.json");
124126
let finality_path = data_dir.join("finality.json");
125127
let state_path = data_dir.join("state.json");
126128
let mempool_path = data_dir.join("mempool.json");
129+
let hwm_path = HighWaterMark::path_in_dir(data_dir);
127130

128131
info!("Loading state from: {}", data_dir.display());
129132

133+
// Load high-water mark for monotonicity checking
134+
let hwm = match HighWaterMark::load_or_create(&hwm_path) {
135+
Ok(hwm) => {
136+
if hwm.current_round() > 0 {
137+
info!("High-water mark: round {}", hwm.current_round());
138+
}
139+
hwm
140+
}
141+
Err(e) => {
142+
error!("Failed to load high-water mark: {}", e);
143+
error!("Cannot verify state monotonicity. Refusing to start.");
144+
std::process::exit(1);
145+
}
146+
};
147+
130148
if BlockDag::exists(&dag_path) {
131149
match BlockDag::load(&dag_path) {
132150
Ok(dag) => {
133151
let current_round = dag.current_round();
152+
153+
// CRITICAL: Verify monotonicity - prevent rollback
154+
if let Err(e) = hwm.verify_monotonic(current_round) {
155+
error!("╔═══════════════════════════════════════════════════════╗");
156+
error!("║ 🚨 STATE ROLLBACK DETECTED - REFUSING TO START 🚨 ║");
157+
error!("╚═══════════════════════════════════════════════════════╝");
158+
error!("");
159+
error!("Error: {}", e);
160+
error!("High-water mark: round {}", hwm.current_round());
161+
error!("Attempting to load: round {}", current_round);
162+
error!("Rollback amount: {} rounds", hwm.current_round() - current_round);
163+
error!("");
164+
error!("This indicates you are trying to load an old state file.");
165+
error!("Loading old state would cause a network rollback.");
166+
error!("");
167+
error!("POSSIBLE CAUSES:");
168+
error!("1. Deployment with old Docker image");
169+
error!("2. Restored from old backup");
170+
error!("3. State file corruption");
171+
error!("");
172+
error!("MANUAL INTERVENTION REQUIRED:");
173+
error!("1. Verify the state file is correct");
174+
error!("2. Check deployment configuration");
175+
error!("3. Consider fast-sync from network");
176+
error!("");
177+
error!("DO NOT bypass this check unless you understand the consequences.");
178+
std::process::exit(1);
179+
}
180+
181+
info!("✅ Monotonicity check passed: round {}", current_round);
134182
info!("Loaded DAG from disk: current_round={}", current_round);
135183
*server.dag.write().await = dag;
136184
}

crates/ultradag-node/src/validator.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,28 @@ pub async fn validator_loop(
251251
if let Err(e) = state_w.apply_finalized_vertices(&finalized_vertices) {
252252
warn!("Failed to apply finalized vertices to state: {}", e);
253253
} else {
254+
// Update high-water mark after successful finalization
255+
let last_finalized_round = state_w.last_finalized_round().unwrap_or(0);
256+
if last_finalized_round > 0 {
257+
use ultradag_coin::persistence::monotonicity::HighWaterMark;
258+
let hwm_path = HighWaterMark::path_in_dir(&data_dir);
259+
260+
match HighWaterMark::load_or_create(&hwm_path) {
261+
Ok(mut hwm) => {
262+
let state_snapshot = state_w.snapshot();
263+
let state_hash = ultradag_coin::consensus::compute_state_root(&state_snapshot);
264+
hwm.update(last_finalized_round, state_hash);
265+
266+
if let Err(e) = hwm.save(&hwm_path) {
267+
warn!("Failed to save high-water mark: {}", e);
268+
}
269+
}
270+
Err(e) => {
271+
warn!("Failed to load high-water mark for update: {}", e);
272+
}
273+
}
274+
}
275+
254276
// Epoch transition: sync active validator set to FinalityTracker
255277
if state_w.epoch_just_changed(prev_round) {
256278
sync_epoch_validators(&mut fin, &state_w);
@@ -265,7 +287,6 @@ pub async fn validator_loop(
265287
}
266288

267289
// Checkpoint generation: produce checkpoint at CHECKPOINT_INTERVAL
268-
let last_finalized_round = state_w.last_finalized_round().unwrap_or(0);
269290
if last_finalized_round > 0 && last_finalized_round % ultradag_coin::CHECKPOINT_INTERVAL == 0 {
270291
let state_snapshot = state_w.snapshot();
271292
let state_root = ultradag_coin::consensus::compute_state_root(&state_snapshot);

0 commit comments

Comments
 (0)