Skip to content
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
8f3a7b8
Introduce typed overlay peer identity
metaphorics Oct 24, 2026
1330e41
Add Tor and I2P overlay transports
metaphorics Oct 25, 2026
5e2500b
Record bounded overlay interoperability runs
metaphorics Oct 25, 2026
30bf633
Isolate SAM fixtures from unrelated probes
metaphorics Oct 25, 2026
042ae72
Run overlay checks on isolated pinned fixtures
metaphorics Oct 26, 2026
e86b4b6
Refuse local DNS under privacy routes
metaphorics Oct 27, 2026
f31ae21
Negotiate BIP434 peer features
metaphorics Oct 25, 2026
c5822a0
Relay negotiated stale chain tips
metaphorics Oct 25, 2026
0fe7730
Retry initial stale tips until send acknowledgement
metaphorics Oct 26, 2026
f8c0fac
Add negotiated BIP331 package relay
metaphorics Oct 25, 2026
7f84741
Solicit packages for negotiated orphan peers
metaphorics Oct 25, 2026
a0a30ba
Relay-policy lane: preserve orphan fallback, inbound cadence, retire …
metaphorics Oct 27, 2026
854a0ab
Review fixes for the peer-resolution lane: soft-blocked scan, probe-r…
metaphorics Oct 27, 2026
f8e9a60
Route stale-tip batches through identified peer with minimum-work hol…
metaphorics Oct 27, 2026
c224590
Strengthen stale-tip regressions to drive the drain
metaphorics Oct 27, 2026
5b59792
Remove unused package submission result types
metaphorics Oct 27, 2026
89901a4
Resolve protocol review test lint findings
metaphorics Oct 27, 2026
f987444
Remove stale RPC line-count expectation
metaphorics Oct 27, 2026
c31411e
Document opt-in BIP332 negotiation
metaphorics Oct 27, 2026
c6becf8
Request held orphan parents by package or legacy path, never both
metaphorics Oct 27, 2026
549d228
Ignore notfound answers to questions nobody asked
metaphorics Oct 27, 2026
d9f5053
Document implemented BIP331 negotiation
metaphorics Oct 27, 2026
151a566
Deduplicate chain tip and relay predicates
metaphorics Oct 27, 2026
347a8b5
Enable RPC protocol test seams
metaphorics Oct 27, 2026
612de0c
Update peer test-seam documentation
metaphorics Oct 27, 2026
4107f13
Complete addrv2 rotation and package parent fallback
metaphorics Oct 27, 2026
ffeb1b8
Align config and addrman restart compatibility tests
metaphorics Oct 27, 2026
08da46b
Order unsorted BIP331 ancestor packages before admission
gormes-agent[bot] Oct 28, 2026
59b0993
Fall back to held orphan parents when a package claim dies
gormes-agent[bot] Oct 28, 2026
f58daa5
Fix anonymous peer serving and route prefiltering
metaphorics Oct 28, 2026
dcfce42
Merge main into stack/07-protocol
metaphorics Oct 28, 2026
67ffcbe
Merge main into stack/07-protocol
metaphorics Oct 28, 2026
fcc3fe1
Fix p2p test-only endpoint-identity type errors
metaphorics Oct 28, 2026
87cd648
Fix workspace merge-resolution errors at root
metaphorics Oct 28, 2026
4a02d52
Merge origin/main into stack/07-protocol: resolve wire/payload and co…
metaphorics Oct 28, 2026
62729a5
Fix workspace merge-resolution errors: close validate_overlay, port p…
metaphorics Oct 28, 2026
0312ecc
p2p(listener): publish negotiated package-relay bits before ready
metaphorics Oct 28, 2026
b6f02d5
p2p(service): retain overlay workers for rollback and join
metaphorics Oct 28, 2026
efaadc0
p2p(peer_info): support AnonymousTor inbound metadata
metaphorics Oct 28, 2026
149c29b
p2p(service): capture restart anchors before cancelling workers
metaphorics Oct 28, 2026
b98fa4c
Merge origin/main 8a222c32f into stack/07-protocol
metaphorics Oct 28, 2026
6693c7e
Fix duplicate submit_package_attempt after main merge
metaphorics Oct 28, 2026
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
138 changes: 138 additions & 0 deletions bin/bitcoin-rs/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,38 @@ impl CoreValue for Option<PathBuf> {
}
}

impl CoreValue for Option<SocketAddr> {
fn apply(value: &str, slot: &mut Self) {
if let Ok(addr) = value.parse() {
*slot = Some(addr);
}
}
}

