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.3.0"
version = "0.4.0"
edition = "2021"
authors = ["Michael Taylor <michael@berkeleycompute.com>"]
description = "DIG Network L2 protocol types extending Chia's wire protocol (opcodes 200+)"
Expand Down
17 changes: 4 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,6 @@ impl DigMessage {
pub fn from_bytes_owned(buf: Vec<u8>) -> Option<Self>; // same rules; moves payload instead of copying

// Interop with stock Chia Message
pub fn from_chia_message(msg: &Message) -> Self; // lossless, clones data
pub fn from_chia_message_owned(msg: Message) -> Self; // lossless, moves data
pub fn try_into_chia_message(&self) -> Option<Message>; // None if opcode ∉ ProtocolMessageTypes; clones data
pub fn into_chia_message(self) -> Option<Message>; // same, moves data

// Opcode-range predicates
pub fn is_dig_extension(&self) -> bool; // msg_type >= 200
Expand All @@ -132,10 +128,6 @@ impl DigMessage {
| `to_bytes` | `&self` | `Vec<u8>` | infallible |
| `from_bytes` | `&[u8]` | `Option<DigMessage>` | `None` if truncated or `data_len` > `MAX_MESSAGE_SIZE` |
| `from_bytes_owned` | `Vec<u8>` | `Option<DigMessage>` | same as `from_bytes`; moves payload, no copy |
| `from_chia_message` | `&Message` | `DigMessage` | infallible; clones payload |
| `from_chia_message_owned` | `Message` | `DigMessage` | infallible; moves payload |
| `try_into_chia_message` | `&self` | `Option<Message>` | `None` if opcode not in Chia enum; clones payload |
| `into_chia_message` | `self` | `Option<Message>` | same as `try_into_chia_message`; moves payload |

`from_bytes`/`from_bytes_owned` reject any `data_len` prefix above `MAX_MESSAGE_SIZE`
(16 MiB) before slicing/allocating the payload. This bounds only the allocation these
Expand Down Expand Up @@ -266,7 +258,7 @@ These use `#[streamable(message)]` because opcodes 63/64 exist in stock `Protoco

| Category | Types |
|-------------------|-----------------------------------------------------------------------------------|
| Framing | `Message`, `Handshake`, `ProtocolMessageTypes`, `NodeType`, `Bytes`, `BytesImpl` |
| Framing | DIG-owned `DigMessage`, `Bytes`, `NodeType`; `ProtocolMessageTypes` + `ChiaProtocolMessage` + `TimestampedPeerInfo` re-exported by name for chia paths |
| Block | `FullBlock`, `HeaderBlock`, `BlockRecord`, `Foliage`, `FoliageBlockData` |
| Peer discovery | `RequestPeers`, `RespondPeers`, `TimestampedPeerInfo` |
| Mempool/tx | `NewTransaction`, `RequestTransaction`, `RespondTransaction`, `MempoolItemsAdded` |
Expand Down Expand Up @@ -348,10 +340,9 @@ match DigMessageType::try_from(msg.msg_type) {
}
Ok(other) => { /* dispatch other DIG opcode */ }
Err(_) => {
// Not a DIG opcode — try as Chia Message
if let Some(chia_msg) = msg.try_into_chia_message() {
// dispatch Chia handler
}
// Not a DIG opcode. `msg` still holds the raw opcode and payload; dispatch on
// `msg.msg_type` directly. There is deliberately no conversion to
// `chia_protocol::Message` — see SPEC.md §2.4.
}
}
```
Expand Down
29 changes: 12 additions & 17 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,20 +108,15 @@ DigMessage::MAX_MESSAGE_SIZE: usize = 16 * 1024 * 1024 // 16 MiB — see §2.2

`DigMessage` is `Debug + Clone + PartialEq + Eq`.

### 2.4 Interoperability with `chia_protocol::Message`

- `DigMessage::from_chia_message(&Message) -> DigMessage` — lossless; the enum
discriminant becomes the raw `u8`. Clones `msg.data`.
- `DigMessage::from_chia_message_owned(Message) -> DigMessage` — same conversion,
taking `Message` by value and moving `data` instead of cloning it. Prefer this when
the source `Message` does not need to be kept afterward.
- `DigMessage::try_into_chia_message(&self) -> Option<Message>` — succeeds iff
`msg_type` is a valid `ProtocolMessageTypes` discriminant; MUST return `None` for
opcodes the Chia enum does not define. DIG-only traffic stays in `DigMessage`. Clones
`self.data`.
- `DigMessage::into_chia_message(self) -> Option<Message>` — same conversion and same
failure condition, taking `self` by value and moving `data` instead of cloning it.
Prefer this when `self` does not need to be kept afterward.
### 2.4 No interoperability with `chia_protocol::Message`

`DigMessage` MUST NOT provide conversion to or from `chia_protocol::Message`. The DIG
envelope is native and self-contained; a cheap bridge to `Message` is an on-ramp back to
the closed `ProtocolMessageTypes` enum this crate exists to escape.

Traffic addressed to a chia full node uses `DigLink`'s typed `send`/`request`, which
derive their opcode from `ChiaProtocolMessage` (§7). That is the only supported chia path.

- Classification helpers: `is_dig_extension()` is `msg_type >= 200`;
`is_chia_standard()` is `msg_type < 200`. The boundary is exactly 200
(199 is Chia-standard, 200 is DIG-extension).
Expand Down Expand Up @@ -224,7 +219,7 @@ Payload fields, encoded with Chia `Streamable` in declaration order:
|---|-------|------|---------------------|---------|
| 1 | `ip` | `String` | u32 BE length + UTF-8 bytes | Externally reachable IP or hostname |
| 2 | `port` | `u16` | u16 BE | P2P listening port |
| 3 | `node_type` | `NodeType` | u8 discriminant | Declared service role; gossip nodes register as `NodeType::FullNode` |
| 3 | `node_type` | `NodeType` (DIG-owned) | u8 discriminant, frozen at `FullNode=1`, `Harvester=2`, `Farmer=3`, `Timelord=4`, `Introducer=5`, `Wallet=6`, `DataLayer=7` | Declared service role; gossip nodes register as `NodeType::FullNode`. A byte naming no role MUST be refused, never defaulted. |

API:

Expand Down Expand Up @@ -272,13 +267,13 @@ importing the underlying crates:

| Source crate | Re-exported |
|--------------|-------------|
| `chia-protocol` | everything (`pub use chia_protocol::*`): `Message`, `Handshake`, `ProtocolMessageTypes`, `NodeType`, `Bytes`, `TimestampedPeerInfo`, … |
| `chia-protocol` | `ChiaProtocolMessage`, `ProtocolMessageTypes`, `TimestampedPeerInfo` — NAMED only. There MUST NOT be a glob re-export: a consumer needing another chia wire type depends on `chia-protocol` directly. |
| `chia-sdk-client` (always) | `load_ssl_cert`, `ClientError`, `Network`, `Peer`, `PeerOptions`, `RateLimit`, `RateLimiter`, `RateLimits`, `V2_RATE_LIMITS` |
| `chia-sdk-client` (TLS-gated, §7) | `Client`, `ClientState`, `Connector`; `create_native_tls_connector` / `create_rustls_connector` per feature |
| `chia-ssl` | `ChiaCertificate` |
| `chia-traits` | `Streamable` |
| `chia_streamable_macro` | `streamable` (proc macro) |
| DIG extensions | `DigMessage`, `DigMessageType`, `UnknownDigMessageType`, `RegisterPeer`, `RegisterAck`, `RequestPeersIntroducer`, `RespondPeersIntroducer` |
| DIG extensions | `Bytes`, `NodeType`, `UnknownNodeType`, `DigMessage`, `DigMessageType`, `UnknownDigMessageType`, `RegisterPeer`, `RegisterAck`, `RequestPeersIntroducer`, `RespondPeersIntroducer` |
| DIG opcodes | `DIG_BAND_START`, `FREE_BAND_START`, `DIG_MESSAGE`, `STORE_MELTED`, `HOLDINGS_ANNOUNCE`, `ALL_DIG_OPCODES`, `is_dig_opcode` |
| DIG peer link (§7) | `DigLink`, `LinkOptions`, `LinkError`, `OpcodeRateLimiter`, `OpcodeRateLimits` |

Expand Down
218 changes: 218 additions & 0 deletions src/bytes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
//! [`Bytes`] — the DIG-owned payload container for [`DigMessage`](crate::DigMessage).
//!
//! ## Why this is not `chia_protocol::Bytes`
//!
//! The DIG peer wire is a native protocol, not a chia protocol that happens to carry extra
//! opcodes. A DIG frame's payload type is part of DIG's public API: every consumer that builds
//! or reads a DIG message names it. Sourcing that type from `chia-protocol` meant a `chia-protocol`
//! version bump was a breaking change to the DIG wire API, and it is what let chia types leak
//! into consumers that have no chia traffic at all.
//!
//! ## Byte-identity is the whole constraint
//!
//! This is a live network. The [`Streamable`] encoding below is deliberately identical to the one
//! it replaces — a `u32` big-endian length prefix followed by the raw bytes — so a DIG peer on the
//! old type and a DIG peer on this one exchange the same frames. `tests/golden_wire_vectors.rs`
//! pins that as absolute hex; it is not inferred from a round-trip.

use std::{fmt, io::Cursor, ops::Deref};

use chia_sha2::Sha256;
use chia_traits::{Error, Result, Streamable};

/// A length-prefixed byte payload.
///
/// Cheap to build from anything that owns or borrows bytes, and derefs to `[u8]`, so it reads as
/// a slice everywhere it is consumed.
#[derive(Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Bytes(Vec<u8>);

impl Bytes {
/// Wrap an owned byte vector, taking ownership without copying.
#[must_use]
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}

/// Number of payload bytes.
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}

/// Whether the payload is empty. A zero-length payload is a valid DIG frame.
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}

