use std::collections::{HashMap, HashSet};
use std::sync::{Mutex, OnceLock, RwLock};
use nostr_sdk::EventId;
pub const REPLAY_WINDOW_SECS: i64 = 60;
static SPAM_GATE: OnceLock<SpamGate> = OnceLock::new();
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallError {
AlreadyInstalled,
}
struct ReplayGuard {
seen: HashMap<EventId, i64>,
window_secs: i64,
}
impl ReplayGuard {
fn new(window_secs: i64) -> Self {
Self {
seen: HashMap::new(),
window_secs,
}
}
fn check_and_record(&mut self, id: EventId, now: i64) -> bool {
let cutoff = now - self.window_secs;
self.seen.retain(|_, &mut seen_at| seen_at >= cutoff);
self.seen.insert(id, now).is_some()
}
}
pub struct SpamGate {
known: RwLock<HashSet<String>>,
replay: Mutex<ReplayGuard>,
}
impl SpamGate {
pub fn new(replay_window_secs: i64) -> Self {
Self {
known: RwLock::new(HashSet::new()),
replay: Mutex::new(ReplayGuard::new(replay_window_secs)),
}
}
pub fn install_global(self) -> Result<(), InstallError> {
SPAM_GATE
.set(self)
.map_err(|_| InstallError::AlreadyInstalled)
}
pub fn global() -> Option<&'static SpamGate> {
SPAM_GATE.get()
}
pub fn set_known<I: IntoIterator<Item = String>>(&self, keys: I) {
match self.known.write() {
Ok(mut set) => {
*set = keys.into_iter().collect();
}
Err(_) => tracing::error!("spam_gate: known-keys lock poisoned; skipping refresh"),
}
}
pub fn is_known(&self, pubkey: &str) -> bool {
self.known
.read()
.map(|set| set.contains(pubkey))
.unwrap_or(false)
}
pub fn known_count(&self) -> usize {
self.known.read().map(|set| set.len()).unwrap_or(0)
}
pub fn is_replay(&self, id: EventId, now: i64) -> bool {
match self.replay.lock() {
Ok(mut guard) => guard.check_and_record(id, now),
Err(_) => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use nostr_sdk::{EventBuilder, Keys};
fn an_event_id(note: &str) -> EventId {
EventBuilder::text_note(note)
.sign_with_keys(&Keys::generate())
.expect("sign test event")
.id
}
#[test]
fn known_set_membership_and_replace() {
let gate = SpamGate::new(REPLAY_WINDOW_SECS);
assert!(!gate.is_known("a"));
gate.set_known(["a".to_string(), "b".to_string()]);
assert!(gate.is_known("a"));
assert!(gate.is_known("b"));
assert!(!gate.is_known("c"));
assert_eq!(gate.known_count(), 2);
gate.set_known(["c".to_string()]);
assert!(gate.is_known("c"));
assert!(!gate.is_known("a"));
assert_eq!(gate.known_count(), 1);
}
#[test]
fn replay_first_seen_then_duplicate() {
let gate = SpamGate::new(REPLAY_WINDOW_SECS);
let id = an_event_id("dup");
let now = 1_000_000;
assert!(!gate.is_replay(id, now), "first sighting is not a replay");
assert!(
gate.is_replay(id, now + 1),
"second sighting within window is a replay"
);
assert!(
gate.is_replay(id, now + 30),
"still a replay later in the window"
);
}
#[test]
fn distinct_ids_are_independent() {
let gate = SpamGate::new(REPLAY_WINDOW_SECS);
let a = an_event_id("a");
let b = an_event_id("b");
let now = 500;
assert!(!gate.is_replay(a, now));
assert!(!gate.is_replay(b, now), "a different id is not a replay");
assert!(gate.is_replay(a, now), "but re-seeing a is");
}
#[test]
fn entry_expires_after_window() {
let mut guard = ReplayGuard::new(60);
let id = an_event_id("expire");
assert!(!guard.check_and_record(id, 1_000));
assert!(!guard.check_and_record(id, 1_000 + 61));
assert!(guard.check_and_record(id, 1_000 + 61));
}
#[test]
fn prune_keeps_map_bounded_to_window() {
let mut guard = ReplayGuard::new(60);
for i in 0..100 {
guard.check_and_record(an_event_id(&format!("e{i}")), 10_000 + i);
}
let fresh = an_event_id("fresh");
guard.check_and_record(fresh, 10_000 + 100 + 61);
assert_eq!(
guard.seen.len(),
1,
"stale entries must be pruned, leaving only the fresh sighting"
);
}
}