dig_peer_protocol/rate_limit.rs
1//! Directional 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 message, in either [`Direction`].
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/// Which side of the link a limiter guards — and therefore whether a REFUSED message is charged.
102///
103/// The two directions need opposite accounting, and the difference is the anti-flood ratchet:
104///
105/// - [`Direction::Inbound`] charges a refusal, because the peer already spent our bandwidth
106/// delivering the frame. A peer whose frames are being rejected keeps burning the budget, so the
107/// window stays exhausted and the flood cannot run for free.
108/// - [`Direction::Outbound`] does not, because we chose not to send: a caller that backs off and
109/// retries must not be permanently penalised for having asked early.
110///
111/// This is an enum rather than upstream's positional `bool` deliberately. A bare `true` in
112/// `new(true, ..)` reads as nothing at the call site and can be dropped by a signature change with
113/// no reviewer noticing — which is exactly how the inbound rule went missing here once already.
114/// `Direction::Inbound` is checkable at a glance.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum Direction {
117 /// Frames received from a peer; a refusal IS charged.
118 Inbound,
119 /// Messages we are about to send; a refusal is NOT charged.
120 Outbound,
121}
122
123/// A sliding-window limiter over [`OpcodeRateLimits`], in one [`Direction`].
124///
125/// Mirrors Chia's algorithm: per-period per-opcode count and cumulative size, plus an aggregate
126/// budget for everything that is not a transaction message.
127#[derive(Debug, Clone)]
128pub struct OpcodeRateLimiter {
129 direction: Direction,
130 reset_seconds: u64,
131 period: u64,
132 limit_factor: f64,
133 counts: HashMap<u8, f64>,
134 cumulative_sizes: HashMap<u8, f64>,
135 non_tx_count: f64,
136 non_tx_size: f64,
137 limits: OpcodeRateLimits,
138}
139
140impl OpcodeRateLimiter {
141 /// A limiter over `limits` guarding `direction`, resetting its window every `reset_seconds`.
142 ///
143 /// `direction` selects the accounting rule for a REFUSED message — see [`Direction`]; it is
144 /// the first parameter so that a call site names it before anything else.
145 ///
146 /// `limit_factor` scales every budget, so a peer can be given a fraction of the nominal
147 /// allowance (Chia's clients default to `0.6`).
148 #[must_use]
149 pub fn new(
150 direction: Direction,
151 reset_seconds: u64,
152 limit_factor: f64,
153 limits: OpcodeRateLimits,
154 ) -> Self {
155 Self {
156 direction,
157 reset_seconds,
158 period: now_seconds() / reset_seconds,
159 limit_factor,
160 counts: HashMap::new(),
161 cumulative_sizes: HashMap::new(),
162 non_tx_count: 0.0,
163 non_tx_size: 0.0,
164 limits,
165 }
166 }
167
168 /// Whether `message` passes the limiter now, charging it against the budget per [`Direction`].
169 ///
170 /// Prefer [`Self::admit`] where the caller intends to retry: `true`/`false` cannot say
171 /// whether waiting could ever help.
172 pub fn allow(&mut self, message: &DigMessage) -> bool {
173 self.admit(message) == Admission::Admitted
174 }
175
176 /// Whether `message` passes now — and, when it does not, whether waiting could help.
177 ///
178 /// The distinction is what keeps a caller from spinning forever: a *frequency* or
179 /// *cumulative* budget clears on the next window roll, but a message larger than the
180 /// per-message cap (or than a whole window's budget) is refused identically in every window
181 /// that will ever exist. Only [`Admission::Deferred`] is worth retrying.
182 ///
183 /// Whether a REFUSAL is charged depends on the limiter's [`Direction`], and nothing else: the
184 /// verdict itself is computed identically either way.
185 pub fn admit(&mut self, message: &DigMessage) -> Admission {
186 self.roll_window();
187
188 let size = f64::from(u32::try_from(message.data.len()).unwrap_or(u32::MAX));
189 let opcode = message.msg_type;
190
191 let mut limit = self.limits.default_settings;
192 let mut counts_against_non_tx = false;
193 if let Some(tx_limit) = self.limits.tx.get(&opcode) {
194 limit = *tx_limit;
195 } else if let Some(other_limit) = self.limits.other.get(&opcode) {
196 limit = *other_limit;
197 counts_against_non_tx = true;
198 }
199
200 let max_total = limit
201 .max_total_size
202 .unwrap_or(limit.frequency * limit.max_size);
203
204 // Measured against an EMPTY window, so it isolates the budgets a window roll cannot
205 // clear. A message failing here is unsendable on this link, permanently.
206 let fits_an_empty_window = size <= limit.max_size
207 && size <= max_total * self.limit_factor
208 && 1.0 <= limit.frequency * self.limit_factor
209 && (!counts_against_non_tx
210 || (1.0 <= self.limits.non_tx_frequency * self.limit_factor
211 && size <= self.limits.non_tx_max_total_size * self.limit_factor));
212
213 let new_count = self.counts.get(&opcode).unwrap_or(&0.0) + 1.0;
214 let new_cumulative = self.cumulative_sizes.get(&opcode).unwrap_or(&0.0) + size;
215 let (new_non_tx_count, new_non_tx_size) = if counts_against_non_tx {
216 (self.non_tx_count + 1.0, self.non_tx_size + size)
217 } else {
218 (self.non_tx_count, self.non_tx_size)
219 };
220
221 let fits_this_window = new_non_tx_count <= self.limits.non_tx_frequency * self.limit_factor
222 && new_non_tx_size <= self.limits.non_tx_max_total_size * self.limit_factor
223 && new_count <= limit.frequency * self.limit_factor
224 && new_cumulative <= max_total * self.limit_factor;
225
226 let verdict = match (fits_an_empty_window, fits_this_window) {
227 (false, _) => Admission::Unsendable,
228 (true, false) => Admission::Deferred,
229 (true, true) => Admission::Admitted,
230 };
231
232 // The one place the two directions differ. An inbound frame is charged even when refused,
233 // because the peer already spent our bandwidth delivering it — that is the ratchet that
234 // keeps a rejected flood from being free. An outbound refusal is not charged, because we
235 // chose not to send and a backing-off caller must not be penalised for asking early.
236 let charge = self.direction == Direction::Inbound || verdict == Admission::Admitted;
237 if charge {
238 self.counts.insert(opcode, new_count);
239 self.cumulative_sizes.insert(opcode, new_cumulative);
240 self.non_tx_count = new_non_tx_count;
241 self.non_tx_size = new_non_tx_size;
242 }
243
244 verdict
245 }
246
247 /// Clear the accumulated budget when the wall clock crosses into a new window.
248 fn roll_window(&mut self) {
249 let period = now_seconds() / self.reset_seconds;
250 if self.period == period {
251 return;
252 }
253 self.period = period;
254 self.counts.clear();
255 self.cumulative_sizes.clear();
256 self.non_tx_count = 0.0;
257 self.non_tx_size = 0.0;
258 }
259}
260
261fn now_seconds() -> u64 {
262 SystemTime::now()
263 .duration_since(UNIX_EPOCH)
264 .expect("system clock is before the unix epoch")
265 .as_secs()
266}
267
268#[cfg(test)]
269mod tests {
270 use super::{Admission, Direction, OpcodeRateLimiter, OpcodeRateLimits};
271 use crate::{Bytes, DigMessage, DIG_MESSAGE};
272 use chia_protocol::ProtocolMessageTypes;
273 use chia_sdk_client::{RateLimit, RateLimits, V2_RATE_LIMITS};
274 use chia_traits::Streamable;
275
276 fn message(opcode: u8, payload_len: usize) -> DigMessage {
277 DigMessage::new(opcode, None, Bytes::new(vec![0u8; payload_len]))
278 }
279
280 /// The wire byte `Handshake` streams to — the same derivation the re-key itself performs.
281 fn handshake_opcode() -> u8 {
282 *ProtocolMessageTypes::Handshake
283 .to_bytes()
284 .expect("encode")
285 .first()
286 .expect("one byte")
287 }
288
289 /// `V2_RATE_LIMITS` with `Handshake` retuned to admit only two messages per window.
290 ///
291 /// Two is chosen because upstream's own `Handshake` frequency is 5: a limiter built from this
292 /// table refuses a third message that a limiter built from the upstream table admits, so the
293 /// two are distinguishable by observation rather than by inspecting private fields.
294 fn handshake_capped_at_two() -> RateLimits {
295 let mut limits = V2_RATE_LIMITS.clone();
296 limits.other.insert(
297 ProtocolMessageTypes::Handshake,
298 RateLimit::new(2.0, 10.0 * 1024.0, None),
299 );
300 limits
301 }
302
303 /// Admit `count` handshakes of a size no cap can refuse, returning the verdict on each.
304 ///
305 /// The payload is deliberately tiny so the per-message and cumulative SIZE budgets can never
306 /// bind: the only budget that can produce a refusal is `frequency`, which is the axis the
307 /// custom table moves.
308 fn admit_handshakes(limits: OpcodeRateLimits, count: usize) -> Vec<Admission> {
309 let mut limiter = OpcodeRateLimiter::new(Direction::Outbound, 60, 1.0, limits);
310 (0..count)
311 .map(|_| limiter.admit(&message(handshake_opcode(), 16)))
312 .collect()
313 }
314
315 /// The wire byte `NewPeak` streams to — a SECOND `other` opcode, distinct from `Handshake`.
316 ///
317 /// A second opcode is what makes the aggregate tests possible: charging can be observed on a
318 /// message whose own per-opcode counters were never touched.
319 fn new_peak_opcode() -> u8 {
320 *ProtocolMessageTypes::NewPeak
321 .to_bytes()
322 .expect("encode")
323 .first()
324 .expect("one byte")
325 }
326
327 /// The size, in bytes, of a frame that `handshake_capped_at_two` refuses on SIZE alone.
328 ///
329 /// One byte over the table's own `max_size`, so the refusal is `Unsendable` — the flood shape
330 /// that used to cost the peer nothing.
331 const OVERSIZED_HANDSHAKE: usize = 10 * 1024 + 1;
332
333 /// Offer `count` oversized handshakes, then one perfectly legal handshake.
334 ///
335 /// Every oversized frame must be refused on size, and the closing legal frame is the probe:
336 /// its verdict is decided entirely by whether those refusals were charged.
337 fn flood_then_probe(direction: Direction) -> Admission {
338 let mut limiter = OpcodeRateLimiter::new(
339 direction,
340 60,
341 1.0,
342 OpcodeRateLimits::from(&handshake_capped_at_two()),
343 );
344
345 for i in 0..2 {
346 assert_eq!(
347 limiter.admit(&message(handshake_opcode(), OVERSIZED_HANDSHAKE)),
348 Admission::Unsendable,
349 "flood frame {i} was not refused on size -- the fixture is not exercising a refusal"
350 );
351 }
352
353 limiter.admit(&message(handshake_opcode(), 16))
354 }
355
356 /// An INBOUND refusal is charged: a peer flooding frames we reject still burns the window.
357 ///
358 /// The peer spent our bandwidth delivering each frame, so refusing it must ratchet the budget
359 /// down — otherwise a flood of frames that are all rejected on size costs the attacker nothing
360 /// and the limiter never closes. Two frames over a `frequency` of 2 exhaust the count, so the
361 /// closing LEGAL frame — which an empty window would admit — must now be refused.
362 ///
363 /// `Deferred`, not `Unsendable`: the probe fits an empty window, so the only thing that can
364 /// refuse it is an exhausted budget, and that budget can only be exhausted by the charges.
365 #[test]
366 fn an_inbound_refusal_is_charged_against_the_window() {
367 assert_eq!(
368 flood_then_probe(Direction::Inbound),
369 Admission::Deferred,
370 "a legal inbound frame was admitted after two refused frames -- the refusals were not \
371 charged, so a rejected flood is free"
372 );
373 }
374
375 /// An OUTBOUND refusal is NOT charged — the documented behaviour this fix must preserve.
376 ///
377 /// The control for the test above, on the IDENTICAL fixture: we chose not to send, so a caller
378 /// that backs off and retries must not be penalised for having asked early. That the same
379 /// sequence yields opposite verdicts is what proves the direction, and not the fixture, is
380 /// doing the work.
381 #[test]
382 fn an_outbound_refusal_is_not_charged_against_the_window() {
383 assert_eq!(
384 flood_then_probe(Direction::Outbound),
385 Admission::Admitted,
386 "a refused outbound message was charged -- a backing-off caller is now penalised"
387 );
388 }
389
390 /// The inbound charge reaches the shared `non_tx` COUNT aggregate, not only the per-opcode
391 /// counters.
392 ///
393 /// This is the axis the real-world flood runs on: oversized `Handshake` frames exhaust the
394 /// aggregate and lock out every other `other` opcode for the window. The probe is therefore a
395 /// DIFFERENT opcode (`NewPeak`), whose own counters were never touched — an implementation
396 /// that charged only per-opcode would admit it and leave the actual hole open.
397 ///
398 /// `Handshake`'s own frequency is raised well clear of the flood so it cannot be the budget
399 /// that binds, and `non_tx_frequency` is lowered to 2 so the aggregate is reached in two
400 /// frames rather than a thousand.
401 #[test]
402 fn an_inbound_refusal_is_charged_against_the_non_tx_count_aggregate() {
403 let probe = |direction| {
404 let mut table = V2_RATE_LIMITS.clone();
405 table.non_tx_frequency = 2.0;
406 table.other.insert(
407 ProtocolMessageTypes::Handshake,
408 RateLimit::new(100.0, 10.0 * 1024.0, None),
409 );
410 table.other.insert(
411 ProtocolMessageTypes::NewPeak,
412 RateLimit::new(100.0, 10.0 * 1024.0, None),
413 );
414
415 let mut limiter =
416 OpcodeRateLimiter::new(direction, 60, 1.0, OpcodeRateLimits::from(&table));
417 for _ in 0..2 {
418 assert_eq!(
419 limiter.admit(&message(handshake_opcode(), OVERSIZED_HANDSHAKE)),
420 Admission::Unsendable
421 );
422 }
423 limiter.admit(&message(new_peak_opcode(), 16))
424 };
425
426 assert_eq!(
427 probe(Direction::Inbound),
428 Admission::Deferred,
429 "a second `other` opcode was admitted after two refused frames -- the non_tx COUNT \
430 aggregate was not charged, so an oversized flood cannot exhaust the shared budget"
431 );
432 assert_eq!(
433 probe(Direction::Outbound),
434 Admission::Admitted,
435 "the fixture cannot distinguish the directions"
436 );
437 }
438
439 /// The inbound charge reaches the shared `non_tx` SIZE aggregate too.
440 ///
441 /// `non_tx_max_total_size` is the budget the worked example actually drains, and it is a
442 /// separate field from the count: a fix that charged only the count would pass the test above
443 /// and still let a flood of large refused frames run free. The count budget is left wide open
444 /// here so that only the size aggregate can produce the refusal.
445 #[test]
446 fn an_inbound_refusal_is_charged_against_the_non_tx_size_aggregate() {
447 let probe = |direction| {
448 let mut table = V2_RATE_LIMITS.clone();
449 table.non_tx_frequency = 1000.0;
450 // Two oversized frames (10 KiB + 1 each) exceed this; one legal probe alone does not.
451 table.non_tx_max_total_size = 15.0 * 1024.0;
452 table.other.insert(
453 ProtocolMessageTypes::Handshake,
454 RateLimit::new(100.0, 10.0 * 1024.0, None),
455 );
456 table.other.insert(
457 ProtocolMessageTypes::NewPeak,
458 RateLimit::new(100.0, 10.0 * 1024.0, None),
459 );
460
461 let mut limiter =
462 OpcodeRateLimiter::new(direction, 60, 1.0, OpcodeRateLimits::from(&table));
463 for _ in 0..2 {
464 assert_eq!(
465 limiter.admit(&message(handshake_opcode(), OVERSIZED_HANDSHAKE)),
466 Admission::Unsendable
467 );
468 }
469 limiter.admit(&message(new_peak_opcode(), 16))
470 };
471
472 assert_eq!(
473 probe(Direction::Inbound),
474 Admission::Deferred,
475 "the non_tx SIZE aggregate was not charged for refused frames -- a flood of large \
476 rejected frames still costs the peer nothing"
477 );
478 assert_eq!(
479 probe(Direction::Outbound),
480 Admission::Admitted,
481 "the fixture cannot distinguish the directions"
482 );
483 }
484
485 /// The table is DERIVED, not copied: a Chia opcode with a specific entry upstream must have
486 /// that same entry here, under its wire byte. `Handshake` is checked because it has a much
487 /// tighter frequency than `default_settings`, so a re-key that silently produced an empty
488 /// map would let far more through and fail this test.
489 #[test]
490 fn chia_opcodes_keep_their_upstream_limits() {
491 let limits = OpcodeRateLimits::default();
492 let handshake = *ProtocolMessageTypes::Handshake
493 .to_bytes()
494 .expect("encode")
495 .first()
496 .expect("one byte");
497
498 let upstream = chia_sdk_client::V2_RATE_LIMITS
499 .other
500 .get(&ProtocolMessageTypes::Handshake)
501 .expect("upstream defines a handshake limit");
502 let ours = limits
503 .other
504 .get(&handshake)
505 .expect("re-keyed table kept the handshake limit");
506
507 assert_eq!(ours.frequency, upstream.frequency);
508 assert_eq!(ours.max_size, upstream.max_size);
509 }
510
511 /// EVERY upstream opcode survives the re-key with its limits intact — not just `Handshake`.
512 ///
513 /// The single-entry check above cannot see the two failures this module's lockstep pin exists
514 /// to prevent, because both leave `Handshake` untouched while corrupting the rest of the table:
515 ///
516 /// - `rekey` is a `filter_map` whose `ok()?` / `first()?` DROP an entry that fails to encode,
517 /// and a dropped Chia opcode silently falls through to `default_settings` — a loosening, in
518 /// the permissive direction, with no compile error and no panic.
519 /// - two `ProtocolMessageTypes` variants that streamed to the SAME byte would collide in the
520 /// `HashMap`, so one limit would silently overwrite the other.
521 ///
522 /// Cardinality is asserted as well as content precisely because both failures are subtractive:
523 /// a per-entry loop over the re-keyed map would still pass while entries were missing from it,
524 /// so the count is what makes a drop observable. Checking `tx` and `other` separately keeps a
525 /// row that moved between the two tables from cancelling out in a combined total.
526 #[test]
527 fn every_upstream_opcode_survives_the_rekey_with_its_limits() {
528 let ours = OpcodeRateLimits::default();
529 let upstream = &*V2_RATE_LIMITS;
530
531 for (label, upstream_map, our_map) in [
532 ("tx", &upstream.tx, &ours.tx),
533 ("other", &upstream.other, &ours.other),
534 ] {
535 assert_eq!(
536 our_map.len(),
537 upstream_map.len(),
538 "{label}: re-key changed the entry count, so an opcode was dropped or collided",
539 );
540 assert!(
541 !upstream_map.is_empty(),
542 "{label}: upstream table is empty, so this test proves nothing",
543 );
544
545 for (msg_type, expected) in upstream_map.iter() {
546 let opcode = *msg_type
547 .to_bytes()
548 .expect("ProtocolMessageTypes encodes")
549 .first()
550 .expect("one byte");
551 let got = our_map.get(&opcode).unwrap_or_else(|| {
552 panic!("{label}: {msg_type:?} (opcode {opcode}) missing after re-key")
553 });
554 assert_eq!(got.frequency, expected.frequency, "{label}: {msg_type:?}");
555 assert_eq!(got.max_size, expected.max_size, "{label}: {msg_type:?}");
556 }
557 }
558 }
559
560 /// A caller-supplied table governs the limiter — the CUSTOM row is honoured, not upstream's.
561 ///
562 /// The conversion is observed through behaviour rather than through the derived fields, so it
563 /// stays honest about what a consumer can actually do with it: three handshakes are offered to
564 /// a limiter whose table caps them at two, and the third must be refused. `Deferred` rather
565 /// than merely "not admitted", because a frequency exhaustion is the refusal that a window
566 /// roll clears; an `Unsendable` here would mean the size fixture, not the custom row, did the
567 /// refusing.
568 #[test]
569 fn a_caller_supplied_table_governs_the_limiter() {
570 let verdicts = admit_handshakes(OpcodeRateLimits::from(&handshake_capped_at_two()), 3);
571
572 assert_eq!(
573 verdicts,
574 vec![
575 Admission::Admitted,
576 Admission::Admitted,
577 Admission::Deferred
578 ],
579 "the custom frequency of 2 did not govern"
580 );
581 }
582
583 /// `Default` is unchanged by the delegation: it still derives from `V2_RATE_LIMITS`.
584 ///
585 /// The probe is the message the custom table classifies DIFFERENTLY — the third handshake,
586 /// refused under a cap of two. Both the `Default`-built and the explicitly
587 /// `V2_RATE_LIMITS`-built limiter must admit it, which is a claim a `Default` accidentally
588 /// rerouted to some other table could not satisfy.
589 #[test]
590 fn default_still_derives_from_the_upstream_table() {
591 let via_default = admit_handshakes(OpcodeRateLimits::default(), 3);
592 let via_upstream = admit_handshakes(OpcodeRateLimits::from(&*V2_RATE_LIMITS), 3);
593
594 assert_eq!(
595 via_default, via_upstream,
596 "Default no longer agrees with the table it is documented to derive from"
597 );
598 assert_eq!(
599 via_default[2],
600 Admission::Admitted,
601 "upstream admits a third handshake (frequency 5); this probe cannot distinguish tables \
602 if it does not"
603 );
604 }
605
606 /// A DIG opcode has no upstream entry, so it is governed by `default_settings` — it is
607 /// neither blocked outright nor unlimited. Sending one message must pass.
608 #[test]
609 fn dig_opcodes_fall_back_to_the_default_budget() {
610 let mut limiter =
611 OpcodeRateLimiter::new(Direction::Outbound, 60, 1.0, OpcodeRateLimits::default());
612 assert!(limiter.allow(&message(DIG_MESSAGE, 16)));
613 }
614
615 /// The frequency budget is pinned from BOTH sides: exactly `frequency` messages pass and
616 /// the next one is refused. A limiter that never refused would pass a one-sided test.
617 #[test]
618 fn frequency_budget_admits_up_to_the_bound_and_refuses_past_it() {
619 let limits = OpcodeRateLimits::default();
620 let allowance = limits.default_settings.frequency as usize;
621 let mut limiter = OpcodeRateLimiter::new(Direction::Outbound, 60, 1.0, limits);
622
623 for i in 0..allowance {
624 assert!(
625 limiter.allow(&message(DIG_MESSAGE, 1)),
626 "message {i} refused below the bound"
627 );
628 }
629 assert!(
630 !limiter.allow(&message(DIG_MESSAGE, 1)),
631 "one message over the bound was admitted"
632 );
633 }
634
635 /// The two refusals are distinguishable, which is the whole point of [`Admission`]: one
636 /// clears on the next window, the other never does.
637 ///
638 /// Both cases are driven on the SAME opcode and the same limiter shape, so the only thing
639 /// separating them is which budget was exceeded — an implementation that collapsed them into
640 /// a single "refused" verdict could not pass both halves.
641 #[test]
642 fn a_deferrable_refusal_is_distinguished_from_a_permanent_one() {
643 let limits = OpcodeRateLimits::default();
644 let allowance = limits.default_settings.frequency as usize;
645 let max_size = limits.default_settings.max_size as usize;
646
647 let mut exhausted = OpcodeRateLimiter::new(Direction::Outbound, 60, 1.0, limits);
648 for _ in 0..allowance {
649 assert_eq!(
650 exhausted.admit(&message(DIG_MESSAGE, 1)),
651 Admission::Admitted
652 );
653 }
654 assert_eq!(
655 exhausted.admit(&message(DIG_MESSAGE, 1)),
656 Admission::Deferred,
657 "an exhausted frequency budget resets on the next window, so waiting can help"
658 );
659
660 let mut fresh =
661 OpcodeRateLimiter::new(Direction::Outbound, 60, 1.0, OpcodeRateLimits::default());
662 assert_eq!(
663 fresh.admit(&message(DIG_MESSAGE, max_size + 1)),
664 Admission::Unsendable,
665 "an oversized message is refused identically in every window"
666 );
667 }
668
669 /// An oversized single message is refused on size alone — and the at-bound message is
670 /// admitted, so the cap is pinned from both sides.
671 ///
672 /// Each case gets a FRESH limiter on purpose: reusing one would let the accumulated
673 /// cumulative-size budget refuse the second message, which would make the test pass for a
674 /// reason that has nothing to do with the per-message size cap.
675 #[test]
676 fn size_cap_is_pinned_from_both_sides() {
677 let max_size = OpcodeRateLimits::default().default_settings.max_size as usize;
678
679 let mut at_bound =
680 OpcodeRateLimiter::new(Direction::Outbound, 60, 1.0, OpcodeRateLimits::default());
681 assert!(at_bound.allow(&message(DIG_MESSAGE, max_size)));
682
683 let mut over_bound =
684 OpcodeRateLimiter::new(Direction::Outbound, 60, 1.0, OpcodeRateLimits::default());
685 assert!(!over_bound.allow(&message(DIG_MESSAGE, max_size + 1)));
686 }
687
688 /// The re-keyed table is pinned to ABSOLUTE values, opcode byte by opcode byte.
689 ///
690 /// This is the test the module header's lockstep warning demands. `V2_RATE_LIMITS` comes from
691 /// `chia-sdk-client` keyed by `chia_protocol::ProtocolMessageTypes`; `rekey` derives each
692 /// opcode byte by *streaming that enum*. If the two crates ever resolve different versions of
693 /// it, the derived bytes shift, every Chia opcode misses its entry and falls to
694 /// `default_settings` — a large LOOSENING, with no compile error and no panic. A silently
695 /// permissive rate limiter is a DoS surface.
696 ///
697 /// A test comparing this table against `V2_RATE_LIMITS` cannot see that: it would ask the
698 /// same possibly-shifted enum for the key and agree with itself. So the expectations below
699 /// are literals — the opcode byte and both limit numbers, transcribed from the upstream table
700 /// and independent of any enum this crate can resolve.
701 ///
702 /// The chosen opcodes discriminate against the specific failure: `Handshake` (1) sits in
703 /// `other` with an entry FAR tighter than `default_settings` on both axes, so a
704 /// fall-to-default shows up as a wrong number rather than a missing key; `NewTransaction`
705 /// (21) and `TransactionAck` (49) sit in `tx`, so a re-key that dropped one map while
706 /// keeping the other still fails here.
707 #[test]
708 fn the_rekeyed_table_pins_upstream_limits_at_absolute_values() {
709 let limits = OpcodeRateLimits::default();
710
711 // (opcode byte, which map, frequency, max_size)
712 let handshake = limits
713 .other
714 .get(&1)
715 .expect("opcode 1 (Handshake) kept its entry");
716 assert_eq!(handshake.frequency, 5.0, "Handshake frequency");
717 assert_eq!(handshake.max_size, 10.0 * 1024.0, "Handshake max_size");
718
719 let tx_ack = limits
720 .tx
721 .get(&49)
722 .expect("opcode 49 (TransactionAck) kept its tx entry");
723 assert_eq!(tx_ack.frequency, 5000.0, "TransactionAck frequency");
724 assert_eq!(tx_ack.max_size, 2048.0, "TransactionAck max_size");
725
726 let new_tx = limits
727 .tx
728 .get(&21)
729 .expect("opcode 21 (NewTransaction) kept its tx entry");
730 assert_eq!(new_tx.frequency, 5000.0, "NewTransaction frequency");
731 assert_eq!(new_tx.max_size, 100.0, "NewTransaction max_size");
732
733 // The aggregate budgets are part of the same table and equally silent if lost.
734 assert_eq!(limits.non_tx_frequency, 1000.0);
735 assert_eq!(limits.non_tx_max_total_size, 100.0 * 1024.0 * 1024.0);
736 assert_eq!(limits.default_settings.frequency, 100.0);
737 assert_eq!(limits.default_settings.max_size, 1024.0 * 1024.0);
738 }
739
740 /// A pinned entry must be TIGHTER than `default_settings`, or the test above could pass on a
741 /// table that had silently collapsed to the default everywhere.
742 ///
743 /// This is the guard against the exact vacuity the module header warns about: it names the
744 /// property ("losing an entry is a loosening") rather than restating a number, so it stays
745 /// meaningful even if upstream retunes the values.
746 #[test]
747 fn falling_back_to_the_default_would_be_a_detectable_loosening() {
748 let limits = OpcodeRateLimits::default();
749 let handshake = limits
750 .other
751 .get(&1)
752 .expect("opcode 1 (Handshake) kept its entry");
753
754 assert!(
755 handshake.frequency < limits.default_settings.frequency,
756 "Handshake ({}) is not tighter than default ({}) -- the pin above can no longer distinguish a re-keyed table from a collapsed one",
757 handshake.frequency,
758 limits.default_settings.frequency
759 );
760 assert!(
761 handshake.max_size < limits.default_settings.max_size,
762 "Handshake max_size is not tighter than default"
763 );
764 }
765
766 /// The table must retain a REALISTIC number of entries. An emptied `other` map would still
767 /// satisfy a test that only inspected keys it happens to look up, if those lookups were
768 /// themselves derived from the same shifted enum.
769 #[test]
770 fn the_rekeyed_table_retains_the_bulk_of_the_upstream_entries() {
771 let limits = OpcodeRateLimits::default();
772 assert!(
773 limits.other.len() >= 30,
774 "other map holds only {} entries -- the re-key lost most of the table",
775 limits.other.len()
776 );
777 assert!(
778 limits.tx.len() >= 5,
779 "tx map holds only {} entries -- the re-key lost most of the table",
780 limits.tx.len()
781 );
782 // Every key must be a real wire byte; a shifted enum would produce values outside the
783 // chia band, which is a direct signal of the version split.
784 for opcode in limits.other.keys().chain(limits.tx.keys()) {
785 assert!(
786 *opcode < 200,
787 "opcode {opcode} is outside the chia band -- the re-key is keying off a different ProtocolMessageTypes than the wire uses"
788 );
789 }
790 }
791}