use rand::RngExt;
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
sync::Arc,
time::{Duration, Instant},
};
use thiserror::Error;
use tokio::sync::RwLock;
pub const DEFAULT_POW_BITS: u8 = 12;
const SEED_TTL_SECS: u64 = 120;
const SEED_STORE_CAP: usize = 10_000;
#[derive(Debug, Clone, Error, PartialEq, Eq)]
pub enum PowError {
#[error("challenge seed invalid or already used")]
InvalidSeed,
#[error("challenge expired")]
Expired,
#[error("insufficient proof of work")]
InsufficientWork,
#[error("difficulty out of range [4, 32]")]
InvalidBits,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PowChallengeIssued {
pub seed: String,
pub bits: u8,
}
struct SeedEntry {
issued_at: Instant,
}
pub struct PowChallenge {
seeds: Arc<RwLock<HashMap<String, SeedEntry>>>,
ttl: Duration,
cap: usize,
}
impl Default for PowChallenge {
fn default() -> Self {
Self::new()
}
}
impl PowChallenge {
#[must_use]
pub fn new() -> Self {
Self {
seeds: Arc::new(RwLock::new(HashMap::new())),
ttl: Duration::from_secs(SEED_TTL_SECS),
cap: SEED_STORE_CAP,
}
}
#[must_use]
pub fn with_limits(ttl: Duration, cap: usize) -> Self {
Self {
seeds: Arc::new(RwLock::new(HashMap::new())),
ttl,
cap,
}
}
pub async fn issue(&self, bits: u8) -> Result<PowChallengeIssued, PowError> {
if !(4..=32).contains(&bits) {
return Err(PowError::InvalidBits);
}
self.prune().await;
let seed = {
let mut rng = rand::rng();
let raw: [u8; 32] = rng.random();
hex::encode(Sha256::digest(raw))
};
let mut store = self.seeds.write().await;
if store.len() >= self.cap {
let mut entries: Vec<(String, SeedEntry)> = store.drain().collect();
entries.sort_by_key(|(_, e)| e.issued_at);
store.extend(entries.into_iter().rev().take(self.cap));
}
store.insert(
seed.clone(),
SeedEntry {
issued_at: Instant::now(),
},
);
Ok(PowChallengeIssued { seed, bits })
}
pub async fn verify(&self, seed: &str, counter: u64, bits: u8) -> Result<(), PowError> {
let entry = self
.seeds
.write()
.await
.remove(seed)
.ok_or(PowError::InvalidSeed)?;
if entry.issued_at.elapsed() > self.ttl {
return Err(PowError::Expired);
}
let mut hasher = Sha256::new();
hasher.update(seed.as_bytes());
hasher.update(counter.to_string().as_bytes());
let hash = hasher.finalize();
if leading_zero_bits(&hash) < u32::from(bits) {
return Err(PowError::InsufficientWork);
}
Ok(())
}
async fn prune(&self) {
let mut store = self.seeds.write().await;
store.retain(|_, e| e.issued_at.elapsed() <= self.ttl);
}
pub async fn len(&self) -> usize {
self.seeds.read().await.len()
}
pub async fn is_empty(&self) -> bool {
self.seeds.read().await.is_empty()
}
}
pub fn leading_zero_bits(hash: &[u8]) -> u32 {
let mut count = 0u32;
for &byte in hash {
if byte == 0 {
count += 8;
} else {
count += byte.leading_zeros();
break;
}
}
count
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn issue_and_verify_roundtrip() {
let challenge = PowChallenge::new();
let issued = challenge.issue(DEFAULT_POW_BITS).await.unwrap();
assert_eq!(issued.bits, DEFAULT_POW_BITS);
assert!(!issued.seed.is_empty());
let mut counter: u64 = 0;
let mut solution: Option<u64> = None;
while counter < 100_000 {
let mut hasher = Sha256::new();
hasher.update(issued.seed.as_bytes());
hasher.update(counter.to_string().as_bytes());
let hash = hasher.finalize();
if leading_zero_bits(&hash) >= u32::from(issued.bits) {
solution = Some(counter);
break;
}
counter += 1;
}
let counter = solution.expect("a valid counter within 100k attempts");
assert!(challenge
.verify(&issued.seed, counter, issued.bits)
.await
.is_ok());
assert_eq!(
challenge.verify(&issued.seed, counter, issued.bits).await,
Err(PowError::InvalidSeed)
);
}
#[tokio::test]
async fn insufficient_work_is_rejected_and_consumed() {
let challenge = PowChallenge::new();
let issued = challenge.issue(DEFAULT_POW_BITS).await.unwrap();
let err = challenge
.verify(&issued.seed, 0, issued.bits)
.await
.unwrap_err();
assert_eq!(err, PowError::InsufficientWork);
assert_eq!(
challenge.verify(&issued.seed, 0, issued.bits).await,
Err(PowError::InvalidSeed)
);
}
#[tokio::test]
async fn expired_seed_is_rejected() {
let challenge = PowChallenge::with_limits(Duration::from_millis(1), 100);
let issued = challenge.issue(8).await.unwrap();
tokio::time::sleep(Duration::from_millis(5)).await;
assert_eq!(
challenge.verify(&issued.seed, 0, 8).await,
Err(PowError::Expired)
);
}
#[tokio::test]
async fn bits_out_of_range_is_rejected() {
let challenge = PowChallenge::new();
assert_eq!(challenge.issue(2).await, Err(PowError::InvalidBits));
assert_eq!(challenge.issue(40).await, Err(PowError::InvalidBits));
}
#[tokio::test]
async fn leading_zero_bits_counts_correctly() {
let mut h = [0u8; 32];
h[0] = 0x80;
assert_eq!(leading_zero_bits(&h), 0);
h[0] = 0x01;
assert_eq!(leading_zero_bits(&h), 7);
h[0] = 0x00;
h[1] = 0x00;
h[2] = 0x10;
assert_eq!(leading_zero_bits(&h), 16 + 3);
}
}