Skip to main content

tenzro_consensus/
vote_state.rs

1//! Persistent vote state for double-sign protection.
2//!
3//! Mirrors CometBFT's `FilePVLastSignState` (`privval/file.go:206-243`) and
4//! Aptos' `PersistentSafetyStorage` (`consensus/safety-rules`): a validator
5//! must record `(view, height, step, block_hash, signature)` durably **before**
6//! its vote leaves the wire. On startup, the engine refuses to sign any
7//! `(view, height, step) ≤ last_persisted` — so a crash between broadcast and
8//! persist cannot lead to a re-vote on a different block in the same view,
9//! which is the textbook trigger for self-equivocation slashing.
10//!
11//! # Why this lives in `tenzro-consensus`
12//!
13//! Persistence is layer-pure file I/O (`std::fs`) — no RocksDB, no other
14//! crate. CometBFT does the same: `privval/file.go` is plain JSON with an
15//! atomic `tempfile.WriteFileAtomic` rename. Keeping it in-crate avoids a
16//! circular dep with `tenzro-storage` and matches the upstream pattern.
17//!
18//! # Atomic write protocol
19//!
20//! 1. Serialize updated state to bytes
21//! 2. Write to `<path>.tmp` via `File::create` + `write_all` + `sync_all`
22//!    (fsync on the temp file)
23//! 3. Rename `<path>.tmp` → `<path>` (POSIX rename is atomic)
24//! 4. fsync the parent directory so the rename is durable across crash
25//!
26//! Step 4 is critical and is the most-missed step in non-CometBFT
27//! implementations (POSIX guarantees rename atomicity but not durability of
28//! the directory entry without an explicit fsync of the dir).
29
30use crate::error::{ConsensusError, Result};
31use crate::voter::VoteType;
32use serde::{Deserialize, Serialize};
33use std::path::{Path, PathBuf};
34use std::sync::Arc;
35use tenzro_types::primitives::Hash;
36
37/// Step within a (view, height) — matches CometBFT's `step int8`.
38///
39/// Order is significant: `Prepare < Commit < Decide`. `check_vrs` uses
40/// lexicographic ordering on `(view, height, step)` so a validator that has
41/// already cast a Prepare vote at `(v=10, h=42)` will refuse another Prepare
42/// in the same `(v, h)`, but is still allowed to cast a Commit.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
44#[repr(u8)]
45pub enum VoteStep {
46    Prepare = 1,
47    Commit = 2,
48    Decide = 3,
49}
50
51impl VoteStep {
52    pub fn from_vote_type(vt: VoteType) -> Self {
53        match vt {
54            VoteType::Prepare => VoteStep::Prepare,
55            VoteType::Commit => VoteStep::Commit,
56        }
57    }
58}
59
60/// Last sign state — durable across crashes.
61///
62/// Field order intentionally mirrors CometBFT's `FilePVLastSignState` so the
63/// schema is recognizable to operators familiar with that ecosystem.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct LastSignState {
66    /// Format version. Bump on incompatible schema changes.
67    #[serde(default = "default_version")]
68    pub version: u8,
69
70    /// View number of the last vote we signed
71    pub view: u64,
72
73    /// Height of the last vote we signed
74    pub height: u64,
75
76    /// Step within `(view, height)` — Prepare/Commit/Decide
77    pub step: VoteStep,
78
79    /// Block hash we voted for (canonical commitment to *what* we voted on).
80    /// Stored so an incoming proposal that hashes to the same bytes can be
81    /// re-signed safely without producing a new signature (idempotent retry).
82    #[serde(default)]
83    pub block_hash: Option<Hash>,
84
85    /// Signature bytes of the last vote (composite-signature wire format).
86    /// CometBFT stores this so an in-flight retry of the *same* (h,r,s,hash)
87    /// returns the same signature bytes instead of re-signing — preventing a
88    /// stuck-validator scenario where a transient panic between sign and
89    /// broadcast leaves us unable to re-emit our own vote.
90    #[serde(default)]
91    pub signature: Option<Vec<u8>>,
92}
93
94fn default_version() -> u8 {
95    1
96}
97
98impl LastSignState {
99    /// Empty state: no votes ever cast. All `(view, height, step)` are valid.
100    pub fn empty() -> Self {
101        Self {
102            version: 1,
103            view: 0,
104            height: 0,
105            step: VoteStep::Prepare,
106            block_hash: None,
107            signature: None,
108        }
109    }
110
111    /// Returns `true` if a candidate `(view, height, step)` is strictly past
112    /// the last persisted tuple — i.e. safe to sign.
113    ///
114    /// Lexicographic ordering: candidate must be greater than persisted.
115    /// Equal tuples are **rejected** unless the block hash matches the
116    /// already-signed block (idempotent retry of identical vote — handled by
117    /// the caller via `check_vrs`).
118    pub fn is_strictly_after(&self, view: u64, height: u64, step: VoteStep) -> bool {
119        (view, height, step) > (self.view, self.height, self.step)
120    }
121}
122
123/// Outcome of a `check_vrs` (Vote Rejection / Sign) call.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum VrsDecision {
126    /// Safe to sign. Caller should sign and then call `record(...)` to persist.
127    Sign,
128    /// Identical to the last signed vote — caller may re-broadcast the
129    /// persisted signature (idempotent retry).
130    Reuse {
131        signature: Vec<u8>,
132    },
133    /// Refusal — signing this would equivocate or reverse a finalized step.
134    Reject {
135        reason: String,
136    },
137}
138
139/// Trait for persisting vote state. Sync API — fsync-on-write must complete
140/// before returning.
141///
142/// Implementations MUST guarantee:
143/// - `record` returns only after the new state is durable on disk
144/// - `load` returns the most recently `record`ed state, even after a crash
145/// - All operations are safe across concurrent callers (interior locking is
146///   the implementor's responsibility)
147pub trait VoteStateStore: Send + Sync {
148    /// Loads the persisted state. Returns `LastSignState::empty()` if no state
149    /// has been recorded yet.
150    fn load(&self) -> Result<LastSignState>;
151
152    /// Atomically persists the new state. Must fsync before returning.
153    fn record(&self, state: &LastSignState) -> Result<()>;
154
155    /// Checks whether `(view, height, step)` for `block_hash` is safe to sign.
156    ///
157    /// Default implementation enforces the canonical CometBFT `CheckHRS` rule:
158    /// strictly after the last persisted tuple. An override can implement the
159    /// idempotent-reuse path if the block hash matches.
160    fn check_vrs(
161        &self,
162        view: u64,
163        height: u64,
164        step: VoteStep,
165        block_hash: &Hash,
166    ) -> Result<VrsDecision> {
167        let last = self.load()?;
168
169        // Strictly after — safe.
170        if last.is_strictly_after(view, height, step) {
171            return Ok(VrsDecision::Sign);
172        }
173
174        // Equal tuple AND same block hash AND we have the signature — reuse.
175        if last.view == view
176            && last.height == height
177            && last.step == step
178            && last.block_hash.as_ref() == Some(block_hash)
179            && let Some(sig) = last.signature.clone()
180        {
181            return Ok(VrsDecision::Reuse { signature: sig });
182        }
183
184        // Anything else is a refusal.
185        Ok(VrsDecision::Reject {
186            reason: format!(
187                "double-sign refused: candidate (view={}, height={}, step={:?}, hash={}) \
188                 conflicts with last (view={}, height={}, step={:?}, hash={:?})",
189                view,
190                height,
191                step,
192                block_hash,
193                last.view,
194                last.height,
195                last.step,
196                last.block_hash,
197            ),
198        })
199    }
200}
201
202/// In-memory store. Useful for tests and ephemeral validators (e.g. light
203/// clients, dev networks). Holds state under a `parking_lot::Mutex` for
204/// concurrent access.
205pub struct MemoryVoteStateStore {
206    inner: parking_lot::Mutex<LastSignState>,
207}
208
209impl MemoryVoteStateStore {
210    pub fn new() -> Self {
211        Self {
212            inner: parking_lot::Mutex::new(LastSignState::empty()),
213        }
214    }
215}
216
217impl Default for MemoryVoteStateStore {
218    fn default() -> Self {
219        Self::new()
220    }
221}
222
223impl VoteStateStore for MemoryVoteStateStore {
224    fn load(&self) -> Result<LastSignState> {
225        Ok(self.inner.lock().clone())
226    }
227
228    fn record(&self, state: &LastSignState) -> Result<()> {
229        let mut guard = self.inner.lock();
230        *guard = state.clone();
231        Ok(())
232    }
233}
234
235/// File-backed store using atomic-rename + fsync semantics.
236///
237/// Persists JSON to a single file (e.g. `<data-dir>/consensus/last_sign.json`).
238/// Updates are serialized through a mutex; the disk write itself is sync.
239pub struct FileVoteStateStore {
240    path: PathBuf,
241    parent_dir: PathBuf,
242    inner: parking_lot::Mutex<LastSignState>,
243}
244
245impl FileVoteStateStore {
246    /// Opens (or creates) the store at `path`. If the file exists, parses it;
247    /// otherwise initializes empty state.
248    ///
249    /// The parent directory must already exist — the caller is responsible for
250    /// ensuring `<data-dir>/consensus/` is created during node startup.
251    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
252        let path = path.as_ref().to_path_buf();
253        let parent_dir = path
254            .parent()
255            .ok_or_else(|| {
256                ConsensusError::Internal(format!(
257                    "FileVoteStateStore: path {:?} has no parent directory",
258                    path
259                ))
260            })?
261            .to_path_buf();
262
263        let initial = if path.exists() {
264            let bytes = std::fs::read(&path).map_err(|e| {
265                ConsensusError::Internal(format!(
266                    "FileVoteStateStore: failed to read {:?}: {}",
267                    path, e
268                ))
269            })?;
270            // If the file exists but is empty, treat as fresh.
271            if bytes.is_empty() {
272                LastSignState::empty()
273            } else {
274                serde_json::from_slice::<LastSignState>(&bytes).map_err(|e| {
275                    ConsensusError::Internal(format!(
276                        "FileVoteStateStore: corrupt state at {:?}: {}",
277                        path, e
278                    ))
279                })?
280            }
281        } else {
282            LastSignState::empty()
283        };
284
285        Ok(Self {
286            path,
287            parent_dir,
288            inner: parking_lot::Mutex::new(initial),
289        })
290    }
291
292    /// Atomic write: temp file → fsync → rename → fsync(parent).
293    fn write_atomic(&self, state: &LastSignState) -> Result<()> {
294        use std::io::Write;
295
296        let bytes = serde_json::to_vec_pretty(state).map_err(|e| {
297            ConsensusError::Internal(format!(
298                "FileVoteStateStore: serialize failed: {}",
299                e
300            ))
301        })?;
302
303        let tmp_path = self.path.with_extension("tmp");
304
305        // Step 1+2: write + fsync the temp file.
306        {
307            let mut tmp = std::fs::OpenOptions::new()
308                .write(true)
309                .create(true)
310                .truncate(true)
311                .open(&tmp_path)
312                .map_err(|e| {
313                    ConsensusError::Internal(format!(
314                        "FileVoteStateStore: open tmp {:?}: {}",
315                        tmp_path, e
316                    ))
317                })?;
318            tmp.write_all(&bytes).map_err(|e| {
319                ConsensusError::Internal(format!(
320                    "FileVoteStateStore: write tmp {:?}: {}",
321                    tmp_path, e
322                ))
323            })?;
324            tmp.sync_all().map_err(|e| {
325                ConsensusError::Internal(format!(
326                    "FileVoteStateStore: fsync tmp {:?}: {}",
327                    tmp_path, e
328                ))
329            })?;
330        }
331
332        // Step 3: atomic rename.
333        std::fs::rename(&tmp_path, &self.path).map_err(|e| {
334            ConsensusError::Internal(format!(
335                "FileVoteStateStore: rename {:?} -> {:?}: {}",
336                tmp_path, self.path, e
337            ))
338        })?;
339
340        // Step 4: fsync the parent directory so the rename is durable.
341        // CometBFT does this; without it, a crash between the rename and the
342        // dir-entry hitting disk can lose the new state.
343        if let Ok(dir) = std::fs::File::open(&self.parent_dir) {
344            // Best-effort: not all filesystems require this (NTFS, APFS-on-mac
345            // are mostly fine), and fsync on a directory is a no-op on some
346            // platforms. But on ext4/xfs/zfs it is necessary for durability.
347            let _ = dir.sync_all();
348        }
349
350        Ok(())
351    }
352}
353
354impl VoteStateStore for FileVoteStateStore {
355    fn load(&self) -> Result<LastSignState> {
356        Ok(self.inner.lock().clone())
357    }
358
359    fn record(&self, state: &LastSignState) -> Result<()> {
360        let mut guard = self.inner.lock();
361        // Persist first, in-memory second — if the disk write fails we don't
362        // advance the in-memory tip (caller must surface the error).
363        self.write_atomic(state)?;
364        *guard = state.clone();
365        Ok(())
366    }
367}
368
369/// Convenience: returns an `Arc<dyn VoteStateStore>` configured for production.
370///
371/// Path is `<data_dir>/consensus/last_sign.json`. Creates the parent directory
372/// if missing.
373pub fn open_default_file_store(data_dir: &Path) -> Result<Arc<dyn VoteStateStore>> {
374    let consensus_dir = data_dir.join("consensus");
375    std::fs::create_dir_all(&consensus_dir).map_err(|e| {
376        ConsensusError::Internal(format!(
377            "open_default_file_store: create_dir_all {:?}: {}",
378            consensus_dir, e
379        ))
380    })?;
381    let path = consensus_dir.join("last_sign.json");
382    let store = FileVoteStateStore::open(path)?;
383    Ok(Arc::new(store))
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use tempfile::TempDir;
390
391    fn h(b: u8) -> Hash {
392        Hash::new([b; 32])
393    }
394
395    #[test]
396    fn empty_state_allows_anything() {
397        let s = LastSignState::empty();
398        assert!(s.is_strictly_after(0, 1, VoteStep::Prepare));
399        assert!(s.is_strictly_after(1, 0, VoteStep::Prepare));
400    }
401
402    #[test]
403    fn lexicographic_ordering() {
404        let s = LastSignState {
405            version: 1,
406            view: 5,
407            height: 10,
408            step: VoteStep::Prepare,
409            block_hash: Some(h(1)),
410            signature: Some(vec![0xAA]),
411        };
412
413        // Same (view, height) but later step — allowed
414        assert!(s.is_strictly_after(5, 10, VoteStep::Commit));
415        // Same (view, height, step) — REJECTED (equal not strictly after)
416        assert!(!s.is_strictly_after(5, 10, VoteStep::Prepare));
417        // Earlier view — REJECTED
418        assert!(!s.is_strictly_after(4, 10, VoteStep::Decide));
419        // Same view, earlier height — REJECTED
420        assert!(!s.is_strictly_after(5, 9, VoteStep::Decide));
421        // Strictly later view — allowed
422        assert!(s.is_strictly_after(6, 0, VoteStep::Prepare));
423    }
424
425    #[test]
426    fn memory_store_sign_then_reject_double_sign() {
427        let store = MemoryVoteStateStore::new();
428
429        // First vote — Sign.
430        match store.check_vrs(10, 42, VoteStep::Prepare, &h(1)).unwrap() {
431            VrsDecision::Sign => {}
432            other => panic!("expected Sign, got {:?}", other),
433        }
434
435        // Record it.
436        store
437            .record(&LastSignState {
438                version: 1,
439                view: 10,
440                height: 42,
441                step: VoteStep::Prepare,
442                block_hash: Some(h(1)),
443                signature: Some(vec![0xDE, 0xAD]),
444            })
445            .unwrap();
446
447        // Same (v,h,step) but DIFFERENT block — Reject (this is the
448        // equivocation case).
449        match store.check_vrs(10, 42, VoteStep::Prepare, &h(2)).unwrap() {
450            VrsDecision::Reject { .. } => {}
451            other => panic!("expected Reject, got {:?}", other),
452        }
453
454        // Same (v,h,step) and SAME block — Reuse (idempotent retry).
455        match store.check_vrs(10, 42, VoteStep::Prepare, &h(1)).unwrap() {
456            VrsDecision::Reuse { signature } => assert_eq!(signature, vec![0xDE, 0xAD]),
457            other => panic!("expected Reuse, got {:?}", other),
458        }
459
460        // Earlier view — Reject (regression attack).
461        match store.check_vrs(9, 99, VoteStep::Decide, &h(3)).unwrap() {
462            VrsDecision::Reject { .. } => {}
463            other => panic!("expected Reject for earlier view, got {:?}", other),
464        }
465
466        // Strictly later — Sign.
467        match store.check_vrs(11, 0, VoteStep::Prepare, &h(4)).unwrap() {
468            VrsDecision::Sign => {}
469            other => panic!("expected Sign for later view, got {:?}", other),
470        }
471    }
472
473    #[test]
474    fn memory_store_advancing_step_within_same_view() {
475        let store = MemoryVoteStateStore::new();
476        store
477            .record(&LastSignState {
478                version: 1,
479                view: 7,
480                height: 21,
481                step: VoteStep::Prepare,
482                block_hash: Some(h(9)),
483                signature: Some(vec![1, 2, 3]),
484            })
485            .unwrap();
486
487        // Commit on the same (view, height) — allowed.
488        match store.check_vrs(7, 21, VoteStep::Commit, &h(9)).unwrap() {
489            VrsDecision::Sign => {}
490            other => panic!("expected Sign for Commit advance, got {:?}", other),
491        }
492    }
493
494    #[test]
495    fn file_store_round_trip() {
496        let tmp = TempDir::new().unwrap();
497        let path = tmp.path().join("last_sign.json");
498
499        let store = FileVoteStateStore::open(&path).unwrap();
500        store
501            .record(&LastSignState {
502                version: 1,
503                view: 12,
504                height: 100,
505                step: VoteStep::Commit,
506                block_hash: Some(h(7)),
507                signature: Some(vec![0xCA, 0xFE]),
508            })
509            .unwrap();
510
511        // Reopen — state should survive.
512        let store2 = FileVoteStateStore::open(&path).unwrap();
513        let loaded = store2.load().unwrap();
514        assert_eq!(loaded.view, 12);
515        assert_eq!(loaded.height, 100);
516        assert_eq!(loaded.step, VoteStep::Commit);
517        assert_eq!(loaded.block_hash, Some(h(7)));
518        assert_eq!(loaded.signature, Some(vec![0xCA, 0xFE]));
519    }
520
521    #[test]
522    fn file_store_rejects_replay_after_restart() {
523        let tmp = TempDir::new().unwrap();
524        let path = tmp.path().join("last_sign.json");
525
526        let store = FileVoteStateStore::open(&path).unwrap();
527        store
528            .record(&LastSignState {
529                version: 1,
530                view: 50,
531                height: 200,
532                step: VoteStep::Prepare,
533                block_hash: Some(h(1)),
534                signature: Some(vec![0xAB]),
535            })
536            .unwrap();
537
538        // Simulate restart by reopening.
539        let store2 = FileVoteStateStore::open(&path).unwrap();
540
541        // The exact equivocation scenario: same view, different block.
542        match store2.check_vrs(50, 200, VoteStep::Prepare, &h(2)).unwrap() {
543            VrsDecision::Reject { reason } => {
544                assert!(reason.contains("double-sign refused"), "reason: {}", reason);
545            }
546            other => panic!("expected Reject after restart, got {:?}", other),
547        }
548    }
549
550    #[test]
551    fn file_store_handles_corrupt_file_via_error() {
552        let tmp = TempDir::new().unwrap();
553        let path = tmp.path().join("last_sign.json");
554        std::fs::write(&path, b"not valid json{{{").unwrap();
555
556        let result = FileVoteStateStore::open(&path);
557        assert!(result.is_err(), "expected error opening corrupt file");
558    }
559
560    #[test]
561    fn file_store_handles_empty_file_as_fresh() {
562        let tmp = TempDir::new().unwrap();
563        let path = tmp.path().join("last_sign.json");
564        std::fs::write(&path, b"").unwrap();
565
566        let store = FileVoteStateStore::open(&path).unwrap();
567        let loaded = store.load().unwrap();
568        assert_eq!(loaded, LastSignState::empty());
569    }
570}