use std::sync::Arc;
use std::time::Duration;
use dashmap::DashMap;
use freenet_stdlib::prelude::ContractInstanceId;
use tokio::time::Instant;
use crate::contract::storages::Storage;
use crate::util::time_source::TimeSource;
pub(crate) const BROKEN_INVARIANT_TTL: Duration = Duration::from_secs(300);
pub(crate) const BROKEN_INVARIANT_TTL_CAP: Duration = Duration::from_secs(6 * 60 * 60);
const REFLAG_WINDOW_MULT: u32 = 2;
pub(crate) const IDENTITY_PROBE_COOLDOWN: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrokenInvariant {
NonIdempotent,
}
impl BrokenInvariant {
fn to_byte(self) -> u8 {
match self {
BrokenInvariant::NonIdempotent => 0,
}
}
fn from_byte(b: u8) -> Option<Self> {
match b {
0 => Some(BrokenInvariant::NonIdempotent),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy)]
struct FlagEntry {
kind: BrokenInvariant,
recorded_at: Instant,
ttl: Duration,
}
pub(crate) struct BrokenInvariantsTracker {
flags: Arc<DashMap<ContractInstanceId, FlagEntry>>,
identity_probe_claims: DashMap<ContractInstanceId, Instant>,
storage: parking_lot::RwLock<Option<Storage>>,
time_source: Arc<dyn TimeSource + Send + Sync>,
}
impl BrokenInvariantsTracker {
pub fn new(time_source: Arc<dyn TimeSource + Send + Sync>) -> Self {
Self {
flags: Arc::new(DashMap::new()),
identity_probe_claims: DashMap::new(),
storage: parking_lot::RwLock::new(None),
time_source,
}
}
pub fn try_claim_identity_probe(&self, id: &ContractInstanceId) -> bool {
use dashmap::mapref::entry::Entry;
let now = self.time_source.now();
match self.identity_probe_claims.entry(*id) {
Entry::Occupied(mut o) => {
if now.saturating_duration_since(*o.get()) >= IDENTITY_PROBE_COOLDOWN {
*o.get_mut() = now;
true
} else {
false
}
}
Entry::Vacant(v) => {
v.insert(now);
true
}
}
}
pub fn is_broken(&self, id: &ContractInstanceId) -> bool {
let now = self.time_source.now();
match self.flags.get(id) {
Some(entry) => now.saturating_duration_since(entry.recorded_at) < entry.ttl,
None => false,
}
}
#[cfg(test)]
pub fn get(&self, id: &ContractInstanceId) -> Option<BrokenInvariant> {
let now = self.time_source.now();
self.flags.get(id).and_then(|entry| {
if now.saturating_duration_since(entry.recorded_at) < entry.ttl {
Some(entry.kind)
} else {
None
}
})
}
#[cfg(test)]
pub fn current_ttl(&self, id: &ContractInstanceId) -> Option<Duration> {
self.flags.get(id).map(|entry| entry.ttl)
}
pub fn record(&self, id: ContractInstanceId, kind: BrokenInvariant) {
use dashmap::mapref::entry::Entry;
let now = self.time_source.now();
enum Outcome {
New,
Escalated(Duration),
Refreshed,
}
let outcome = match self.flags.entry(id) {
Entry::Occupied(mut o) => {
let elapsed = now.saturating_duration_since(o.get().recorded_at);
let ttl = o.get().ttl;
let reflag_window = ttl * REFLAG_WINDOW_MULT;
let (new_ttl, outcome) = if elapsed >= reflag_window {
(BROKEN_INVARIANT_TTL, Outcome::Refreshed)
} else if elapsed >= ttl {
let escalated = (ttl * 2).min(BROKEN_INVARIANT_TTL_CAP);
(escalated, Outcome::Escalated(escalated))
} else {
(ttl, Outcome::Refreshed)
};
*o.get_mut() = FlagEntry {
kind,
recorded_at: now,
ttl: new_ttl,
};
outcome
}
Entry::Vacant(v) => {
v.insert(FlagEntry {
kind,
recorded_at: now,
ttl: BROKEN_INVARIANT_TTL,
});
Outcome::New
}
};
match outcome {
Outcome::New => {
tracing::warn!(
contract = %id,
invariant = ?kind,
event = "broken_invariant_detected",
"Marking contract as broken — gating outbound broadcast and merge propagation"
);
#[cfg(feature = "redb")]
if let Some(storage) = self.storage.read().as_ref() {
if let Err(e) = storage.store_broken_invariant(&id, kind.to_byte()) {
tracing::warn!(
contract = %id,
error = %e,
"Failed to persist broken-invariant flag (in-memory flag still active)"
);
}
}
}
Outcome::Escalated(new_ttl) => {
tracing::debug!(
contract = %id,
escalated_ttl_secs = new_ttl.as_secs(),
event = "broken_invariant_ttl_escalated",
"Re-detected broken contract — escalating suppression TTL (#4902 egress oscillation)"
);
}
Outcome::Refreshed => {}
}
}
#[allow(dead_code)] pub fn clear(&self, id: &ContractInstanceId) -> Option<BrokenInvariant> {
let previous = self.flags.remove(id).map(|(_, v)| v.kind);
if previous.is_some() {
self.remove_from_storage(id);
tracing::warn!(
contract = %id,
event = "broken_invariant_cleared",
"Operator cleared broken-invariant flag — outbound broadcast re-enabled"
);
}
previous
}
pub fn cleanup(&self) {
let now = self.time_source.now();
let reclaimable = |entry: &FlagEntry| {
now.saturating_duration_since(entry.recorded_at) >= entry.ttl * REFLAG_WINDOW_MULT
};
let candidates: Vec<ContractInstanceId> = self
.flags
.iter()
.filter(|e| reclaimable(e.value()))
.map(|e| *e.key())
.collect();
for id in candidates {
let removed = self.flags.remove_if(&id, |_, entry| reclaimable(entry));
if removed.is_some() {
self.remove_from_storage(&id);
}
}
self.identity_probe_claims
.retain(|_, t| now.saturating_duration_since(*t) < IDENTITY_PROBE_COOLDOWN);
}
fn remove_from_storage(&self, id: &ContractInstanceId) {
#[cfg(feature = "redb")]
if let Some(storage) = self.storage.read().as_ref() {
if let Err(e) = storage.remove_broken_invariant(id) {
tracing::warn!(
contract = %id,
error = %e,
"Failed to remove persisted broken-invariant flag (in-memory flag already cleared)"
);
}
}
#[cfg(not(feature = "redb"))]
let _ = id;
}
pub fn set_storage(&self, storage: Storage) {
{
let mut slot = self.storage.write();
if slot.is_some() {
tracing::warn!("BrokenInvariantsTracker storage already set; ignoring re-init");
return;
}
*slot = Some(storage.clone());
}
#[cfg(feature = "redb")]
match storage.load_all_broken_invariants() {
Ok(entries) => {
let recorded_at = self.time_source.now();
for (id, byte) in entries {
if let Some(kind) = BrokenInvariant::from_byte(byte) {
self.flags.insert(
id,
FlagEntry {
kind,
recorded_at,
ttl: BROKEN_INVARIANT_TTL,
},
);
} else {
tracing::warn!(
contract = %id,
byte,
"Skipping unknown broken-invariant byte on load"
);
}
}
tracing::debug!(
count = self.flags.len(),
"Loaded broken-invariant flags from storage"
);
}
Err(e) => {
tracing::warn!(error = %e, "Failed to load broken-invariant flags from storage");
}
}
}
pub(crate) fn clear_storage(&self) {
*self.storage.write() = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::time_source::SharedMockTimeSource;
fn fake_id(seed: u8) -> ContractInstanceId {
let mut bytes = [0u8; 32];
bytes[0] = seed;
ContractInstanceId::new(bytes)
}
fn mk_tracker() -> (BrokenInvariantsTracker, SharedMockTimeSource) {
let ts = SharedMockTimeSource::new();
(BrokenInvariantsTracker::new(Arc::new(ts.clone())), ts)
}
#[test]
fn record_then_query_returns_true() {
let (t, _ts) = mk_tracker();
let id = fake_id(1);
assert!(!t.is_broken(&id));
t.record(id, BrokenInvariant::NonIdempotent);
assert!(t.is_broken(&id));
assert_eq!(t.get(&id), Some(BrokenInvariant::NonIdempotent));
}
#[test]
fn record_is_idempotent() {
let (t, _ts) = mk_tracker();
let id = fake_id(2);
t.record(id, BrokenInvariant::NonIdempotent);
t.record(id, BrokenInvariant::NonIdempotent);
assert!(t.is_broken(&id));
}
#[test]
fn unrelated_contracts_unaffected() {
let (t, _ts) = mk_tracker();
let broken = fake_id(3);
let healthy = fake_id(4);
t.record(broken, BrokenInvariant::NonIdempotent);
assert!(t.is_broken(&broken));
assert!(!t.is_broken(&healthy));
}
#[test]
fn clear_returns_previous_and_unsets() {
let (t, _ts) = mk_tracker();
let id = fake_id(5);
assert_eq!(t.clear(&id), None);
t.record(id, BrokenInvariant::NonIdempotent);
assert!(t.is_broken(&id));
let prev = t.clear(&id);
assert_eq!(prev, Some(BrokenInvariant::NonIdempotent));
assert!(
!t.is_broken(&id),
"after clear the contract is no longer broken"
);
assert_eq!(t.clear(&id), None);
}
#[test]
fn flag_expires_after_ttl() {
let (t, ts) = mk_tracker();
let id = fake_id(6);
t.record(id, BrokenInvariant::NonIdempotent);
assert!(t.is_broken(&id), "freshly recorded flag is active");
ts.advance_time(BROKEN_INVARIANT_TTL - Duration::from_secs(1));
assert!(t.is_broken(&id), "flag still active just before TTL");
ts.advance_time(Duration::from_secs(2));
assert!(
!t.is_broken(&id),
"flag must expire after TTL so a false positive self-heals"
);
assert_eq!(t.get(&id), None, "expired flag reports no kind");
}
#[test]
fn cleanup_reclaims_entries_past_reset_boundary() {
let (t, ts) = mk_tracker();
let id = fake_id(7);
t.record(id, BrokenInvariant::NonIdempotent);
assert_eq!(t.flags.len(), 1);
ts.advance_time(BROKEN_INVARIANT_TTL + Duration::from_secs(1));
assert!(
!t.is_broken(&id),
"past its ttl the flag no longer suppresses"
);
t.cleanup();
assert_eq!(
t.flags.len(),
1,
"entry retained as escalation memory until the reset boundary"
);
ts.advance_time(BROKEN_INVARIANT_TTL);
t.cleanup();
assert_eq!(
t.flags.len(),
0,
"entry past the reset boundary reclaimed by cleanup"
);
}
#[test]
fn record_refreshes_ttl() {
let (t, ts) = mk_tracker();
let id = fake_id(8);
t.record(id, BrokenInvariant::NonIdempotent);
ts.advance_time(BROKEN_INVARIANT_TTL - Duration::from_secs(1));
t.record(id, BrokenInvariant::NonIdempotent);
ts.advance_time(Duration::from_secs(2));
assert!(
t.is_broken(&id),
"re-recording must refresh the expiry window"
);
}
#[test]
fn identity_probe_claim_respects_cooldown() {
let (t, ts) = mk_tracker();
let a = fake_id(9);
let b = fake_id(10);
assert!(t.try_claim_identity_probe(&a), "first claim granted");
assert!(
!t.try_claim_identity_probe(&a),
"second claim within cooldown denied"
);
assert!(
t.try_claim_identity_probe(&b),
"unrelated contract unaffected"
);
ts.advance_time(IDENTITY_PROBE_COOLDOWN);
assert!(
t.try_claim_identity_probe(&a),
"claim granted again after the cooldown"
);
ts.advance_time(IDENTITY_PROBE_COOLDOWN);
t.cleanup();
assert!(t.identity_probe_claims.is_empty(), "stale claims swept");
}
#[test]
fn byte_roundtrip_stable() {
let kinds: &[BrokenInvariant] = &[BrokenInvariant::NonIdempotent];
for kind in kinds {
assert_eq!(BrokenInvariant::from_byte(kind.to_byte()), Some(*kind));
}
assert_eq!(BrokenInvariant::from_byte(255), None);
}
#[test]
fn first_flag_uses_baseline_ttl() {
let (t, ts) = mk_tracker();
let id = fake_id(20);
t.record(id, BrokenInvariant::NonIdempotent);
assert_eq!(
t.current_ttl(&id),
Some(BROKEN_INVARIANT_TTL),
"first flag = baseline TTL"
);
ts.advance_time(BROKEN_INVARIANT_TTL - Duration::from_secs(1));
assert!(t.is_broken(&id), "suppressed right up to the baseline TTL");
ts.advance_time(Duration::from_secs(2));
assert!(
!t.is_broken(&id),
"first flag expires after exactly the baseline TTL"
);
}
#[test]
fn re_detection_escalates_ttl_geometrically_to_cap() {
let (t, ts) = mk_tracker();
let id = fake_id(21);
t.record(id, BrokenInvariant::NonIdempotent);
let mut expected = BROKEN_INVARIANT_TTL;
assert_eq!(t.current_ttl(&id), Some(expected));
for cycle in 0..12 {
let ttl = t.current_ttl(&id).unwrap();
ts.advance_time(ttl + Duration::from_secs(1));
assert!(!t.is_broken(&id), "flag expired before re-detection");
t.record(id, BrokenInvariant::NonIdempotent);
expected = (expected * 2).min(BROKEN_INVARIANT_TTL_CAP);
assert_eq!(
t.current_ttl(&id),
Some(expected),
"re-detection at cycle {cycle} must double the TTL (capped)"
);
}
assert_eq!(t.current_ttl(&id), Some(BROKEN_INVARIANT_TTL_CAP));
}
#[test]
fn quiet_window_resets_escalation_to_baseline() {
let (t, ts) = mk_tracker();
let id = fake_id(22);
t.record(id, BrokenInvariant::NonIdempotent);
ts.advance_time(BROKEN_INVARIANT_TTL + Duration::from_secs(1));
t.record(id, BrokenInvariant::NonIdempotent);
assert_eq!(t.current_ttl(&id), Some(BROKEN_INVARIANT_TTL * 2));
let ttl = t.current_ttl(&id).unwrap();
ts.advance_time(ttl * 2 + Duration::from_secs(1));
t.record(id, BrokenInvariant::NonIdempotent);
assert_eq!(
t.current_ttl(&id),
Some(BROKEN_INVARIANT_TTL),
"a re-detection after a full quiet window resets to baseline"
);
}
#[test]
fn single_false_positive_never_escalates() {
let (t, ts) = mk_tracker();
let id = fake_id(23);
t.record(id, BrokenInvariant::NonIdempotent);
assert_eq!(t.current_ttl(&id), Some(BROKEN_INVARIANT_TTL));
ts.advance_time(BROKEN_INVARIANT_TTL + Duration::from_secs(1));
assert!(!t.is_broken(&id), "false positive self-heals after one TTL");
assert_eq!(
t.current_ttl(&id),
Some(BROKEN_INVARIANT_TTL),
"no re-detection means no escalation"
);
ts.advance_time(BROKEN_INVARIANT_TTL);
t.cleanup();
assert!(
t.current_ttl(&id).is_none(),
"reclaimed after the reset boundary"
);
}
#[test]
fn oscillator_escalation_collapses_duty_cycle() {
let (t, ts) = mk_tracker();
let id = fake_id(24);
let redetect_delay = Duration::from_secs(10);
t.record(id, BrokenInvariant::NonIdempotent);
let mut total_dark = Duration::ZERO;
let mut total_active = Duration::ZERO;
let mut total_emission = 0.0_f64;
for _ in 0..20 {
let ttl = t.current_ttl(&id).unwrap();
assert!(
t.is_broken(&id),
"suppressed at the start of the dark window"
);
total_dark += ttl;
ts.advance_time(ttl);
assert!(
!t.is_broken(&id),
"flag expired at the end of the dark window"
);
let cycle_len = ttl + redetect_delay;
let active_fraction = redetect_delay.as_secs_f64() / cycle_len.as_secs_f64();
total_emission += ttl.as_secs_f64() * active_fraction;
total_active += redetect_delay;
ts.advance_time(redetect_delay);
t.record(id, BrokenInvariant::NonIdempotent);
}
let dark_fraction =
total_dark.as_secs_f64() / (total_dark.as_secs_f64() + total_active.as_secs_f64());
assert!(
dark_fraction > 0.98,
"dark fraction must converge toward 1 (duty cycle collapses), got {dark_fraction}"
);
assert_eq!(t.current_ttl(&id), Some(BROKEN_INVARIANT_TTL_CAP));
let horizon = total_dark + total_active;
let baseline_cycle = BROKEN_INVARIANT_TTL + redetect_delay;
let baseline_cycles = horizon.as_secs_f64() / baseline_cycle.as_secs_f64();
let baseline_active_fraction = redetect_delay.as_secs_f64() / baseline_cycle.as_secs_f64();
let baseline_emission =
baseline_cycles * BROKEN_INVARIANT_TTL.as_secs_f64() * baseline_active_fraction;
assert!(
total_emission < baseline_emission / 5.0,
"escalation must slash cumulative re-entry cascade emission vs the \
fixed-TTL baseline (escalated {total_emission:.1}, baseline {baseline_emission:.1})"
);
}
}