diff --git a/Cargo.toml b/Cargo.toml index 60032ec..502751a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,3 +41,11 @@ path = "tests/crypto/dkg_serialization_roundtrip_test.rs" [[test]] name = "proof_of_connectivity_epoch_nonce_test" path = "tests/attestation/proof_of_connectivity_epoch_nonce_test.rs" + +[[test]] +name = "backup_verification_test" +path = "tests/backup_verification_test.rs" + +[[test]] +name = "webhook_delivery_test" +path = "tests/webhook_delivery_test.rs" diff --git a/src/backup/mod.rs b/src/backup/mod.rs new file mode 100644 index 0000000..93d3a06 --- /dev/null +++ b/src/backup/mod.rs @@ -0,0 +1,8 @@ +//! State snapshot backup and restore verification (issue #70). +//! +//! This module provides scheduled state snapshot creation, integrity +//! verification, and restore testing. Snapshots are identified by epoch and +//! carry a Merkle-style integrity hash derived from the stored state so that +//! corruption or incomplete restores can be detected deterministically. + +pub mod state_snapshot; diff --git a/src/backup/state_snapshot.rs b/src/backup/state_snapshot.rs new file mode 100644 index 0000000..38357dc --- /dev/null +++ b/src/backup/state_snapshot.rs @@ -0,0 +1,449 @@ +//! Scheduled state snapshot, backup verification, and restore testing. +//! +//! Follows the same `no_std`-friendly, alloc-only pattern used by the +//! committee cache and slashing modules. Snapshots are scoped by epoch; +//! a newly-created snapshot computes an integrity hash from the supplied +//! state chunks so that restore verification can re-derive the hash and +//! detect discrepancies. + +extern crate alloc; +use alloc::collections::BTreeMap; +use alloc::vec::Vec; +use crate::crypto::sha256::sha256; +use crate::crypto::merkle::Hash256; + +// --- CONSTANTS --- + +/// How often (in seconds) a scheduled snapshot should be taken. +pub const SNAPSHOT_INTERVAL_SECONDS: u64 = 21_600; // 6 hours + +/// Maximum number of snapshots to retain in the cache. +pub const MAX_SNAPSHOT_COUNT: usize = 256; + +/// Maximum number of state chunks a single snapshot may reference. +pub const MAX_CHUNKS_PER_SNAPSHOT: usize = 128; + +// --- TYPES --- + +/// Describes whether a backup snapshot is healthy. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SnapshotHealth { + /// The snapshot hashes match — state passed integrity verification. + Healthy, + /// The snapshot hashes do not match — possible corruption. + Corrupted, + /// The snapshot was never created (epoch not found). + Missing, +} + +/// A single state-chunk record paired with its integrity hash. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StateChunk { + /// Opaque chunk identifier assigned by the caller (e.g., a storage-slot + /// ordinal). + pub chunk_id: u64, + /// Raw data whose integrity must be verified. + pub data: Vec, + /// SHA-256 digest of `data`, computed at snapshot time. + pub hash: Hash256, +} + +impl StateChunk { + /// Create a new state chunk, computing its hash from `data`. + pub fn new(chunk_id: u64, data: &[u8]) -> Self { + let hash = sha256(data); + Self { + chunk_id, + data: data.to_vec(), + hash, + } + } + + /// Verify this chunk's integrity: re-hash `data` and compare. + pub fn verify(&self) -> bool { + sha256(&self.data) == self.hash + } +} + +/// A complete state snapshot taken at a specific epoch. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StateSnapshot { + /// Epoch at which the snapshot was taken. + pub epoch: u64, + /// Wall-clock timestamp when the snapshot was created. + pub created_at: u64, + /// Merkle-style root hash over all chunk hashes for quick comparison. + pub root_hash: Hash256, + /// Individual state chunks covered by this snapshot. + pub chunks: Vec, +} + +impl StateSnapshot { + /// Build a snapshot from an epoch, timestamp, and a list of + /// `(chunk_id, data)` pairs. The root hash is the SHA-256 of the + /// concatenation of all individual chunk hashes (deterministic ordering). + pub fn create(epoch: u64, created_at: u64, raw_chunks: &[(u64, &[u8])]) -> Self { + let chunks: Vec = raw_chunks + .iter() + .map(|(id, data)| StateChunk::new(*id, data)) + .collect(); + + let root_hash = compute_root_hash(&chunks); + + Self { + epoch, + created_at, + root_hash, + chunks, + } + } + + /// Verify every chunk in the snapshot, then confirm the root hash. + pub fn verify_integrity(&self) -> SnapshotHealth { + for c in &self.chunks { + if !c.verify() { + return SnapshotHealth::Corrupted; + } + } + + if compute_root_hash(&self.chunks) == self.root_hash { + SnapshotHealth::Healthy + } else { + SnapshotHealth::Corrupted + } + } + + /// Number of chunks in this snapshot. + pub fn chunk_count(&self) -> usize { + self.chunks.len() + } + + /// Retrieve a specific chunk by id. + pub fn get_chunk(&self, chunk_id: u64) -> Option<&StateChunk> { + self.chunks.iter().find(|c| c.chunk_id == chunk_id) + } +} + +// --- SCHEDULER --- + +/// The backup scheduler tracks when the next snapshot is due and manages +/// the cache of stored snapshots. +#[derive(Clone, Debug)] +pub struct BackupScheduler { + /// Last time (wall-clock seconds) a snapshot was taken. + /// **Note**: `pub` for test access; prefer `take_snapshot()` for normal use. + pub last_snapshot_time: u64, + /// Interval between scheduled snapshots. + pub interval_seconds: u64, + /// Snapshots organized by epoch. + snapshots: BTreeMap, +} + +impl BackupScheduler { + /// Create a new scheduler with the standard interval. + pub fn new() -> Self { + Self { + last_snapshot_time: 0, + interval_seconds: SNAPSHOT_INTERVAL_SECONDS, + snapshots: BTreeMap::new(), + } + } + + /// Create a scheduler with a custom interval (useful for testing). + pub fn with_interval(interval_seconds: u64) -> Self { + Self { + last_snapshot_time: 0, + interval_seconds, + snapshots: BTreeMap::new(), + } + } + + /// Whether a snapshot is due given the current wall-clock time. + pub fn is_due(&self, current_time: u64) -> bool { + current_time >= self.last_snapshot_time + self.interval_seconds + } + + /// Take a snapshot and store it. Returns `None` if the chunk list exceeds + /// `MAX_CHUNKS_PER_SNAPSHOT`. + pub fn take_snapshot( + &mut self, + epoch: u64, + current_time: u64, + raw_chunks: &[(u64, &[u8])], + ) -> Option { + if raw_chunks.len() > MAX_CHUNKS_PER_SNAPSHOT { + return None; + } + + let snapshot = StateSnapshot::create(epoch, current_time, raw_chunks); + self.last_snapshot_time = current_time; + self.snapshots.insert(epoch, snapshot.clone()); + self.evict_oldest_if_needed(); + Some(snapshot) + } + + /// Retrieve a stored snapshot by epoch. + pub fn get_snapshot(&self, epoch: u64) -> Option<&StateSnapshot> { + self.snapshots.get(&epoch) + } + + /// Verify the integrity of the snapshot stored at `epoch`. + pub fn verify_snapshot(&self, epoch: u64) -> SnapshotHealth { + match self.snapshots.get(&epoch) { + Some(s) => s.verify_integrity(), + None => SnapshotHealth::Missing, + } + } + + /// Number of stored snapshots. + pub fn len(&self) -> usize { + self.snapshots.len() + } + + /// Whether the cache is empty. + pub fn is_empty(&self) -> bool { + self.snapshots.is_empty() + } + + fn evict_oldest_if_needed(&mut self) { + while self.snapshots.len() > MAX_SNAPSHOT_COUNT { + if let Some(&oldest) = self.snapshots.keys().next() { + self.snapshots.remove(&oldest); + } else { + break; + } + } + } +} + +impl Default for BackupScheduler { + fn default() -> Self { + Self::new() + } +} + +/// Compute the deterministic root hash from a slice of state chunks. +/// SHA-256( h0 || h1 || … || hn ). +fn compute_root_hash(chunks: &[StateChunk]) -> Hash256 { + let mut buf = Vec::new(); + for c in chunks { + buf.extend_from_slice(&c.hash); + } + sha256(&buf) +} + +// --- RESTORE TESTING --- + +/// Result of a restore test: either success or a description of what +/// mismatched. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RestoreResult { + /// The restored state matches the snapshot. + Success, + /// Mismatch found in the given chunk. + ChunkMismatch { chunk_id: u64, expected_hash: Hash256, actual_hash: Hash256 }, + /// The expected snapshot was not found. + SnapshotMissing, +} + +/// Simulate a restore by comparing `restored_chunks` against the snapshot +/// stored at `epoch`. Every chunk in the snapshot must have an exact match +/// in the restored data; extra or missing chunks are flagged. +pub fn test_restore( + scheduler: &BackupScheduler, + epoch: u64, + restored_chunks: &[(u64, &[u8])], +) -> RestoreResult { + let snapshot = match scheduler.get_snapshot(epoch) { + Some(s) => s, + None => return RestoreResult::SnapshotMissing, + }; + + for chunk in &snapshot.chunks { + let restored = restored_chunks.iter().find(|(id, _)| *id == chunk.chunk_id); + match restored { + Some((_, data)) => { + if sha256(data) != chunk.hash { + return RestoreResult::ChunkMismatch { + chunk_id: chunk.chunk_id, + expected_hash: chunk.hash, + actual_hash: sha256(data), + }; + } + } + None => { + return RestoreResult::ChunkMismatch { + chunk_id: chunk.chunk_id, + expected_hash: chunk.hash, + actual_hash: [0u8; 32], + }; + } + } + } + + RestoreResult::Success +} + +// --- TESTS --- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_chunk_creation_and_verification() { + let data = b"hello world"; + let chunk = StateChunk::new(1, data); + assert!(chunk.verify()); + assert_eq!(chunk.chunk_id, 1); + } + + #[test] + fn test_chunk_tamper_detection() { + let mut chunk = StateChunk::new(1, b"original"); + chunk.data = b"tampered".to_vec(); + assert!(!chunk.verify()); + } + + #[test] + fn test_snapshot_creation_and_verification() { + let chunks = vec![ + (1u64, &b"chunk_a"[..]), + (2u64, &b"chunk_b"[..]), + (3u64, &b"chunk_c"[..]), + ]; + let snapshot = StateSnapshot::create(100, 1000, &chunks); + assert_eq!(snapshot.epoch, 100); + assert_eq!(snapshot.created_at, 1000); + assert_eq!(snapshot.chunk_count(), 3); + assert_eq!(snapshot.verify_integrity(), SnapshotHealth::Healthy); + } + + #[test] + fn test_snapshot_root_changes_when_chunk_changes() { + let chunks_a = vec![(1u64, &b"foo"[..])]; + let chunks_b = vec![(1u64, &b"bar"[..])]; + let snap_a = StateSnapshot::create(1, 0, &chunks_a); + let snap_b = StateSnapshot::create(1, 0, &chunks_b); + // Different data → different root hash. + assert_ne!(snap_a.root_hash, snap_b.root_hash); + } + + #[test] + fn test_snapshot_deterministic_root() { + let chunks = vec![(1u64, &b"xyz"[..])]; + let a = StateSnapshot::create(1, 0, &chunks); + let b = StateSnapshot::create(1, 0, &chunks); + assert_eq!(a.root_hash, b.root_hash); + } + + #[test] + fn test_get_chunk() { + let chunks = vec![(42u64, &b"answer"[..])]; + let snap = StateSnapshot::create(1, 0, &chunks); + assert!(snap.get_chunk(42).is_some()); + assert!(snap.get_chunk(99).is_none()); + } + + // --- Scheduler tests --- + + #[test] + fn test_scheduler_is_due() { + let mut s = BackupScheduler::with_interval(100); + assert!(s.is_due(101)); + s.last_snapshot_time = 50; + assert!(!s.is_due(100)); // 50 + 100 = 150, so 100 is not yet due + assert!(s.is_due(150)); + } + + #[test] + fn test_take_and_retrieve_snapshot() { + let mut s = BackupScheduler::with_interval(100); + let chunks = vec![(1u64, &b"data"[..])]; + let snap = s.take_snapshot(10, 200, &chunks).unwrap(); + assert_eq!(snap.epoch, 10); + assert_eq!(s.len(), 1); + + let retrieved = s.get_snapshot(10).unwrap(); + assert_eq!(retrieved.root_hash, snap.root_hash); + } + + #[test] + fn test_verify_snapshot_healthy() { + let mut s = BackupScheduler::with_interval(100); + let chunks = vec![(1u64, &b"safe"[..])]; + s.take_snapshot(5, 100, &chunks); + assert_eq!(s.verify_snapshot(5), SnapshotHealth::Healthy); + } + + #[test] + fn test_verify_snapshot_missing() { + let s = BackupScheduler::new(); + assert_eq!(s.verify_snapshot(99), SnapshotHealth::Missing); + } + + #[test] + fn test_scheduler_eviction() { + let mut s = BackupScheduler::with_interval(1); + // Fill past MAX_SNAPSHOT_COUNT (default 256). + for epoch in 0..300u64 { + s.take_snapshot(epoch, epoch, &[(epoch, &b"x"[..])]); + } + // Only the most recent MAX_SNAPSHOT_COUNT should remain. + assert_eq!(s.len(), MAX_SNAPSHOT_COUNT); + // The oldest entries should be gone. + assert!(s.get_snapshot(0).is_none()); + assert!(s.get_snapshot(299).is_some()); + } + + // --- Restore tests --- + + #[test] + fn test_restore_success() { + let mut s = BackupScheduler::with_interval(100); + let chunks = vec![(1u64, &b"one"[..]), (2u64, &b"two"[..])]; + s.take_snapshot(7, 100, &chunks); + + let restored = vec![(1u64, &b"one"[..]), (2u64, &b"two"[..])]; + assert_eq!(test_restore(&s, 7, &restored), RestoreResult::Success); + } + + #[test] + fn test_restore_chunk_mismatch() { + let mut s = BackupScheduler::with_interval(100); + s.take_snapshot(7, 100, &[(1u64, &b"correct"[..])]); + + let restored = vec![(1u64, &b"wrong"[..])]; + let result = test_restore(&s, 7, &restored); + assert!(matches!(result, RestoreResult::ChunkMismatch { .. })); + } + + #[test] + fn test_restore_missing_snapshot() { + let s = BackupScheduler::new(); + assert_eq!( + test_restore(&s, 999, &[]), + RestoreResult::SnapshotMissing + ); + } + + #[test] + fn test_restore_extra_restored_chunk_is_ok() { + // Extra restored data beyond what the snapshot knows about is fine. + let mut s = BackupScheduler::with_interval(100); + s.take_snapshot(1, 100, &[(1u64, &b"core"[..])]); + + let restored = vec![(1u64, &b"core"[..]), (99u64, &b"extra"[..])]; + assert_eq!(test_restore(&s, 1, &restored), RestoreResult::Success); + } + + #[test] + fn test_snapshot_exceeds_max_chunks() { + let mut s = BackupScheduler::with_interval(1); + let too_many: Vec<(u64, &[u8])> = + (0..=MAX_CHUNKS_PER_SNAPSHOT as u64) + .map(|i| (i, &b"x"[..])) + .collect(); + assert!(s.take_snapshot(0, 0, &too_many).is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs index c81f93b..c0d97a6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,16 @@ pub mod mempool; // a reentrancy guard to prevent bond pool drainage via ERC-20 callback attacks. pub mod pool_manager; +// State snapshot backup verification with restore testing (issue #70). +// Scheduled snapshots carry Merkle-style integrity hashes to detect +// corruption; restore testing verifies that a restored state matches. +pub mod backup; + +// Webhook delivery service with retry and signature verification (issue #68). +// Signed, domain-separated outbound payloads are delivered with +// exponential-backoff retry and verified via BLS signature checks. +pub mod webhook; + // --- ERROR CODES --- #[contracterror] diff --git a/src/pool_manager/reentrancy_guard.rs b/src/pool_manager/reentrancy_guard.rs index c7181ed..376fc2b 100644 --- a/src/pool_manager/reentrancy_guard.rs +++ b/src/pool_manager/reentrancy_guard.rs @@ -69,37 +69,50 @@ impl<'a> Drop for ReentrancyGuard<'a> { #[cfg(test)] mod tests { use super::*; + use crate::SoroSusu; use soroban_sdk::Env; #[test] fn test_reentrancy_guard_allows_first_call() { let env = Env::default(); - let _guard = ReentrancyGuard::new(&env); - // Should not panic + let contract_id = env.register_contract(None, SoroSusu); + env.as_contract(&contract_id, || { + let _guard = ReentrancyGuard::new(&env); + // Should not panic + }); } #[test] #[should_panic(expected = "ReentrancyGuard: reentrant call")] fn test_reentrancy_guard_blocks_reentrant_call() { let env = Env::default(); - let _guard1 = ReentrancyGuard::new(&env); - let _guard2 = ReentrancyGuard::new(&env); // Should panic + let contract_id = env.register_contract(None, SoroSusu); + env.as_contract(&contract_id, || { + let _guard1 = ReentrancyGuard::new(&env); + let _guard2 = ReentrancyGuard::new(&env); // Should panic + }); } #[test] fn test_reentrancy_guard_allows_after_drop() { let env = Env::default(); - { - let _guard = ReentrancyGuard::new(&env); - } // Guard dropped here - let _guard2 = ReentrancyGuard::new(&env); // Should not panic + let contract_id = env.register_contract(None, SoroSusu); + env.as_contract(&contract_id, || { + { + let _guard = ReentrancyGuard::new(&env); + } // Guard dropped here + let _guard2 = ReentrancyGuard::new(&env); // Should not panic + }); } #[test] fn test_reentrancy_guard_manual_release() { let env = Env::default(); - let guard = ReentrancyGuard::new(&env); - guard.release(); - let _guard2 = ReentrancyGuard::new(&env); // Should not panic + let contract_id = env.register_contract(None, SoroSusu); + env.as_contract(&contract_id, || { + let guard = ReentrancyGuard::new(&env); + guard.release(); + let _guard2 = ReentrancyGuard::new(&env); // Should not panic + }); } } diff --git a/src/pool_manager/tenant_bond.rs b/src/pool_manager/tenant_bond.rs index a4bc815..bc98a9a 100644 --- a/src/pool_manager/tenant_bond.rs +++ b/src/pool_manager/tenant_bond.rs @@ -295,7 +295,8 @@ impl TenantBondManager { #[cfg(test)] mod tests { use super::*; - use soroban_sdk::{testutils::Address as _, Env}; + use crate::SoroSusu; + use soroban_sdk::{testutils::Address as _, testutils::Ledger as _, Env}; // --------------------------------------------------------------------------- // Lock tests @@ -304,45 +305,57 @@ mod tests { #[test] fn test_lock_valid_bond() { let env = Env::default(); + let contract_id = env.register_contract(None, SoroSusu); let tenant = Address::generate(&env); - TenantBondManager::lock_tenant_bond(&env, &tenant, MIN_BOND_AMOUNT) - .expect("lock should succeed"); + env.as_contract(&contract_id, || { + TenantBondManager::lock_tenant_bond(&env, &tenant, MIN_BOND_AMOUNT) + .expect("lock should succeed"); - let entry = TenantBondManager::get_bond(&env, &tenant).expect("bond should exist"); - assert!(entry.is_locked); - assert_eq!(entry.amount, MIN_BOND_AMOUNT); - assert_eq!(TenantBondManager::total_bonded(&env), MIN_BOND_AMOUNT); + let entry = TenantBondManager::get_bond(&env, &tenant).expect("bond should exist"); + assert!(entry.is_locked); + assert_eq!(entry.amount, MIN_BOND_AMOUNT); + assert_eq!(TenantBondManager::total_bonded(&env), MIN_BOND_AMOUNT); + }); } #[test] fn test_lock_rejects_below_minimum() { let env = Env::default(); + let contract_id = env.register_contract(None, SoroSusu); let tenant = Address::generate(&env); - let result = TenantBondManager::lock_tenant_bond(&env, &tenant, MIN_BOND_AMOUNT - 1); - assert_eq!(result, Err(BondError::InvalidBondAmount)); + env.as_contract(&contract_id, || { + let result = TenantBondManager::lock_tenant_bond(&env, &tenant, MIN_BOND_AMOUNT - 1); + assert_eq!(result, Err(BondError::InvalidBondAmount)); + }); } #[test] fn test_lock_rejects_above_maximum() { let env = Env::default(); + let contract_id = env.register_contract(None, SoroSusu); let tenant = Address::generate(&env); - let result = TenantBondManager::lock_tenant_bond(&env, &tenant, MAX_BOND_AMOUNT + 1); - assert_eq!(result, Err(BondError::InvalidBondAmount)); + env.as_contract(&contract_id, || { + let result = TenantBondManager::lock_tenant_bond(&env, &tenant, MAX_BOND_AMOUNT + 1); + assert_eq!(result, Err(BondError::InvalidBondAmount)); + }); } #[test] fn test_lock_rejects_duplicate_bond() { let env = Env::default(); + let contract_id = env.register_contract(None, SoroSusu); let tenant = Address::generate(&env); - TenantBondManager::lock_tenant_bond(&env, &tenant, MIN_BOND_AMOUNT) - .expect("first lock should succeed"); + env.as_contract(&contract_id, || { + TenantBondManager::lock_tenant_bond(&env, &tenant, MIN_BOND_AMOUNT) + .expect("first lock should succeed"); - let result = TenantBondManager::lock_tenant_bond(&env, &tenant, MIN_BOND_AMOUNT); - assert_eq!(result, Err(BondError::BondAlreadyExists)); + let result = TenantBondManager::lock_tenant_bond(&env, &tenant, MIN_BOND_AMOUNT); + assert_eq!(result, Err(BondError::BondAlreadyExists)); + }); } // --------------------------------------------------------------------------- @@ -352,68 +365,84 @@ mod tests { #[test] fn test_unlock_rejects_missing_bond() { let env = Env::default(); + let contract_id = env.register_contract(None, SoroSusu); let tenant = Address::generate(&env); - let result = TenantBondManager::unlock_tenant_bond(&env, &tenant); - assert_eq!(result, Err(BondError::BondNotFound)); + env.as_contract(&contract_id, || { + let result = TenantBondManager::unlock_tenant_bond(&env, &tenant); + assert_eq!(result, Err(BondError::BondNotFound)); + }); } #[test] fn test_unlock_rejects_before_lock_duration() { let env = Env::default(); + let contract_id = env.register_contract(None, SoroSusu); let tenant = Address::generate(&env); - TenantBondManager::lock_tenant_bond(&env, &tenant, MIN_BOND_AMOUNT) - .expect("lock should succeed"); + env.as_contract(&contract_id, || { + TenantBondManager::lock_tenant_bond(&env, &tenant, MIN_BOND_AMOUNT) + .expect("lock should succeed"); - // Attempt unlock immediately (lock duration not elapsed) - let result = TenantBondManager::unlock_tenant_bond(&env, &tenant); - assert_eq!(result, Err(BondError::LockDurationNotElapsed)); + // Attempt unlock immediately (lock duration not elapsed) + let result = TenantBondManager::unlock_tenant_bond(&env, &tenant); + assert_eq!(result, Err(BondError::LockDurationNotElapsed)); + }); } #[test] fn test_unlock_succeeds_after_lock_duration() { let env = Env::default(); + let contract_id = env.register_contract(None, SoroSusu); let tenant = Address::generate(&env); - TenantBondManager::lock_tenant_bond(&env, &tenant, MIN_BOND_AMOUNT) - .expect("lock should succeed"); + env.as_contract(&contract_id, || { + TenantBondManager::lock_tenant_bond(&env, &tenant, MIN_BOND_AMOUNT) + .expect("lock should succeed"); + }); // Advance ledger time past the minimum lock duration env.ledger().with_mut(|l| { l.timestamp = MIN_LOCK_DURATION + 1; }); - let unlocked = TenantBondManager::unlock_tenant_bond(&env, &tenant) - .expect("unlock should succeed"); - assert_eq!(unlocked, MIN_BOND_AMOUNT); + env.as_contract(&contract_id, || { + let unlocked = TenantBondManager::unlock_tenant_bond(&env, &tenant) + .expect("unlock should succeed"); + assert_eq!(unlocked, MIN_BOND_AMOUNT); - // Bond should now be marked as unlocked - let entry = TenantBondManager::get_bond(&env, &tenant).expect("entry should exist"); - assert!(!entry.is_locked); + // Bond should now be marked as unlocked + let entry = TenantBondManager::get_bond(&env, &tenant).expect("entry should exist"); + assert!(!entry.is_locked); - // Total bonded should be zero - assert_eq!(TenantBondManager::total_bonded(&env), 0); + // Total bonded should be zero + assert_eq!(TenantBondManager::total_bonded(&env), 0); + }); } #[test] fn test_unlock_rejects_already_unlocked() { let env = Env::default(); + let contract_id = env.register_contract(None, SoroSusu); let tenant = Address::generate(&env); - TenantBondManager::lock_tenant_bond(&env, &tenant, MIN_BOND_AMOUNT) - .expect("lock should succeed"); + env.as_contract(&contract_id, || { + TenantBondManager::lock_tenant_bond(&env, &tenant, MIN_BOND_AMOUNT) + .expect("lock should succeed"); + }); env.ledger().with_mut(|l| { l.timestamp = MIN_LOCK_DURATION + 1; }); - TenantBondManager::unlock_tenant_bond(&env, &tenant) - .expect("first unlock should succeed"); + env.as_contract(&contract_id, || { + TenantBondManager::unlock_tenant_bond(&env, &tenant) + .expect("first unlock should succeed"); - // Second unlock attempt should fail - let result = TenantBondManager::unlock_tenant_bond(&env, &tenant); - assert_eq!(result, Err(BondError::BondNotLocked)); + // Second unlock attempt should fail + let result = TenantBondManager::unlock_tenant_bond(&env, &tenant); + assert_eq!(result, Err(BondError::BondNotLocked)); + }); } // --------------------------------------------------------------------------- @@ -428,41 +457,47 @@ mod tests { #[test] fn test_invariant_total_bonded_equals_sum_of_active_bonds() { let env = Env::default(); + let contract_id = env.register_contract(None, SoroSusu); const N: usize = 100; let mut tenants: Vec
= Vec::with_capacity(N); let amount: i128 = 500; // within [MIN_BOND_AMOUNT, MAX_BOND_AMOUNT] - // Lock bonds for N tenants - for _ in 0..N { - let tenant = Address::generate(&env); - TenantBondManager::lock_tenant_bond(&env, &tenant, amount) - .expect("lock should succeed"); - tenants.push(tenant); - } + // Batch all lock operations in a single as_contract call to stay + // under the Soroban host budget limit. + env.as_contract(&contract_id, || { + for _ in 0..N { + let tenant = Address::generate(&env); + TenantBondManager::lock_tenant_bond(&env, &tenant, amount) + .expect("lock should succeed"); + tenants.push(tenant); + } + }); // Advance time past lock duration env.ledger().with_mut(|l| { l.timestamp = MIN_LOCK_DURATION + 1; }); - // Verify invariant before unlocking: totalBonded == sum(activeBonds) - let total = TenantBondManager::total_bonded(&env); - assert_eq!(total, amount * N as i128, "invariant broken before unlock"); - - // Unlock all tenants one by one and verify invariant at each step - for (i, tenant) in tenants.iter().enumerate() { - TenantBondManager::unlock_tenant_bond(&env, tenant) - .expect("unlock should succeed"); - - let expected_remaining = amount * (N - i - 1) as i128; - let actual_total = TenantBondManager::total_bonded(&env); - assert_eq!( - actual_total, expected_remaining, - "invariant broken at step {i}: totalBonded={actual_total}, expected={expected_remaining}" - ); - } - - assert_eq!(TenantBondManager::total_bonded(&env), 0, "pool should be empty"); + // Verify invariant before unlocking, then unlock all tenants in a + // single as_contract call to stay under the host budget. + env.as_contract(&contract_id, || { + let total = TenantBondManager::total_bonded(&env); + assert_eq!(total, amount * N as i128, "invariant broken before unlock"); + + for (i, tenant) in tenants.iter().enumerate() { + TenantBondManager::unlock_tenant_bond(&env, tenant) + .expect("unlock should succeed"); + + let expected_remaining = amount * (N - i - 1) as i128; + let actual_total = TenantBondManager::total_bonded(&env); + assert_eq!( + actual_total, expected_remaining, + "invariant broken at step {i}: totalBonded={actual_total}, expected={expected_remaining}" + ); + } + + assert_eq!(TenantBondManager::total_bonded(&env), 0, "pool should be empty"); + }); } /// Simulate 100 reentrant call patterns: verify the reentrancy guard @@ -474,16 +509,19 @@ mod tests { #[test] fn test_reentrancy_guard_prevents_reentry_100_patterns() { let env = Env::default(); + let contract_id = env.register_contract(None, SoroSusu); for pattern in 0..100u32 { // Attempt to acquire a guard while one is already active. // We test this by directly exercising the guard rather than // going through TenantBondManager, as Soroban's WASM sandbox // serializes all external calls. - let guard_acquired = std::panic::catch_unwind(|| { - let _guard1 = ReentrancyGuard::new(&env); - // Inner guard should panic - let _guard2 = ReentrancyGuard::new(&env); + let guard_acquired = env.as_contract(&contract_id, || { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard1 = ReentrancyGuard::new(&env); + // Inner guard should panic + let _guard2 = ReentrancyGuard::new(&env); + })) }); assert!( @@ -491,10 +529,12 @@ mod tests { "reentrant pattern {pattern}: guard should have rejected double entry" ); - // The outer guard dropped in the catch_unwind, so the flag is cleared. - // Verify we can enter again after the guard is released. - let sequential_ok = std::panic::catch_unwind(|| { - let _g = ReentrancyGuard::new(&env); + // The outer guard, having panicked inside catch_unwind, dropped + // its storage entry. Verify we can enter again after release. + let sequential_ok = env.as_contract(&contract_id, || { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _g = ReentrancyGuard::new(&env); + })) }); assert!( sequential_ok.is_ok(), @@ -510,46 +550,57 @@ mod tests { #[test] fn test_claim_slashed_bond_succeeds() { let env = Env::default(); + let contract_id = env.register_contract(None, SoroSusu); let tenant = Address::generate(&env); - TenantBondManager::lock_tenant_bond(&env, &tenant, 1000) - .expect("lock should succeed"); + env.as_contract(&contract_id, || { + TenantBondManager::lock_tenant_bond(&env, &tenant, 1000) + .expect("lock should succeed"); - let claimed = TenantBondManager::claim_slashed_bond(&env, &tenant) - .expect("claim should succeed"); - assert_eq!(claimed, 1000); + let claimed = TenantBondManager::claim_slashed_bond(&env, &tenant) + .expect("claim should succeed"); + assert_eq!(claimed, 1000); - let entry = TenantBondManager::get_bond(&env, &tenant).expect("entry should exist"); - assert!(!entry.is_locked); - assert_eq!(entry.amount, 0); - assert_eq!(TenantBondManager::total_bonded(&env), 0); + let entry = TenantBondManager::get_bond(&env, &tenant).expect("entry should exist"); + assert!(!entry.is_locked); + assert_eq!(entry.amount, 0); + assert_eq!(TenantBondManager::total_bonded(&env), 0); + }); } #[test] fn test_claim_slashed_bond_rejects_missing_bond() { let env = Env::default(); + let contract_id = env.register_contract(None, SoroSusu); let tenant = Address::generate(&env); - let result = TenantBondManager::claim_slashed_bond(&env, &tenant); - assert_eq!(result, Err(BondError::BondNotFound)); + env.as_contract(&contract_id, || { + let result = TenantBondManager::claim_slashed_bond(&env, &tenant); + assert_eq!(result, Err(BondError::BondNotFound)); + }); } #[test] fn test_claim_slashed_bond_rejects_already_unlocked() { let env = Env::default(); + let contract_id = env.register_contract(None, SoroSusu); let tenant = Address::generate(&env); - TenantBondManager::lock_tenant_bond(&env, &tenant, 500) - .expect("lock should succeed"); + env.as_contract(&contract_id, || { + TenantBondManager::lock_tenant_bond(&env, &tenant, 500) + .expect("lock should succeed"); + }); env.ledger().with_mut(|l| { l.timestamp = MIN_LOCK_DURATION + 1; }); - TenantBondManager::unlock_tenant_bond(&env, &tenant) - .expect("unlock should succeed"); + env.as_contract(&contract_id, || { + TenantBondManager::unlock_tenant_bond(&env, &tenant) + .expect("unlock should succeed"); - let result = TenantBondManager::claim_slashed_bond(&env, &tenant); - assert_eq!(result, Err(BondError::BondNotLocked)); + let result = TenantBondManager::claim_slashed_bond(&env, &tenant); + assert_eq!(result, Err(BondError::BondNotLocked)); + }); } } diff --git a/tests/backup_verification_test.rs b/tests/backup_verification_test.rs new file mode 100644 index 0000000..4ee71d4 --- /dev/null +++ b/tests/backup_verification_test.rs @@ -0,0 +1,186 @@ +//! Integration tests for backup verification and restore testing (issue #70). +//! +//! Exercises the full snapshot lifecycle: scheduling, creation, integrity +//! verification, cache eviction, and restore testing. + +use sorosusu_contracts::backup::state_snapshot::{ + BackupScheduler, RestoreResult, SnapshotHealth, StateChunk, StateSnapshot, + MAX_SNAPSHOT_COUNT, SNAPSHOT_INTERVAL_SECONDS, +}; + +#[test] +fn test_full_snapshot_lifecycle() { + let mut scheduler = BackupScheduler::new(); + assert!(scheduler.is_empty()); + + // Create chunks representing different parts of system state. + let chunks = vec![ + (1u64, &b"committee_cache"[..]), + (2u64, &b"validator_set"[..]), + (3u64, &b"slashing_events"[..]), + (4u64, &b"reputation_scores"[..]), + ]; + + // Take snapshot at epoch 42. + let snap = scheduler.take_snapshot(42, SNAPSHOT_INTERVAL_SECONDS, &chunks).unwrap(); + assert_eq!(snap.epoch, 42); + assert_eq!(snap.chunk_count(), 4); + assert_eq!(snap.verify_integrity(), SnapshotHealth::Healthy); + + // Verify via scheduler. + assert_eq!(scheduler.verify_snapshot(42), SnapshotHealth::Healthy); + assert_eq!(scheduler.verify_snapshot(999), SnapshotHealth::Missing); +} + +#[test] +fn test_restore_with_matching_state() { + let mut scheduler = BackupScheduler::new(); + let chunks = vec![ + (10u64, &b"accounts"[..]), + (20u64, &b"balances"[..]), + (30u64, &b"contracts"[..]), + ]; + scheduler.take_snapshot(1, 1000, &chunks); + + // "Restore" the same data. + let restored = vec![ + (10u64, &b"accounts"[..]), + (20u64, &b"balances"[..]), + (30u64, &b"contracts"[..]), + ]; + + let result = sorosusu_contracts::backup::state_snapshot::test_restore( + &scheduler, + 1, + &restored, + ); + assert_eq!(result, RestoreResult::Success); +} + +#[test] +fn test_restore_detects_corruption() { + let mut scheduler = BackupScheduler::new(); + scheduler.take_snapshot(5, 500, &[(1u64, &b"critical_data"[..])]); + + let corrupted = vec![(1u64, &b"corrupted_data"[..])]; + let result = sorosusu_contracts::backup::state_snapshot::test_restore( + &scheduler, + 5, + &corrupted, + ); + match result { + RestoreResult::ChunkMismatch { chunk_id, .. } => { + assert_eq!(chunk_id, 1); + } + other => panic!("Expected ChunkMismatch, got {:?}", other), + } +} + +#[test] +fn test_restore_missing_chunk() { + let mut scheduler = BackupScheduler::new(); + scheduler.take_snapshot(3, 300, &[(100u64, &b"must_exist"[..])]); + + // Restored data is missing chunk 100. + let incomplete = vec![(99u64, &b"something_else"[..])]; + let result = sorosusu_contracts::backup::state_snapshot::test_restore( + &scheduler, + 3, + &incomplete, + ); + match result { + RestoreResult::ChunkMismatch { chunk_id, .. } => { + assert_eq!(chunk_id, 100); + } + other => panic!("Expected ChunkMismatch due to missing chunk, got {:?}", other), + } +} + +#[test] +fn test_scheduler_does_not_snapshot_before_interval() { + let mut scheduler = BackupScheduler::with_interval(100); + scheduler.take_snapshot(1, 50, &[(1u64, &b"a"[..])]); + // last_snapshot_time is now 50; next is due at 150. + assert!(!scheduler.is_due(120)); + assert!(scheduler.is_due(150)); +} + +#[test] +fn test_multiple_snapshots_and_eviction() { + let mut scheduler = BackupScheduler::with_interval(1); + + // Store more than the max. + let total = MAX_SNAPSHOT_COUNT + 50; + for epoch in 0..total as u64 { + scheduler.take_snapshot(epoch, epoch, &[(epoch, &b"data"[..])]); + } + + assert_eq!(scheduler.len(), MAX_SNAPSHOT_COUNT); + + // Oldest entries evicted. + for epoch in 0..50u64 { + assert_eq!(scheduler.verify_snapshot(epoch), SnapshotHealth::Missing); + } + // Newest entries present. + for epoch in (total - 10) as u64..total as u64 { + assert_eq!(scheduler.verify_snapshot(epoch), SnapshotHealth::Healthy); + } +} + +#[test] +fn test_snapshot_deterministic_across_identical_inputs() { + let chunks = vec![(1u64, &b"const_data"[..]), (2u64, &b"immutable"[..])]; + let snap1 = StateSnapshot::create(10, 1000, &chunks); + let snap2 = StateSnapshot::create(10, 1000, &chunks); + assert_eq!(snap1.root_hash, snap2.root_hash); + assert_eq!(snap1.verify_integrity(), SnapshotHealth::Healthy); + assert_eq!(snap2.verify_integrity(), SnapshotHealth::Healthy); +} + +#[test] +fn test_snapshot_with_empty_chunks() { + let snapshot = StateSnapshot::create(0, 0, &[]); + assert_eq!(snapshot.chunk_count(), 0); + assert_eq!(snapshot.verify_integrity(), SnapshotHealth::Healthy); + // Root hash for empty input is still a valid hash. + assert_eq!(snapshot.root_hash.len(), 32); +} + +#[test] +fn test_chunk_verification_edge_cases() { + // Large data chunk. + let large_data = vec![0xABu8; 10_000]; + let chunk = StateChunk::new(1, &large_data); + assert!(chunk.verify()); + + // Empty data chunk. + let empty_chunk = StateChunk::new(2, &[]); + assert!(empty_chunk.verify()); +} + +#[test] +fn test_scheduled_backup_interval_defaults() { + let scheduler = BackupScheduler::new(); + assert_eq!(scheduler.interval_seconds, SNAPSHOT_INTERVAL_SECONDS); + assert!(scheduler.is_empty()); +} + +#[test] +fn test_restore_result_equality() { + assert_eq!(RestoreResult::Success, RestoreResult::Success); + assert_eq!(RestoreResult::SnapshotMissing, RestoreResult::SnapshotMissing); + + let mismatch = RestoreResult::ChunkMismatch { + chunk_id: 1, + expected_hash: [1u8; 32], + actual_hash: [2u8; 32], + }; + match mismatch { + RestoreResult::ChunkMismatch { chunk_id, expected_hash, actual_hash } => { + assert_eq!(chunk_id, 1); + assert_eq!(expected_hash, [1u8; 32]); + assert_eq!(actual_hash, [2u8; 32]); + } + _ => panic!("Expected ChunkMismatch"), + } +}