impl CoreValue for Option<Vec<bitcoin_rs_node::options::NetworkLabel>> {
fn apply(value: &str, slot: &mut Self) {
let entries = slot.get_or_insert_with(Vec::new);
for item in value.split(',') {
if let Ok(label) = bitcoin_rs_node::options::parse_network_label(item.trim()) {
if !entries.contains(&label) {
entries.push(label);
}
}
}
}
}
impl CoreValue for Option<Vec<bitcoin_rs_node::options::PeerNetwork>> {
fn apply(value: &str, slot: &mut Self) {
let entries = slot.get_or_insert_with(Vec::new);
for item in value.split(',') {
if let Ok(network) = bitcoin_rs_node::options::parse_peer_network(item.trim()) {
if !entries.contains(&network) {
entries.push(network);
}
}
}
}
}
/// Sets the one Core key that no table row can express: `listen` is a boolean
/// switch over the bind list, not an address, and only its false value is an
/// override. Returns whether it consumed `key`.
Expand Down Expand Up @@ -400,6 +432,7 @@ mod compat_tests {
use anyhow::Result;
use bitcoin_rs_node::{Auth, Network, NodeConfig, resolve};
use std::fs;
use std::net::SocketAddr;

fn load_conf(text: &str, network: Network) -> Result<NodeConfig> {
let temp = tempfile::tempdir()?;
Expand Down Expand Up @@ -431,6 +464,45 @@ mod compat_tests {
assert_eq!(config.p2p.listen, []);
assert!(config.indexes.txindex);
assert_eq!(config.storage.dbcache_mb, 768);

let conf_with_overlays = load_conf(
r"
proxy=127.0.0.1:9050
onion=127.0.0.1:9051
dnsseed=0
proxyrandomize=1
torcontrol=127.0.0.1:9052
i2psam=127.0.0.1:7656
i2pacceptincoming=1
onlynet=onion,i2p
",
Network::Mainnet,
)?;
assert_eq!(
conf_with_overlays.p2p.overlay.proxy,
Some(SocketAddr::from(([127, 0, 0, 1], 9050)))
);
assert_eq!(
conf_with_overlays.p2p.overlay.onion_proxy,
Some(SocketAddr::from(([127, 0, 0, 1], 9051)))
);
assert!(conf_with_overlays.p2p.overlay.randomize_credentials);
assert_eq!(
conf_with_overlays.p2p.overlay.tor_control,
Some(SocketAddr::from(([127, 0, 0, 1], 9052)))
);
assert_eq!(
conf_with_overlays.p2p.overlay.i2p_sam,
Some(SocketAddr::from(([127, 0, 0, 1], 7656)))
);
assert!(conf_with_overlays.p2p.overlay.i2p_accept);
assert_eq!(
conf_with_overlays.p2p.overlay.onlynet,
vec![
bitcoin_rs_node::options::PeerNetwork::Onion,
bitcoin_rs_node::options::PeerNetwork::I2p
]
);
Ok(())
}

Expand Down Expand Up @@ -501,3 +573,69 @@ assumevalid=0000000000000000000000000000000000000000000000000000000000000000
}
}
}

