dig_peer_protocol/rate_limit.rs
1//! Outbound rate limiting for [`DigLink`], keyed by raw `u8` opcode.
2//!
3//! Chia's own `RateLimiter` is keyed by `ProtocolMessageTypes`, an enum that cannot represent a
4//! DIG opcode — which is the same closed-namespace problem that forced the vendored fork in the
5//! first place. So the link carries its own limiter keyed by `u8`.
6//!
7//! It does **not** carry its own limit *table*: the numbers are lifted from Chia's
8//! `V2_RATE_LIMITS` at construction by re-keying each entry to its wire byte. Copying the tables
9//! would have created a second set of numbers to drift; deriving them means a Chia opcode is
10//! rate-limited exactly as a stock peer would rate-limit it, forever.
11//!
12//! ## Lockstep pin (do not relax)
13//!
14//! Deriving buys correctness at the price of one coupling: `V2_RATE_LIMITS` comes from
15//! `chia-sdk-client` and is keyed by `chia_protocol::ProtocolMessageTypes`, so the two crates
16//! MUST resolve to a single version of that enum. If they ever diverge, `rekey` would key the
17//! table by the *other* crate's discriminants and every Chia opcode would silently fall to
18//! `default_settings` — a loosening, with no compile error. Bump `chia-protocol` and
19//! `chia-sdk-client` together, and never pin them independently.
20//!
21//! [`DigLink`]: crate::DigLink
22
23use std::{
24 collections::HashMap,
25 time::{SystemTime, UNIX_EPOCH},
26};
27
28use chia_sdk_client::{RateLimit, RateLimits, V2_RATE_LIMITS};
29use chia_traits::Streamable;
30
31use crate::DigMessage;
32
33/// Chia's `V2_RATE_LIMITS`, re-keyed from `ProtocolMessageTypes` to the wire byte.
34///
35/// DIG opcodes are absent by construction and therefore fall to `default_settings`, which is
36/// what Chia itself applies to any message it has no specific entry for.
37#[derive(Debug, Clone)]
38pub struct OpcodeRateLimits {
39 default_settings: RateLimit,
40 non_tx_frequency: f64,
41 non_tx_max_total_size: f64,
42 tx: HashMap<u8, RateLimit>,
43 other: HashMap<u8, RateLimit>,
44}
45
46impl OpcodeRateLimits {
47 /// Re-key a Chia limit table onto raw opcodes.
48 fn from_chia(limits: &RateLimits) -> Self {
49 // `ProtocolMessageTypes` is a streamable single-byte enum, so its encoding IS its wire
50 // opcode — the same identity `DigMessage` relies on.
51 let rekey = |map: &HashMap<chia_protocol::ProtocolMessageTypes, RateLimit>| {
52 map.iter()
53 .filter_map(|(msg_type, limit)| Some((*msg_type.to_bytes().ok()?.first()?, *limit)))
54 .collect()
55 };
56
57 Self {
58 default_settings: limits.default_settings,
59 non_tx_frequency: limits.non_tx_frequency,
60 non_tx_max_total_size: limits.non_tx_max_total_size,
61 tx: rekey(&limits.tx),
62 other: rekey(&limits.other),
63 }
64 }
65}
66
67impl Default for OpcodeRateLimits {
68 fn default() -> Self {
69 Self::from_chia(&V2_RATE_LIMITS)
70 }
71}
72
73/// The verdict on one outbound message.
74///
75/// Refusal is split in two because the two halves demand opposite caller behaviour: one is
76/// worth waiting out, the other is a permanent error.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum Admission {
79 /// May be sent now; its cost has been charged to the current window.
80 Admitted,
81 /// Refused for now, but a later window could admit it — the budget it exhausted resets.
82 Deferred,
83 /// Refused in every window: the message exceeds a per-message or whole-window bound, so
84 /// waiting can never help.
85 Unsendable,
86}
87
88/// A sliding-window outbound limiter over [`OpcodeRateLimits`].
89///
90/// Mirrors Chia's algorithm: per-period per-opcode count and cumulative size, plus an aggregate
91/// budget for everything that is not a transaction message.
92#[derive(Debug, Clone)]
93pub struct OpcodeRateLimiter {
94 reset_seconds: u64,
95 period: u64,
96 limit_factor: f64,
97 counts: HashMap<u8, f64>,
98 cumulative_sizes: HashMap<u8, f64>,
99 non_tx_count: f64,
100 non_tx_size: f64,
101 limits: OpcodeRateLimits,
102}
103
104impl OpcodeRateLimiter {
105 /// A limiter over `limits`, resetting its window every `reset_seconds`.
106 ///
107 /// `limit_factor` scales every budget, so a peer can be given a fraction of the nominal
108 /// allowance (Chia's clients default to `0.6`).
109 #[must_use]
110 pub fn new(reset_seconds: u64, limit_factor: f64, limits: OpcodeRateLimits) -> Self {
111 Self {
112 reset_seconds,
113 period: now_seconds() / reset_seconds,
114 limit_factor,
115 counts: HashMap::new(),
116 cumulative_sizes: HashMap::new(),
117 non_tx_count: 0.0,
118 non_tx_size: 0.0,
119 limits,
120 }
121 }
122
123 /// Whether `message` may be sent now, charging it against the budget when it may.
124 ///
125 /// A refused message is NOT charged, so a caller that backs off and retries is not
126 /// permanently penalised for having asked early.
127 ///
128 /// Prefer [`Self::admit`] where the caller intends to retry: `true`/`false` cannot say
129 /// whether waiting could ever help.
130 pub fn allow(&mut self, message: &DigMessage) -> bool {
131 self.admit(message) == Admission::Admitted
132 }
133
134 /// Whether `message` may be sent now — and, when it may not, whether waiting could help.
135 ///
136 /// The distinction is what keeps a caller from spinning forever: a *frequency* or
137 /// *cumulative* budget clears on the next window roll, but a message larger than the
138 /// per-message cap (or than a whole window's budget) is refused identically in every window
139 /// that will ever exist. Only [`Admission::Deferred`] is worth retrying.
140 pub fn admit(&mut self, message: &DigMessage) -> Admission {
141 self.roll_window();
142
143 let size = f64::from(u32::try_from(message.data.len()).unwrap_or(u32::MAX));
144 let opcode = message.msg_type;
145
146 let mut limit = self.limits.default_settings;
147 let mut counts_against_non_tx = false;
148 if let Some(tx_limit) = self.limits.tx.get(&opcode) {
149 limit = *tx_limit;
150 } else if let Some(other_limit) = self.limits.other.get(&opcode) {
151 limit = *other_limit;
152 counts_against_non_tx = true;
153 }
154
155 let max_total = limit
156 .max_total_size
157 .unwrap_or(limit.frequency * limit.max_size);
158
159 // Measured against an EMPTY window, so it isolates the budgets a window roll cannot
160 // clear. A message failing here is unsendable on this link, permanently.
161 let fits_an_empty_window = size <= limit.max_size
162 && size <= max_total * self.limit_factor
163 && 1.0 <= limit.frequency * self.limit_factor
164 && (!counts_against_non_tx
165 || (1.0 <= self.limits.non_tx_frequency * self.limit_factor
166 && size <= self.limits.non_tx_max_total_size * self.limit_factor));
167 if !fits_an_empty_window {
168 return Admission::Unsendable;
169 }
170
171 let new_count = self.counts.get(&opcode).unwrap_or(&0.0) + 1.0;
172 let new_cumulative = self.cumulative_sizes.get(&opcode).unwrap_or(&0.0) + size;
173 let (new_non_tx_count, new_non_tx_size) = if counts_against_non_tx {
174 (self.non_tx_count + 1.0, self.non_tx_size + size)
175 } else {
176 (self.non_tx_count, self.non_tx_size)
177 };
178
179 let allowed = new_non_tx_count <= self.limits.non_tx_frequency * self.limit_factor
180 && new_non_tx_size <= self.limits.non_tx_max_total_size * self.limit_factor
181 && new_count <= limit.frequency * self.limit_factor
182 && new_cumulative <= max_total * self.limit_factor;
183
184 if !allowed {
185 return Admission::Deferred;
186 }
187
188 self.counts.insert(opcode, new_count);
189 self.cumulative_sizes.insert(opcode, new_cumulative);
190 self.non_tx_count = new_non_tx_count;
191 self.non_tx_size = new_non_tx_size;
192 Admission::Admitted
193 }
194
195 /// Clear the accumulated budget when the wall clock crosses into a new window.
196 fn roll_window(&mut self) {
197 let period = now_seconds() / self.reset_seconds;
198 if self.period == period {
199 return;
200 }
201 self.period = period;
202 self.counts.clear();
203 self.cumulative_sizes.clear();
204 self.non_tx_count = 0.0;
205 self.non_tx_size = 0.0;
206 }
207}
208
209fn now_seconds() -> u64 {
210 SystemTime::now()
211 .duration_since(UNIX_EPOCH)
212 .expect("system clock is before the unix epoch")
213 .as_secs()
214}
215
216#[cfg(test)]
217mod tests {
218 use super::{Admission, OpcodeRateLimiter, OpcodeRateLimits};
219 use crate::{Bytes, DigMessage, DIG_MESSAGE};
220 use chia_protocol::ProtocolMessageTypes;
221 use chia_traits::Streamable;
222
223 fn message(opcode: u8, payload_len: usize) -> DigMessage {
224 DigMessage::new(opcode, None, Bytes::new(vec![0u8; payload_len]))
225 }
226
227 /// The table is DERIVED, not copied: a Chia opcode with a specific entry upstream must have
228 /// that same entry here, under its wire byte. `Handshake` is checked because it has a much
229 /// tighter frequency than `default_settings`, so a re-key that silently produced an empty
230 /// map would let far more through and fail this test.
231 #[test]
232 fn chia_opcodes_keep_their_upstream_limits() {
233 let limits = OpcodeRateLimits::default();
234 let handshake = *ProtocolMessageTypes::Handshake
235 .to_bytes()
236 .expect("encode")
237 .first()
238 .expect("one byte");
239
240 let upstream = chia_sdk_client::V2_RATE_LIMITS
241 .other
242 .get(&ProtocolMessageTypes::Handshake)
243 .expect("upstream defines a handshake limit");
244 let ours = limits
245 .other
246 .get(&handshake)
247 .expect("re-keyed table kept the handshake limit");
248
249 assert_eq!(ours.frequency, upstream.frequency);
250 assert_eq!(ours.max_size, upstream.max_size);
251 }
252
253 /// A DIG opcode has no upstream entry, so it is governed by `default_settings` — it is
254 /// neither blocked outright nor unlimited. Sending one message must pass.
255 #[test]
256 fn dig_opcodes_fall_back_to_the_default_budget() {
257 let mut limiter = OpcodeRateLimiter::new(60, 1.0, OpcodeRateLimits::default());
258 assert!(limiter.allow(&message(DIG_MESSAGE, 16)));
259 }
260
261 /// The frequency budget is pinned from BOTH sides: exactly `frequency` messages pass and
262 /// the next one is refused. A limiter that never refused would pass a one-sided test.
263 #[test]
264 fn frequency_budget_admits_up_to_the_bound_and_refuses_past_it() {
265 let limits = OpcodeRateLimits::default();
266 let allowance = limits.default_settings.frequency as usize;
267 let mut limiter = OpcodeRateLimiter::new(60, 1.0, limits);
268
269 for i in 0..allowance {
270 assert!(
271 limiter.allow(&message(DIG_MESSAGE, 1)),
272 "message {i} refused below the bound"
273 );
274 }
275 assert!(
276 !limiter.allow(&message(DIG_MESSAGE, 1)),
277 "one message over the bound was admitted"
278 );
279 }
280
281 /// The two refusals are distinguishable, which is the whole point of [`Admission`]: one
282 /// clears on the next window, the other never does.
283 ///
284 /// Both cases are driven on the SAME opcode and the same limiter shape, so the only thing
285 /// separating them is which budget was exceeded — an implementation that collapsed them into
286 /// a single "refused" verdict could not pass both halves.
287 #[test]
288 fn a_deferrable_refusal_is_distinguished_from_a_permanent_one() {
289 let limits = OpcodeRateLimits::default();
290 let allowance = limits.default_settings.frequency as usize;
291 let max_size = limits.default_settings.max_size as usize;
292
293 let mut exhausted = OpcodeRateLimiter::new(60, 1.0, limits);
294 for _ in 0..allowance {
295 assert_eq!(
296 exhausted.admit(&message(DIG_MESSAGE, 1)),
297 Admission::Admitted
298 );
299 }
300 assert_eq!(
301 exhausted.admit(&message(DIG_MESSAGE, 1)),
302 Admission::Deferred,
303 "an exhausted frequency budget resets on the next window, so waiting can help"
304 );
305
306 let mut fresh = OpcodeRateLimiter::new(60, 1.0, OpcodeRateLimits::default());
307 assert_eq!(
308 fresh.admit(&message(DIG_MESSAGE, max_size + 1)),
309 Admission::Unsendable,
310 "an oversized message is refused identically in every window"
311 );
312 }
313
314 /// An oversized single message is refused on size alone — and the at-bound message is
315 /// admitted, so the cap is pinned from both sides.
316 ///
317 /// Each case gets a FRESH limiter on purpose: reusing one would let the accumulated
318 /// cumulative-size budget refuse the second message, which would make the test pass for a
319 /// reason that has nothing to do with the per-message size cap.
320 #[test]
321 fn size_cap_is_pinned_from_both_sides() {
322 let max_size = OpcodeRateLimits::default().default_settings.max_size as usize;
323
324 let mut at_bound = OpcodeRateLimiter::new(60, 1.0, OpcodeRateLimits::default());
325 assert!(at_bound.allow(&message(DIG_MESSAGE, max_size)));
326
327 let mut over_bound = OpcodeRateLimiter::new(60, 1.0, OpcodeRateLimits::default());
328 assert!(!over_bound.allow(&message(DIG_MESSAGE, max_size + 1)));
329 }
330
331 /// The re-keyed table is pinned to ABSOLUTE values, opcode byte by opcode byte.
332 ///
333 /// This is the test the module header's lockstep warning demands. `V2_RATE_LIMITS` comes from
334 /// `chia-sdk-client` keyed by `chia_protocol::ProtocolMessageTypes`; `rekey` derives each
335 /// opcode byte by *streaming that enum*. If the two crates ever resolve different versions of
336 /// it, the derived bytes shift, every Chia opcode misses its entry and falls to
337 /// `default_settings` — a large LOOSENING, with no compile error and no panic. A silently
338 /// permissive rate limiter is a DoS surface.
339 ///
340 /// A test comparing this table against `V2_RATE_LIMITS` cannot see that: it would ask the
341 /// same possibly-shifted enum for the key and agree with itself. So the expectations below
342 /// are literals — the opcode byte and both limit numbers, transcribed from the upstream table
343 /// and independent of any enum this crate can resolve.
344 ///
345 /// The chosen opcodes discriminate against the specific failure: `Handshake` (1) sits in
346 /// `other` with an entry FAR tighter than `default_settings` on both axes, so a
347 /// fall-to-default shows up as a wrong number rather than a missing key; `NewTransaction`
348 /// (21) and `TransactionAck` (49) sit in `tx`, so a re-key that dropped one map while
349 /// keeping the other still fails here.
350 #[test]
351 fn the_rekeyed_table_pins_upstream_limits_at_absolute_values() {
352 let limits = OpcodeRateLimits::default();
353
354 // (opcode byte, which map, frequency, max_size)
355 let handshake = limits
356 .other
357 .get(&1)
358 .expect("opcode 1 (Handshake) kept its entry");
359 assert_eq!(handshake.frequency, 5.0, "Handshake frequency");
360 assert_eq!(handshake.max_size, 10.0 * 1024.0, "Handshake max_size");
361
362 let tx_ack = limits
363 .tx
364 .get(&49)
365 .expect("opcode 49 (TransactionAck) kept its tx entry");
366 assert_eq!(tx_ack.frequency, 5000.0, "TransactionAck frequency");
367 assert_eq!(tx_ack.max_size, 2048.0, "TransactionAck max_size");
368
369 let new_tx = limits
370 .tx
371 .get(&21)
372 .expect("opcode 21 (NewTransaction) kept its tx entry");
373 assert_eq!(new_tx.frequency, 5000.0, "NewTransaction frequency");
374 assert_eq!(new_tx.max_size, 100.0, "NewTransaction max_size");
375
376 // The aggregate budgets are part of the same table and equally silent if lost.
377 assert_eq!(limits.non_tx_frequency, 1000.0);
378 assert_eq!(limits.non_tx_max_total_size, 100.0 * 1024.0 * 1024.0);
379 assert_eq!(limits.default_settings.frequency, 100.0);
380 assert_eq!(limits.default_settings.max_size, 1024.0 * 1024.0);
381 }
382
383 /// A pinned entry must be TIGHTER than `default_settings`, or the test above could pass on a
384 /// table that had silently collapsed to the default everywhere.
385 ///
386 /// This is the guard against the exact vacuity the module header warns about: it names the
387 /// property ("losing an entry is a loosening") rather than restating a number, so it stays
388 /// meaningful even if upstream retunes the values.
389 #[test]
390 fn falling_back_to_the_default_would_be_a_detectable_loosening() {
391 let limits = OpcodeRateLimits::default();
392 let handshake = limits
393 .other
394 .get(&1)
395 .expect("opcode 1 (Handshake) kept its entry");
396
397 assert!(
398 handshake.frequency < limits.default_settings.frequency,
399 "Handshake ({}) is not tighter than default ({}) -- the pin above can no longer distinguish a re-keyed table from a collapsed one",
400 handshake.frequency,
401 limits.default_settings.frequency
402 );
403 assert!(
404 handshake.max_size < limits.default_settings.max_size,
405 "Handshake max_size is not tighter than default"
406 );
407 }
408
409 /// The table must retain a REALISTIC number of entries. An emptied `other` map would still
410 /// satisfy a test that only inspected keys it happens to look up, if those lookups were
411 /// themselves derived from the same shifted enum.
412 #[test]
413 fn the_rekeyed_table_retains_the_bulk_of_the_upstream_entries() {
414 let limits = OpcodeRateLimits::default();
415 assert!(
416 limits.other.len() >= 30,
417 "other map holds only {} entries -- the re-key lost most of the table",
418 limits.other.len()
419 );
420 assert!(
421 limits.tx.len() >= 5,
422 "tx map holds only {} entries -- the re-key lost most of the table",
423 limits.tx.len()
424 );
425 // Every key must be a real wire byte; a shifted enum would produce values outside the
426 // chia band, which is a direct signal of the version split.
427 for opcode in limits.other.keys().chain(limits.tx.keys()) {
428 assert!(
429 *opcode < 200,
430 "opcode {opcode} is outside the chia band -- the re-key is keying off a different ProtocolMessageTypes than the wire uses"
431 );
432 }
433 }
434}