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
46/// Re-key any Chia limit table onto raw opcodes.
47///
48/// The numbers remain DERIVED — a caller chooses the *source table*, never the individual limits —
49/// so the drift this type exists to prevent stays prevented. A table assembled from
50/// `V2_RATE_LIMITS` (retuned, extended, or narrowed for a test) is exactly as trustworthy as the
51/// default.
52///
53/// The module header's lockstep pin applies undiminished, and a caller-supplied table is the one
54/// way to violate it from outside this crate: the keys are `chia_protocol::ProtocolMessageTypes`
55/// values streamed to their wire byte, so a table keyed by a *different* `chia_protocol` version's
56/// enum re-keys to shifted bytes, every Chia opcode misses its entry and falls to
57/// `default_settings` — a silent loosening with no compile error. Build the table with the
58/// `chia_protocol` this crate resolves; re-export it from here (`crate::RateLimits`) rather than
59/// depending on `chia-sdk-client` independently.
60impl From<&RateLimits> for OpcodeRateLimits {
61 fn from(limits: &RateLimits) -> Self {
62 // `ProtocolMessageTypes` is a streamable single-byte enum, so its encoding IS its wire
63 // opcode — the same identity `DigMessage` relies on.
64 let rekey = |map: &HashMap<chia_protocol::ProtocolMessageTypes, RateLimit>| {
65 map.iter()
66 .filter_map(|(msg_type, limit)| Some((*msg_type.to_bytes().ok()?.first()?, *limit)))
67 .collect()
68 };
69
70 Self {
71 default_settings: limits.default_settings,
72 non_tx_frequency: limits.non_tx_frequency,
73 non_tx_max_total_size: limits.non_tx_max_total_size,
74 tx: rekey(&limits.tx),
75 other: rekey(&limits.other),
76 }
77 }
78}
79
80impl Default for OpcodeRateLimits {
81 fn default() -> Self {
82 Self::from(&*V2_RATE_LIMITS)
83 }
84}
85
86/// The verdict on one outbound message.
87///
88/// Refusal is split in two because the two halves demand opposite caller behaviour: one is
89/// worth waiting out, the other is a permanent error.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum Admission {
92 /// May be sent now; its cost has been charged to the current window.
93 Admitted,
94 /// Refused for now, but a later window could admit it — the budget it exhausted resets.
95 Deferred,
96 /// Refused in every window: the message exceeds a per-message or whole-window bound, so
97 /// waiting can never help.
98 Unsendable,
99}
100
101/// A sliding-window outbound limiter over [`OpcodeRateLimits`].
102///
103/// Mirrors Chia's algorithm: per-period per-opcode count and cumulative size, plus an aggregate
104/// budget for everything that is not a transaction message.
105#[derive(Debug, Clone)]
106pub struct OpcodeRateLimiter {
107 reset_seconds: u64,
108 period: u64,
109 limit_factor: f64,
110 counts: HashMap<u8, f64>,
111 cumulative_sizes: HashMap<u8, f64>,
112 non_tx_count: f64,
113 non_tx_size: f64,
114 limits: OpcodeRateLimits,
115}
116
117impl OpcodeRateLimiter {
118 /// A limiter over `limits`, resetting its window every `reset_seconds`.
119 ///
120 /// `limit_factor` scales every budget, so a peer can be given a fraction of the nominal
121 /// allowance (Chia's clients default to `0.6`).
122 #[must_use]
123 pub fn new(reset_seconds: u64, limit_factor: f64, limits: OpcodeRateLimits) -> Self {
124 Self {
125 reset_seconds,
126 period: now_seconds() / reset_seconds,
127 limit_factor,
128 counts: HashMap::new(),
129 cumulative_sizes: HashMap::new(),
130 non_tx_count: 0.0,
131 non_tx_size: 0.0,
132 limits,
133 }
134 }
135
136 /// Whether `message` may be sent now, charging it against the budget when it may.
137 ///
138 /// A refused message is NOT charged, so a caller that backs off and retries is not
139 /// permanently penalised for having asked early.
140 ///
141 /// Prefer [`Self::admit`] where the caller intends to retry: `true`/`false` cannot say
142 /// whether waiting could ever help.
143 pub fn allow(&mut self, message: &DigMessage) -> bool {
144 self.admit(message) == Admission::Admitted
145 }
146
147 /// Whether `message` may be sent now — and, when it may not, whether waiting could help.
148 ///
149 /// The distinction is what keeps a caller from spinning forever: a *frequency* or
150 /// *cumulative* budget clears on the next window roll, but a message larger than the
151 /// per-message cap (or than a whole window's budget) is refused identically in every window
152 /// that will ever exist. Only [`Admission::Deferred`] is worth retrying.
153 pub fn admit(&mut self, message: &DigMessage) -> Admission {
154 self.roll_window();
155
156 let size = f64::from(u32::try_from(message.data.len()).unwrap_or(u32::MAX));
157 let opcode = message.msg_type;
158
159 let mut limit = self.limits.default_settings;
160 let mut counts_against_non_tx = false;
161 if let Some(tx_limit) = self.limits.tx.get(&opcode) {
162 limit = *tx_limit;
163 } else if let Some(other_limit) = self.limits.other.get(&opcode) {
164 limit = *other_limit;
165 counts_against_non_tx = true;
166 }
167
168 let max_total = limit
169 .max_total_size
170 .unwrap_or(limit.frequency * limit.max_size);
171
172 // Measured against an EMPTY window, so it isolates the budgets a window roll cannot
173 // clear. A message failing here is unsendable on this link, permanently.
174 let fits_an_empty_window = size <= limit.max_size
175 && size <= max_total * self.limit_factor
176 && 1.0 <= limit.frequency * self.limit_factor
177 && (!counts_against_non_tx
178 || (1.0 <= self.limits.non_tx_frequency * self.limit_factor
179 && size <= self.limits.non_tx_max_total_size * self.limit_factor));
180 if !fits_an_empty_window {
181 return Admission::Unsendable;
182 }
183
184 let new_count = self.counts.get(&opcode).unwrap_or(&0.0) + 1.0;
185 let new_cumulative = self.cumulative_sizes.get(&opcode).unwrap_or(&0.0) + size;
186 let (new_non_tx_count, new_non_tx_size) = if counts_against_non_tx {
187 (self.non_tx_count + 1.0, self.non_tx_size + size)
188 } else {
189 (self.non_tx_count, self.non_tx_size)
190 };
191
192 let allowed = new_non_tx_count <= self.limits.non_tx_frequency * self.limit_factor
193 && new_non_tx_size <= self.limits.non_tx_max_total_size * self.limit_factor
194 && new_count <= limit.frequency * self.limit_factor
195 && new_cumulative <= max_total * self.limit_factor;
196
197 if !allowed {
198 return Admission::Deferred;
199 }
200
201 self.counts.insert(opcode, new_count);
202 self.cumulative_sizes.insert(opcode, new_cumulative);
203 self.non_tx_count = new_non_tx_count;
204 self.non_tx_size = new_non_tx_size;
205 Admission::Admitted
206 }
207
208 /// Clear the accumulated budget when the wall clock crosses into a new window.
209 fn roll_window(&mut self) {
210 let period = now_seconds() / self.reset_seconds;
211 if self.period == period {
212 return;
213 }
214 self.period = period;
215 self.counts.clear();
216 self.cumulative_sizes.clear();
217 self.non_tx_count = 0.0;
218 self.non_tx_size = 0.0;
219 }
220}
221
222fn now_seconds() -> u64 {
223 SystemTime::now()
224 .duration_since(UNIX_EPOCH)
225 .expect("system clock is before the unix epoch")
226 .as_secs()
227}
228
229#[cfg(test)]
230mod tests {
231 use super::{Admission, OpcodeRateLimiter, OpcodeRateLimits};
232 use crate::{Bytes, DigMessage, DIG_MESSAGE};
233 use chia_protocol::ProtocolMessageTypes;
234 use chia_sdk_client::{RateLimit, RateLimits, V2_RATE_LIMITS};
235 use chia_traits::Streamable;
236
237 fn message(opcode: u8, payload_len: usize) -> DigMessage {
238 DigMessage::new(opcode, None, Bytes::new(vec![0u8; payload_len]))
239 }
240
241 /// The wire byte `Handshake` streams to — the same derivation the re-key itself performs.
242 fn handshake_opcode() -> u8 {
243 *ProtocolMessageTypes::Handshake
244 .to_bytes()
245 .expect("encode")
246 .first()
247 .expect("one byte")
248 }
249
250 /// `V2_RATE_LIMITS` with `Handshake` retuned to admit only two messages per window.
251 ///
252 /// Two is chosen because upstream's own `Handshake` frequency is 5: a limiter built from this
253 /// table refuses a third message that a limiter built from the upstream table admits, so the
254 /// two are distinguishable by observation rather than by inspecting private fields.
255 fn handshake_capped_at_two() -> RateLimits {
256 let mut limits = V2_RATE_LIMITS.clone();
257 limits.other.insert(
258 ProtocolMessageTypes::Handshake,
259 RateLimit::new(2.0, 10.0 * 1024.0, None),
260 );
261 limits
262 }
263
264 /// Admit `count` handshakes of a size no cap can refuse, returning the verdict on each.
265 ///
266 /// The payload is deliberately tiny so the per-message and cumulative SIZE budgets can never
267 /// bind: the only budget that can produce a refusal is `frequency`, which is the axis the
268 /// custom table moves.
269 fn admit_handshakes(limits: OpcodeRateLimits, count: usize) -> Vec<Admission> {
270 let mut limiter = OpcodeRateLimiter::new(60, 1.0, limits);
271 (0..count)
272 .map(|_| limiter.admit(&message(handshake_opcode(), 16)))
273 .collect()
274 }
275
276 /// The table is DERIVED, not copied: a Chia opcode with a specific entry upstream must have
277 /// that same entry here, under its wire byte. `Handshake` is checked because it has a much
278 /// tighter frequency than `default_settings`, so a re-key that silently produced an empty
279 /// map would let far more through and fail this test.
280 #[test]
281 fn chia_opcodes_keep_their_upstream_limits() {
282 let limits = OpcodeRateLimits::default();
283 let handshake = *ProtocolMessageTypes::Handshake
284 .to_bytes()
285 .expect("encode")
286 .first()
287 .expect("one byte");
288
289 let upstream = chia_sdk_client::V2_RATE_LIMITS
290 .other
291 .get(&ProtocolMessageTypes::Handshake)
292 .expect("upstream defines a handshake limit");
293 let ours = limits
294 .other
295 .get(&handshake)
296 .expect("re-keyed table kept the handshake limit");
297
298 assert_eq!(ours.frequency, upstream.frequency);
299 assert_eq!(ours.max_size, upstream.max_size);
300 }
301
302 /// A caller-supplied table governs the limiter — the CUSTOM row is honoured, not upstream's.
303 ///
304 /// The conversion is observed through behaviour rather than through the derived fields, so it
305 /// stays honest about what a consumer can actually do with it: three handshakes are offered to
306 /// a limiter whose table caps them at two, and the third must be refused. `Deferred` rather
307 /// than merely "not admitted", because a frequency exhaustion is the refusal that a window
308 /// roll clears; an `Unsendable` here would mean the size fixture, not the custom row, did the
309 /// refusing.
310 #[test]
311 fn a_caller_supplied_table_governs_the_limiter() {
312 let verdicts = admit_handshakes(OpcodeRateLimits::from(&handshake_capped_at_two()), 3);
313
314 assert_eq!(
315 verdicts,
316 vec![
317 Admission::Admitted,
318 Admission::Admitted,
319 Admission::Deferred
320 ],
321 "the custom frequency of 2 did not govern"
322 );
323 }
324
325 /// `Default` is unchanged by the delegation: it still derives from `V2_RATE_LIMITS`.
326 ///
327 /// The probe is the message the custom table classifies DIFFERENTLY — the third handshake,
328 /// refused under a cap of two. Both the `Default`-built and the explicitly
329 /// `V2_RATE_LIMITS`-built limiter must admit it, which is a claim a `Default` accidentally
330 /// rerouted to some other table could not satisfy.
331 #[test]
332 fn default_still_derives_from_the_upstream_table() {
333 let via_default = admit_handshakes(OpcodeRateLimits::default(), 3);
334 let via_upstream = admit_handshakes(OpcodeRateLimits::from(&*V2_RATE_LIMITS), 3);
335
336 assert_eq!(
337 via_default, via_upstream,
338 "Default no longer agrees with the table it is documented to derive from"
339 );
340 assert_eq!(
341 via_default[2],
342 Admission::Admitted,
343 "upstream admits a third handshake (frequency 5); this probe cannot distinguish tables \
344 if it does not"
345 );
346 }
347
348 /// A DIG opcode has no upstream entry, so it is governed by `default_settings` — it is
349 /// neither blocked outright nor unlimited. Sending one message must pass.
350 #[test]
351 fn dig_opcodes_fall_back_to_the_default_budget() {
352 let mut limiter = OpcodeRateLimiter::new(60, 1.0, OpcodeRateLimits::default());
353 assert!(limiter.allow(&message(DIG_MESSAGE, 16)));
354 }
355
356 /// The frequency budget is pinned from BOTH sides: exactly `frequency` messages pass and
357 /// the next one is refused. A limiter that never refused would pass a one-sided test.
358 #[test]
359 fn frequency_budget_admits_up_to_the_bound_and_refuses_past_it() {
360 let limits = OpcodeRateLimits::default();
361 let allowance = limits.default_settings.frequency as usize;
362 let mut limiter = OpcodeRateLimiter::new(60, 1.0, limits);
363
364 for i in 0..allowance {
365 assert!(
366 limiter.allow(&message(DIG_MESSAGE, 1)),
367 "message {i} refused below the bound"
368 );
369 }
370 assert!(
371 !limiter.allow(&message(DIG_MESSAGE, 1)),
372 "one message over the bound was admitted"
373 );
374 }
375
376 /// The two refusals are distinguishable, which is the whole point of [`Admission`]: one
377 /// clears on the next window, the other never does.
378 ///
379 /// Both cases are driven on the SAME opcode and the same limiter shape, so the only thing
380 /// separating them is which budget was exceeded — an implementation that collapsed them into
381 /// a single "refused" verdict could not pass both halves.
382 #[test]
383 fn a_deferrable_refusal_is_distinguished_from_a_permanent_one() {
384 let limits = OpcodeRateLimits::default();
385 let allowance = limits.default_settings.frequency as usize;
386 let max_size = limits.default_settings.max_size as usize;
387
388 let mut exhausted = OpcodeRateLimiter::new(60, 1.0, limits);
389 for _ in 0..allowance {
390 assert_eq!(
391 exhausted.admit(&message(DIG_MESSAGE, 1)),
392 Admission::Admitted
393 );
394 }
395 assert_eq!(
396 exhausted.admit(&message(DIG_MESSAGE, 1)),
397 Admission::Deferred,
398 "an exhausted frequency budget resets on the next window, so waiting can help"
399 );
400
401 let mut fresh = OpcodeRateLimiter::new(60, 1.0, OpcodeRateLimits::default());
402 assert_eq!(
403 fresh.admit(&message(DIG_MESSAGE, max_size + 1)),
404 Admission::Unsendable,
405 "an oversized message is refused identically in every window"
406 );
407 }
408
409 /// An oversized single message is refused on size alone — and the at-bound message is
410 /// admitted, so the cap is pinned from both sides.
411 ///
412 /// Each case gets a FRESH limiter on purpose: reusing one would let the accumulated
413 /// cumulative-size budget refuse the second message, which would make the test pass for a
414 /// reason that has nothing to do with the per-message size cap.
415 #[test]
416 fn size_cap_is_pinned_from_both_sides() {
417 let max_size = OpcodeRateLimits::default().default_settings.max_size as usize;
418
419 let mut at_bound = OpcodeRateLimiter::new(60, 1.0, OpcodeRateLimits::default());
420 assert!(at_bound.allow(&message(DIG_MESSAGE, max_size)));
421
422 let mut over_bound = OpcodeRateLimiter::new(60, 1.0, OpcodeRateLimits::default());
423 assert!(!over_bound.allow(&message(DIG_MESSAGE, max_size + 1)));
424 }
425
426 /// The re-keyed table is pinned to ABSOLUTE values, opcode byte by opcode byte.
427 ///
428 /// This is the test the module header's lockstep warning demands. `V2_RATE_LIMITS` comes from
429 /// `chia-sdk-client` keyed by `chia_protocol::ProtocolMessageTypes`; `rekey` derives each
430 /// opcode byte by *streaming that enum*. If the two crates ever resolve different versions of
431 /// it, the derived bytes shift, every Chia opcode misses its entry and falls to
432 /// `default_settings` — a large LOOSENING, with no compile error and no panic. A silently
433 /// permissive rate limiter is a DoS surface.
434 ///
435 /// A test comparing this table against `V2_RATE_LIMITS` cannot see that: it would ask the
436 /// same possibly-shifted enum for the key and agree with itself. So the expectations below
437 /// are literals — the opcode byte and both limit numbers, transcribed from the upstream table
438 /// and independent of any enum this crate can resolve.
439 ///
440 /// The chosen opcodes discriminate against the specific failure: `Handshake` (1) sits in
441 /// `other` with an entry FAR tighter than `default_settings` on both axes, so a
442 /// fall-to-default shows up as a wrong number rather than a missing key; `NewTransaction`
443 /// (21) and `TransactionAck` (49) sit in `tx`, so a re-key that dropped one map while
444 /// keeping the other still fails here.
445 #[test]
446 fn the_rekeyed_table_pins_upstream_limits_at_absolute_values() {
447 let limits = OpcodeRateLimits::default();
448
449 // (opcode byte, which map, frequency, max_size)
450 let handshake = limits
451 .other
452 .get(&1)
453 .expect("opcode 1 (Handshake) kept its entry");
454 assert_eq!(handshake.frequency, 5.0, "Handshake frequency");
455 assert_eq!(handshake.max_size, 10.0 * 1024.0, "Handshake max_size");
456
457 let tx_ack = limits
458 .tx
459 .get(&49)
460 .expect("opcode 49 (TransactionAck) kept its tx entry");
461 assert_eq!(tx_ack.frequency, 5000.0, "TransactionAck frequency");
462 assert_eq!(tx_ack.max_size, 2048.0, "TransactionAck max_size");
463
464 let new_tx = limits
465 .tx
466 .get(&21)
467 .expect("opcode 21 (NewTransaction) kept its tx entry");
468 assert_eq!(new_tx.frequency, 5000.0, "NewTransaction frequency");
469 assert_eq!(new_tx.max_size, 100.0, "NewTransaction max_size");
470
471 // The aggregate budgets are part of the same table and equally silent if lost.
472 assert_eq!(limits.non_tx_frequency, 1000.0);
473 assert_eq!(limits.non_tx_max_total_size, 100.0 * 1024.0 * 1024.0);
474 assert_eq!(limits.default_settings.frequency, 100.0);
475 assert_eq!(limits.default_settings.max_size, 1024.0 * 1024.0);
476 }
477
478 /// A pinned entry must be TIGHTER than `default_settings`, or the test above could pass on a
479 /// table that had silently collapsed to the default everywhere.
480 ///
481 /// This is the guard against the exact vacuity the module header warns about: it names the
482 /// property ("losing an entry is a loosening") rather than restating a number, so it stays
483 /// meaningful even if upstream retunes the values.
484 #[test]
485 fn falling_back_to_the_default_would_be_a_detectable_loosening() {
486 let limits = OpcodeRateLimits::default();
487 let handshake = limits
488 .other
489 .get(&1)
490 .expect("opcode 1 (Handshake) kept its entry");
491
492 assert!(
493 handshake.frequency < limits.default_settings.frequency,
494 "Handshake ({}) is not tighter than default ({}) -- the pin above can no longer distinguish a re-keyed table from a collapsed one",
495 handshake.frequency,
496 limits.default_settings.frequency
497 );
498 assert!(
499 handshake.max_size < limits.default_settings.max_size,
500 "Handshake max_size is not tighter than default"
501 );
502 }
503
504 /// The table must retain a REALISTIC number of entries. An emptied `other` map would still
505 /// satisfy a test that only inspected keys it happens to look up, if those lookups were
506 /// themselves derived from the same shifted enum.
507 #[test]
508 fn the_rekeyed_table_retains_the_bulk_of_the_upstream_entries() {
509 let limits = OpcodeRateLimits::default();
510 assert!(
511 limits.other.len() >= 30,
512 "other map holds only {} entries -- the re-key lost most of the table",
513 limits.other.len()
514 );
515 assert!(
516 limits.tx.len() >= 5,
517 "tx map holds only {} entries -- the re-key lost most of the table",
518 limits.tx.len()
519 );
520 // Every key must be a real wire byte; a shifted enum would produce values outside the
521 // chia band, which is a direct signal of the version split.
522 for opcode in limits.other.keys().chain(limits.tx.keys()) {
523 assert!(
524 *opcode < 200,
525 "opcode {opcode} is outside the chia band -- the re-key is keying off a different ProtocolMessageTypes than the wire uses"
526 );
527 }
528 }
529}