use crate::error::{ConsensusError, Result};
use crate::voter::VoteType;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tenzro_types::primitives::Hash;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[repr(u8)]
pub enum VoteStep {
Prepare = 1,
Commit = 2,
Decide = 3,
}
impl VoteStep {
pub fn from_vote_type(vt: VoteType) -> Self {
match vt {
VoteType::Prepare => VoteStep::Prepare,
VoteType::Commit => VoteStep::Commit,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LastSignState {
#[serde(default = "default_version")]
pub version: u8,
pub view: u64,
pub height: u64,
pub step: VoteStep,
#[serde(default)]
pub block_hash: Option<Hash>,
#[serde(default)]
pub signature: Option<Vec<u8>>,
}
fn default_version() -> u8 {
1
}
impl LastSignState {
pub fn empty() -> Self {
Self {
version: 1,
view: 0,
height: 0,
step: VoteStep::Prepare,
block_hash: None,
signature: None,
}
}
pub fn is_strictly_after(&self, view: u64, height: u64, step: VoteStep) -> bool {
(view, height, step) > (self.view, self.height, self.step)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VrsDecision {
Sign,
Reuse {
signature: Vec<u8>,
},
Reject {
reason: String,
},
}
pub trait VoteStateStore: Send + Sync {
fn load(&self) -> Result<LastSignState>;
fn record(&self, state: &LastSignState) -> Result<()>;
fn check_vrs(
&self,
view: u64,
height: u64,
step: VoteStep,
block_hash: &Hash,
) -> Result<VrsDecision> {
let last = self.load()?;
if last.is_strictly_after(view, height, step) {
return Ok(VrsDecision::Sign);
}
if last.view == view
&& last.height == height
&& last.step == step
&& last.block_hash.as_ref() == Some(block_hash)
&& let Some(sig) = last.signature.clone()
{
return Ok(VrsDecision::Reuse { signature: sig });
}
Ok(VrsDecision::Reject {
reason: format!(
"double-sign refused: candidate (view={}, height={}, step={:?}, hash={}) \
conflicts with last (view={}, height={}, step={:?}, hash={:?})",
view,
height,
step,
block_hash,
last.view,
last.height,
last.step,
last.block_hash,
),
})
}
}
pub struct MemoryVoteStateStore {
inner: parking_lot::Mutex<LastSignState>,
}
impl MemoryVoteStateStore {
pub fn new() -> Self {
Self {
inner: parking_lot::Mutex::new(LastSignState::empty()),
}
}
}
impl Default for MemoryVoteStateStore {
fn default() -> Self {
Self::new()
}
}
impl VoteStateStore for MemoryVoteStateStore {
fn load(&self) -> Result<LastSignState> {
Ok(self.inner.lock().clone())
}
fn record(&self, state: &LastSignState) -> Result<()> {
let mut guard = self.inner.lock();
*guard = state.clone();
Ok(())
}
}
pub struct FileVoteStateStore {
path: PathBuf,
parent_dir: PathBuf,
inner: parking_lot::Mutex<LastSignState>,
}
impl FileVoteStateStore {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref().to_path_buf();
let parent_dir = path
.parent()
.ok_or_else(|| {
ConsensusError::Internal(format!(
"FileVoteStateStore: path {:?} has no parent directory",
path
))
})?
.to_path_buf();
let initial = if path.exists() {
let bytes = std::fs::read(&path).map_err(|e| {
ConsensusError::Internal(format!(
"FileVoteStateStore: failed to read {:?}: {}",
path, e
))
})?;
if bytes.is_empty() {
LastSignState::empty()
} else {
serde_json::from_slice::<LastSignState>(&bytes).map_err(|e| {
ConsensusError::Internal(format!(
"FileVoteStateStore: corrupt state at {:?}: {}",
path, e
))
})?
}
} else {
LastSignState::empty()
};
Ok(Self {
path,
parent_dir,
inner: parking_lot::Mutex::new(initial),
})
}
fn write_atomic(&self, state: &LastSignState) -> Result<()> {
use std::io::Write;
let bytes = serde_json::to_vec_pretty(state).map_err(|e| {
ConsensusError::Internal(format!(
"FileVoteStateStore: serialize failed: {}",
e
))
})?;
let tmp_path = self.path.with_extension("tmp");
{
let mut tmp = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp_path)
.map_err(|e| {
ConsensusError::Internal(format!(
"FileVoteStateStore: open tmp {:?}: {}",
tmp_path, e
))
})?;
tmp.write_all(&bytes).map_err(|e| {
ConsensusError::Internal(format!(
"FileVoteStateStore: write tmp {:?}: {}",
tmp_path, e
))
})?;
tmp.sync_all().map_err(|e| {
ConsensusError::Internal(format!(
"FileVoteStateStore: fsync tmp {:?}: {}",
tmp_path, e
))
})?;
}
std::fs::rename(&tmp_path, &self.path).map_err(|e| {
ConsensusError::Internal(format!(
"FileVoteStateStore: rename {:?} -> {:?}: {}",
tmp_path, self.path, e
))
})?;
if let Ok(dir) = std::fs::File::open(&self.parent_dir) {
let _ = dir.sync_all();
}
Ok(())
}
}
impl VoteStateStore for FileVoteStateStore {
fn load(&self) -> Result<LastSignState> {
Ok(self.inner.lock().clone())
}
fn record(&self, state: &LastSignState) -> Result<()> {
let mut guard = self.inner.lock();
self.write_atomic(state)?;
*guard = state.clone();
Ok(())
}
}
pub fn open_default_file_store(data_dir: &Path) -> Result<Arc<dyn VoteStateStore>> {
let consensus_dir = data_dir.join("consensus");
std::fs::create_dir_all(&consensus_dir).map_err(|e| {
ConsensusError::Internal(format!(
"open_default_file_store: create_dir_all {:?}: {}",
consensus_dir, e
))
})?;
let path = consensus_dir.join("last_sign.json");
let store = FileVoteStateStore::open(path)?;
Ok(Arc::new(store))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn h(b: u8) -> Hash {
Hash::new([b; 32])
}
#[test]
fn empty_state_allows_anything() {
let s = LastSignState::empty();
assert!(s.is_strictly_after(0, 1, VoteStep::Prepare));
assert!(s.is_strictly_after(1, 0, VoteStep::Prepare));
}
#[test]
fn lexicographic_ordering() {
let s = LastSignState {
version: 1,
view: 5,
height: 10,
step: VoteStep::Prepare,
block_hash: Some(h(1)),
signature: Some(vec![0xAA]),
};
assert!(s.is_strictly_after(5, 10, VoteStep::Commit));
assert!(!s.is_strictly_after(5, 10, VoteStep::Prepare));
assert!(!s.is_strictly_after(4, 10, VoteStep::Decide));
assert!(!s.is_strictly_after(5, 9, VoteStep::Decide));
assert!(s.is_strictly_after(6, 0, VoteStep::Prepare));
}
#[test]
fn memory_store_sign_then_reject_double_sign() {
let store = MemoryVoteStateStore::new();
match store.check_vrs(10, 42, VoteStep::Prepare, &h(1)).unwrap() {
VrsDecision::Sign => {}
other => panic!("expected Sign, got {:?}", other),
}
store
.record(&LastSignState {
version: 1,
view: 10,
height: 42,
step: VoteStep::Prepare,
block_hash: Some(h(1)),
signature: Some(vec![0xDE, 0xAD]),
})
.unwrap();
match store.check_vrs(10, 42, VoteStep::Prepare, &h(2)).unwrap() {
VrsDecision::Reject { .. } => {}
other => panic!("expected Reject, got {:?}", other),
}
match store.check_vrs(10, 42, VoteStep::Prepare, &h(1)).unwrap() {
VrsDecision::Reuse { signature } => assert_eq!(signature, vec![0xDE, 0xAD]),
other => panic!("expected Reuse, got {:?}", other),
}
match store.check_vrs(9, 99, VoteStep::Decide, &h(3)).unwrap() {
VrsDecision::Reject { .. } => {}
other => panic!("expected Reject for earlier view, got {:?}", other),
}
match store.check_vrs(11, 0, VoteStep::Prepare, &h(4)).unwrap() {
VrsDecision::Sign => {}
other => panic!("expected Sign for later view, got {:?}", other),
}
}
#[test]
fn memory_store_advancing_step_within_same_view() {
let store = MemoryVoteStateStore::new();
store
.record(&LastSignState {
version: 1,
view: 7,
height: 21,
step: VoteStep::Prepare,
block_hash: Some(h(9)),
signature: Some(vec![1, 2, 3]),
})
.unwrap();
match store.check_vrs(7, 21, VoteStep::Commit, &h(9)).unwrap() {
VrsDecision::Sign => {}
other => panic!("expected Sign for Commit advance, got {:?}", other),
}
}
#[test]
fn file_store_round_trip() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("last_sign.json");
let store = FileVoteStateStore::open(&path).unwrap();
store
.record(&LastSignState {
version: 1,
view: 12,
height: 100,
step: VoteStep::Commit,
block_hash: Some(h(7)),
signature: Some(vec![0xCA, 0xFE]),
})
.unwrap();
let store2 = FileVoteStateStore::open(&path).unwrap();
let loaded = store2.load().unwrap();
assert_eq!(loaded.view, 12);
assert_eq!(loaded.height, 100);
assert_eq!(loaded.step, VoteStep::Commit);
assert_eq!(loaded.block_hash, Some(h(7)));
assert_eq!(loaded.signature, Some(vec![0xCA, 0xFE]));
}
#[test]
fn file_store_rejects_replay_after_restart() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("last_sign.json");
let store = FileVoteStateStore::open(&path).unwrap();
store
.record(&LastSignState {
version: 1,
view: 50,
height: 200,
step: VoteStep::Prepare,
block_hash: Some(h(1)),
signature: Some(vec![0xAB]),
})
.unwrap();
let store2 = FileVoteStateStore::open(&path).unwrap();
match store2.check_vrs(50, 200, VoteStep::Prepare, &h(2)).unwrap() {
VrsDecision::Reject { reason } => {
assert!(reason.contains("double-sign refused"), "reason: {}", reason);
}
other => panic!("expected Reject after restart, got {:?}", other),
}
}
#[test]
fn file_store_handles_corrupt_file_via_error() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("last_sign.json");
std::fs::write(&path, b"not valid json{{{").unwrap();
let result = FileVoteStateStore::open(&path);
assert!(result.is_err(), "expected error opening corrupt file");
}
#[test]
fn file_store_handles_empty_file_as_fresh() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("last_sign.json");
std::fs::write(&path, b"").unwrap();
let store = FileVoteStateStore::open(&path).unwrap();
let loaded = store.load().unwrap();
assert_eq!(loaded, LastSignState::empty());
}
}