#![cfg(feature = "nip98-replay")]
use std::num::NonZeroUsize;
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use lru::LruCache;
use tokio::sync::Mutex;
pub use super::replay_store::{ReplayError, ReplayStore};
pub const DEFAULT_TTL_SECS: u64 = 120;
pub const DEFAULT_MAX_SIZE: usize = 10_000;
pub const ENV_TTL_SECS: &str = "SOLID_POD_NIP98_REPLAY_TTL_SECS";
pub const ENV_MAX_SIZE: &str = "SOLID_POD_NIP98_REPLAY_MAX_SIZE";
#[must_use]
pub fn sizing_floor(peak_rps: u64, ttl: Duration) -> usize {
let secs = ttl.as_secs_f64().max(0.0);
let needed = (peak_rps as f64 * secs).ceil();
if !needed.is_finite() || needed <= 0.0 {
return 1;
}
if needed >= usize::MAX as f64 {
return usize::MAX;
}
(needed as usize).max(1)
}
#[derive(Debug, Clone)]
pub struct Nip98ReplayCache {
inner: Arc<Mutex<Inner>>,
ttl: Duration,
max_size: usize,
}
#[derive(Debug)]
struct Inner {
entries: LruCache<String, Instant>,
}
impl Nip98ReplayCache {
pub fn from_env() -> Self {
let ttl_secs = std::env::var(ENV_TTL_SECS)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.filter(|n| *n > 0)
.unwrap_or(DEFAULT_TTL_SECS);
let max_size = std::env::var(ENV_MAX_SIZE)
.ok()
.and_then(|s| s.parse::<usize>().ok())
.filter(|n| *n > 0)
.unwrap_or(DEFAULT_MAX_SIZE);
Self::with_config(Duration::from_secs(ttl_secs), max_size)
}
pub fn with_config(ttl: Duration, max_size: usize) -> Self {
let cap = NonZeroUsize::new(max_size.max(1)).expect("clamped to >= 1 above");
Self {
inner: Arc::new(Mutex::new(Inner {
entries: LruCache::new(cap),
})),
ttl,
max_size: max_size.max(1),
}
}
pub fn ttl(&self) -> Duration {
self.ttl
}
pub fn max_size(&self) -> usize {
self.max_size
}
pub async fn len(&self) -> usize {
self.inner.lock().await.entries.len()
}
pub async fn is_empty(&self) -> bool {
self.len().await == 0
}
pub async fn evict_expired(&self) -> usize {
let now = Instant::now();
let mut guard = self.inner.lock().await;
Self::reclaim_expired(&mut guard, now, self.ttl)
}
fn reclaim_expired(inner: &mut Inner, now: Instant, ttl: Duration) -> usize {
let expired: Vec<String> = inner
.entries
.iter()
.filter_map(|(id, seen)| {
if now.saturating_duration_since(*seen) >= ttl {
Some(id.clone())
} else {
None
}
})
.collect();
let removed = expired.len();
for id in expired {
inner.entries.pop(&id);
}
removed
}
pub fn spawn_evictor(self, period: Duration) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(period);
ticker.tick().await; loop {
ticker.tick().await;
let _ = self.evict_expired().await;
}
})
}
}
#[async_trait]
impl ReplayStore for Nip98ReplayCache {
async fn check_and_record(&self, event_id: &str) -> Result<(), ReplayError> {
let now = Instant::now();
let mut guard = self.inner.lock().await;
if let Some(first_seen) = guard.entries.peek(event_id).copied() {
if now.saturating_duration_since(first_seen) < self.ttl {
return Err(ReplayError::Replayed { ttl: self.ttl });
}
guard.entries.put(event_id.to_string(), now);
return Ok(());
}
if guard.entries.len() >= self.max_size {
Self::reclaim_expired(&mut guard, now, self.ttl);
if guard.entries.len() >= self.max_size {
return Err(ReplayError::CapacityExhausted {
capacity: self.max_size,
ttl: self.ttl,
});
}
}
guard.entries.put(event_id.to_string(), now);
Ok(())
}
}
impl Default for Nip98ReplayCache {
fn default() -> Self {
Self::with_config(Duration::from_secs(DEFAULT_TTL_SECS), DEFAULT_MAX_SIZE)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn first_sighting_accepts_replay_rejects() {
let cache = Nip98ReplayCache::with_config(Duration::from_secs(60), 8);
let id = "a".repeat(64);
assert!(cache.check_and_record(&id).await.is_ok());
let err = cache.check_and_record(&id).await.unwrap_err();
assert!(matches!(err, ReplayError::Replayed { .. }));
assert!(cache.check_and_record(&"b".repeat(64)).await.is_ok());
}
#[tokio::test]
async fn expired_entry_treated_as_fresh() {
let cache = Nip98ReplayCache::with_config(Duration::from_secs(0), 8);
let id = "c".repeat(64);
assert!(cache.check_and_record(&id).await.is_ok());
assert!(cache.check_and_record(&id).await.is_ok());
}
#[tokio::test]
async fn clones_share_storage() {
let a = Nip98ReplayCache::with_config(Duration::from_secs(60), 8);
let b = a.clone();
let id = "d".repeat(64);
assert!(a.check_and_record(&id).await.is_ok());
assert!(b.check_and_record(&id).await.is_err());
}
#[tokio::test]
async fn trait_object_dispatch_rejects_replay() {
let store: Arc<dyn ReplayStore> =
Arc::new(Nip98ReplayCache::with_config(Duration::from_secs(60), 8));
let id = "e".repeat(64);
assert!(store.check_and_record(&id).await.is_ok());
assert!(matches!(
store.check_and_record(&id).await.unwrap_err(),
ReplayError::Replayed { .. }
));
}
#[tokio::test]
async fn capacity_pressure_refuses_new_ids_and_never_evicts_unexpired() {
let cache = Nip98ReplayCache::with_config(Duration::from_secs(600), 2);
cache.check_and_record("id-1").await.unwrap();
cache.check_and_record("id-2").await.unwrap();
assert_eq!(cache.len().await, 2);
let err = cache.check_and_record("id-3").await.unwrap_err();
assert!(
matches!(err, ReplayError::CapacityExhausted { capacity: 2, .. }),
"expected CapacityExhausted, got {err:?}"
);
assert_eq!(cache.len().await, 2);
assert!(matches!(
cache.check_and_record("id-1").await.unwrap_err(),
ReplayError::Replayed { .. }
));
assert!(matches!(
cache.check_and_record("id-2").await.unwrap_err(),
ReplayError::Replayed { .. }
));
}
#[tokio::test]
async fn capacity_one_cannot_be_made_to_accept_a_replay() {
let cache = Nip98ReplayCache::with_config(Duration::from_secs(600), 1);
let id = "f".repeat(64);
assert!(cache.check_and_record(&id).await.is_ok());
for n in 0..64 {
let filler = format!("filler-{n:064}");
assert!(matches!(
cache.check_and_record(&filler).await.unwrap_err(),
ReplayError::CapacityExhausted { .. }
));
}
assert!(matches!(
cache.check_and_record(&id).await.unwrap_err(),
ReplayError::Replayed { .. }
));
}
#[tokio::test]
async fn expired_entries_are_reclaimed_to_admit_a_new_id() {
let cache = Nip98ReplayCache::with_config(Duration::from_secs(0), 1);
assert!(cache.check_and_record("old").await.is_ok());
assert!(
cache.check_and_record("new").await.is_ok(),
"an expired entry must be reclaimed, not cause a refusal"
);
assert_eq!(cache.len().await, 1);
}
#[tokio::test]
async fn re_presenting_an_expired_id_reuses_its_slot_at_capacity() {
let cache = Nip98ReplayCache::with_config(Duration::from_secs(0), 1);
let id = "g".repeat(64);
assert!(cache.check_and_record(&id).await.is_ok());
assert!(cache.check_and_record(&id).await.is_ok());
assert_eq!(cache.len().await, 1);
}
#[tokio::test]
async fn concurrent_first_sightings_admit_exactly_one() {
let cache = Nip98ReplayCache::with_config(Duration::from_secs(600), 128);
let id = "h".repeat(64);
let mut tasks = Vec::new();
for _ in 0..32 {
let c = cache.clone();
let id = id.clone();
tasks.push(tokio::spawn(async move { c.check_and_record(&id).await }));
}
let mut accepted = 0usize;
for t in tasks {
match t.await.expect("task panicked") {
Ok(()) => accepted += 1,
Err(ReplayError::Replayed { .. }) => {}
Err(e) => panic!("unexpected error: {e:?}"),
}
}
assert_eq!(accepted, 1, "check-and-record must be atomic");
assert_eq!(cache.len().await, 1);
}
#[tokio::test]
async fn concurrent_distinct_ids_never_exceed_capacity() {
let cache = Nip98ReplayCache::with_config(Duration::from_secs(600), 8);
let mut tasks = Vec::new();
for n in 0..64 {
let c = cache.clone();
tasks.push(tokio::spawn(async move {
c.check_and_record(&format!("id-{n:064}")).await
}));
}
let mut accepted = 0usize;
let mut refused = 0usize;
for t in tasks {
match t.await.expect("task panicked") {
Ok(()) => accepted += 1,
Err(ReplayError::CapacityExhausted { .. }) => refused += 1,
Err(e) => panic!("unexpected error: {e:?}"),
}
}
assert_eq!(
accepted, 8,
"exactly `capacity` distinct ids may be admitted"
);
assert_eq!(refused, 56);
assert_eq!(cache.len().await, 8);
}
#[tokio::test]
async fn evict_expired_frees_the_store_for_reuse() {
let cache = Nip98ReplayCache::with_config(Duration::from_secs(0), 2);
cache.check_and_record("a").await.unwrap();
cache.check_and_record("b").await.unwrap();
assert_eq!(cache.evict_expired().await, 2);
assert!(cache.is_empty().await);
}
#[test]
fn sizing_floor_covers_the_offered_rate() {
assert_eq!(sizing_floor(100, Duration::from_secs(120)), 12_000);
assert_eq!(sizing_floor(0, Duration::from_secs(120)), 1);
assert_eq!(sizing_floor(1, Duration::from_millis(1500)), 2);
assert!(
sizing_floor(83, Duration::from_secs(DEFAULT_TTL_SECS)) <= DEFAULT_MAX_SIZE,
"documented default sizing claim must hold"
);
assert!(sizing_floor(84, Duration::from_secs(DEFAULT_TTL_SECS)) > DEFAULT_MAX_SIZE);
}
#[tokio::test]
async fn restart_semantics_reopen_the_window() {
let before = Nip98ReplayCache::with_config(Duration::from_secs(600), 8);
let id = "i".repeat(64);
assert!(before.check_and_record(&id).await.is_ok());
assert!(before.check_and_record(&id).await.is_err());
let after = Nip98ReplayCache::with_config(Duration::from_secs(600), 8);
assert!(
after.check_and_record(&id).await.is_ok(),
"a process-local store cannot survive a restart; this is the \
documented limit, not a regression"
);
}
}