Skip to content

Commit e130389

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 ab4aef8 commit e130389

File tree

2 files changed

+93
-3
lines changed

2 files changed

+93
-3
lines changed

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ impl HeadersPipeline {
169169
if segment.complete && segment.target_height.is_none() {
170170
segment.complete = false;
171171
self.next_to_store = idx;
172+
// Mark as in-flight so the coordinator accepts these unsolicited headers
173+
segment.coordinator.mark_sent(&[prev_hash]);
172174
tracing::debug!(
173175
"Tip segment {} receiving post-sync headers, reset for continued processing",
174176
segment.segment_id
@@ -393,4 +395,33 @@ mod tests {
393395
let ready = pipeline.take_ready_to_store();
394396
assert!(ready.is_empty());
395397
}
398+
399+
#[test]
400+
fn test_completed_tip_segment_accepts_unsolicited_post_sync_headers() {
401+
// After initial sync completes, peers may push new block headers without
402+
// us requesting them. The completed tip segment should accept these
403+
// unsolicited headers by marking them as in-flight before processing.
404+
let tip_hash = BlockHash::dummy(99);
405+
406+
let mut tip_seg = SegmentState::new(0, 1000, tip_hash, None, None);
407+
tip_seg.complete = true;
408+
tip_seg.current_height = 1000;
409+
tip_seg.current_tip_hash = tip_hash;
410+
411+
let cm = create_test_checkpoint_manager(true);
412+
let mut pipeline = HeadersPipeline::new(cm);
413+
pipeline.initialized = true;
414+
pipeline.segments = vec![tip_seg];
415+
416+
// Simulate an unsolicited header arriving from a peer (no in-flight request)
417+
let mut header = Header::dummy(1);
418+
header.prev_blockhash = tip_hash;
419+
420+
let matched = pipeline.receive_headers(&[header]).unwrap();
421+
assert_eq!(matched, Some(0), "Tip segment should accept unsolicited post-sync headers");
422+
423+
assert!(!pipeline.segments[0].complete, "Tip segment should be reset to non-complete");
424+
assert_eq!(pipeline.segments[0].buffered_headers.len(), 1);
425+
assert_eq!(pipeline.segments[0].current_height, 1001);
426+
}
396427
}

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

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ pub(super) struct SegmentState {
2020
/// Target hash (next checkpoint hash for validation).
2121
target_hash: Option<BlockHash>,
2222
/// Current tip hash for GetHeaders locator.
23-
current_tip_hash: BlockHash,
23+
pub(super) current_tip_hash: BlockHash,
2424
/// Current height reached in this segment.
2525
pub(super) current_height: u32,
2626
/// Download coordinator for tracking in-flight requests.
@@ -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)