Skip to main content

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}