#![forbid(unsafe_code)]
#![warn(missing_docs)]
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "lowercase")]
pub enum MiningMode {
Disabled,
Fixed {
difficulty: u32,
},
Dynamic {
initial: u32,
target_secs: u64,
reports_per_epoch: u32,
},
}
impl MiningMode {
pub fn webcash_production() -> Self {
MiningMode::Dynamic {
initial: 24,
target_secs: 1_000,
reports_per_epoch: 1_000,
}
}
pub fn webcash_testnet() -> Self {
MiningMode::Fixed { difficulty: 16 }
}
pub fn issued_default() -> Self {
Self::webcash_production()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MiningConfig {
pub mode: MiningMode,
pub mining_amount_wats: i64,
pub subsidy_amount_wats: i64,
pub max_issuance: Option<u128>,
pub require_pow_for_issuance: bool,
}
impl Default for MiningConfig {
fn default() -> Self {
Self {
mode: MiningMode::webcash_production(),
mining_amount_wats: 19_531_250_000,
subsidy_amount_wats: 976_562_500,
max_issuance: None,
require_pow_for_issuance: false,
}
}
}
impl MiningConfig {
pub fn current_difficulty(&self) -> Option<u32> {
match &self.mode {
MiningMode::Disabled => None,
MiningMode::Fixed { difficulty } => Some(*difficulty),
MiningMode::Dynamic { initial, .. } => Some(*initial),
}
}
}
pub fn leading_zero_bits(hash: &[u8]) -> u32 {
let full_zero_bytes = hash.iter().take_while(|&&b| b == 0).count() as u32;
hash.get(full_zero_bytes as usize)
.map_or(0, |b| b.leading_zeros())
+ full_zero_bytes * 8
}
pub fn verify_pow(preimage: &str, difficulty_bits: u32) -> bool {
let hash = Sha256::digest(preimage.as_bytes());
leading_zero_bits(&hash) >= difficulty_bits
}
pub fn adjust_difficulty(
current_difficulty: u32,
actual_time_secs: u64,
target_time_secs: u64,
actual_reports: u64,
expected_reports: u64,
) -> u32 {
let time_ratio = actual_time_secs as f64 / target_time_secs as f64;
let report_ratio = actual_reports as f64 / expected_reports as f64;
let new_diff = if time_ratio <= 1.0 && report_ratio >= 1.0 {
current_difficulty.saturating_add(1)
} else if time_ratio >= 1.0 && report_ratio <= 1.0 {
current_difficulty.saturating_sub(1).max(1)
} else {
current_difficulty
};
let max = current_difficulty.saturating_add(2);
let min = current_difficulty.saturating_sub(2).max(1);
new_diff.clamp(min, max)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leading_zeros_all_zero() {
let hash = [0u8; 32];
assert_eq!(leading_zero_bits(&hash), 256);
}
#[test]
fn leading_zeros_one_byte() {
let mut hash = [0u8; 32];
hash[0] = 0x01;
assert_eq!(leading_zero_bits(&hash), 7);
}
#[test]
fn leading_zeros_two_bytes() {
let mut hash = [0u8; 32];
hash[1] = 0x0F;
assert_eq!(leading_zero_bits(&hash), 12);
}
#[test]
fn verify_pow_trivial() {
assert!(verify_pow("anything", 0));
}
#[test]
fn difficulty_adjustment_too_fast() {
assert_eq!(adjust_difficulty(16, 500, 1000, 100, 100), 17);
}
#[test]
fn difficulty_adjustment_too_slow() {
assert_eq!(adjust_difficulty(16, 2000, 1000, 50, 100), 15);
}
#[test]
fn difficulty_floor() {
assert_eq!(adjust_difficulty(1, 99999, 1000, 1, 100), 1);
}
#[test]
fn current_difficulty_for_each_mode() {
let cfg_disabled = MiningConfig {
mode: MiningMode::Disabled,
..MiningConfig::default()
};
assert_eq!(cfg_disabled.current_difficulty(), None);
let cfg_fixed = MiningConfig {
mode: MiningMode::Fixed { difficulty: 8 },
..MiningConfig::default()
};
assert_eq!(cfg_fixed.current_difficulty(), Some(8));
let cfg_dynamic = MiningConfig {
mode: MiningMode::Dynamic {
initial: 24,
target_secs: 1000,
reports_per_epoch: 1000,
},
..MiningConfig::default()
};
assert_eq!(cfg_dynamic.current_difficulty(), Some(24));
}
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(512))]
#[test]
fn leading_zero_bits_in_range(bytes in prop::collection::vec(any::<u8>(), 0..=64)) {
let n = leading_zero_bits(&bytes);
prop_assert!(n <= bytes.len() as u32 * 8);
}
#[test]
fn all_zeros_yields_full_count(len in 0usize..=64) {
let z = vec![0u8; len];
prop_assert_eq!(leading_zero_bits(&z), len as u32 * 8);
}
#[test]
fn verify_pow_zero_is_total(s: String) {
prop_assert!(verify_pow(&s, 0));
}
#[test]
fn verify_pow_is_monotone(
s: String,
n in 0u32..=24,
) {
if verify_pow(&s, n + 1) {
prop_assert!(verify_pow(&s, n));
}
}
#[test]
fn verify_pow_matches_leading_zero_bits(s: String, n in 0u32..=12) {
use sha2::{Digest, Sha256};
let h = Sha256::digest(s.as_bytes());
prop_assert_eq!(verify_pow(&s, n), leading_zero_bits(&h) >= n);
}
#[test]
fn adjust_difficulty_clamped_per_epoch(
current in 1u32..=64,
actual_secs in 1u64..=10_000,
target_secs in 1u64..=10_000,
actual_reports in 0u64..=10_000,
expected_reports in 1u64..=10_000,
) {
let new = adjust_difficulty(
current, actual_secs, target_secs, actual_reports, expected_reports,
);
let diff = new.abs_diff(current);
prop_assert!(diff <= 2, "delta {diff} > 2 for current={current} → new={new}");
prop_assert!(new >= 1);
}
#[test]
fn adjust_difficulty_floor_at_one(
actual_secs in 5_000u64..=u64::MAX / 2,
actual_reports in 0u64..=10,
) {
let new = adjust_difficulty(1, actual_secs, 1_000, actual_reports, 100);
prop_assert_eq!(new, 1);
}
#[test]
fn adjust_difficulty_equilibrium_is_stable(
current in 1u32..=32,
t in 1u64..=10_000,
r in 1u64..=10_000,
) {
let new = adjust_difficulty(current, t, t, r, r);
prop_assert!(new == current || new == current + 1,
"equilibrium drift > 1: {current} → {new}");
}
}
}