/// Consume into the underlying vector, without copying.
#[must_use]
pub fn into_inner(self) -> Vec<u8> {
self.0
}
}

/// Hex, because a payload is read against a wire dump far more often than as a decimal list.
impl fmt::Display for Bytes {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for byte in &self.0 {
write!(f, "{byte:02x}")?;
}
Ok(())
}
}

/// Same hex rendering as [`Display`](fmt::Display) — a `Vec<u8>`'s derived `Debug` is unreadable
/// at payload sizes, and a payload is almost always inspected inside a larger `Debug` dump.
impl fmt::Debug for Bytes {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Bytes({self})")
}
}

impl Streamable for Bytes {
fn update_digest(&self, digest: &mut Sha256) {
#[allow(clippy::cast_possible_truncation)]
(self.0.len() as u32).update_digest(digest);
digest.update(&self.0);
}

fn stream(&self, out: &mut Vec<u8>) -> Result<()> {
// The length prefix is a u32, so a payload that cannot be described by one is not
// expressible on the wire at all — refused here rather than silently truncated.
if self.0.len() > u32::MAX as usize {
return Err(Error::SequenceTooLarge);
}
#[allow(clippy::cast_possible_truncation)]
(self.0.len() as u32).stream(out)?;
out.extend_from_slice(&self.0);
Ok(())
}

fn parse<const TRUSTED: bool>(input: &mut Cursor<&[u8]>) -> Result<Self> {
let len = u32::parse::<TRUSTED>(input)? as usize;
let start = usize::try_from(input.position()).map_err(|_| Error::EndOfBuffer)?;
let end = start.checked_add(len).ok_or(Error::EndOfBuffer)?;
let buf = *input.get_ref();
if buf.len() < end {
return Err(Error::EndOfBuffer);
}
input.set_position(end as u64);
Ok(Self(buf[start..end].to_vec()))
}
}