#[cfg(test)]
mod feature_option_tests {
use std::ffi::OsString;

use anyhow::Result;
use bitcoin_rs_node::NodeConfig;
use clap::Parser as _;

use super::{CliArgs, resolve};

fn load(args: &[&str], vars: &[(&str, &str)]) -> Result<NodeConfig> {
let argv = std::iter::once("bitcoin-rs").chain(args.iter().copied());
let cli = CliArgs::try_parse_from(argv)?;
resolve(
cli,
vars.iter()
.map(|(key, value)| (OsString::from(*key), OsString::from(*value))),
)
}

fn refusal(args: &[&str], vars: &[(&str, &str)]) -> String {
match load(args, vars) {
Ok(_) => panic!("configuration must be refused"),
Err(error) => error.to_string(),
}
}

#[test]
fn relay_switches_are_bare_cli_flags_and_the_command_line_beats_environment() -> Result<()> {
let off = load(&[], &[])?.p2p;
assert!(
!off.relay_offers.package_relay
&& !off.relay_offers.stale_tip_relay
&& !off.relay_offers.stale_tip_prefers_blocks
);

let on = load(
&[
"--p2p-package-relay",
"--p2p-stale-tip-relay",
"--p2p-stale-tip-prefers-blocks",
],
&[],
)?
.p2p;
assert!(
on.relay_offers.package_relay
&& on.relay_offers.stale_tip_relay
&& on.relay_offers.stale_tip_prefers_blocks
);

let env_on = load(&[], &[("BITCOIN_RS_P2P_PACKAGE_RELAY", "true")])?.p2p;
assert!(env_on.relay_offers.package_relay);
let cli_off = load(
&["--p2p-package-relay=false"],
&[("BITCOIN_RS_P2P_PACKAGE_RELAY", "true")],
)?
.p2p;
assert!(!cli_off.relay_offers.package_relay);

let dangling = refusal(&["--p2p-stale-tip-prefers-blocks"], &[]);
assert!(dangling.contains("--p2p-stale-tip-relay"));
Ok(())
}
}
1 change: 1 addition & 0 deletions crates/chain/src/deployment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,7 @@ mod tests {
// prev_blockhash and the by_hash key pointing at a discarded hash.
let node_id = if height == activation_height {
tree.insert_header_with_hash(header, expected_hash, NodeStatus::HeaderValid)?
.id
} else {
tree.insert_header(header, NodeStatus::HeaderValid)?
};
Expand Down
165 changes: 155 additions & 10 deletions crates/chain/src/header_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use pow::{compact_to_target, target_to_compact};
use crate::{
ChainError,
node::{BlockHeader, ChainWork, NodeId, NodeStatus},
tree::BlockTree,
tree::{BlockTree, Inserted},
};

// Maximum live future drift from the caller's network-adjusted clock.
Expand Down Expand Up @@ -39,6 +39,9 @@ pub enum HeaderAdmission {
announced_tip: Option<Hash256>,
/// Height of `announced_tip` on the active chain, when resolvable.
active_height: Option<i32>,
/// The active tip this batch moved onto another branch, when it did;
/// see [`AcceptHeaders::demoted_tip`].
demoted_tip: Option<Hash256>,
},
/// Header validation rejected the batch (peer-fault classification stays
/// with the caller).
Expand All @@ -48,6 +51,63 @@ pub enum HeaderAdmission {
Refused(Box<dyn core::error::Error + Send + Sync>),
}

/// Facts of one batch admitted by [`accept_headers`].
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct AcceptHeaders {
/// One node id per input header, in input order, including idempotent
/// duplicates.
pub ids: Vec<NodeId>,
/// The active tip this batch moved onto another branch, when it did: the
/// tip that was active before the batch, now a stale side-branch tip.
/// `None` when the batch extended the active tip or left it unchanged.
///
/// A contiguous batch displaces at most one tip, because once the tip lies
/// on the batch's own chain every later header extends it. A batch the
/// validation rejects after a prefix moved the tip returns `Err`, so that
/// displacement is not reported here; the old tip stays a stale leaf and
/// the caller recovers it with [`demoted_tip_after_rejection`].
pub demoted_tip: Option<Hash256>,
}

impl AcceptHeaders {
fn push_inserted(&mut self, inserted: Inserted) {
self.ids.push(inserted.id);
self.demoted_tip = self.demoted_tip.or(inserted.demoted_tip);
}
}

/// Demoted tip when a batch [`accept_headers`] rejected still displaced the tip.
///
/// [`accept_headers`] inserts each valid prefix header before validating the
/// next, so a batch whose prefix carries more work than the active tip moves
/// the tip and marks the old tip `Stale` even when a later header fails and
/// the call returns `Err`. The `Err` itself reports no displacement (see
/// [`AcceptHeaders::demoted_tip`); the old tip stays a stale leaf and must
/// still enter stale-tip relay.
///
/// Call after an `Err` return with `pre_tip` captured before the call and the
/// same `tree`. Returns `pre_tip` when the tip moved onto a branch that does
/// not contain it, else `None` (no movement, linear extension, or unknown
/// tips). The tree stays the authority; this only reads it.
///
/// Single ownership: header admission owns tip mutation; this helper and the
/// stale-tip retainer only read the displacement it left behind.
#[must_use]
pub fn demoted_tip_after_rejection(tree: &BlockTree, pre_tip: Option<Hash256>) -> Option<Hash256> {
let pre_hash = pre_tip?;
let pre_id = tree.lookup(pre_hash)?;
let current = tree.tip()?;
if current.hash == pre_hash {
return None;
}
// A linear extension keeps the old tip as an ancestor: nothing was
// displaced onto another branch.
if tree.find_common_ancestor(pre_id, current.tip_id) == Some(pre_id) {
return None;
}
Some(pre_hash)
}

/// Accepts a contiguous batch of headers after proof-of-work validation.
///
/// A header already in the tree is idempotent: a known-invalid one returns
Expand All @@ -61,15 +121,18 @@ pub fn accept_headers(
network: Network,
now_secs: u32,
mode: HeaderValidationMode,
) -> Result<Vec<NodeId>, ChainError> {
let mut accepted = Vec::with_capacity(headers.len());
) -> Result<AcceptHeaders, ChainError> {
let mut accepted = AcceptHeaders {
ids: Vec::with_capacity(headers.len()),
demoted_tip: None,
};
for header in headers {
let hash = Hash256::from(header.compute_hash());
if let Some(existing_id) = tree.lookup(hash) {
if matches!(tree.node(existing_id)?.status, NodeStatus::Invalid) {
return Err(ChainError::KnownInvalidHeader { hash });
}
accepted.push(existing_id);
accepted.ids.push(existing_id);
continue;
}
validate_pow(header, hash, network)?;
Expand All @@ -82,8 +145,8 @@ pub fn accept_headers(
}
// The validated genesis root: the tree is empty, so the
// header has no parent and no contextual rule applies.
let id = tree.insert_header_with_hash(*header, hash, NodeStatus::HeaderValid)?;
accepted.push(id);
let inserted = tree.insert_header_with_hash(*header, hash, NodeStatus::HeaderValid)?;
accepted.push_inserted(inserted);
continue;
}
let prev_hash = Hash256::from(header.prev_blockhash);
Expand All @@ -96,8 +159,8 @@ pub fn accept_headers(
return Err(ChainError::InvalidParent { parent: parent_id });
}
validate_contextual_header(tree, parent_id, header, network, now_secs, mode)?;
let id = tree.insert_header_with_hash(*header, hash, NodeStatus::HeaderValid)?;
accepted.push(id);
let inserted = tree.insert_header_with_hash(*header, hash, NodeStatus::HeaderValid)?;
accepted.push_inserted(inserted);
}
Ok(accepted)
}
Expand Down Expand Up @@ -565,8 +628,8 @@ mod fixture {
#[cfg(test)]
mod contextual_header_tests {
use super::{
HeaderValidationMode, MAX_FUTURE_TIME_SECONDS, accept_headers, fixture::mine_regtest,
next_work_required, validate_contextual_header,
HeaderValidationMode, MAX_FUTURE_TIME_SECONDS, accept_headers, demoted_tip_after_rejection,
fixture::mine_regtest, next_work_required, validate_contextual_header,
};
use crate::{
ChainError,
Expand Down Expand Up @@ -886,4 +949,86 @@ mod contextual_header_tests {

Ok(())
}

/// A rejected batch whose valid higher-work prefix displaced the tip
/// still reports the old tip through [`demoted_tip_after_rejection`].
///
/// The `Err` itself carries no displacement; the tree retains the prefix
/// and the helper recovers the stale leaf for relay instead of losing it.
/// A linear extension that fails the same way reports no demotion.
#[test]
fn partial_prefix_failure_preserves_demoted_tip_via_helper()
-> Result<(), Box<dyn std::error::Error>> {
let network = Network::Regtest;
let genesis = network.genesis_block();
let base_time = genesis.header.time;
let mut tree = BlockTree::new();
accept_headers(
&mut tree,
&[genesis.header],
network,
base_time,
HeaderValidationMode::LiveAdmission,
)?;
let mut prev = genesis.block_hash();
for height in 1..=2_u32 {
extend_regtest(&mut tree, &mut prev, height, 4, base_time);
}
let pre_tip = tree.tip().map(|tip| tip.hash);

// Side branch from genesis with more headers than the main chain, so
// its prefix carries strictly higher work and displaces the tip.
let mut side_prev = genesis.block_hash();
let mut batch = Vec::new();
for height in [101_u32, 102, 103] {
let header = mine_regtest(side_prev, height, base_time + height * 600, 4);
side_prev = header.compute_hash();
batch.push(header);
}
// Invalid tail on the displaced tip: mined for its garbage parent so
// proof of work passes, then rejected with `MissingParent` after the
// prefix has already moved the tip.
let garbage = BlockHash::from(Hash256::from_le_bytes(&[0xFF_u8; 32]));
let tail = mine_regtest(garbage, 104, base_time + 104 * 600, 4);
batch.push(tail);

let now = base_time + 200 * 600;
let result = accept_headers(
&mut tree,
&batch,
network,
now,
HeaderValidationMode::LiveAdmission,
);
assert!(
matches!(result, Err(ChainError::MissingParent { .. })),
"the tail must fail after the prefix moved the tip, got {result:?}"
);
assert_eq!(
demoted_tip_after_rejection(&tree, pre_tip),
pre_tip,
"the tip active before the batch is the preserved demotion"
);

// A linear extension that fails the same way displaces nothing.
let tip_now = tree.tip().map(|tip| tip.hash);
let bad_child = mine_regtest(garbage, 999, now, 4);
let linear = accept_headers(
&mut tree,
&[bad_child],
network,
now,
HeaderValidationMode::LiveAdmission,
);
assert!(
matches!(linear, Err(ChainError::MissingParent { .. })),
"linear failure must also reject, got {linear:?}"
);
assert_eq!(
demoted_tip_after_rejection(&tree, tip_now),
None,
"no movement means no demotion to preserve"
);
Ok(())
}
}
Loading
Loading