Discovery protocol: LookupRequest/LookupResponse handlers

Implement the coordinate discovery protocol with flood-based lookup
and reverse-path response routing.

Wire format: LookupRequest (0x30) encode/decode with TTL, visited
bloom filter, origin coords. LookupResponse (0x31) encode/decode
with target coords and Schnorr proof signature.

Handler logic: request dedup by request_id, visited filter loop
prevention, TTL enforcement, lazy purge of expired entries (10s).
Response routing: originator caches route in route_cache, transit
nodes reverse-path forward via recent_requests.

Node state: RecentRequest struct, route_cache (RouteCache), and
recent_requests map for dedup + reverse-path forwarding.

13 handler tests (9 unit + 4 integration) plus 4 protocol tests.
392 tests pass, clean build.
This commit is contained in:
Johnathan Corgan
2026-02-12 14:46:33 +00:00
parent 009101ee4a
commit 9a7fa921ab
8 changed files with 919 additions and 7 deletions
+203
View File
@@ -1,6 +1,8 @@
//! Discovery messages: LookupRequest and LookupResponse.
use crate::bloom::BloomFilter;
use crate::protocol::error::ProtocolError;
use crate::protocol::session::{decode_coords, encode_coords};
use crate::tree::TreeCoordinate;
use crate::NodeAddr;
use secp256k1::schnorr::Signature;
@@ -79,6 +81,90 @@ impl LookupRequest {
pub fn was_visited(&self, node_addr: &NodeAddr) -> bool {
self.visited.contains(node_addr)
}
/// Encode as wire format (includes msg_type byte).
///
/// Format: `[0x30][request_id:8][target:16][origin:16][ttl:1]`
/// `[origin_coords_cnt:2][origin_coords:16×n]`
/// `[visited_hash_cnt:1][visited_bits:256]`
pub fn encode(&self) -> Vec<u8> {
let visited_bytes = self.visited.as_bytes();
let mut buf = Vec::with_capacity(44 + self.origin_coords.depth() * 16 + 1 + visited_bytes.len());
buf.push(0x30); // msg_type
buf.extend_from_slice(&self.request_id.to_le_bytes());
buf.extend_from_slice(self.target.as_bytes());
buf.extend_from_slice(self.origin.as_bytes());
buf.push(self.ttl);
encode_coords(&self.origin_coords, &mut buf);
buf.push(self.visited.hash_count());
buf.extend_from_slice(visited_bytes);
buf
}
/// Decode from wire format (after msg_type byte has been consumed).
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> {
// Minimum: request_id(8) + target(16) + origin(16) + ttl(1)
// + coords_count(2) + hash_count(1) = 44 bytes
if payload.len() < 44 {
return Err(ProtocolError::MessageTooShort {
expected: 44,
got: payload.len(),
});
}
let mut pos = 0;
let request_id = u64::from_le_bytes(
payload[pos..pos + 8]
.try_into()
.map_err(|_| ProtocolError::Malformed("bad request_id".into()))?,
);
pos += 8;
let mut target_bytes = [0u8; 16];
target_bytes.copy_from_slice(&payload[pos..pos + 16]);
let target = NodeAddr::from_bytes(target_bytes);
pos += 16;
let mut origin_bytes = [0u8; 16];
origin_bytes.copy_from_slice(&payload[pos..pos + 16]);
let origin = NodeAddr::from_bytes(origin_bytes);
pos += 16;
let ttl = payload[pos];
pos += 1;
let (origin_coords, consumed) = decode_coords(&payload[pos..])?;
pos += consumed;
if payload.len() < pos + 1 {
return Err(ProtocolError::MessageTooShort {
expected: pos + 1,
got: payload.len(),
});
}
let hash_count = payload[pos];
pos += 1;
let filter_bytes = &payload[pos..];
if filter_bytes.is_empty() {
return Err(ProtocolError::Malformed("visited filter missing".into()));
}
let visited = BloomFilter::from_slice(filter_bytes, hash_count)
.map_err(|e| ProtocolError::Malformed(format!("bad visited filter: {e}")))?;
Ok(Self {
request_id,
target,
origin,
origin_coords,
ttl,
visited,
})
}
}
/// Response to a lookup request with target's coordinates.
@@ -121,6 +207,65 @@ impl LookupResponse {
bytes.extend_from_slice(target.as_bytes());
bytes
}
/// Encode as wire format (includes msg_type byte).
///
/// Format: `[0x31][request_id:8][target:16][target_coords_cnt:2][target_coords:16×n][proof:64]`
pub fn encode(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(91 + self.target_coords.depth() * 16);
buf.push(0x31); // msg_type
buf.extend_from_slice(&self.request_id.to_le_bytes());
buf.extend_from_slice(self.target.as_bytes());
encode_coords(&self.target_coords, &mut buf);
buf.extend_from_slice(self.proof.as_ref());
buf
}
/// Decode from wire format (after msg_type byte has been consumed).
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> {
// Minimum: request_id(8) + target(16) + coords_count(2) + proof(64) = 90
if payload.len() < 90 {
return Err(ProtocolError::MessageTooShort {
expected: 90,
got: payload.len(),
});
}
let mut pos = 0;
let request_id = u64::from_le_bytes(
payload[pos..pos + 8]
.try_into()
.map_err(|_| ProtocolError::Malformed("bad request_id".into()))?,
);
pos += 8;
let mut target_bytes = [0u8; 16];
target_bytes.copy_from_slice(&payload[pos..pos + 16]);
let target = NodeAddr::from_bytes(target_bytes);
pos += 16;
let (target_coords, consumed) = decode_coords(&payload[pos..])?;
pos += consumed;
if payload.len() < pos + 64 {
return Err(ProtocolError::MessageTooShort {
expected: pos + 64,
got: payload.len(),
});
}
let proof = Signature::from_slice(&payload[pos..pos + 64])
.map_err(|_| ProtocolError::Malformed("bad proof signature".into()))?;
Ok(Self {
request_id,
target,
target_coords,
proof,
})
}
}
#[cfg(test)]
@@ -190,4 +335,62 @@ mod tests {
assert_eq!(&bytes[0..8], &12345u64.to_le_bytes());
assert_eq!(&bytes[8..24], target.as_bytes());
}
#[test]
fn test_lookup_request_encode_decode_roundtrip() {
let target = make_node_addr(10);
let origin = make_node_addr(20);
let coords = make_coords(&[20, 0]);
let mut request = LookupRequest::new(12345, target, origin, coords.clone(), 8);
request.forward(&make_node_addr(30));
let encoded = request.encode();
assert_eq!(encoded[0], 0x30);
let decoded = LookupRequest::decode(&encoded[1..]).unwrap();
assert_eq!(decoded.request_id, 12345);
assert_eq!(decoded.target, target);
assert_eq!(decoded.origin, origin);
assert_eq!(decoded.ttl, 7); // decremented by forward()
assert!(decoded.was_visited(&make_node_addr(30)));
}
#[test]
fn test_lookup_request_decode_too_short() {
assert!(LookupRequest::decode(&[]).is_err());
assert!(LookupRequest::decode(&[0u8; 40]).is_err());
}
#[test]
fn test_lookup_response_encode_decode_roundtrip() {
use secp256k1::Secp256k1;
let target = make_node_addr(42);
let coords = make_coords(&[42, 1, 0]);
// Create a dummy signature for testing
let secp = Secp256k1::new();
let keypair = secp256k1::Keypair::new(&secp, &mut rand::thread_rng());
let proof_data = LookupResponse::proof_bytes(999, &target);
use sha2::Digest;
let digest: [u8; 32] = sha2::Sha256::digest(&proof_data).into();
let sig = secp.sign_schnorr(&digest, &keypair);
let response = LookupResponse::new(999, target, coords.clone(), sig);
let encoded = response.encode();
assert_eq!(encoded[0], 0x31);
let decoded = LookupResponse::decode(&encoded[1..]).unwrap();
assert_eq!(decoded.request_id, 999);
assert_eq!(decoded.target, target);
assert_eq!(decoded.proof, sig);
}
#[test]
fn test_lookup_response_decode_too_short() {
assert!(LookupResponse::decode(&[]).is_err());
assert!(LookupResponse::decode(&[0u8; 50]).is_err());
}
}
+2 -2
View File
@@ -76,7 +76,7 @@ impl fmt::Display for SessionMessageType {
///
/// Session-layer messages serialize coordinates as NodeAddr arrays (16 bytes each),
/// without the sequence/timestamp metadata used by the tree gossip protocol.
fn encode_coords(coords: &TreeCoordinate, buf: &mut Vec<u8>) {
pub(crate) fn encode_coords(coords: &TreeCoordinate, buf: &mut Vec<u8>) {
let addrs: Vec<&NodeAddr> = coords.node_addrs().collect();
let count = addrs.len() as u16;
buf.extend_from_slice(&count.to_le_bytes());
@@ -88,7 +88,7 @@ fn encode_coords(coords: &TreeCoordinate, buf: &mut Vec<u8>) {
/// Decode a TreeCoordinate from address-only wire format.
///
/// Returns the decoded coordinate and the number of bytes consumed.
fn decode_coords(data: &[u8]) -> Result<(TreeCoordinate, usize), ProtocolError> {
pub(crate) fn decode_coords(data: &[u8]) -> Result<(TreeCoordinate, usize), ProtocolError> {
if data.len() < 2 {
return Err(ProtocolError::MessageTooShort {
expected: 2,