commit 2996a16fa32189fadffa800d7f086fd25e228550
parent 55e3cad8573abf1fd863bb1f430abc51d8bcfcc7
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Thu, 27 Aug 2026 11:26:17 +0200
fix(p2p): recover from forked block pages
Diffstat:
2 files changed, 86 insertions(+), 1 deletion(-)
diff --git a/src/adapters/p2p/peer_addr.rs b/src/adapters/p2p/peer_addr.rs
@@ -148,16 +148,20 @@ pub(super) fn is_possible_fork_error(error: &anyhow::Error) -> bool {
message.contains("does not extend local tip")
|| message.contains("conflicts with local chain")
|| message.contains("expected block height")
+ || message.contains("block page has no common ancestor with local chain")
}
pub(super) fn inbound_error_counts_as_misbehavior(message: &str) -> bool {
!message.contains("block timestamp is too far in the future")
&& !message.contains("block timestamp is before finalizer rank")
+ && !message.contains("block page has no common ancestor with local chain")
}
#[cfg(test)]
mod tests {
- use super::inbound_error_counts_as_misbehavior;
+ use anyhow::anyhow;
+
+ use super::{inbound_error_counts_as_misbehavior, is_possible_fork_error};
#[test]
fn future_and_unopened_rank_slot_errors_are_temporal_not_misbehavior() {
@@ -169,4 +173,12 @@ mod tests {
));
assert!(inbound_error_counts_as_misbehavior("block hash is invalid"));
}
+
+ #[test]
+ fn missing_block_page_ancestor_triggers_fork_recovery_without_peer_penalty() {
+ let message = "block batch: block page has no common ancestor with local chain";
+
+ assert!(is_possible_fork_error(&anyhow!(message)));
+ assert!(!inbound_error_counts_as_misbehavior(message));
+ }
}
diff --git a/src/adapters/p2p/tests.rs b/src/adapters/p2p/tests.rs
@@ -175,6 +175,79 @@ async fn single_block_fork_error_requests_blocks_by_locator() {
}
#[tokio::test]
+async fn block_page_without_local_ancestor_requests_blocks_by_locator() {
+ let alice = Wallet::from_seed("block-page-fork-alice");
+ let allocations = allocations(std::slice::from_ref(&alice), 1_000);
+ let mut local_node = node("local-block-page-fork", alice.clone(), allocations.clone());
+ let mut remote_node = node("remote-block-page-fork", alice.clone(), allocations);
+
+ queue_plaintext_burn(&mut local_node, &alice, 1);
+ local_node.drain_outbox();
+ local_node.mine_one_at(1).unwrap();
+ local_node.drain_outbox();
+
+ for timestamp_ms in [2, 3] {
+ queue_plaintext_burn(&mut remote_node, &alice, 1);
+ remote_node.drain_outbox();
+ remote_node.mine_one_at(timestamp_ms).unwrap();
+ remote_node.drain_outbox();
+ }
+ let remote_page = remote_node.blocks_from(2, 10);
+ assert_eq!(remote_page.len(), 1);
+ assert_ne!(
+ remote_page[0].prev_hash,
+ local_node.ledger().tip_hash().to_string()
+ );
+
+ let network = gossip_network(
+ Arc::new(tokio::sync::Mutex::new(local_node)),
+ Arc::new(tokio::sync::Mutex::new(PeerBook::default())),
+ "127.0.0.1:9545".parse().unwrap(),
+ None,
+ );
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let client = tokio::net::TcpStream::connect(listener.local_addr().unwrap())
+ .await
+ .unwrap();
+ let (server, remote_addr) = listener.accept().await.unwrap();
+ let (_server_reader, mut server_writer) = server.into_split();
+ let (client_reader, _client_writer) = client.into_split();
+ let mut client_reader = super::LimitedLineReader::new(client_reader);
+ let mut known_peer = None;
+
+ super::process_envelope(
+ &network,
+ &mut server_writer,
+ remote_addr,
+ &mut known_peer,
+ GossipEnvelope::Blocks {
+ blocks: remote_page,
+ },
+ )
+ .await
+ .unwrap();
+
+ let line = tokio::time::timeout(std::time::Duration::from_secs(1), client_reader.read_line())
+ .await
+ .unwrap()
+ .unwrap()
+ .unwrap();
+ let GossipEnvelope::BlockLocatorRequest { locator, limit } =
+ super::parse_envelope(&line).unwrap()
+ else {
+ panic!("expected block locator request");
+ };
+ let fork_blocks = remote_node.blocks_after_locator(&locator, limit);
+ assert_eq!(fork_blocks.len(), 2);
+ let local_ledger = network.inner.node.lock().await.clone_ledger();
+ let adopted =
+ super::validate_blocks_extension(local_ledger, fork_blocks, crate::app::now_ms(), |_| {})
+ .await
+ .unwrap();
+ assert_eq!(adopted.tip_hash(), remote_node.ledger().tip_hash());
+}
+
+#[tokio::test]
async fn future_block_rejection_does_not_poison_peer_or_later_acceptance() {
let alice = Wallet::from_seed("future-block-p2p-alice");
let allocations = allocations(std::slice::from_ref(&alice), 1_000);