From e710e3191220c38c2881e23602d7410c26793e20 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 10 Aug 2026 21:08:12 -0700 Subject: [PATCH 1/3] chore(rate-limit): open lane for a public RateLimits conversion Co-Authored-By: Claude From f67e490774e8012dd35105f0c14810605f8c6362 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 10 Aug 2026 21:20:05 -0700 Subject: [PATCH 2/3] feat(rate-limit): public From<&RateLimits> for OpcodeRateLimits The re-key was private, so Default was the only way to build the type and a consumer could not exercise it against a cheap table -- any test was forced to couple itself to upstream chia's exact numbers. Exposing the conversion rather than the fields keeps the numbers DERIVED: a caller picks the source table, never an individual limit, so the second set of drifting numbers this type exists to prevent stays prevented. Co-Authored-By: Claude --- src/rate_limit.rs | 103 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 99 insertions(+), 4 deletions(-) diff --git a/src/rate_limit.rs b/src/rate_limit.rs index 793f6fd..8ae693b 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -43,9 +43,22 @@ pub struct OpcodeRateLimits { other: HashMap, } -impl OpcodeRateLimits { - /// Re-key a Chia limit table onto raw opcodes. - fn from_chia(limits: &RateLimits) -> Self { +/// Re-key any Chia limit table onto raw opcodes. +/// +/// The numbers remain DERIVED — a caller chooses the *source table*, never the individual limits — +/// so the drift this type exists to prevent stays prevented. A table assembled from +/// `V2_RATE_LIMITS` (retuned, extended, or narrowed for a test) is exactly as trustworthy as the +/// default. +/// +/// The module header's lockstep pin applies undiminished, and a caller-supplied table is the one +/// way to violate it from outside this crate: the keys are `chia_protocol::ProtocolMessageTypes` +/// values streamed to their wire byte, so a table keyed by a *different* `chia_protocol` version's +/// enum re-keys to shifted bytes, every Chia opcode misses its entry and falls to +/// `default_settings` — a silent loosening with no compile error. Build the table with the +/// `chia_protocol` this crate resolves; re-export it from here (`crate::RateLimits`) rather than +/// depending on `chia-sdk-client` independently. +impl From<&RateLimits> for OpcodeRateLimits { + fn from(limits: &RateLimits) -> Self { // `ProtocolMessageTypes` is a streamable single-byte enum, so its encoding IS its wire // opcode — the same identity `DigMessage` relies on. let rekey = |map: &HashMap| { @@ -66,7 +79,7 @@ impl OpcodeRateLimits { impl Default for OpcodeRateLimits { fn default() -> Self { - Self::from_chia(&V2_RATE_LIMITS) + Self::from(&*V2_RATE_LIMITS) } } @@ -218,12 +231,48 @@ mod tests { use super::{Admission, OpcodeRateLimiter, OpcodeRateLimits}; use crate::{Bytes, DigMessage, DIG_MESSAGE}; use chia_protocol::ProtocolMessageTypes; + use chia_sdk_client::{RateLimit, RateLimits, V2_RATE_LIMITS}; use chia_traits::Streamable; fn message(opcode: u8, payload_len: usize) -> DigMessage { DigMessage::new(opcode, None, Bytes::new(vec![0u8; payload_len])) } + /// The wire byte `Handshake` streams to — the same derivation the re-key itself performs. + fn handshake_opcode() -> u8 { + *ProtocolMessageTypes::Handshake + .to_bytes() + .expect("encode") + .first() + .expect("one byte") + } + + /// `V2_RATE_LIMITS` with `Handshake` retuned to admit only two messages per window. + /// + /// Two is chosen because upstream's own `Handshake` frequency is 5: a limiter built from this + /// table refuses a third message that a limiter built from the upstream table admits, so the + /// two are distinguishable by observation rather than by inspecting private fields. + fn handshake_capped_at_two() -> RateLimits { + let mut limits = V2_RATE_LIMITS.clone(); + limits.other.insert( + ProtocolMessageTypes::Handshake, + RateLimit::new(2.0, 10.0 * 1024.0, None), + ); + limits + } + + /// Admit `count` handshakes of a size no cap can refuse, returning the verdict on each. + /// + /// The payload is deliberately tiny so the per-message and cumulative SIZE budgets can never + /// bind: the only budget that can produce a refusal is `frequency`, which is the axis the + /// custom table moves. + fn admit_handshakes(limits: OpcodeRateLimits, count: usize) -> Vec { + let mut limiter = OpcodeRateLimiter::new(60, 1.0, limits); + (0..count) + .map(|_| limiter.admit(&message(handshake_opcode(), 16))) + .collect() + } + /// The table is DERIVED, not copied: a Chia opcode with a specific entry upstream must have /// that same entry here, under its wire byte. `Handshake` is checked because it has a much /// tighter frequency than `default_settings`, so a re-key that silently produced an empty @@ -250,6 +299,52 @@ mod tests { assert_eq!(ours.max_size, upstream.max_size); } + /// A caller-supplied table governs the limiter — the CUSTOM row is honoured, not upstream's. + /// + /// The conversion is observed through behaviour rather than through the derived fields, so it + /// stays honest about what a consumer can actually do with it: three handshakes are offered to + /// a limiter whose table caps them at two, and the third must be refused. `Deferred` rather + /// than merely "not admitted", because a frequency exhaustion is the refusal that a window + /// roll clears; an `Unsendable` here would mean the size fixture, not the custom row, did the + /// refusing. + #[test] + fn a_caller_supplied_table_governs_the_limiter() { + let verdicts = admit_handshakes(OpcodeRateLimits::from(&handshake_capped_at_two()), 3); + + assert_eq!( + verdicts, + vec![ + Admission::Admitted, + Admission::Admitted, + Admission::Deferred + ], + "the custom frequency of 2 did not govern" + ); + } + + /// `Default` is unchanged by the delegation: it still derives from `V2_RATE_LIMITS`. + /// + /// The probe is the message the custom table classifies DIFFERENTLY — the third handshake, + /// refused under a cap of two. Both the `Default`-built and the explicitly + /// `V2_RATE_LIMITS`-built limiter must admit it, which is a claim a `Default` accidentally + /// rerouted to some other table could not satisfy. + #[test] + fn default_still_derives_from_the_upstream_table() { + let via_default = admit_handshakes(OpcodeRateLimits::default(), 3); + let via_upstream = admit_handshakes(OpcodeRateLimits::from(&*V2_RATE_LIMITS), 3); + + assert_eq!( + via_default, via_upstream, + "Default no longer agrees with the table it is documented to derive from" + ); + assert_eq!( + via_default[2], + Admission::Admitted, + "upstream admits a third handshake (frequency 5); this probe cannot distinguish tables \ + if it does not" + ); + } + /// A DIG opcode has no upstream entry, so it is governed by `default_settings` — it is /// neither blocked outright nor unlimited. Sending one message must pass. #[test] From e4f180c696d972c2b9a0d12370b6a5acb2a6eede Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 10 Aug 2026 21:25:04 -0700 Subject: [PATCH 3/3] chore(release): bump to 0.5.0 and spec the selectable rate-limit table Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- SPEC.md | 9 ++++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 80bd380..79fd16b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -700,7 +700,7 @@ dependencies = [ [[package]] name = "dig-peer-protocol" -version = "0.4.0" +version = "0.5.0" dependencies = [ "chia-protocol", "chia-sdk-client", diff --git a/Cargo.toml b/Cargo.toml index 575537e..f0c865c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dig-peer-protocol" -version = "0.4.0" +version = "0.5.0" edition = "2021" authors = ["Michael Taylor "] description = "DIG Network L2 protocol types extending Chia's wire protocol (opcodes 200+)" diff --git a/SPEC.md b/SPEC.md index d049a9b..ca5f021 100644 --- a/SPEC.md +++ b/SPEC.md @@ -362,6 +362,13 @@ Chia opcode is limited exactly as a stock peer limits it; DIG opcodes have no up entry and fall to `default_settings`. A refused message MUST NOT be charged against the budget. +The source table is selectable: `OpcodeRateLimits` implements `From<&RateLimits>`, and +`Default` is defined as `From<&V2_RATE_LIMITS>`. The limits remain derived under either — +a caller chooses the *table*, never an individual limit, and the type exposes no field-wise +constructor. A supplied table MUST be keyed by the `chia_protocol::ProtocolMessageTypes` +this crate resolves (re-exported from its root); a table keyed by another version's enum +re-keys to shifted wire bytes, silently loosening every Chia opcode to `default_settings`. + A refusal MUST be classified, because the two kinds demand opposite behaviour: | Verdict | Meaning | Required sender behaviour | @@ -441,7 +448,7 @@ Runtime configuration is limited to `LinkOptions` (§7), which scales the outbou | C10 | `DigMessage::to_bytes` is byte-identical to `chia_protocol::Message::to_bytes` for **every** opcode `chia-protocol` accepts, across present/absent ids and payloads spanning the `u32` length prefix | §2.1, §2.4; `tests/wire_compatibility.rs` | | C11 | An inbound DIG opcode (218) is decoded and delivered, and the link survives it — where `Message::from_bytes` would reject the same frame and end the loop | §7.1; `tests/inbound_dig_opcode.rs` | | C12 | An inbound message whose id matches no live waiter is delivered to the application, not treated as fatal | §7.2; `tests/inbound_dig_opcode.rs` | -| C13 | Chia opcodes keep their upstream rate limits under the re-keyed table; DIG opcodes fall to `default_settings`; budgets are enforced from both sides of the bound | §7.4; tests in `src/rate_limit.rs` | +| C13 | Chia opcodes keep their upstream rate limits under the re-keyed table; a caller-supplied table governs the limiter and `Default` still derives from `V2_RATE_LIMITS`; DIG opcodes fall to `default_settings`; budgets are enforced from both sides of the bound | §7.4; tests in `src/rate_limit.rs` | | C14 | A malformed binary frame is skipped, not fatal: the frame that follows it still decodes and routes to its correlated waiter | §7.1 rule 4; `tests/inbound_dig_opcode.rs` | | C15 | A late reply to a request that has already timed out is never delivered to a subsequent waiter | §7.3; `tests/link_liveness.rs` |