Skip to content

Commit c871e02

Browse files
committed
feat: validate received headers were actually requested
Reject headers in `SegmentState::receive_headers` if the coordinator does not recognize the `prev_hash` as an in-flight request. Returns an `InvalidState` error instead of silently processing unexpected responses. Also adjust the post-sync processing to allow for unrequested headers since we don't request post-sync headers, they get announced.
1 parent 64b8573 commit c871e02

File tree

2 files changed

+92
-2
lines changed

2 files changed

+92
-2
lines changed

dash-spv/src/sync/block_headers/pipeline.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@ impl HeadersPipeline {
175175
if segment.complete && segment.target_height.is_none() {
176176
segment.complete = false;
177177
self.next_to_store = idx;
178+
// Mark as in-flight so the coordinator accepts these unsolicited headers
179+
segment.coordinator.mark_sent(&[prev_hash]);
178180
tracing::debug!(
179181
"Tip segment {} receiving post-sync headers, reset for continued processing",
180182
segment.segment_id
@@ -401,6 +403,35 @@ mod tests {
401403
assert!(ready.is_empty());
402404
}
403405

406+
#[test]
407+
fn test_completed_tip_segment_accepts_unsolicited_post_sync_headers() {
408+
// After initial sync completes, peers may push new block headers without
409+
// us requesting them. The completed tip segment should accept these
410+
// unsolicited headers by marking them as in-flight before processing.
411+
let tip_hash = BlockHash::dummy(99);
412+
413+
let mut tip_seg = SegmentState::new(0, 1000, tip_hash, None, None);
414+
tip_seg.complete = true;
415+
tip_seg.current_height = 1000;
416+
tip_seg.current_tip_hash = tip_hash;
417+
418+
let cm = create_test_checkpoint_manager(true);
419+
let mut pipeline = HeadersPipeline::new(cm);
420+
pipeline.initialized = true;
421+
pipeline.segments = vec![tip_seg];
422+
423+
// Simulate an unsolicited header arriving from a peer (no in-flight request)
424+
let mut header = Header::dummy(1);
425+
header.prev_blockhash = tip_hash;
426+
427+
let matched = pipeline.receive_headers(&[header]).unwrap();
428+
assert_eq!(matched, Some(0), "Tip segment should accept unsolicited post-sync headers");
429+
430+
assert!(!pipeline.segments[0].complete, "Tip segment should be reset to non-complete");
431+
assert_eq!(pipeline.segments[0].buffered_headers.len(), 1);
432+
assert_eq!(pipeline.segments[0].current_height, 1001);
433+
}
434+
404435
#[test]
405436
fn test_completed_segment_does_not_steal_next_segment_headers() {
406437
// Create two segments which share the checkpoint hash boundary.

dash-spv/src/sync/block_headers/segment_state.rs

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,24 @@ impl SegmentState {
100100
return Ok(0);
101101
}
102102

103-
// Mark the request as received
103+
// Reject headers on a segment that already reached its checkpoint
104+
if self.complete {
105+
return Err(SyncError::InvalidState(format!(
106+
"Segment {}: received {} headers on completed segment (height {})",
107+
self.segment_id,
108+
headers.len(),
109+
self.current_height
110+
)));
111+
}
112+
113+
// Mark the request as received, reject if we never requested this hash
104114
let prev_hash = headers[0].prev_blockhash;
105-
self.coordinator.receive(&prev_hash);
115+
if !self.coordinator.receive(&prev_hash) {
116+
return Err(SyncError::InvalidState(format!(
117+
"Segment {}: received unrequested headers (prev_hash {})",
118+
self.segment_id, prev_hash
119+
)));
120+
}
106121

107122
// Process headers
108123
let mut processed = 0;
@@ -308,4 +323,48 @@ mod tests {
308323
assert!(segment.complete);
309324
assert_eq!(segment.buffered_headers.len(), 1);
310325
}
326+
327+
#[test]
328+
fn test_unrequested_headers_returns_error() {
329+
let start_hash = BlockHash::dummy(0);
330+
let mut segment = SegmentState::new(0, 0, start_hash, None, None);
331+
332+
let mut header = Header::dummy(1);
333+
header.prev_blockhash = start_hash;
334+
335+
let result = segment.receive_headers(&[header]);
336+
assert!(result.is_err());
337+
match result.unwrap_err() {
338+
SyncError::InvalidState(msg) => {
339+
assert!(msg.contains("unrequested headers"));
340+
}
341+
other => panic!("Expected SyncError::InvalidState, got {:?}", other),
342+
}
343+
assert!(segment.buffered_headers.is_empty());
344+
}
345+
346+
#[test]
347+
fn test_completed_segment_rejects_new_headers() {
348+
let start_hash = BlockHash::dummy(0);
349+
let mut segment = SegmentState::new(0, 0, start_hash, Some(100), None);
350+
351+
// Mark segment as complete (simulating checkpoint reached)
352+
segment.complete = true;
353+
segment.current_height = 100;
354+
355+
// Create a header that would match
356+
let mut header = Header::dummy(1);
357+
header.prev_blockhash = start_hash;
358+
359+
// Completed segment should return an invalid state error
360+
let result = segment.receive_headers(&[header]);
361+
assert!(result.is_err());
362+
match result.unwrap_err() {
363+
SyncError::InvalidState(msg) => {
364+
assert!(msg.contains("completed segment"));
365+
}
366+
other => panic!("Expected SyncError::InvalidState, got {:?}", other),
367+
}
368+
assert!(segment.buffered_headers.is_empty());
369+
}
311370
}

0 commit comments

Comments
 (0)