Skip to main content

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}