summaryrefslogtreecommitdiffstats
path: root/timing
diff options
context:
space:
mode:
authorgabrix73 <gabriel1@frozenstar.info>2026-06-01 18:41:36 +0200
committergabrix73 <gabriel1@frozenstar.info>2026-06-01 18:41:36 +0200
commit9f5d864d533ce86459e654f5d78212933c0269ea (patch)
tree4b71e8c7ab4e78da93e5f3164803da4de68c7031 /timing
downloadonioncoin-9e960fa6d453b29aef970ba3cd81e9cbbfd94bcc.tar.gz
onioncoin-9e960fa6d453b29aef970ba3cd81e9cbbfd94bcc.tar.xz
onioncoin-9e960fa6d453b29aef970ba3cd81e9cbbfd94bcc.zip
Initial commit: OnionCoin prototype with Proof-of-Relay consensusHEADv0.1.0main
OnionCoin is a privacy cryptocurrency that rewards Tor relay operators through a unique Proof-of-Contribution consensus mechanism. Core Features: - Proof-of-Relay: 30% of block rewards go to Tor operators - Native .onion node identity (no IP exposure) - Temporal obfuscation protocols - Dandelion++ over Tor propagation - Native inheritance system with dead man's switch Technical Stack: - Rust workspace with 8 crates - Ed25519/X25519 cryptography - arti (Rust Tor client) integration planned - 10 minute block time, 5-10 TPS design Status: Prototype - Consensus logic complete with passing tests (30/33) - Network layer conceptual design complete - Tor integration pending - Testnet launch planned Q3 2026 License: MIT Author: Gabriele Salati (virebent) Contact: g48rix@gmail.com Website: https://www.gabrielesalati.eu Repository: https://git.virebent.art/virebent/onioncoin
Diffstat (limited to 'timing')
-rw-r--r--timing/Cargo.toml16
-rw-r--r--timing/src/delay.rs259
-rw-r--r--timing/src/lib.rs9
-rw-r--r--timing/src/mixing_pool.rs307
-rw-r--r--timing/src/obfuscation.rs243
-rw-r--r--timing/src/timerange.rs211
6 files changed, 1045 insertions, 0 deletions
diff --git a/timing/Cargo.toml b/timing/Cargo.toml
new file mode 100644
index 0000000..6494808
--- /dev/null
+++ b/timing/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "onioncoin-timing"
+version.workspace = true
+edition.workspace = true
+authors.workspace = true
+license.workspace = true
+
+[dependencies]
+serde.workspace = true
+serde_json.workspace = true
+rand.workspace = true
+blake3.workspace = true
+chrono.workspace = true
+thiserror.workspace = true
+tokio.workspace = true
+async-trait.workspace = true
diff --git a/timing/src/delay.rs b/timing/src/delay.rs
new file mode 100644
index 0000000..b264381
--- /dev/null
+++ b/timing/src/delay.rs
@@ -0,0 +1,259 @@
+use rand::Rng;
+use std::time::Duration;
+use serde::{Deserialize, Serialize};
+
+/// Strategy for introducing random delays in transaction propagation
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum DelayStrategy {
+ /// Fixed delay
+ Fixed(Duration),
+
+ /// Random delay within a range
+ Random { min: Duration, max: Duration },
+
+ /// Exponential backoff with jitter
+ Exponential {
+ base: Duration,
+ max: Duration,
+ attempt: u32,
+ },
+
+ /// Delay based on observed Tor latency
+ TorAdaptive {
+ observed_latency: Duration,
+ multiplier: f64,
+ },
+}
+
+impl DelayStrategy {
+ /// Wallet broadcast delay: 5-60 minutes
+ pub fn wallet_broadcast() -> Self {
+ Self::Random {
+ min: Duration::from_secs(5 * 60),
+ max: Duration::from_secs(60 * 60),
+ }
+ }
+
+ /// Node rebroadcast delay: 10s - 2 minutes
+ pub fn node_rebroadcast() -> Self {
+ Self::Random {
+ min: Duration::from_secs(10),
+ max: Duration::from_secs(120),
+ }
+ }
+
+ /// Mixing pool batch delay: 10-30 minutes
+ pub fn mixing_pool() -> Self {
+ Self::Random {
+ min: Duration::from_secs(10 * 60),
+ max: Duration::from_secs(30 * 60),
+ }
+ }
+
+ /// Validation delay before processing: 5s - 60s
+ pub fn validation() -> Self {
+ Self::Random {
+ min: Duration::from_secs(5),
+ max: Duration::from_secs(60),
+ }
+ }
+
+ /// Dandelion STEM hop delay: 30s - 5 minutes
+ pub fn dandelion_stem() -> Self {
+ Self::Random {
+ min: Duration::from_secs(30),
+ max: Duration::from_secs(5 * 60),
+ }
+ }
+
+ /// Create adaptive delay based on observed Tor latency
+ pub fn from_tor_latency(observed: Duration) -> Self {
+ Self::TorAdaptive {
+ observed_latency: observed,
+ multiplier: 2.0, // Amplify variability
+ }
+ }
+
+ /// Calculate the actual delay to use
+ pub fn calculate(&self) -> Duration {
+ let mut rng = rand::thread_rng();
+
+ match self {
+ Self::Fixed(d) => *d,
+
+ Self::Random { min, max } => {
+ let min_ms = min.as_millis() as u64;
+ let max_ms = max.as_millis() as u64;
+ let delay_ms = rng.gen_range(min_ms..=max_ms);
+ Duration::from_millis(delay_ms)
+ }
+
+ Self::Exponential { base, max, attempt } => {
+ let base_ms = base.as_millis() as u64;
+ let max_ms = max.as_millis() as u64;
+
+ // Exponential: base * 2^attempt with jitter
+ let exp_delay = base_ms.saturating_mul(2u64.saturating_pow(*attempt));
+ let capped = exp_delay.min(max_ms);
+
+ // Add ±25% jitter
+ let jitter_range = (capped as f64 * 0.25) as u64;
+ let jitter = rng.gen_range(0..=jitter_range);
+ let with_jitter = if rng.gen_bool(0.5) {
+ capped.saturating_add(jitter)
+ } else {
+ capped.saturating_sub(jitter)
+ };
+
+ Duration::from_millis(with_jitter)
+ }
+
+ Self::TorAdaptive { observed_latency, multiplier } => {
+ let base_ms = observed_latency.as_millis() as u64;
+ let amplified = (base_ms as f64 * multiplier) as u64;
+
+ // Random delay up to amplified latency
+ let delay_ms = rng.gen_range(0..=amplified);
+ Duration::from_millis(delay_ms)
+ }
+ }
+ }
+
+ /// Async sleep with this delay strategy
+ pub async fn sleep(&self) {
+ let delay = self.calculate();
+ tokio::time::sleep(delay).await;
+ }
+}
+
+/// Helper for managing multiple delays in sequence
+#[derive(Debug)]
+pub struct DelaySequence {
+ strategies: Vec<DelayStrategy>,
+ current: usize,
+}
+
+impl DelaySequence {
+ pub fn new(strategies: Vec<DelayStrategy>) -> Self {
+ Self {
+ strategies,
+ current: 0,
+ }
+ }
+
+ /// Create a Dandelion++ STEM sequence (1-4 random hops)
+ pub fn dandelion_stem() -> Self {
+ let mut rng = rand::thread_rng();
+ let hops = rng.gen_range(1..=4);
+
+ let strategies = (0..hops)
+ .map(|_| DelayStrategy::dandelion_stem())
+ .collect();
+
+ Self::new(strategies)
+ }
+
+ /// Get next delay, returns None when sequence is exhausted
+ pub fn next(&mut self) -> Option<&DelayStrategy> {
+ if self.current < self.strategies.len() {
+ let strategy = &self.strategies[self.current];
+ self.current += 1;
+ Some(strategy)
+ } else {
+ None
+ }
+ }
+
+ /// Reset sequence to beginning
+ pub fn reset(&mut self) {
+ self.current = 0;
+ }
+
+ /// Total number of delays in sequence
+ pub fn len(&self) -> usize {
+ self.strategies.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.strategies.is_empty()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_fixed_delay() {
+ let strategy = DelayStrategy::Fixed(Duration::from_secs(10));
+ let delay = strategy.calculate();
+ assert_eq!(delay, Duration::from_secs(10));
+ }
+
+ #[test]
+ fn test_random_delay() {
+ let strategy = DelayStrategy::Random {
+ min: Duration::from_secs(5),
+ max: Duration::from_secs(10),
+ };
+
+ for _ in 0..100 {
+ let delay = strategy.calculate();
+ assert!(delay >= Duration::from_secs(5));
+ assert!(delay <= Duration::from_secs(10));
+ }
+ }
+
+ #[test]
+ fn test_exponential_delay() {
+ let base = Duration::from_secs(1);
+ let max = Duration::from_secs(100);
+
+ for attempt in 0..5 {
+ let strategy = DelayStrategy::Exponential {
+ base,
+ max,
+ attempt,
+ };
+ let delay = strategy.calculate();
+ assert!(delay <= max);
+ }
+ }
+
+ #[test]
+ fn test_tor_adaptive_delay() {
+ let observed = Duration::from_secs(2);
+ let strategy = DelayStrategy::from_tor_latency(observed);
+
+ for _ in 0..100 {
+ let delay = strategy.calculate();
+ // Should be between 0 and 2x observed latency
+ assert!(delay <= Duration::from_secs(4));
+ }
+ }
+
+ #[test]
+ fn test_delay_sequence() {
+ let mut sequence = DelaySequence::dandelion_stem();
+ let len = sequence.len();
+
+ assert!(len >= 1 && len <= 4);
+
+ let mut count = 0;
+ while sequence.next().is_some() {
+ count += 1;
+ }
+
+ assert_eq!(count, len);
+ }
+
+ #[tokio::test]
+ async fn test_async_sleep() {
+ let strategy = DelayStrategy::Fixed(Duration::from_millis(10));
+ let start = std::time::Instant::now();
+ strategy.sleep().await;
+ let elapsed = start.elapsed();
+
+ assert!(elapsed >= Duration::from_millis(10));
+ }
+}
diff --git a/timing/src/lib.rs b/timing/src/lib.rs
new file mode 100644
index 0000000..e55dea2
--- /dev/null
+++ b/timing/src/lib.rs
@@ -0,0 +1,9 @@
+pub mod obfuscation;
+pub mod timerange;
+pub mod delay;
+pub mod mixing_pool;
+
+pub use obfuscation::{TimingObfuscator, TimingMetadata};
+pub use timerange::{TimeRange, TimeRangeProof};
+pub use delay::DelayStrategy;
+pub use mixing_pool::MixingPool;
diff --git a/timing/src/mixing_pool.rs b/timing/src/mixing_pool.rs
new file mode 100644
index 0000000..931f0be
--- /dev/null
+++ b/timing/src/mixing_pool.rs
@@ -0,0 +1,307 @@
+use std::collections::VecDeque;
+use std::time::{Duration, Instant};
+use rand::seq::SliceRandom;
+use serde::{Deserialize, Serialize};
+
+/// A pool that collects items and releases them in shuffled batches
+/// Used for temporal obfuscation of transaction broadcasts
+#[derive(Debug)]
+pub struct MixingPool<T> {
+ /// Items waiting to be released
+ pool: VecDeque<PoolItem<T>>,
+
+ /// Minimum time to collect items before releasing
+ min_batch_duration: Duration,
+
+ /// Maximum time to collect items before forcing release
+ max_batch_duration: Duration,
+
+ /// Maximum number of items in pool before forcing release
+ max_pool_size: usize,
+
+ /// Time when current batch started collecting
+ batch_start: Option<Instant>,
+}
+
+#[derive(Debug, Clone)]
+struct PoolItem<T> {
+ item: T,
+ #[allow(dead_code)]
+ added_at: Instant,
+}
+
+impl<T> MixingPool<T> {
+ /// Create a new mixing pool with custom parameters
+ pub fn new(
+ min_batch_duration: Duration,
+ max_batch_duration: Duration,
+ max_pool_size: usize,
+ ) -> Self {
+ Self {
+ pool: VecDeque::new(),
+ min_batch_duration,
+ max_batch_duration,
+ max_pool_size,
+ batch_start: None,
+ }
+ }
+
+ /// Create a mixing pool with default parameters (10-30 min batches)
+ pub fn default() -> Self {
+ Self::new(
+ Duration::from_secs(10 * 60), // 10 minutes min
+ Duration::from_secs(30 * 60), // 30 minutes max
+ 1000, // max 1000 items
+ )
+ }
+
+ /// Add an item to the mixing pool
+ pub fn add(&mut self, item: T) {
+ if self.batch_start.is_none() {
+ self.batch_start = Some(Instant::now());
+ }
+
+ self.pool.push_back(PoolItem {
+ item,
+ added_at: Instant::now(),
+ });
+ }
+
+ /// Check if pool should release a batch
+ pub fn should_release(&self) -> bool {
+ if self.pool.is_empty() {
+ return false;
+ }
+
+ if let Some(start) = self.batch_start {
+ let elapsed = start.elapsed();
+
+ // Force release if max duration reached
+ if elapsed >= self.max_batch_duration {
+ return true;
+ }
+
+ // Force release if pool is full
+ if self.pool.len() >= self.max_pool_size {
+ return true;
+ }
+
+ // Release if min duration passed and we have items
+ if elapsed >= self.min_batch_duration && !self.pool.is_empty() {
+ return true;
+ }
+ }
+
+ false
+ }
+
+ /// Release a shuffled batch of all pooled items
+ pub fn release_batch(&mut self) -> Vec<T> {
+ let mut items: Vec<T> = self.pool.drain(..).map(|pi| pi.item).collect();
+
+ // Shuffle to remove temporal ordering
+ let mut rng = rand::thread_rng();
+ items.shuffle(&mut rng);
+
+ // Reset batch timer
+ self.batch_start = None;
+
+ items
+ }
+
+ /// Get current pool size
+ pub fn len(&self) -> usize {
+ self.pool.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.pool.is_empty()
+ }
+
+ /// Get time until next potential release (min batch duration)
+ pub fn time_until_release(&self) -> Option<Duration> {
+ self.batch_start.map(|start| {
+ let elapsed = start.elapsed();
+ if elapsed >= self.min_batch_duration {
+ Duration::from_secs(0)
+ } else {
+ self.min_batch_duration - elapsed
+ }
+ })
+ }
+}
+
+/// Stats about mixing pool performance
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MixingPoolStats {
+ pub total_items_processed: u64,
+ pub total_batches_released: u64,
+ pub avg_batch_size: f64,
+ pub avg_wait_time_secs: f64,
+}
+
+/// Mixing pool with statistics tracking
+#[derive(Debug)]
+pub struct MixingPoolWithStats<T> {
+ pool: MixingPool<T>,
+ stats: MixingPoolStats,
+ #[allow(dead_code)]
+ item_wait_times: Vec<Duration>,
+}
+
+impl<T> MixingPoolWithStats<T> {
+ pub fn new(pool: MixingPool<T>) -> Self {
+ Self {
+ pool,
+ stats: MixingPoolStats {
+ total_items_processed: 0,
+ total_batches_released: 0,
+ avg_batch_size: 0.0,
+ avg_wait_time_secs: 0.0,
+ },
+ item_wait_times: Vec::new(),
+ }
+ }
+
+ pub fn add(&mut self, item: T) {
+ self.pool.add(item);
+ }
+
+ pub fn should_release(&self) -> bool {
+ self.pool.should_release()
+ }
+
+ pub fn release_batch(&mut self) -> Vec<T> {
+ let batch = self.pool.release_batch();
+ let batch_size = batch.len();
+
+ // Update stats
+ self.stats.total_items_processed += batch_size as u64;
+ self.stats.total_batches_released += 1;
+
+ // Update average batch size
+ let total_batches = self.stats.total_batches_released as f64;
+ self.stats.avg_batch_size =
+ (self.stats.avg_batch_size * (total_batches - 1.0) + batch_size as f64) / total_batches;
+
+ batch
+ }
+
+ pub fn get_stats(&self) -> &MixingPoolStats {
+ &self.stats
+ }
+
+ pub fn len(&self) -> usize {
+ self.pool.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.pool.is_empty()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_mixing_pool_add() {
+ let mut pool = MixingPool::<u32>::default();
+ pool.add(1);
+ pool.add(2);
+ pool.add(3);
+
+ assert_eq!(pool.len(), 3);
+ }
+
+ #[test]
+ fn test_mixing_pool_release() {
+ let mut pool = MixingPool::<u32>::new(
+ Duration::from_millis(10),
+ Duration::from_secs(1),
+ 100,
+ );
+
+ pool.add(1);
+ pool.add(2);
+ pool.add(3);
+
+ // Should not release immediately
+ assert!(!pool.should_release());
+
+ // Wait for min duration
+ std::thread::sleep(Duration::from_millis(15));
+
+ assert!(pool.should_release());
+
+ let batch = pool.release_batch();
+ assert_eq!(batch.len(), 3);
+ assert!(pool.is_empty());
+ }
+
+ #[test]
+ fn test_mixing_pool_shuffle() {
+ let mut pool = MixingPool::<u32>::new(
+ Duration::from_millis(10),
+ Duration::from_secs(1),
+ 100,
+ );
+
+ // Add items in order
+ for i in 0..100 {
+ pool.add(i);
+ }
+
+ std::thread::sleep(Duration::from_millis(15));
+
+ let batch = pool.release_batch();
+
+ // Check all items are present
+ let mut sorted = batch.clone();
+ sorted.sort();
+ assert_eq!(sorted, (0..100).collect::<Vec<_>>());
+
+ // Check they were shuffled (very unlikely to be in original order)
+ assert_ne!(batch, sorted);
+ }
+
+ #[test]
+ fn test_mixing_pool_max_size() {
+ let mut pool = MixingPool::<u32>::new(
+ Duration::from_secs(100), // Long min duration
+ Duration::from_secs(200),
+ 10, // Small max size
+ );
+
+ // Add items up to max
+ for i in 0..10 {
+ pool.add(i);
+ }
+
+ // Should force release due to max size
+ assert!(pool.should_release());
+ }
+
+ #[test]
+ fn test_mixing_pool_with_stats() {
+ let pool = MixingPool::<u32>::new(
+ Duration::from_millis(10),
+ Duration::from_secs(1),
+ 100,
+ );
+ let mut pool_with_stats = MixingPoolWithStats::new(pool);
+
+ for i in 0..5 {
+ pool_with_stats.add(i);
+ }
+
+ std::thread::sleep(Duration::from_millis(15));
+
+ pool_with_stats.release_batch();
+
+ let stats = pool_with_stats.get_stats();
+ assert_eq!(stats.total_items_processed, 5);
+ assert_eq!(stats.total_batches_released, 1);
+ assert_eq!(stats.avg_batch_size, 5.0);
+ }
+}
diff --git a/timing/src/obfuscation.rs b/timing/src/obfuscation.rs
new file mode 100644
index 0000000..b48ea82
--- /dev/null
+++ b/timing/src/obfuscation.rs
@@ -0,0 +1,243 @@
+use crate::{TimeRange, TimeRangeProof, DelayStrategy, MixingPool};
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+use std::time::Duration;
+
+/// Orchestrates all timing obfuscation strategies
+#[derive(Debug)]
+pub struct TimingObfuscator<T> {
+ /// Mixing pool for batch releases
+ mixing_pool: MixingPool<T>,
+
+ /// Strategy for adding delays
+ delay_strategy: DelayStrategy,
+
+ /// Configuration
+ config: ObfuscationConfig,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ObfuscationConfig {
+ /// Enable mixing pool batching
+ pub use_mixing_pool: bool,
+
+ /// Enable random delays
+ pub use_delays: bool,
+
+ /// Enable fuzzy timestamps
+ pub use_fuzzy_timestamps: bool,
+
+ /// Ratio of fake items to inject (0.0 - 1.0)
+ pub fake_item_ratio: f32,
+}
+
+impl Default for ObfuscationConfig {
+ fn default() -> Self {
+ Self {
+ use_mixing_pool: true,
+ use_delays: true,
+ use_fuzzy_timestamps: true,
+ fake_item_ratio: 0.3, // 30% fake traffic
+ }
+ }
+}
+
+impl<T> TimingObfuscator<T> {
+ pub fn new(config: ObfuscationConfig) -> Self {
+ Self {
+ mixing_pool: MixingPool::default(),
+ delay_strategy: DelayStrategy::node_rebroadcast(),
+ config,
+ }
+ }
+
+ /// Add item to mixing pool
+ pub fn add_to_pool(&mut self, item: T) {
+ if self.config.use_mixing_pool {
+ self.mixing_pool.add(item);
+ }
+ }
+
+ /// Check if should release batch
+ pub fn should_release_batch(&self) -> bool {
+ if !self.config.use_mixing_pool {
+ return false;
+ }
+ self.mixing_pool.should_release()
+ }
+
+ /// Release shuffled batch
+ pub fn release_batch(&mut self) -> Vec<T> {
+ self.mixing_pool.release_batch()
+ }
+
+ /// Apply delay strategy
+ pub async fn apply_delay(&self) {
+ if self.config.use_delays {
+ self.delay_strategy.sleep().await;
+ }
+ }
+
+ /// Get current pool size
+ pub fn pool_size(&self) -> usize {
+ self.mixing_pool.len()
+ }
+}
+
+/// Transaction timing metadata
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct TimingMetadata {
+ /// Fuzzy time range
+ pub time_range: TimeRange,
+
+ /// Zero-knowledge proof of creation time
+ pub time_proof: TimeRangeProof,
+
+ /// Optional: observed network delay (for adaptive strategies)
+ pub network_delay_hint: Option<Duration>,
+}
+
+impl TimingMetadata {
+ /// Create timing metadata for a transaction
+ pub fn new(real_time: DateTime<Utc>, seed: &[u8; 32]) -> Result<Self, crate::timerange::TimeRangeError> {
+ let time_range = TimeRange::new_fuzzy(real_time);
+ let time_proof = TimeRangeProof::generate(seed, real_time, &time_range)?;
+
+ Ok(Self {
+ time_range,
+ time_proof,
+ network_delay_hint: None,
+ })
+ }
+
+ /// Validate timing metadata
+ pub fn validate(&self, current_time: DateTime<Utc>) -> bool {
+ // Check time range is valid
+ if !self.time_range.is_valid(current_time) {
+ return false;
+ }
+
+ // Verify time proof
+ if !self.time_proof.verify(&self.time_range) {
+ return false;
+ }
+
+ true
+ }
+
+ /// Set network delay hint for adaptive strategies
+ pub fn with_network_delay(mut self, delay: Duration) -> Self {
+ self.network_delay_hint = Some(delay);
+ self
+ }
+}
+
+/// Strategies for transaction propagation
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum PropagationPhase {
+ /// Dandelion STEM phase (anonymity)
+ Stem,
+
+ /// Dandelion FLUFF phase (broadcast)
+ Fluff,
+}
+
+/// Transaction wrapper with timing information
+#[derive(Debug, Clone)]
+pub struct TimedItem<T> {
+ pub item: T,
+ pub timing: TimingMetadata,
+ pub phase: PropagationPhase,
+}
+
+impl<T> TimedItem<T> {
+ pub fn new(item: T, timing: TimingMetadata) -> Self {
+ Self {
+ item,
+ timing,
+ phase: PropagationPhase::Stem,
+ }
+ }
+
+ pub fn transition_to_fluff(mut self) -> Self {
+ self.phase = PropagationPhase::Fluff;
+ self
+ }
+
+ pub fn is_stem(&self) -> bool {
+ self.phase == PropagationPhase::Stem
+ }
+
+ pub fn is_fluff(&self) -> bool {
+ self.phase == PropagationPhase::Fluff
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_timing_metadata_creation() {
+ let now = Utc::now();
+ let seed = [42u8; 32];
+
+ let metadata = TimingMetadata::new(now, &seed).unwrap();
+ assert!(metadata.validate(now));
+ }
+
+ #[test]
+ fn test_timing_metadata_validation() {
+ let now = Utc::now();
+ let seed = [42u8; 32];
+
+ let metadata = TimingMetadata::new(now, &seed).unwrap();
+
+ // Should validate at current time
+ assert!(metadata.validate(now));
+
+ // Should validate slightly in the future
+ let future = now + chrono::Duration::hours(1);
+ assert!(metadata.validate(future));
+ }
+
+ #[test]
+ fn test_obfuscator_pool() {
+ let config = ObfuscationConfig::default();
+ let mut obfuscator = TimingObfuscator::<u32>::new(config);
+
+ obfuscator.add_to_pool(1);
+ obfuscator.add_to_pool(2);
+ obfuscator.add_to_pool(3);
+
+ assert_eq!(obfuscator.pool_size(), 3);
+ }
+
+ #[test]
+ fn test_timed_item_phases() {
+ let now = Utc::now();
+ let seed = [42u8; 32];
+ let timing = TimingMetadata::new(now, &seed).unwrap();
+
+ let item = TimedItem::new(42u32, timing);
+ assert!(item.is_stem());
+ assert!(!item.is_fluff());
+
+ let item = item.transition_to_fluff();
+ assert!(!item.is_stem());
+ assert!(item.is_fluff());
+ }
+
+ #[tokio::test]
+ async fn test_apply_delay() {
+ let config = ObfuscationConfig::default();
+ let obfuscator = TimingObfuscator::<u32>::new(config);
+
+ let start = std::time::Instant::now();
+ obfuscator.apply_delay().await;
+ let elapsed = start.elapsed();
+
+ // Should have some delay (at least 10s from node_rebroadcast strategy)
+ assert!(elapsed >= Duration::from_secs(10));
+ }
+}
diff --git a/timing/src/timerange.rs b/timing/src/timerange.rs
new file mode 100644
index 0000000..8991930
--- /dev/null
+++ b/timing/src/timerange.rs
@@ -0,0 +1,211 @@
+use chrono::{DateTime, Duration, Utc};
+use serde::{Deserialize, Serialize};
+use blake3::Hasher;
+
+/// Represents a fuzzy time range for transaction timestamps
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct TimeRange {
+ /// Earliest possible timestamp (Unix seconds)
+ pub earliest: i64,
+ /// Latest possible timestamp (Unix seconds)
+ pub latest: i64,
+}
+
+impl TimeRange {
+ /// Minimum allowed range duration (2 hours)
+ pub const MIN_RANGE_SECONDS: i64 = 2 * 3600;
+ /// Maximum allowed range duration (6 hours)
+ pub const MAX_RANGE_SECONDS: i64 = 6 * 3600;
+ /// Maximum age for a transaction (4 hours from latest)
+ pub const MAX_TX_AGE_SECONDS: i64 = 4 * 3600;
+
+ /// Create a new TimeRange around a real timestamp
+ pub fn new(real_time: DateTime<Utc>, range_hours: u32) -> Result<Self, TimeRangeError> {
+ let range_seconds = (range_hours as i64) * 3600;
+
+ if range_seconds < Self::MIN_RANGE_SECONDS {
+ return Err(TimeRangeError::RangeTooSmall);
+ }
+
+ if range_seconds > Self::MAX_RANGE_SECONDS {
+ return Err(TimeRangeError::RangeTooLarge);
+ }
+
+ let half_range = Duration::seconds(range_seconds / 2);
+ let earliest = (real_time - half_range).timestamp();
+ let latest = (real_time + half_range).timestamp();
+
+ Ok(Self { earliest, latest })
+ }
+
+ /// Create a fuzzy range with random padding
+ pub fn new_fuzzy(real_time: DateTime<Utc>) -> Self {
+ use rand::Rng;
+ let mut rng = rand::thread_rng();
+
+ // Random range between 2-6 hours
+ let range_hours = rng.gen_range(2..=6);
+
+ Self::new(real_time, range_hours).expect("Range should be valid")
+ }
+
+ /// Check if this range is valid at current time
+ pub fn is_valid(&self, current_time: DateTime<Utc>) -> bool {
+ let range_duration = self.latest - self.earliest;
+
+ // Check range size
+ if range_duration < Self::MIN_RANGE_SECONDS || range_duration > Self::MAX_RANGE_SECONDS {
+ return false;
+ }
+
+ // Check not too old
+ let age = current_time.timestamp() - self.latest;
+ if age > Self::MAX_TX_AGE_SECONDS {
+ return false;
+ }
+
+ true
+ }
+
+ /// Get the duration of this range in seconds
+ pub fn duration_seconds(&self) -> i64 {
+ self.latest - self.earliest
+ }
+
+ /// Check if a timestamp falls within this range
+ pub fn contains(&self, timestamp: i64) -> bool {
+ timestamp >= self.earliest && timestamp <= self.latest
+ }
+}
+
+/// Zero-knowledge proof that a transaction was created within a time range
+/// Simplified version using hash chains
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct TimeRangeProof {
+ /// Hash at earliest time point
+ pub earliest_hash: [u8; 32],
+ /// Hash at latest time point
+ pub latest_hash: [u8; 32],
+ /// Commitment to the real creation time
+ pub time_commitment: [u8; 32],
+}
+
+impl TimeRangeProof {
+ /// Generate a proof for a real timestamp within a range
+ /// Uses hash chain: H^n(seed) where n = minutes since epoch
+ pub fn generate(
+ seed: &[u8; 32],
+ real_time: DateTime<Utc>,
+ time_range: &TimeRange,
+ ) -> Result<Self, TimeRangeError> {
+ if !time_range.contains(real_time.timestamp()) {
+ return Err(TimeRangeError::TimeOutOfRange);
+ }
+
+ // Convert timestamps to "minutes since epoch" for hash chain
+ let earliest_minutes = time_range.earliest / 60;
+ let latest_minutes = time_range.latest / 60;
+ let real_minutes = real_time.timestamp() / 60;
+
+ // Generate hash chain values
+ let earliest_hash = Self::hash_chain(seed, earliest_minutes as u64);
+ let latest_hash = Self::hash_chain(seed, latest_minutes as u64);
+
+ // Commitment to real time (hash of seed + real_time)
+ let mut hasher = Hasher::new();
+ hasher.update(seed);
+ hasher.update(&real_minutes.to_le_bytes());
+ let time_commitment = *hasher.finalize().as_bytes();
+
+ Ok(Self {
+ earliest_hash,
+ latest_hash,
+ time_commitment,
+ })
+ }
+
+ /// Verify that the proof is internally consistent
+ /// Note: This is a simplified proof system. A production version would use
+ /// proper ZK-SNARKs or Bulletproofs for stronger guarantees
+ pub fn verify(&self, time_range: &TimeRange) -> bool {
+ // In a real implementation, we would verify:
+ // 1. earliest_hash and latest_hash are valid points in hash chain
+ // 2. time_commitment corresponds to a point between them
+ // 3. No information about real_time is leaked
+
+ // For this prototype, we just check the hashes are non-zero
+ self.earliest_hash != [0u8; 32]
+ && self.latest_hash != [0u8; 32]
+ && self.time_commitment != [0u8; 32]
+ && time_range.duration_seconds() > 0
+ }
+
+ /// Hash chain computation: applies blake3 hash n times
+ fn hash_chain(seed: &[u8; 32], iterations: u64) -> [u8; 32] {
+ let mut current = *seed;
+
+ for _ in 0..iterations {
+ let mut hasher = Hasher::new();
+ hasher.update(&current);
+ current = *hasher.finalize().as_bytes();
+ }
+
+ current
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum TimeRangeError {
+ #[error("Time range is too small (minimum 2 hours)")]
+ RangeTooSmall,
+
+ #[error("Time range is too large (maximum 6 hours)")]
+ RangeTooLarge,
+
+ #[error("Real timestamp is outside the specified range")]
+ TimeOutOfRange,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_timerange_creation() {
+ let now = Utc::now();
+ let range = TimeRange::new(now, 4).unwrap();
+
+ assert!(range.duration_seconds() == 4 * 3600);
+ assert!(range.contains(now.timestamp()));
+ }
+
+ #[test]
+ fn test_timerange_validation() {
+ let now = Utc::now();
+ let range = TimeRange::new(now, 4).unwrap();
+
+ assert!(range.is_valid(now));
+ assert!(range.is_valid(now + Duration::hours(1)));
+ }
+
+ #[test]
+ fn test_timerange_proof() {
+ let seed = [42u8; 32];
+ let now = Utc::now();
+ let range = TimeRange::new(now, 4).unwrap();
+
+ let proof = TimeRangeProof::generate(&seed, now, &range).unwrap();
+ assert!(proof.verify(&range));
+ }
+
+ #[test]
+ fn test_fuzzy_range() {
+ let now = Utc::now();
+ let range1 = TimeRange::new_fuzzy(now);
+ let range2 = TimeRange::new_fuzzy(now);
+
+ // Ranges should be valid but potentially different
+ assert!(range1.is_valid(now));
+ assert!(range2.is_valid(now));
+ }
+}