impl From<Vec<u8>> for Bytes {
fn from(value: Vec<u8>) -> Self {
Self(value)
}
}

impl From<&[u8]> for Bytes {
fn from(value: &[u8]) -> Self {
Self(value.to_vec())
}
}

impl From<Bytes> for Vec<u8> {
fn from(value: Bytes) -> Self {
value.0
}
}

impl AsRef<[u8]> for Bytes {
fn as_ref(&self) -> &[u8] {
&self.0
}
}

impl Deref for Bytes {
type Target = [u8];

fn deref(&self) -> &Self::Target {
&self.0
}
}

#[cfg(test)]
mod tests {
use super::*;

/// The encoding is pinned as an absolute value, not compared against another encoder: a
/// four-byte big-endian length followed by the raw payload. An encoder using a narrower
/// prefix, or little-endian, produces different bytes here.
#[test]
fn streams_a_u32_big_endian_length_prefix_then_the_raw_payload() {
let bytes = Bytes::new(vec![0xab, 0xcd, 0xef]);
assert_eq!(
bytes.to_bytes().expect("encode"),
vec![0x00, 0x00, 0x00, 0x03, 0xab, 0xcd, 0xef]
);
}

/// A length that no narrower prefix could express, so a `u8` or `u16` prefix masquerading as
/// a `u32` cannot pass. 300 needs two bytes; the prefix must still occupy four.
#[test]
fn a_length_beyond_one_byte_still_occupies_four_prefix_bytes() {
let encoded = Bytes::new(vec![7u8; 300]).to_bytes().expect("encode");
assert_eq!(&encoded[..4], &[0x00, 0x00, 0x01, 0x2c]);
assert_eq!(encoded.len(), 4 + 300);
}

#[test]
fn empty_payload_streams_as_a_bare_zero_length() {
assert_eq!(
Bytes::default().to_bytes().expect("encode"),
vec![0x00, 0x00, 0x00, 0x00]
);
}

#[test]
fn round_trips_through_parse() {
let original = Bytes::new((0..300).map(|i| (i % 251) as u8).collect());
let decoded = Bytes::from_bytes(&original.to_bytes().expect("encode")).expect("decode");
assert_eq!(decoded, original);
}

/// A length prefix that promises more bytes than the buffer holds must be an error, not a
/// panic and not a short read — the prefix is peer-controlled.
#[test]
fn a_length_prefix_longer_than_the_buffer_is_rejected() {
let truncated = [0x00, 0x00, 0x00, 0x08, 0xab, 0xcd];
assert!(Bytes::from_bytes(&truncated).is_err());
}

/// `u32::MAX` as a length must not overflow the cursor arithmetic on any pointer width.
#[test]
fn a_maximal_length_prefix_errors_rather_than_overflowing() {
let hostile = [0xff, 0xff, 0xff, 0xff, 0x00];
assert!(Bytes::from_bytes(&hostile).is_err());
}

#[test]
fn renders_as_hex_in_both_display_and_debug() {
let bytes = Bytes::new(vec![0x00, 0x0f, 0xff]);
assert_eq!(bytes.to_string(), "000fff");
assert_eq!(format!("{bytes:?}"), "Bytes(000fff)");
}

#[test]
fn converts_to_and_from_the_shapes_callers_actually_hold() {
let from_vec: Bytes = vec![1u8, 2, 3].into();
let from_slice: Bytes = [1u8, 2, 3].as_slice().into();
assert_eq!(from_vec, from_slice);
assert_eq!(from_vec.as_ref(), &[1, 2, 3]);
assert_eq!(&*from_slice, &[1, 2, 3]);
assert_eq!(Vec::<u8>::from(from_vec.clone()), vec![1, 2, 3]);
assert_eq!(from_vec.into_inner(), vec![1, 2, 3]);
}

#[test]
fn reports_its_own_length_and_emptiness() {
assert!(Bytes::default().is_empty());
assert_eq!(Bytes::default().len(), 0);
assert!(!Bytes::new(vec![1]).is_empty());
assert_eq!(Bytes::new(vec![1, 2]).len(), 2);
}
}
Loading
Loading