Skip to main content

decdn_node/
content_deny.rs

1//! The node's live ORIGIN deny-set (ADR 011 §Local Denylist, §On Blacklist
2//! Event) — which operator addresses this node refuses to accept payment from.
3//!
4//! Neither hash half of the denylist lives here — local and governance alike are
5//! held by `CacheEngine` beside the eviction set, because "will this node
6//! serve/announce/acquire this hash" has five consumers (serve, probe hold, DHT
7//! republish, populate, and the per-MB in-flight re-check) and ADR 011 requires
8//! one answer for all of them. See `CacheEngine::refuses`. Addresses have two —
9//! the open-time delivery gate and that same in-flight re-check — and the cache
10//! knows nothing about them, so they stay here.
11//!
12//! Two sources feed it and they are deliberately kept in separate slots:
13//!
14//! - **Local**, from `[content] denied_origins`. Operator-set, local-only, and
15//!   applied on the next reload without a restart. ADR 011 §One-hour removal
16//!   orders makes this the only mechanism sized to a sub-day statutory deadline,
17//!   because it is the only one entirely within the order recipient's control.
18//! - **On-chain**, from `ContentBlacklist`'s `OriginBlacklistUpdated` AND
19//!   `OperatorBlacklisted` — two separate on-chain mappings the node unions.
20//!   Watching only the first would leave the primary governance path
21//!   (`addOperator`, which also ejects from `CapacityBond`) unenforced at the
22//!   delivery gate.
23//!
24//! Separate slots because their lifecycles are independent — a config reload
25//! must not clobber what the chain watcher learned, and vice versa — but
26//! [`ContentDenylist::is_origin_denied`] unions them, and the wire refusal that
27//! results does not say which matched. That is a requirement, not an omission: ADR 011 §`StreamRequest`
28//! Response specifies the response must not distinguish governance from local
29//! sources. A client able to tell them apart could map an operator's private
30//! legal exposure by probing.
31//!
32//! Reads land on the request hot path, so each slot is an [`ArcSwap`] — the same
33//! shape `CacheEngine` uses for its pinned set. A lookup is one atomic load and
34//! a hash-set probe; a swap never blocks a reader.
35
36use std::collections::HashSet;
37use std::sync::Arc;
38
39use alloy::primitives::Address;
40use arc_swap::ArcSwap;
41use decdn_common::config::ResolvedContent;
42
43/// The union of the operator's local denylist and the on-chain origin
44/// blacklist, as consulted by the delivery path.
45#[derive(Debug)]
46pub struct ContentDenylist {
47    /// `[content] denied_origins`.
48    local_origins: ArcSwap<HashSet<Address>>,
49    /// Addresses governance has blacklisted at the origin OR operator level —
50    /// the union of `ContentBlacklist`'s two mappings.
51    chain_origins: ArcSwap<HashSet<Address>>,
52}
53
54impl ContentDenylist {
55    /// Build from resolved config, with an empty on-chain set — the watcher
56    /// fills that in once its bootstrap enumeration completes (#1504).
57    #[must_use]
58    pub fn new(content: &ResolvedContent) -> Self {
59        Self {
60            local_origins: ArcSwap::from(Arc::new(content.denied_origins.clone())),
61            chain_origins: ArcSwap::from(Arc::new(HashSet::new())),
62        }
63    }
64
65    /// An empty deny-set, for tests and for wiring paths with no `[content]`
66    /// section resolved yet.
67    #[must_use]
68    pub fn empty() -> Self {
69        Self::new(&ResolvedContent::default())
70    }
71
72    /// Is this address denied as a channel funder, by either source?
73    #[must_use]
74    pub fn is_origin_denied(&self, addr: &Address) -> bool {
75        self.local_origins.load().contains(addr) || self.chain_origins.load().contains(addr)
76    }
77
78    /// Swap in the origin half of a freshly resolved `[content]` section,
79    /// returning the new entry count for the reload log line.
80    ///
81    /// Touches only the local slot — the on-chain set is the watcher's, and the
82    /// hash half is the cache engine's.
83    pub fn set_local_origins(&self, content: &ResolvedContent) -> usize {
84        let origin_count = content.denied_origins.len();
85        self.local_origins
86            .store(Arc::new(content.denied_origins.clone()));
87        origin_count
88    }
89
90    /// Replace the on-chain origin set wholesale — the watcher's boot seed from
91    /// the enumerated address union, and its periodic re-enumeration refresh.
92    ///
93    /// The union is the origin ∪ operator deny set read from
94    /// `ContentBlacklist.blacklistedAddresses`, liveness-filtered by
95    /// `isOriginBlacklisted || isOperatorBlacklisted` (see
96    /// `decdn-node::blacklist_watcher`). It is rebuilt from chain each boot, so
97    /// there is no durable projection to reload.
98    pub fn set_chain_origins(&self, origins: HashSet<Address>) {
99        self.chain_origins.store(Arc::new(origins));
100    }
101
102    /// Apply a single `OriginBlacklistUpdated` event.
103    ///
104    /// Read-modify-write rather than an in-place mutation, because [`ArcSwap`]
105    /// has no such thing. Origin events are rare (a governance action), so the
106    /// clone is irrelevant next to keeping reads lock-free; a `RwLock` would
107    /// trade that for a write that is still rare. Returns whether the set
108    /// actually changed, so callers can skip logging a no-op replay — the
109    /// watcher re-scans a block range after a restart and will re-deliver
110    /// events it has already applied.
111    pub fn apply_chain_origin(&self, addr: Address, blacklisted: bool) -> bool {
112        let current = self.chain_origins.load();
113        if current.contains(&addr) == blacklisted {
114            return false;
115        }
116        let mut next = HashSet::clone(&current);
117        if blacklisted {
118            next.insert(addr);
119        } else {
120            next.remove(&addr);
121        }
122        self.chain_origins.store(Arc::new(next));
123        true
124    }
125
126    /// Number of on-chain blacklisted origins currently tracked. For telemetry
127    /// and the admin surface.
128    #[must_use]
129    pub fn chain_origin_count(&self) -> usize {
130        self.chain_origins.load().len()
131    }
132}
133
134impl Default for ContentDenylist {
135    fn default() -> Self {
136        Self::empty()
137    }
138}
139
140#[cfg(test)]
141#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
142mod tests;