Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "dig-peer-protocol"
version = "0.4.0"
version = "0.5.0"
edition = "2021"
authors = ["Michael Taylor <michael@berkeleycompute.com>"]
description = "DIG Network L2 protocol types extending Chia's wire protocol (opcodes 200+)"
Expand Down
9 changes: 8 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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` |

Expand Down
103 changes: 99 additions & 4 deletions src/rate_limit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,22 @@ pub struct OpcodeRateLimits {
other: HashMap<u8, RateLimit>,
}

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<chia_protocol::ProtocolMessageTypes, RateLimit>| {
Expand All @@ -66,7 +79,7 @@ impl OpcodeRateLimits {

impl Default for OpcodeRateLimits {
fn default() -> Self {
Self::from_chia(&V2_RATE_LIMITS)
Self::from(&*V2_RATE_LIMITS)
}
}

Expand Down Expand Up @@ -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<Admission> {
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
Expand All @@ -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]
Expand Down
Loading