956 lines
33 KiB
Rust
956 lines
33 KiB
Rust
// spec, раздел "Хранение" + "Fast Sync".
|
||
// Persistence layer для state + proposal archive.
|
||
// Minimal filesystem-backed store (без RocksDB/sled — pure std::fs + fixed-size records).
|
||
|
||
use mt_consensus::{ProposalHeader, PROPOSAL_HEADER_SIZE};
|
||
use mt_crypto::{Signature, PUBLIC_KEY_SIZE, SIGNATURE_SIZE};
|
||
use mt_state::{
|
||
AccountRecord, AccountTable, CandidatePool, CandidateRecord, NodeRecord, NodeTable,
|
||
ACCOUNT_RECORD_SIZE, CANDIDATE_RECORD_SIZE, NODE_RECORD_SIZE,
|
||
};
|
||
use std::fs;
|
||
use std::io;
|
||
use std::path::{Path, PathBuf};
|
||
|
||
// ============ Phase A: FsStore — filesystem-backed KV ============
|
||
|
||
#[derive(Debug)]
|
||
pub struct FsStore {
|
||
root: PathBuf,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub enum StoreError {
|
||
Io(io::Error),
|
||
CorruptedLength(String),
|
||
ParseFailed(String),
|
||
NotFound(String),
|
||
}
|
||
|
||
impl From<io::Error> for StoreError {
|
||
fn from(e: io::Error) -> Self {
|
||
StoreError::Io(e)
|
||
}
|
||
}
|
||
|
||
impl FsStore {
|
||
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, StoreError> {
|
||
let root = path.as_ref().to_path_buf();
|
||
fs::create_dir_all(&root)?;
|
||
let proposals_dir = root.join("proposals");
|
||
fs::create_dir_all(&proposals_dir)?;
|
||
// M5-LOW-8 closure: cleanup orphaned `.tmp` файлов от прошлого
|
||
// crashed write_atomic (process killed между fs::write tmp и
|
||
// fs::rename → tmp file остаётся на диске). Multiple crashes без
|
||
// cleanup → накопление tmp в root + proposals/.
|
||
// Tmp файлы не influence load_* (load ищет по точному имени без
|
||
// .tmp suffix), но это storage waste. Cleanup на open() — ленивая
|
||
// recovery без impact на normal write path.
|
||
Self::cleanup_orphan_tmp(&root);
|
||
Self::cleanup_orphan_tmp(&proposals_dir);
|
||
Ok(Self { root })
|
||
}
|
||
|
||
fn cleanup_orphan_tmp(dir: &Path) {
|
||
// Best-effort: pri ошибке read_dir / fs::remove_file просто skip
|
||
// (не failure для open — tmp cleanup advisory, не load-bearing).
|
||
if let Ok(entries) = fs::read_dir(dir) {
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
if path.is_file()
|
||
&& path
|
||
.extension()
|
||
.and_then(|e| e.to_str())
|
||
.map(|e| e == "tmp")
|
||
.unwrap_or(false)
|
||
{
|
||
let _ = fs::remove_file(&path);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn root(&self) -> &Path {
|
||
&self.root
|
||
}
|
||
|
||
fn path(&self, name: &str) -> PathBuf {
|
||
self.root.join(name)
|
||
}
|
||
|
||
fn proposal_path(&self, window: u64) -> PathBuf {
|
||
self.root
|
||
.join("proposals")
|
||
.join(format!("{:020}.bin", window))
|
||
}
|
||
|
||
// R5 atomic rename pattern: writes go в `<name>.tmp` ДО fs::rename
|
||
// на final path. POSIX `rename(2)` atomic per single filesystem —
|
||
// observers видят либо old либо new content, не partial. Защита от
|
||
// power-loss / SIGKILL mid-write либо disk-full corruption.
|
||
//
|
||
// Для full crash-safety узел дополнительно использует fsync (в M6
|
||
// operator layer); rename atomicity достаточна для filesystem-level
|
||
// consistency без user-space syncing.
|
||
fn write_atomic(&self, name: &str, data: &[u8]) -> Result<(), StoreError> {
|
||
let final_path = self.path(name);
|
||
let tmp_path = self.path(&format!("{name}.tmp"));
|
||
fs::write(&tmp_path, data)?;
|
||
fs::rename(&tmp_path, &final_path)?;
|
||
Ok(())
|
||
}
|
||
|
||
fn write_atomic_to(&self, final_path: &Path, data: &[u8]) -> Result<(), StoreError> {
|
||
let parent = final_path
|
||
.parent()
|
||
.ok_or_else(|| StoreError::ParseFailed("path has no parent".into()))?;
|
||
let file_name = final_path
|
||
.file_name()
|
||
.ok_or_else(|| StoreError::ParseFailed("path has no file name".into()))?;
|
||
let tmp_path = parent.join(format!("{}.tmp", file_name.to_string_lossy()));
|
||
fs::write(&tmp_path, data)?;
|
||
fs::rename(&tmp_path, final_path)?;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
// ============ Phase B: Table persistence ============
|
||
|
||
// AccountRecord fixed-size decode (inverse CanonicalEncode).
|
||
// Layout (ACCOUNT_RECORD_SIZE = 2059 B под ML-DSA-65) — см. spec раздел
|
||
// "Account — содержимое блока":
|
||
// account_id 32 + balance 16 + suite_id 2 + is_node_operator 1 + frontier_hash 32
|
||
// + op_height 4 + account_chain_length 4 + account_chain_length_snapshot 4
|
||
// + current_pubkey 1952 + creation_window 4 + last_op_window 4
|
||
// + last_activation_window 4
|
||
fn read_32_at(bytes: &[u8], off: usize) -> [u8; 32] {
|
||
let mut h = [0u8; 32];
|
||
h.copy_from_slice(&bytes[off..off + 32]);
|
||
h
|
||
}
|
||
|
||
fn read_u16_at(bytes: &[u8], off: usize) -> u16 {
|
||
let mut b = [0u8; 2];
|
||
b.copy_from_slice(&bytes[off..off + 2]);
|
||
u16::from_le_bytes(b)
|
||
}
|
||
|
||
fn read_u32_at(bytes: &[u8], off: usize) -> u32 {
|
||
let mut b = [0u8; 4];
|
||
b.copy_from_slice(&bytes[off..off + 4]);
|
||
u32::from_le_bytes(b)
|
||
}
|
||
|
||
fn read_u64_at(bytes: &[u8], off: usize) -> u64 {
|
||
let mut b = [0u8; 8];
|
||
b.copy_from_slice(&bytes[off..off + 8]);
|
||
u64::from_le_bytes(b)
|
||
}
|
||
|
||
fn read_u128_at(bytes: &[u8], off: usize) -> u128 {
|
||
let mut b = [0u8; 16];
|
||
b.copy_from_slice(&bytes[off..off + 16]);
|
||
u128::from_le_bytes(b)
|
||
}
|
||
|
||
fn read_pubkey_at(bytes: &[u8], off: usize) -> [u8; PUBLIC_KEY_SIZE] {
|
||
let mut pk = [0u8; PUBLIC_KEY_SIZE];
|
||
pk.copy_from_slice(&bytes[off..off + PUBLIC_KEY_SIZE]);
|
||
pk
|
||
}
|
||
|
||
fn decode_account_record(bytes: &[u8]) -> Result<AccountRecord, StoreError> {
|
||
if bytes.len() != ACCOUNT_RECORD_SIZE {
|
||
return Err(StoreError::CorruptedLength(format!(
|
||
"AccountRecord expect {ACCOUNT_RECORD_SIZE}, got {}",
|
||
bytes.len()
|
||
)));
|
||
}
|
||
let account_id = read_32_at(bytes, 0);
|
||
let balance = read_u128_at(bytes, 32);
|
||
let suite_id = read_u16_at(bytes, 48);
|
||
let is_node_operator = bytes[50] != 0;
|
||
let frontier_hash = read_32_at(bytes, 51);
|
||
let op_height = read_u32_at(bytes, 83);
|
||
let account_chain_length = read_u32_at(bytes, 87);
|
||
let account_chain_length_snapshot = read_u32_at(bytes, 91);
|
||
let current_pubkey = read_pubkey_at(bytes, 95);
|
||
let creation_window = read_u32_at(bytes, 95 + PUBLIC_KEY_SIZE);
|
||
let last_op_window = read_u32_at(bytes, 99 + PUBLIC_KEY_SIZE);
|
||
let last_activation_window = read_u32_at(bytes, 103 + PUBLIC_KEY_SIZE);
|
||
Ok(AccountRecord {
|
||
account_id,
|
||
balance,
|
||
suite_id,
|
||
is_node_operator,
|
||
frontier_hash,
|
||
op_height,
|
||
account_chain_length,
|
||
account_chain_length_snapshot,
|
||
current_pubkey,
|
||
creation_window,
|
||
last_op_window,
|
||
last_activation_window,
|
||
})
|
||
}
|
||
|
||
// NodeRecord fixed-size decode (NODE_RECORD_SIZE = 2098 B под ML-DSA-65):
|
||
// node_id 32 + node_pubkey 1952 + suite_id 2 + operator_account_id 32
|
||
// + start_window 8 + chain_length 8 + chain_length_snapshot 8
|
||
// + chain_length_checkpoints [u64;6] = 48 + last_confirmation_window 8
|
||
fn decode_node_record(bytes: &[u8]) -> Result<NodeRecord, StoreError> {
|
||
if bytes.len() != NODE_RECORD_SIZE {
|
||
return Err(StoreError::CorruptedLength(format!(
|
||
"NodeRecord expect {NODE_RECORD_SIZE}, got {}",
|
||
bytes.len()
|
||
)));
|
||
}
|
||
// Offsets: 32 + 1952 + 2 + 32 + 8 + 8 + 8 + 48 (6×u64) + 8 = 2098
|
||
let node_id = read_32_at(bytes, 0);
|
||
let node_pubkey = read_pubkey_at(bytes, 32);
|
||
let suite_id = read_u16_at(bytes, 32 + PUBLIC_KEY_SIZE);
|
||
let operator_account_id = read_32_at(bytes, 34 + PUBLIC_KEY_SIZE);
|
||
let base = 66 + PUBLIC_KEY_SIZE;
|
||
let start_window = read_u64_at(bytes, base);
|
||
let chain_length = read_u64_at(bytes, base + 8);
|
||
let chain_length_snapshot = read_u64_at(bytes, base + 16);
|
||
let mut chain_length_checkpoints = [0u64; 6];
|
||
for (i, cp) in chain_length_checkpoints.iter_mut().enumerate() {
|
||
*cp = read_u64_at(bytes, base + 24 + i * 8);
|
||
}
|
||
let last_confirmation_window = read_u64_at(bytes, base + 24 + 48);
|
||
Ok(NodeRecord {
|
||
node_id,
|
||
node_pubkey,
|
||
suite_id,
|
||
operator_account_id,
|
||
start_window,
|
||
chain_length,
|
||
chain_length_snapshot,
|
||
chain_length_checkpoints,
|
||
last_confirmation_window,
|
||
})
|
||
}
|
||
|
||
// CandidateRecord fixed-size decode (CANDIDATE_RECORD_SIZE = 2082 B под ML-DSA-65):
|
||
// node_id 32 + node_pubkey 1952 + suite_id 2 + operator_account_id 32
|
||
// + proof_endpoint 32 + w_start 8 + vdf_chain_length 8
|
||
// + registration_window 8 + expires 8
|
||
fn decode_candidate_record(bytes: &[u8]) -> Result<CandidateRecord, StoreError> {
|
||
if bytes.len() != CANDIDATE_RECORD_SIZE {
|
||
return Err(StoreError::CorruptedLength(format!(
|
||
"CandidateRecord expect {CANDIDATE_RECORD_SIZE}, got {}",
|
||
bytes.len()
|
||
)));
|
||
}
|
||
// Offsets: 32 + 1952 + 2 + 32 + 32 + 8 + 8 + 8 + 8 = 2082
|
||
let node_id = read_32_at(bytes, 0);
|
||
let node_pubkey = read_pubkey_at(bytes, 32);
|
||
let suite_id = read_u16_at(bytes, 32 + PUBLIC_KEY_SIZE);
|
||
let operator_account_id = read_32_at(bytes, 34 + PUBLIC_KEY_SIZE);
|
||
let proof_endpoint = read_32_at(bytes, 66 + PUBLIC_KEY_SIZE);
|
||
let base = 98 + PUBLIC_KEY_SIZE;
|
||
let w_start = read_u64_at(bytes, base);
|
||
let vdf_chain_length = read_u64_at(bytes, base + 8);
|
||
let registration_window = read_u64_at(bytes, base + 16);
|
||
let expires = read_u64_at(bytes, base + 24);
|
||
Ok(CandidateRecord {
|
||
node_id,
|
||
node_pubkey,
|
||
suite_id,
|
||
operator_account_id,
|
||
proof_endpoint,
|
||
w_start,
|
||
vdf_chain_length,
|
||
registration_window,
|
||
expires,
|
||
})
|
||
}
|
||
|
||
impl FsStore {
|
||
pub fn save_account_table(&self, table: &AccountTable) -> Result<(), StoreError> {
|
||
use mt_codec::CanonicalEncode;
|
||
let mut buf = Vec::with_capacity(table.len() * ACCOUNT_RECORD_SIZE);
|
||
for rec in table.iter() {
|
||
rec.encode(&mut buf);
|
||
}
|
||
self.write_atomic("accounts.bin", &buf)?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn load_account_table(&self) -> Result<AccountTable, StoreError> {
|
||
let path = self.path("accounts.bin");
|
||
if !path.exists() {
|
||
return Ok(AccountTable::new());
|
||
}
|
||
let bytes = fs::read(&path)?;
|
||
if bytes.len() % ACCOUNT_RECORD_SIZE != 0 {
|
||
return Err(StoreError::CorruptedLength(format!(
|
||
"accounts.bin length {} не кратна {ACCOUNT_RECORD_SIZE}",
|
||
bytes.len()
|
||
)));
|
||
}
|
||
let mut table = AccountTable::new();
|
||
for chunk in bytes.chunks_exact(ACCOUNT_RECORD_SIZE) {
|
||
let rec = decode_account_record(chunk)?;
|
||
table.insert(rec);
|
||
}
|
||
Ok(table)
|
||
}
|
||
|
||
pub fn save_node_table(&self, table: &NodeTable) -> Result<(), StoreError> {
|
||
use mt_codec::CanonicalEncode;
|
||
let mut buf = Vec::with_capacity(table.len() * NODE_RECORD_SIZE);
|
||
for rec in table.iter() {
|
||
rec.encode(&mut buf);
|
||
}
|
||
self.write_atomic("nodes.bin", &buf)?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn load_node_table(&self) -> Result<NodeTable, StoreError> {
|
||
let path = self.path("nodes.bin");
|
||
if !path.exists() {
|
||
return Ok(NodeTable::new());
|
||
}
|
||
let bytes = fs::read(&path)?;
|
||
if bytes.len() % NODE_RECORD_SIZE != 0 {
|
||
return Err(StoreError::CorruptedLength(format!(
|
||
"nodes.bin length {} не кратна {NODE_RECORD_SIZE}",
|
||
bytes.len()
|
||
)));
|
||
}
|
||
let mut table = NodeTable::new();
|
||
for chunk in bytes.chunks_exact(NODE_RECORD_SIZE) {
|
||
let rec = decode_node_record(chunk)?;
|
||
table.insert(rec);
|
||
}
|
||
Ok(table)
|
||
}
|
||
|
||
pub fn save_candidate_pool(&self, pool: &CandidatePool) -> Result<(), StoreError> {
|
||
use mt_codec::CanonicalEncode;
|
||
let mut buf = Vec::with_capacity(pool.len() * CANDIDATE_RECORD_SIZE);
|
||
for rec in pool.iter() {
|
||
rec.encode(&mut buf);
|
||
}
|
||
self.write_atomic("candidates.bin", &buf)?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn load_candidate_pool(&self) -> Result<CandidatePool, StoreError> {
|
||
let path = self.path("candidates.bin");
|
||
if !path.exists() {
|
||
return Ok(CandidatePool::new());
|
||
}
|
||
let bytes = fs::read(&path)?;
|
||
if bytes.len() % CANDIDATE_RECORD_SIZE != 0 {
|
||
return Err(StoreError::CorruptedLength(format!(
|
||
"candidates.bin length {} не кратна {CANDIDATE_RECORD_SIZE}",
|
||
bytes.len()
|
||
)));
|
||
}
|
||
let mut pool = CandidatePool::new();
|
||
for chunk in bytes.chunks_exact(CANDIDATE_RECORD_SIZE) {
|
||
let rec = decode_candidate_record(chunk)?;
|
||
pool.insert(rec);
|
||
}
|
||
Ok(pool)
|
||
}
|
||
}
|
||
|
||
// ============ Phase C: Proposal archive ============
|
||
|
||
fn decode_proposal_header(bytes: &[u8]) -> Result<ProposalHeader, StoreError> {
|
||
if bytes.len() != PROPOSAL_HEADER_SIZE {
|
||
return Err(StoreError::CorruptedLength(format!(
|
||
"ProposalHeader expect {PROPOSAL_HEADER_SIZE}, got {}",
|
||
bytes.len()
|
||
)));
|
||
}
|
||
// Offsets per spec v31.0.0 (winner_class byte удалён; header 3722 B
|
||
// под ML-DSA-65; signed-scope без signature = 413 B; structural offsets
|
||
// 0..413 без изменений):
|
||
// 0..32 prev_proposal_hash, 32..40 window_index (u64),
|
||
// 40..44 protocol_version (u32), 44..76 control_root, 76..108 node_root,
|
||
// 108..140 candidate_root, 140..172 account_root, 172..204 state_root,
|
||
// 204..236 timechain_value, 236..268 included_bundles_root,
|
||
// 268..300 included_reveals_root,
|
||
// 300..332 winner_endpoint, 332..364 winner_id, 364..396 proposer_node_id,
|
||
// 396..412 target (u128), 412 fallback_depth (u8),
|
||
// 413..3722 signature (3309B ML-DSA-65)
|
||
let prev_proposal_hash = read_32_at(bytes, 0);
|
||
let window_index = read_u64_at(bytes, 32);
|
||
let protocol_version = read_u32_at(bytes, 40);
|
||
let control_root = read_32_at(bytes, 44);
|
||
let node_root = read_32_at(bytes, 76);
|
||
let candidate_root = read_32_at(bytes, 108);
|
||
let account_root = read_32_at(bytes, 140);
|
||
let state_root = read_32_at(bytes, 172);
|
||
let timechain_value = read_32_at(bytes, 204);
|
||
let included_bundles_root = read_32_at(bytes, 236);
|
||
let included_reveals_root = read_32_at(bytes, 268);
|
||
let winner_endpoint = read_32_at(bytes, 300);
|
||
let winner_id = read_32_at(bytes, 332);
|
||
let proposer_node_id = read_32_at(bytes, 364);
|
||
let target = read_u128_at(bytes, 396);
|
||
let fallback_depth = bytes[412];
|
||
let mut sig_bytes = [0u8; SIGNATURE_SIZE];
|
||
sig_bytes.copy_from_slice(&bytes[413..413 + SIGNATURE_SIZE]);
|
||
let signature = Signature::from_array(sig_bytes);
|
||
Ok(ProposalHeader {
|
||
prev_proposal_hash,
|
||
window_index,
|
||
protocol_version,
|
||
control_root,
|
||
node_root,
|
||
candidate_root,
|
||
account_root,
|
||
state_root,
|
||
timechain_value,
|
||
included_bundles_root,
|
||
included_reveals_root,
|
||
winner_endpoint,
|
||
winner_id,
|
||
proposer_node_id,
|
||
target,
|
||
fallback_depth,
|
||
signature,
|
||
})
|
||
}
|
||
|
||
impl FsStore {
|
||
pub fn archive_proposal(&self, header: &ProposalHeader) -> Result<(), StoreError> {
|
||
use mt_codec::CanonicalEncode;
|
||
let mut buf = Vec::with_capacity(PROPOSAL_HEADER_SIZE);
|
||
header.encode(&mut buf);
|
||
self.write_atomic_to(&self.proposal_path(header.window_index), &buf)?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn get_proposal_by_window(
|
||
&self,
|
||
window: u64,
|
||
) -> Result<Option<ProposalHeader>, StoreError> {
|
||
let path = self.proposal_path(window);
|
||
if !path.exists() {
|
||
return Ok(None);
|
||
}
|
||
let bytes = fs::read(&path)?;
|
||
Ok(Some(decode_proposal_header(&bytes)?))
|
||
}
|
||
}
|
||
|
||
// ============ Phase D: Crash recovery (meta last_cemented_window) ============
|
||
|
||
impl FsStore {
|
||
pub fn save_meta_last_cemented(&self, window: u64) -> Result<(), StoreError> {
|
||
self.write_atomic("meta_last_cemented.bin", &window.to_le_bytes())?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn load_meta_last_cemented(&self) -> Result<Option<u64>, StoreError> {
|
||
let path = self.path("meta_last_cemented.bin");
|
||
if !path.exists() {
|
||
return Ok(None);
|
||
}
|
||
let bytes = fs::read(&path)?;
|
||
if bytes.len() != 8 {
|
||
return Err(StoreError::CorruptedLength(format!(
|
||
"meta_last_cemented.bin expect 8, got {}",
|
||
bytes.len()
|
||
)));
|
||
}
|
||
let mut b = [0u8; 8];
|
||
b.copy_from_slice(&bytes);
|
||
Ok(Some(u64::from_le_bytes(b)))
|
||
}
|
||
|
||
// Restart consistency: meta — последний целостный commit. При reopen
|
||
// проверяем что proposal (meta.last_cemented_window) существует в archive.
|
||
// Иначе: crash between commit и meta write → fallback на предыдущий.
|
||
pub fn verify_consistency(&self) -> Result<u64, StoreError> {
|
||
let last = self.load_meta_last_cemented()?.unwrap_or(0);
|
||
// Проверка: proposal_{last} существует (если > 0)
|
||
if last > 0 && self.get_proposal_by_window(last)?.is_none() {
|
||
return Err(StoreError::NotFound(format!(
|
||
"meta_last_cemented = {last}, но proposals/{:020}.bin отсутствует",
|
||
last
|
||
)));
|
||
}
|
||
Ok(last)
|
||
}
|
||
}
|
||
|
||
// ============ Phase E: Pruning ============
|
||
|
||
impl FsStore {
|
||
// Удалить proposals с window_index < threshold.
|
||
// Возвращает Vec<u64> удалённых window indices.
|
||
pub fn prune_proposals_before(&self, threshold: u64) -> Result<Vec<u64>, StoreError> {
|
||
let proposals_dir = self.root.join("proposals");
|
||
let mut removed = Vec::new();
|
||
if !proposals_dir.exists() {
|
||
return Ok(removed);
|
||
}
|
||
for entry in fs::read_dir(&proposals_dir)? {
|
||
let entry = entry?;
|
||
let file_name = entry.file_name();
|
||
let name = file_name.to_string_lossy();
|
||
if !name.ends_with(".bin") {
|
||
continue;
|
||
}
|
||
let stem = &name[..name.len() - 4];
|
||
let window: u64 = match stem.parse() {
|
||
Ok(w) => w,
|
||
Err(_) => continue,
|
||
};
|
||
if window < threshold {
|
||
fs::remove_file(entry.path())?;
|
||
removed.push(window);
|
||
}
|
||
}
|
||
removed.sort();
|
||
Ok(removed)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use mt_codec::CanonicalEncode;
|
||
use mt_crypto::SECRET_KEY_SIZE;
|
||
use mt_state::derive_account_id;
|
||
|
||
fn tmp_dir(suffix: &str) -> PathBuf {
|
||
let base = std::env::temp_dir().join(format!("mt-store-test-{suffix}-{}", rand_suffix()));
|
||
let _ = fs::remove_dir_all(&base);
|
||
base
|
||
}
|
||
|
||
fn rand_suffix() -> u64 {
|
||
// Псевдо-рандом через nano system time (не consensus-critical)
|
||
use std::time::{SystemTime, UNIX_EPOCH};
|
||
SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.map(|d| d.subsec_nanos() as u64 ^ (d.as_secs() << 32))
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
fn make_account(seed: u8) -> AccountRecord {
|
||
let pubkey = [seed; PUBLIC_KEY_SIZE];
|
||
AccountRecord {
|
||
account_id: derive_account_id(1, &pubkey),
|
||
balance: 1000 + seed as u128,
|
||
suite_id: 1,
|
||
is_node_operator: seed % 2 == 0,
|
||
frontier_hash: [seed; 32],
|
||
op_height: seed as u32,
|
||
account_chain_length: seed as u32,
|
||
account_chain_length_snapshot: (seed / 2) as u32,
|
||
current_pubkey: pubkey,
|
||
creation_window: seed as u32,
|
||
last_op_window: seed as u32,
|
||
last_activation_window: 0,
|
||
}
|
||
}
|
||
|
||
fn make_node(seed: u8) -> NodeRecord {
|
||
NodeRecord {
|
||
node_id: [seed; 32],
|
||
node_pubkey: [seed; PUBLIC_KEY_SIZE],
|
||
suite_id: 1,
|
||
operator_account_id: [seed; 32],
|
||
start_window: seed as u64,
|
||
chain_length: (seed as u64 + 1) * 100,
|
||
chain_length_snapshot: (seed as u64 + 1) * 50,
|
||
chain_length_checkpoints: [(seed as u64) * 10; 6],
|
||
last_confirmation_window: seed as u64,
|
||
}
|
||
}
|
||
|
||
fn make_candidate(seed: u8) -> CandidateRecord {
|
||
CandidateRecord {
|
||
node_id: [seed; 32],
|
||
node_pubkey: [seed; PUBLIC_KEY_SIZE],
|
||
suite_id: 1,
|
||
operator_account_id: [seed; 32],
|
||
proof_endpoint: [seed; 32],
|
||
w_start: seed as u64,
|
||
vdf_chain_length: seed as u64 * 1000,
|
||
registration_window: seed as u64,
|
||
expires: seed as u64 + 10_000,
|
||
}
|
||
}
|
||
|
||
fn make_header(window: u64) -> ProposalHeader {
|
||
ProposalHeader {
|
||
prev_proposal_hash: [(window % 256) as u8; 32],
|
||
window_index: window,
|
||
protocol_version: 1,
|
||
control_root: [0x02; 32],
|
||
node_root: [0x03; 32],
|
||
candidate_root: [0x04; 32],
|
||
account_root: [0x05; 32],
|
||
state_root: [0x06; 32],
|
||
timechain_value: [0x07; 32],
|
||
included_bundles_root: [0x08; 32],
|
||
included_reveals_root: [0x09; 32],
|
||
winner_endpoint: [0x0A; 32],
|
||
winner_id: [0x0B; 32],
|
||
proposer_node_id: [0xAA; 32],
|
||
target: u128::from(window),
|
||
fallback_depth: 1,
|
||
signature: Signature::from_array([(window % 256) as u8; SIGNATURE_SIZE]),
|
||
}
|
||
}
|
||
|
||
// Phase A
|
||
|
||
#[test]
|
||
fn open_creates_directories() {
|
||
let p = tmp_dir("open");
|
||
let _store = FsStore::open(&p).unwrap();
|
||
assert!(p.exists());
|
||
assert!(p.join("proposals").exists());
|
||
fs::remove_dir_all(&p).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn reopen_existing_is_idempotent() {
|
||
let p = tmp_dir("reopen");
|
||
let s1 = FsStore::open(&p).unwrap();
|
||
let s2 = FsStore::open(&p).unwrap();
|
||
assert_eq!(s1.root(), s2.root());
|
||
fs::remove_dir_all(&p).ok();
|
||
}
|
||
|
||
// M5-LOW-8 closure: anti-regression — orphan .tmp files (от crashed
|
||
// write_atomic) cleanup при FsStore::open. Без cleanup multiple crashes
|
||
// → накопление tmp в root + proposals/.
|
||
#[test]
|
||
fn open_cleans_orphan_tmp_files() {
|
||
let p = tmp_dir("orphan_tmp");
|
||
// Pre-create directories + simulated orphan .tmp от crashed write
|
||
fs::create_dir_all(&p).unwrap();
|
||
fs::create_dir_all(p.join("proposals")).unwrap();
|
||
let orphan_root = p.join("accounts.bin.tmp");
|
||
let orphan_proposal = p.join("proposals").join("00000000000000000042.bin.tmp");
|
||
let normal_file = p.join("accounts.bin");
|
||
fs::write(&orphan_root, b"crashed-write").unwrap();
|
||
fs::write(&orphan_proposal, b"crashed-write").unwrap();
|
||
fs::write(&normal_file, b"valid").unwrap();
|
||
assert!(orphan_root.exists());
|
||
assert!(orphan_proposal.exists());
|
||
|
||
// open() должен cleanup .tmp но НЕ трогать non-tmp
|
||
let _store = FsStore::open(&p).unwrap();
|
||
assert!(!orphan_root.exists(), "orphan root .tmp not cleaned");
|
||
assert!(
|
||
!orphan_proposal.exists(),
|
||
"orphan proposal .tmp not cleaned"
|
||
);
|
||
assert!(normal_file.exists(), "non-tmp file mistakenly removed");
|
||
|
||
fs::remove_dir_all(&p).ok();
|
||
}
|
||
|
||
// Phase B — AccountTable
|
||
|
||
#[test]
|
||
fn account_table_save_load_roundtrip() {
|
||
let p = tmp_dir("acc");
|
||
let store = FsStore::open(&p).unwrap();
|
||
let mut t = AccountTable::new();
|
||
for i in 1u8..=5 {
|
||
t.insert(make_account(i));
|
||
}
|
||
let root_before = t.root();
|
||
store.save_account_table(&t).unwrap();
|
||
let loaded = store.load_account_table().unwrap();
|
||
assert_eq!(loaded.len(), 5);
|
||
assert_eq!(loaded.root(), root_before);
|
||
fs::remove_dir_all(&p).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn account_table_load_missing_returns_empty() {
|
||
let p = tmp_dir("acc-empty");
|
||
let store = FsStore::open(&p).unwrap();
|
||
let t = store.load_account_table().unwrap();
|
||
assert_eq!(t.len(), 0);
|
||
fs::remove_dir_all(&p).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn account_table_decode_is_inverse_of_encode() {
|
||
let a = make_account(42);
|
||
let mut buf = Vec::new();
|
||
a.encode(&mut buf);
|
||
let decoded = decode_account_record(&buf).unwrap();
|
||
assert_eq!(decoded, a);
|
||
}
|
||
|
||
#[test]
|
||
fn account_table_decode_wrong_size_fails() {
|
||
let result = decode_account_record(&[0u8; 100]);
|
||
assert!(matches!(result, Err(StoreError::CorruptedLength(_))));
|
||
}
|
||
|
||
// Phase B — NodeTable
|
||
|
||
#[test]
|
||
fn node_table_save_load_roundtrip() {
|
||
let p = tmp_dir("nodes");
|
||
let store = FsStore::open(&p).unwrap();
|
||
let mut t = NodeTable::new();
|
||
for i in 1u8..=3 {
|
||
t.insert(make_node(i));
|
||
}
|
||
let root_before = t.root();
|
||
store.save_node_table(&t).unwrap();
|
||
let loaded = store.load_node_table().unwrap();
|
||
assert_eq!(loaded.len(), 3);
|
||
assert_eq!(loaded.root(), root_before);
|
||
fs::remove_dir_all(&p).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn node_table_decode_inverse() {
|
||
let n = make_node(77);
|
||
let mut buf = Vec::new();
|
||
n.encode(&mut buf);
|
||
let decoded = decode_node_record(&buf).unwrap();
|
||
assert_eq!(decoded, n);
|
||
}
|
||
|
||
// Phase B — CandidatePool
|
||
|
||
#[test]
|
||
fn candidate_pool_save_load_roundtrip() {
|
||
let p = tmp_dir("cands");
|
||
let store = FsStore::open(&p).unwrap();
|
||
let mut pool = CandidatePool::new();
|
||
for i in 1u8..=4 {
|
||
pool.insert(make_candidate(i));
|
||
}
|
||
let root_before = pool.root();
|
||
store.save_candidate_pool(&pool).unwrap();
|
||
let loaded = store.load_candidate_pool().unwrap();
|
||
assert_eq!(loaded.len(), 4);
|
||
assert_eq!(loaded.root(), root_before);
|
||
fs::remove_dir_all(&p).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn candidate_decode_inverse() {
|
||
let c = make_candidate(99);
|
||
let mut buf = Vec::new();
|
||
c.encode(&mut buf);
|
||
let decoded = decode_candidate_record(&buf).unwrap();
|
||
assert_eq!(decoded, c);
|
||
}
|
||
|
||
// Phase C — Proposal archive
|
||
|
||
#[test]
|
||
fn archive_and_fetch_proposal() {
|
||
let p = tmp_dir("prop");
|
||
let store = FsStore::open(&p).unwrap();
|
||
let h = make_header(100);
|
||
store.archive_proposal(&h).unwrap();
|
||
let loaded = store.get_proposal_by_window(100).unwrap().unwrap();
|
||
assert_eq!(loaded, h);
|
||
fs::remove_dir_all(&p).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn fetch_missing_proposal_returns_none() {
|
||
let p = tmp_dir("prop-none");
|
||
let store = FsStore::open(&p).unwrap();
|
||
assert!(store.get_proposal_by_window(12345).unwrap().is_none());
|
||
fs::remove_dir_all(&p).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn archive_100_proposals_random_access() {
|
||
let p = tmp_dir("prop-many");
|
||
let store = FsStore::open(&p).unwrap();
|
||
for w in 1..=100u64 {
|
||
store.archive_proposal(&make_header(w)).unwrap();
|
||
}
|
||
// Random access
|
||
for w in [1u64, 50, 99, 100] {
|
||
let h = store.get_proposal_by_window(w).unwrap().unwrap();
|
||
assert_eq!(h.window_index, w);
|
||
}
|
||
fs::remove_dir_all(&p).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn proposal_header_decode_inverse() {
|
||
let h = make_header(42);
|
||
let mut buf = Vec::new();
|
||
h.encode(&mut buf);
|
||
let decoded = decode_proposal_header(&buf).unwrap();
|
||
assert_eq!(decoded, h);
|
||
}
|
||
|
||
// Phase D — Crash recovery
|
||
|
||
#[test]
|
||
fn meta_last_cemented_save_load() {
|
||
let p = tmp_dir("meta");
|
||
let store = FsStore::open(&p).unwrap();
|
||
store.save_meta_last_cemented(12345).unwrap();
|
||
let loaded = store.load_meta_last_cemented().unwrap();
|
||
assert_eq!(loaded, Some(12345));
|
||
fs::remove_dir_all(&p).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn meta_missing_returns_none() {
|
||
let p = tmp_dir("meta-none");
|
||
let store = FsStore::open(&p).unwrap();
|
||
assert_eq!(store.load_meta_last_cemented().unwrap(), None);
|
||
fs::remove_dir_all(&p).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn verify_consistency_fresh_store_returns_zero() {
|
||
let p = tmp_dir("consist-fresh");
|
||
let store = FsStore::open(&p).unwrap();
|
||
assert_eq!(store.verify_consistency().unwrap(), 0);
|
||
fs::remove_dir_all(&p).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn verify_consistency_detects_missing_proposal() {
|
||
// meta указывает на window 100, но proposal не archive'd → inconsistency
|
||
let p = tmp_dir("consist-bad");
|
||
let store = FsStore::open(&p).unwrap();
|
||
store.save_meta_last_cemented(100).unwrap();
|
||
assert!(matches!(
|
||
store.verify_consistency(),
|
||
Err(StoreError::NotFound(_))
|
||
));
|
||
fs::remove_dir_all(&p).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn verify_consistency_success_with_proposal() {
|
||
let p = tmp_dir("consist-ok");
|
||
let store = FsStore::open(&p).unwrap();
|
||
store.archive_proposal(&make_header(50)).unwrap();
|
||
store.save_meta_last_cemented(50).unwrap();
|
||
assert_eq!(store.verify_consistency().unwrap(), 50);
|
||
fs::remove_dir_all(&p).ok();
|
||
}
|
||
|
||
// Phase E — Pruning
|
||
|
||
#[test]
|
||
fn prune_removes_old_proposals() {
|
||
let p = tmp_dir("prune");
|
||
let store = FsStore::open(&p).unwrap();
|
||
for w in 1..=20u64 {
|
||
store.archive_proposal(&make_header(w)).unwrap();
|
||
}
|
||
let removed = store.prune_proposals_before(10).unwrap();
|
||
// removed: windows 1..=9
|
||
assert_eq!(removed.len(), 9);
|
||
// Current state proposals 10..20 должны оставаться
|
||
for w in 10..=20u64 {
|
||
assert!(store.get_proposal_by_window(w).unwrap().is_some());
|
||
}
|
||
// Pruned — absent
|
||
for w in 1..=9u64 {
|
||
assert!(store.get_proposal_by_window(w).unwrap().is_none());
|
||
}
|
||
fs::remove_dir_all(&p).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn prune_does_not_touch_tables() {
|
||
let p = tmp_dir("prune-tables");
|
||
let store = FsStore::open(&p).unwrap();
|
||
// Save tables
|
||
let mut at = AccountTable::new();
|
||
at.insert(make_account(1));
|
||
store.save_account_table(&at).unwrap();
|
||
// Archive and prune proposals
|
||
store.archive_proposal(&make_header(5)).unwrap();
|
||
store.prune_proposals_before(100).unwrap();
|
||
// AccountTable intact
|
||
let loaded = store.load_account_table().unwrap();
|
||
assert_eq!(loaded.len(), 1);
|
||
fs::remove_dir_all(&p).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn prune_empty_proposals_returns_empty() {
|
||
let p = tmp_dir("prune-empty");
|
||
let store = FsStore::open(&p).unwrap();
|
||
let removed = store.prune_proposals_before(100).unwrap();
|
||
assert!(removed.is_empty());
|
||
fs::remove_dir_all(&p).ok();
|
||
}
|
||
|
||
// Integration: full restart cycle
|
||
|
||
#[test]
|
||
fn full_restart_cycle_state_preserved() {
|
||
let p = tmp_dir("restart");
|
||
|
||
// === Session 1 ===
|
||
let store = FsStore::open(&p).unwrap();
|
||
let mut at = AccountTable::new();
|
||
for i in 1u8..=3 {
|
||
at.insert(make_account(i));
|
||
}
|
||
let mut nt = NodeTable::new();
|
||
for i in 1u8..=2 {
|
||
nt.insert(make_node(i));
|
||
}
|
||
let mut pool = CandidatePool::new();
|
||
pool.insert(make_candidate(10));
|
||
|
||
let at_root_before = at.root();
|
||
let nt_root_before = nt.root();
|
||
let pool_root_before = pool.root();
|
||
|
||
store.save_account_table(&at).unwrap();
|
||
store.save_node_table(&nt).unwrap();
|
||
store.save_candidate_pool(&pool).unwrap();
|
||
store.archive_proposal(&make_header(50)).unwrap();
|
||
store.save_meta_last_cemented(50).unwrap();
|
||
|
||
drop(store); // close
|
||
|
||
// === Session 2 (restart) ===
|
||
let store2 = FsStore::open(&p).unwrap();
|
||
let at2 = store2.load_account_table().unwrap();
|
||
let nt2 = store2.load_node_table().unwrap();
|
||
let pool2 = store2.load_candidate_pool().unwrap();
|
||
let last = store2.verify_consistency().unwrap();
|
||
|
||
assert_eq!(at2.root(), at_root_before);
|
||
assert_eq!(nt2.root(), nt_root_before);
|
||
assert_eq!(pool2.root(), pool_root_before);
|
||
assert_eq!(last, 50);
|
||
|
||
// Proposals can still be fetched
|
||
let prop = store2.get_proposal_by_window(50).unwrap().unwrap();
|
||
assert_eq!(prop.window_index, 50);
|
||
|
||
fs::remove_dir_all(&p).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn sig_size_sanity() {
|
||
// ML-DSA-65 (FIPS 204 level 3) sizes
|
||
assert_eq!(SECRET_KEY_SIZE, 4032);
|
||
assert_eq!(SIGNATURE_SIZE, 3309);
|
||
}
|
||
}
|