From afc0fe7940a541bb25991e2e08a50861398b7195 Mon Sep 17 00:00:00 2001 From: "rbitcoin-grok[bot]" Date: Fri, 28 Aug 2026 20:07:01 -0700 Subject: [PATCH 1/5] net: Core maxconnections inbound + eviction-at-cap Treat --maxconnections as Core total slots (inbound = N-11) while --maxinbound stays an explicit inbound cap. When inbound is full, AttemptToEvictConnection-shaped protection picks an unprotected peer instead of silently rejecting the new connection (p2p_eviction). --- OPERATOR.md | 5 +- crates/rbitcoin-net/src/eviction.rs | 174 ++++++++++++++++++++++++++++ crates/rbitcoin-net/src/lib.rs | 2 + crates/rbitcoin-net/src/peers.rs | 30 ++++- crates/rbitcoin-net/src/service.rs | 32 ++++- crates/rbitcoin-node/src/cli.rs | 51 +++++++- crates/rbitcoin-node/src/config.rs | 51 +++++++- 7 files changed, 330 insertions(+), 15 deletions(-) create mode 100644 crates/rbitcoin-net/src/eviction.rs diff --git a/OPERATOR.md b/OPERATOR.md index 834a984a..7e4c18c6 100644 --- a/OPERATOR.md +++ b/OPERATOR.md @@ -121,7 +121,8 @@ Routine knobs are **CLI / conf**, not required env vars. Clean smoke: | `--connect ADDR` | (repeatable) | seeds | | `--milestone HEIGHT` | `--assumevalid-height` | network default (mainnet 840000) | | `--max-outbound N` | `--maxoutbound` | 16 live download peers | -| `--maxinbound N` | `--maxconnections` | 125 inbound sessions | +| `--maxinbound N` | | 125 inbound sessions | +| `--maxconnections N` | | Core total slots; inbound = `N − 11` (10 outbound + feeler) | | `--mempool-size-mb N` | `--maxmempool` | ~300 MiB weight | | `--conf FILE` | | none | | `--log-level LEVEL` | | `info` | @@ -265,7 +266,7 @@ Token meanings and ring depth: [`docs/io-modality.md`](docs/io-modality.md). | IBD concurrent getdata | **1024** | code `IbdConfig::window` | | Blocks in transit / peer | **16** | `IbdConfig::per_peer` | | Live IBD peers | **16** | `--max-outbound` | -| Inbound P2P sessions | **125** | `--maxinbound` / `--maxconnections`. Incomplete VERSION/VERACK is dropped after **60 s** (releases the slot). | +| Inbound P2P sessions | **125** | `--maxinbound`, or `--maxconnections` (Core total → inbound `N−11`). At capacity, unprotected inbounds are evicted. Incomplete VERSION/VERACK is dropped after **60 s** (releases the slot). | | Milestone (skip scripts ≤ height) | mainnet **840000**, signet 2000000, … | `--milestone` / `--assumevalid-height` (`0` = full scripts) | | ConfirmParentCache header plans | always on | Tip-ahead header + tx_fks for multi-block MTP (no create pin FIFO) | | Bulk store IO | **uring** (Linux) when available | `RBITCOIN_IO` only. Matrix: [`docs/io-modality.md`](docs/io-modality.md) | diff --git a/crates/rbitcoin-net/src/eviction.rs b/crates/rbitcoin-net/src/eviction.rs new file mode 100644 index 00000000..6bc2f387 --- /dev/null +++ b/crates/rbitcoin-net/src/eviction.rs @@ -0,0 +1,174 @@ +//! Inbound peer eviction (Core `SelectNodeToEvict` / `AttemptToEvictConnection`). +//! +//! When inbound slots are full, accept a new peer only after disconnecting one +//! unprotected inbound. Protection mirrors Core: netgroup, recent blocks, recent +//! txs, lowest min-ping. + +/// One inbound session considered for eviction. +#[derive(Clone, Debug)] +pub struct InboundEvictCandidate { + pub id: u64, + pub connected_at: u64, + pub min_ping: Option, + pub last_block: u64, + pub last_tx: u64, + pub netgroup: u64, + pub noban: bool, +} + +const PROTECT_NETGROUP: usize = 4; +const PROTECT_BLOCKS: usize = 4; +const PROTECT_TXS: usize = 4; +const PROTECT_MINPING: usize = 8; + +/// Pick one inbound id to disconnect, or `None` if every candidate is protected. +pub fn select_inbound_eviction(mut cands: Vec) -> Option { + cands.retain(|c| !c.noban); + if cands.is_empty() { + return None; + } + + protect_by_netgroup(&mut cands, PROTECT_NETGROUP); + if cands.is_empty() { + return None; + } + + cands.sort_by(|a, b| b.last_block.cmp(&a.last_block).then_with(|| a.id.cmp(&b.id))); + remove_first_k(&mut cands, PROTECT_BLOCKS); + if cands.is_empty() { + return None; + } + + cands.sort_by(|a, b| b.last_tx.cmp(&a.last_tx).then_with(|| a.id.cmp(&b.id))); + remove_first_k(&mut cands, PROTECT_TXS); + if cands.is_empty() { + return None; + } + + cands.sort_by(|a, b| { + ping_key(a.min_ping) + .partial_cmp(&ping_key(b.min_ping)) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.id.cmp(&b.id)) + }); + remove_first_k(&mut cands, PROTECT_MINPING); + if cands.is_empty() { + return None; + } + + // Prefer the longest-connected remaining peer (stable id tie-break). + cands.sort_by(|a, b| a.connected_at.cmp(&b.connected_at).then_with(|| a.id.cmp(&b.id))); + Some(cands[0].id) +} + +fn ping_key(min_ping: Option) -> f64 { + min_ping.unwrap_or(f64::MAX) +} + +fn remove_first_k(cands: &mut Vec, k: usize) { + let n = k.min(cands.len()); + cands.drain(0..n); +} + +/// Protect up to `k` peers from the largest keyed netgroups (Core netgroup protect). +fn protect_by_netgroup(cands: &mut Vec, k: usize) { + if k == 0 || cands.is_empty() { + return; + } + use std::collections::HashMap; + let mut counts: HashMap = HashMap::new(); + for c in cands.iter() { + *counts.entry(c.netgroup).or_insert(0) += 1; + } + let mut groups: Vec<(u64, usize)> = counts.into_iter().collect(); + groups.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + + let mut protect_ids = Vec::new(); + for (group, _) in groups { + if protect_ids.len() >= k { + break; + } + if let Some(c) = cands.iter().filter(|c| c.netgroup == group).min_by_key(|c| c.id) { + protect_ids.push(c.id); + } + } + cands.retain(|c| !protect_ids.contains(&c.id)); +} + +/// Stable netgroup key for eviction (IPv4 /24, else full IP hash). +pub fn eviction_netgroup(addr: std::net::SocketAddr) -> u64 { + match addr.ip() { + std::net::IpAddr::V4(v4) => { + let o = v4.octets(); + u64::from(o[0]) << 16 | u64::from(o[1]) << 8 | u64::from(o[2]) + } + std::net::IpAddr::V6(v6) => { + let o = v6.octets(); + u64::from_be_bytes([o[0], o[1], o[2], o[3], o[4], o[5], o[6], o[7]]) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cand( + id: u64, + connected_at: u64, + min_ping: Option, + last_block: u64, + last_tx: u64, + ) -> InboundEvictCandidate { + InboundEvictCandidate { + id, + connected_at, + min_ping, + last_block, + last_tx, + netgroup: 1, + noban: false, + } + } + + #[test] + fn eviction_protects_block_tx_ping_and_netgroup() { + // 4 block + 5 slow + 4 tx + 8 fast = 21; after protects, one slow remains. + let mut cands = Vec::new(); + for i in 0..4 { + cands.push(cand(i, 100 + i, Some(0.05), 1000 + i, 0)); + } + for i in 4..9 { + cands.push(cand(i, 200 + i, Some(0.5), 0, 0)); + } + for i in 9..13 { + cands.push(cand(i, 300 + i, Some(0.05), 0, 1000 + i)); + } + for i in 13..21 { + cands.push(cand(i, 400 + i, Some(0.01), 0, 0)); + } + let victim = select_inbound_eviction(cands).expect("one unprotected slow"); + assert!((4..9).contains(&victim), "victim={victim}"); + } + + #[test] + fn noban_never_evicted_alone() { + let cands = vec![InboundEvictCandidate { + id: 7, + connected_at: 1, + min_ping: Some(9.0), + last_block: 0, + last_tx: 0, + netgroup: 1, + noban: true, + }]; + assert!(select_inbound_eviction(cands).is_none()); + } + + #[test] + fn maxconnections_shaped_protect_count() { + // Fewer than protect budget → nothing to evict. + let cands: Vec<_> = (0..8).map(|i| cand(i, i, Some(0.1), i, i)).collect(); + assert!(select_inbound_eviction(cands).is_none()); + } +} diff --git a/crates/rbitcoin-net/src/lib.rs b/crates/rbitcoin-net/src/lib.rs index 09847a32..1f4722d7 100644 --- a/crates/rbitcoin-net/src/lib.rs +++ b/crates/rbitcoin-net/src/lib.rs @@ -5,6 +5,7 @@ mod chain; mod codec; mod compact; mod error; +mod eviction; mod ibd; mod most_work; mod msg_decode; @@ -24,6 +25,7 @@ pub use chain::{ }; pub use codec::{MAX_HEADERS_RESULTS, MAX_INV_SIZE, MAX_LOCATOR_SZ, MAX_PROTOCOL_MESSAGE_LENGTH}; pub use error::NetError; +pub use eviction::{eviction_netgroup, select_inbound_eviction, InboundEvictCandidate}; pub use ibd::{ format_tip_perf_sizes, ibd, ibd_cancellable, read_proc_rss, IbdConfig, ProcRss, TipPerfSizes, DEFAULT_BLOCKS_IN_TRANSIT_PER_PEER, DEFAULT_IBD_WINDOW, diff --git a/crates/rbitcoin-net/src/peers.rs b/crates/rbitcoin-net/src/peers.rs index b9575517..95c5785b 100644 --- a/crates/rbitcoin-net/src/peers.rs +++ b/crates/rbitcoin-net/src/peers.rs @@ -460,7 +460,7 @@ impl LivePeer { }) } - fn ping_rpc_fields(&self, now_secs: u64) -> (Option, Option, Option) { + pub(crate) fn ping_rpc_fields(&self, now_secs: u64) -> (Option, Option, Option) { let pingtime = *self.pingtime.lock().unwrap_or_else(|e| e.into_inner()); let minping = *self.minping.lock().unwrap_or_else(|e| e.into_inner()); let nonce = self.ping_nonce_sent.load(Ordering::Relaxed); @@ -1098,6 +1098,34 @@ impl PeerHub { } } + /// Core `AttemptToEvictConnection`: disconnect one unprotected inbound. + pub fn try_evict_inbound(&self) -> bool { + let noban = self.is_noban(); + let now = self.now_secs(); + let cands: Vec = self + .live_peers() + .into_iter() + .filter(|p| p.inbound && !p.stop.load(Ordering::Relaxed)) + .map(|p| { + let (_pt, minping, _pw) = p.ping_rpc_fields(now); + crate::eviction::InboundEvictCandidate { + id: p.id, + connected_at: p.connected_at(), + min_ping: minping, + last_block: p.last_block.load(Ordering::Relaxed), + last_tx: p.last_transaction.load(Ordering::Relaxed), + netgroup: crate::eviction::eviction_netgroup(p.addr), + noban, + } + }) + .collect(); + let Some(id) = crate::eviction::select_inbound_eviction(cands) else { + return false; + }; + rbitcoin_log::info!("p2p: evict inbound peer={id} (inbound full)"); + self.disconnect_id(id) + } + pub fn disconnect_addr(&self, addr: SocketAddr) -> bool { let g = self.live.read().unwrap_or_else(|e| e.into_inner()); let mut n = 0usize; diff --git a/crates/rbitcoin-net/src/service.rs b/crates/rbitcoin-net/src/service.rs index e23f5d0c..e5bd65d9 100644 --- a/crates/rbitcoin-net/src/service.rs +++ b/crates/rbitcoin-net/src/service.rs @@ -134,12 +134,32 @@ impl P2PNode { tokio::time::timeout(Duration::from_millis(200), listener.accept()).await; match accept { Ok(Ok((stream, peer_addr))) => { - let Ok(permit) = inbound_sem.clone().try_acquire_owned() else { - rbitcoin_log::warn!( - "p2p: reject inbound {peer_addr} (at max_inbound={max_inbound})" - ); - drop(stream); - continue; + let permit = match inbound_sem.clone().try_acquire_owned() { + Ok(p) => p, + Err(_) => { + if !peers_in.try_evict_inbound() { + rbitcoin_log::warn!( + "p2p: reject inbound {peer_addr} (at max_inbound={max_inbound})" + ); + drop(stream); + continue; + } + match tokio::time::timeout( + Duration::from_millis(500), + inbound_sem.clone().acquire_owned(), + ) + .await + { + Ok(Ok(p)) => p, + _ => { + rbitcoin_log::warn!( + "p2p: reject inbound {peer_addr} (evict slot wait)" + ); + drop(stream); + continue; + } + } + } }; let our = local_addr; let hub = hub_c.clone(); diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index 11a536f4..e43fc12a 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -82,7 +82,7 @@ where [--listen ADDR] [--connect ADDR]... [--electrum-listen ADDR] [--esplora-listen ADDR] \\\n\ [--shindex] [--sptweaks] [--rpc-listen ADDR] [--rpcuser USER] [--rpcpassword PASS] \\\n\ [--milestone|--assumevalid-height HEIGHT] \\\n\ - [--maxoutbound|--max-outbound N] [--maxinbound|--maxconnections N] \\\n\ + [--maxoutbound|--max-outbound N] [--maxinbound N] [--maxconnections N] \\\n\ [--mempool-size-mb|--maxmempool N] \\\n\ [--testactivationheight name@height] [--persistmempool[=0|1]] [--whitelist SPEC] \\\n\ [--blocksonly] [--minrelaytxfee BTC] [--permitbaremultisig[=0|1]] \\\n\ @@ -97,7 +97,7 @@ API log: --api-log PATH writes one JSON line per Electrum/Esplora/RPC call (also Milestone / assumevalid-height: skip script/sig checks at/below HEIGHT.\n\ Defaults: mainnet 840000, signet 2000000, testnet 2500000, regtest 0. Use 0 for full scripts.\n\ Mempool: --mempool-size-mb / --maxmempool (default ~300 MiB weight budget).\n\ -Peers: --maxoutbound (default 16 live download), --maxinbound/--maxconnections (default 125).\n\ +Peers: --maxoutbound (default 16 live download), --maxinbound (default 125), --maxconnections Core total (inbound = N-11).\n\ Scripthash: --shindex (default off) builds Class B for Electrum/Esplora; both require it.\n\ Silent payments: --sptweaks (default off) writes/serves the thin BIP-352 tweak index.\n\ RPC: --rpc-listen ADDR (default off); cookie under datadir/.cookie or --rpcuser/--rpcpassword.\n\ @@ -409,7 +409,7 @@ IBD: up to 1024 concurrent getdata, max 16 in transit per peer.", } i += 1; } - "--max-inbound" | "--maxinbound" | "--maxconnections" => { + "--max-inbound" | "--maxinbound" => { i += 1; if i >= args.len() { eprintln!("error: --maxinbound requires a number"); @@ -431,6 +431,28 @@ IBD: up to 1024 concurrent getdata, max 16 in transit per peer.", } i += 1; } + "--maxconnections" => { + i += 1; + if i >= args.len() { + eprintln!("error: --maxconnections requires a number"); + return ExitCode::from(2); + } + match args[i].to_string_lossy().parse::() { + Ok(n) if n > 0 => { + max_inbound = crate::config::inbound_from_maxconnections(n); + max_inbound_set = true; + } + Ok(_) => { + eprintln!("error: --maxconnections must be >= 1"); + return ExitCode::from(2); + } + Err(e) => { + eprintln!("error: bad --maxconnections: {e}"); + return ExitCode::from(2); + } + } + i += 1; + } "--max-run-secs" => { i += 1; if i >= args.len() { @@ -795,6 +817,27 @@ IBD: up to 1024 concurrent getdata, max 16 in transit per peer.", } other if other.starts_with("--maxconnections=") => { match other["--maxconnections=".len()..].parse::() { + Ok(n) if n > 0 => { + max_inbound = crate::config::inbound_from_maxconnections(n); + max_inbound_set = true; + } + Ok(_) => { + eprintln!("error: --maxconnections must be >= 1"); + return ExitCode::from(2); + } + Err(e) => { + eprintln!("error: bad --maxconnections: {e}"); + return ExitCode::from(2); + } + } + i += 1; + } + other if other.starts_with("--maxinbound=") || other.starts_with("--max-inbound=") => { + let raw = other + .split_once('=') + .map(|(_, v)| v) + .unwrap_or(""); + match raw.parse::() { Ok(n) if n > 0 => { max_inbound = n; max_inbound_set = true; @@ -804,7 +847,7 @@ IBD: up to 1024 concurrent getdata, max 16 in transit per peer.", return ExitCode::from(2); } Err(e) => { - eprintln!("error: bad --maxconnections: {e}"); + eprintln!("error: bad --maxinbound: {e}"); return ExitCode::from(2); } } diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index 98a3dee5..17d703ef 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -9,6 +9,17 @@ use std::path::{Path, PathBuf}; /// Default max concurrent inbound P2P sessions (Core-ish). pub const DEFAULT_MAX_INBOUND: u32 = 125; +/// Core `-maxconnections=N` reserves this many slots for outbound full/block-relay +/// peers plus one feeler (`10 + 1`). Inbound capacity is `N - reserve`. +pub const CORE_MAXCONNECTIONS_OUTBOUND_RESERVE: u32 = 11; + +/// Core total-slot flag → inbound session cap. +pub fn inbound_from_maxconnections(total: u32) -> u32 { + total + .saturating_sub(CORE_MAXCONNECTIONS_OUTBOUND_RESERVE) + .max(1) +} + /// Node process configuration (CLI + optional conf file). /// /// Operator-critical knobs live here. Advanced IO/perf tunables may still be @@ -378,7 +389,8 @@ impl NodeConfig { /// /// Supported keys: `datadir`, `datadir-cold` / `datadir_cold`, `network` / `chain`, `listen`, `connect` (repeatable), /// `milestone` / `assumevalid_height`, `maxoutbound` / `max_outbound`, - /// `maxinbound` / `max_inbound` / `maxconnections`, `mempool_size_mb` / `maxmempool`, + /// `maxinbound` / `max_inbound`, `maxconnections` (Core total → inbound N-11), + /// `mempool_size_mb` / `maxmempool`, /// `log_level`, `api_log`, `electrum_listen`, `esplora_listen`, /// `shindex`, `rpc_listen`, `rpcuser`, `rpcpassword`, /// `noseeds` / `no_seeds`, `signetchallenge`, and `signetblocktime`. @@ -570,12 +582,24 @@ impl NodeConfig { .parse() .map_err(|e| NodeError::Config(format!("conf maxoutbound: {e}")))?; } - "maxinbound" | "max_inbound" | "maxconnections" => { + "maxinbound" | "max_inbound" => { self.max_inbound = val .parse() .map_err(|e| NodeError::Config(format!("conf maxinbound: {e}")))?; self.max_inbound_explicit = true; } + "maxconnections" => { + let total: u32 = val + .parse() + .map_err(|e| NodeError::Config(format!("conf maxconnections: {e}")))?; + if total == 0 { + return Err(NodeError::Config( + "conf maxconnections must be >= 1".into(), + )); + } + self.max_inbound = inbound_from_maxconnections(total); + self.max_inbound_explicit = true; + } "mempool_size_mb" | "maxmempool" => { let mb: u64 = val .parse() @@ -822,6 +846,29 @@ mod tests { assert_eq!(NodeConfig::default().max_inbound, DEFAULT_MAX_INBOUND); } + #[test] + fn maxconnections_derives_inbound_like_core() { + assert_eq!(CORE_MAXCONNECTIONS_OUTBOUND_RESERVE, 11); + assert_eq!(inbound_from_maxconnections(32), 21); + assert_eq!(inbound_from_maxconnections(125), 114); + assert_eq!(inbound_from_maxconnections(11), 1); + assert_eq!(inbound_from_maxconnections(1), 1); + let dir = tmp(); + std::fs::create_dir_all(&dir).unwrap(); + let conf = dir.join("mc.conf"); + std::fs::write(&conf, "maxconnections=32\n").unwrap(); + let mut cfg = NodeConfig::default().with_datadir(dir.join("d")); + cfg.merge_conf_file(&conf).unwrap(); + assert_eq!(cfg.max_inbound, 21); + assert!(cfg.max_inbound_explicit); + let conf2 = dir.join("mi.conf"); + std::fs::write(&conf2, "maxinbound=40\n").unwrap(); + let mut cfg2 = NodeConfig::default().with_datadir(dir.join("d2")); + cfg2.merge_conf_file(&conf2).unwrap(); + assert_eq!(cfg2.max_inbound, 40, "maxinbound stays explicit inbound"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn custom_signet_conf_builds_params() { let dir = tmp(); From aae8da6b1c146caa49b47d0737ca61e98338cbde Mon Sep 17 00:00:00 2001 From: "rbitcoin-grok[bot]" Date: Fri, 28 Aug 2026 20:22:20 -0700 Subject: [PATCH 2/5] net: multi-listen onion binds + GetAddr response cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shim maps Core -bind=…=onion to extra --listen sockets. PeerHub samples addrman for GetAddr (≤1000 / 23%) and caches per bind for 24h so p2p_getaddr_caching sees stable same-bind replies and distinct bind samples. --- crates/rbitcoin-net/src/peer.rs | 9 +- crates/rbitcoin-net/src/peers.rs | 83 ++++++++++ crates/rbitcoin-net/src/service.rs | 244 +++++++++++++++++------------ crates/rbitcoin-node/src/cli.rs | 9 +- crates/rbitcoin-node/src/config.rs | 3 + crates/rbitcoin-node/src/run.rs | 6 + scripts/core-functional/bitcoind | 9 ++ 7 files changed, 256 insertions(+), 107 deletions(-) diff --git a/crates/rbitcoin-net/src/peer.rs b/crates/rbitcoin-net/src/peer.rs index a22181b0..5a796ec5 100644 --- a/crates/rbitcoin-net/src/peer.rs +++ b/crates/rbitcoin-net/src/peer.rs @@ -2208,7 +2208,14 @@ async fn handle_peer_frame( return Ok(()); } NetworkMessage::GetAddr => { - queue_out(out_tx, NetworkMessage::Addr(vec![]))?; + let bind = session.map(|s| s.addrbind).unwrap_or_else(|| { + std::net::SocketAddr::from(([127, 0, 0, 1], 0)) + }); + let addrs = match session.and_then(|s| s.peer_hub()) { + Some(hub) => hub.addr_response_for_bind(bind), + None => Vec::new(), + }; + queue_out(out_tx, NetworkMessage::Addr(addrs))?; } NetworkMessage::Unknown { .. } => {} _ => {} diff --git a/crates/rbitcoin-net/src/peers.rs b/crates/rbitcoin-net/src/peers.rs index 95c5785b..cb44f93f 100644 --- a/crates/rbitcoin-net/src/peers.rs +++ b/crates/rbitcoin-net/src/peers.rs @@ -386,6 +386,10 @@ impl LivePeer { self.connected_at.load(Ordering::Relaxed) } + pub fn peer_hub(&self) -> Option> { + self.owner.upgrade() + } + pub fn set_inv_gen_floor(&self, floor: u64) { self.inv_gen_floor.store(floor, Ordering::Relaxed); } @@ -601,6 +605,12 @@ pub struct PeerHub { cmpct_fills: Mutex>, /// Version nonces of outbound sessions still in handshake (Core self-connect). pending_outbound_nonces: Mutex>, + /// Shared addrman for GetAddr responses (optional until node wires it). + addrman: Mutex>>>, + /// Per-bind GetAddr response cache: bind → (cached_at_secs, addrs). + addr_response_cache: Mutex< + HashMap)>, + >, } impl PeerHub { @@ -619,9 +629,82 @@ impl PeerHub { forcerelay_perm: AtomicBool::new(false), cmpct_fills: Mutex::new(HashMap::new()), pending_outbound_nonces: Mutex::new(HashSet::new()), + addrman: Mutex::new(None), + addr_response_cache: Mutex::new(HashMap::new()), }) } + /// Attach the process addrman so inbound GetAddr can sample peers. + pub fn set_addrman(&self, am: std::sync::Arc>) { + *self + .addrman + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(am); + } + + /// Core GetAddr reply: per-bind cache (24h) of up to 1000 / 23% of addrman. + pub fn addr_response_for_bind( + &self, + bind: SocketAddr, + ) -> Vec<(u32, bitcoin::p2p::address::Address)> { + const MAX_ADDR_TO_SEND: usize = 1000; + const MAX_PCT_ADDR_TO_SEND: usize = 23; + const CACHE_SECS: u64 = 24 * 60 * 60; + let now = self.now_secs(); + { + let cache = self + .addr_response_cache + .lock() + .unwrap_or_else(|e| e.into_inner()); + if let Some((cached_at, addrs)) = cache.get(&bind) { + if now.saturating_sub(*cached_at) < CACHE_SECS { + return addrs.clone(); + } + } + } + let am = { + let g = self.addrman.lock().unwrap_or_else(|e| e.into_inner()); + g.clone() + }; + let Some(am) = am else { + return Vec::new(); + }; + let entries = { + let g = am.lock().unwrap_or_else(|e| e.into_inner()); + g.entries() + }; + let n = entries.len(); + let pct_cap = (n * MAX_PCT_ADDR_TO_SEND / 100).max(1); + let cap = MAX_ADDR_TO_SEND.min(pct_cap).min(n); + if cap == 0 { + return Vec::new(); + } + // Deterministic shuffle from mocktime + bind so same bind caches stably, + // different binds diverge. + let mut idxs: Vec = (0..n).collect(); + let mut state = now + ^ (bind.port() as u64) + ^ bind.ip().to_string().bytes().map(|b| b as u64).sum::(); + for i in (1..idxs.len()).rev() { + state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1); + let j = (state as usize) % (i + 1); + idxs.swap(i, j); + } + let services = crate::peer::local_service_flags(); + let mut out = Vec::with_capacity(cap); + for &i in idxs.iter().take(cap) { + let addr = entries[i].addr; + out.push((now as u32, bitcoin::p2p::address::Address::new(&addr, services))); + } + self.addr_response_cache + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(bind, (now, out.clone())); + out + } + /// Core: register local version nonce while an outbound handshake is open. pub fn note_outbound_nonce(&self, nonce: u64) { self.pending_outbound_nonces diff --git a/crates/rbitcoin-net/src/service.rs b/crates/rbitcoin-net/src/service.rs index e5bd65d9..d1a2d1fa 100644 --- a/crates/rbitcoin-net/src/service.rs +++ b/crates/rbitcoin-net/src/service.rs @@ -66,6 +66,8 @@ pub struct P2PNode { user_agent: String, /// Inbound session cap used by the accept loop (not process env). pub max_inbound: usize, + /// Shared inbound slots across all listen sockets. + inbound_sem: Arc, } pub struct P2PHandle { @@ -116,111 +118,21 @@ impl P2PNode { let (dial_tx, mut dial_rx) = tokio::sync::mpsc::unbounded_channel::(); peers.set_dialer(dial_tx); - let hub_c = hub.clone(); - let shutdown_c = shutdown.clone(); - let magic_c = magic; - let peers_in = peers.clone(); - let ua_in = user_agent.clone(); let max_inbound = max_inbound.max(1); let inbound_sem = inbound_semaphore(max_inbound); let session_tasks = Arc::new(Mutex::new(Vec::>::new())); - let sessions_in = session_tasks.clone(); - let accept_task = tokio::spawn(async move { - loop { - if shutdown_c.load(Ordering::SeqCst) { - break; - } - let accept = - tokio::time::timeout(Duration::from_millis(200), listener.accept()).await; - match accept { - Ok(Ok((stream, peer_addr))) => { - let permit = match inbound_sem.clone().try_acquire_owned() { - Ok(p) => p, - Err(_) => { - if !peers_in.try_evict_inbound() { - rbitcoin_log::warn!( - "p2p: reject inbound {peer_addr} (at max_inbound={max_inbound})" - ); - drop(stream); - continue; - } - match tokio::time::timeout( - Duration::from_millis(500), - inbound_sem.clone().acquire_owned(), - ) - .await - { - Ok(Ok(p)) => p, - _ => { - rbitcoin_log::warn!( - "p2p: reject inbound {peer_addr} (evict slot wait)" - ); - drop(stream); - continue; - } - } - } - }; - let our = local_addr; - let hub = hub_c.clone(); - let height = hub.tip_height().map(|h| h as i32).unwrap_or(0); - let tip_rx = hub.subscribe_tips(); - let peers = peers_in.clone(); - let ua = ua_in.clone(); - let bind = match stream.local_addr() { - Ok(a) => a, - Err(_) => our, - }; - let sessions = sessions_in.clone(); - let h = tokio::spawn(async move { - let _session_slot = permit; - let (ver, reader, writer, wire) = match inbound_connect_and_handshake( - stream, - magic_c, - our, - peer_addr, - height, - &ua, - HandshakePolicy { - hub: Some(hub.as_ref()), - peers: Some(peers.as_ref()), - conn_type: PeerConnType::Inbound, - }, - ) - .await - { - Ok(x) => x, - Err(e) => { - // V1-only peers fail BIP324; log once-style message. - rbitcoin_log::debug!( - "p2p: inbound handshake {peer_addr} failed: {e}" - ); - return; - } - }; - let sess = - peers.register(peer_addr, bind, &ver, true, PeerConnType::Inbound); - if let Some(mp) = hub.mempool() { - sess.set_inv_gen_floor(mp.next_accept_gen()); - } - sess.attach_wire(wire); - let id = sess.id; - let meta = FollowSessionMeta { - peer: Some(peer_addr), - live: None, - session: Some(sess), - }; - let _ = - peer_session_with(reader, writer, magic_c, hub, tip_rx, meta).await; - peers.unregister(id); - }); - push_session_task(&sessions, h); - } - Ok(Err(_)) => break, - Err(_) => continue, - } - } - }); + let accept_task = spawn_inbound_accept( + listener, + local_addr, + hub.clone(), + peers.clone(), + user_agent.clone(), + magic, + max_inbound, + inbound_sem.clone(), + shutdown.clone(), + session_tasks.clone(), + ); let follow_live = Arc::new(AtomicUsize::new(0)); let dial_hub = hub.clone(); @@ -261,9 +173,30 @@ impl P2PNode { peers, user_agent, max_inbound, + inbound_sem, }) } + /// Bind an additional listen socket (Core multi-`-bind`, including `=onion`). + pub async fn add_listen(&mut self, listen: SocketAddr) -> Result { + let listener = TcpListener::bind(listen).await?; + let local_addr = listener.local_addr()?; + let accept_task = spawn_inbound_accept( + listener, + local_addr, + self.hub.clone(), + self.peers.clone(), + self.user_agent.clone(), + self.magic, + self.max_inbound, + self.inbound_sem.clone(), + self.shutdown.clone(), + self.session_tasks.clone(), + ); + self.tasks.push(accept_task); + Ok(local_addr) + } + /// BIP14 subversion advertised in `version` (same as RPC `getnetworkinfo`). pub fn set_user_agent(&mut self, ua: impl Into) { self.user_agent = ua.into(); @@ -415,6 +348,113 @@ fn push_session_task(bag: &Mutex>>, h: JoinHandle<()>) { } } +fn spawn_inbound_accept( + listener: TcpListener, + local_addr: SocketAddr, + hub: Arc, + peers: Arc, + user_agent: String, + magic: Magic, + max_inbound: usize, + inbound_sem: Arc, + shutdown: Arc, + session_tasks: Arc>>>, +) -> JoinHandle<()> { + tokio::spawn(async move { + loop { + if shutdown.load(Ordering::SeqCst) { + break; + } + let accept = tokio::time::timeout(Duration::from_millis(200), listener.accept()).await; + match accept { + Ok(Ok((stream, peer_addr))) => { + let permit = match inbound_sem.clone().try_acquire_owned() { + Ok(p) => p, + Err(_) => { + if !peers.try_evict_inbound() { + rbitcoin_log::warn!( + "p2p: reject inbound {peer_addr} (at max_inbound={max_inbound})" + ); + drop(stream); + continue; + } + match tokio::time::timeout( + Duration::from_millis(500), + inbound_sem.clone().acquire_owned(), + ) + .await + { + Ok(Ok(p)) => p, + _ => { + rbitcoin_log::warn!( + "p2p: reject inbound {peer_addr} (evict slot wait)" + ); + drop(stream); + continue; + } + } + } + }; + let our = local_addr; + let hub = hub.clone(); + let height = hub.tip_height().map(|h| h as i32).unwrap_or(0); + let tip_rx = hub.subscribe_tips(); + let peers = peers.clone(); + let ua = user_agent.clone(); + let bind = match stream.local_addr() { + Ok(a) => a, + Err(_) => our, + }; + let sessions = session_tasks.clone(); + let h = tokio::spawn(async move { + let _session_slot = permit; + let (ver, reader, writer, wire) = match inbound_connect_and_handshake( + stream, + magic, + our, + peer_addr, + height, + &ua, + HandshakePolicy { + hub: Some(hub.as_ref()), + peers: Some(peers.as_ref()), + conn_type: PeerConnType::Inbound, + }, + ) + .await + { + Ok(x) => x, + Err(e) => { + rbitcoin_log::debug!( + "p2p: inbound handshake {peer_addr} failed: {e}" + ); + return; + } + }; + let sess = + peers.register(peer_addr, bind, &ver, true, PeerConnType::Inbound); + if let Some(mp) = hub.mempool() { + sess.set_inv_gen_floor(mp.next_accept_gen()); + } + sess.attach_wire(wire); + let id = sess.id; + let meta = FollowSessionMeta { + peer: Some(peer_addr), + live: None, + session: Some(sess), + }; + let _ = peer_session_with(reader, writer, magic, hub, tip_rx, meta).await; + peers.unregister(id); + }); + push_session_task(&sessions, h); + } + Ok(Err(_)) => break, + Err(_) => continue, + } + } + }) +} + fn default_user_agent() -> String { rbitcoin_primitives::rbitcoin_subversion(env!("CARGO_PKG_VERSION"), &[] as &[&str]) .unwrap_or_else(|_| format!("/rbitcoin:{}/", env!("CARGO_PKG_VERSION"))) diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index e43fc12a..c59549aa 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -26,7 +26,7 @@ where let mut signet_challenge = None; let mut signet_block_time = None; let mut smoke = false; - let mut listen: Option = None; + let mut listen: Vec = Vec::new(); let mut electrum_listen: Option = None; let mut esplora_listen: Option = None; let mut shindex = false; @@ -215,7 +215,7 @@ IBD: up to 1024 concurrent getdata, max 16 in transit per peer.", return ExitCode::from(2); } match args[i].to_string_lossy().parse::() { - Ok(a) => listen = Some(a), + Ok(a) => listen.push(a), Err(e) => { eprintln!("error: bad --listen: {e}"); return ExitCode::from(2); @@ -964,8 +964,9 @@ IBD: up to 1024 concurrent getdata, max 16 in transit per peer.", if signet_block_time.is_some() { config.signet_block_time = signet_block_time; } - if let Some(a) = listen { - config.p2p_listen = Some(a); + if let Some((first, rest)) = listen.split_first() { + config.p2p_listen = Some(*first); + config.p2p_extra_listens.extend(rest.iter().copied()); } if let Some(a) = electrum_listen { config.electrum_listen = Some(a); diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index 17d703ef..e07356e8 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -41,6 +41,8 @@ pub struct NodeConfig { pub signet_block_time: Option, /// Bind address for P2P listen (`None` = do not listen / default bind later). pub p2p_listen: Option, + /// Extra P2P listen sockets (Core multi-`-bind`, including onion binds). + pub p2p_extra_listens: Vec, /// Explicit outbound peers (`--connect`). pub connect: Vec, /// Core `-seednode` host or host:port (resolved with chain default port). @@ -138,6 +140,7 @@ impl Default for NodeConfig { signet_challenge: None, signet_block_time: None, p2p_listen: None, + p2p_extra_listens: Vec::new(), connect: Vec::new(), seednodes: Vec::new(), use_seeds: true, diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index ff426a64..3e8b2486 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -261,6 +261,11 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { ) .await .map_err(|e| NodeError::Config(format!("p2p start: {e}")))?; + for extra in &config.p2p_extra_listens { + node.add_listen(*extra) + .await + .map_err(|e| NodeError::Config(format!("p2p extra listen {extra}: {e}")))?; + } node.hub.set_minimum_chain_work(config.minimum_chain_work); if let Some(secs) = config.max_tip_age_secs { node.hub.set_max_tip_age_secs(secs); @@ -395,6 +400,7 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { warn!("custom signet has no peers; use --connect ADDR or reuse a datadir with known peers"); } let shared_peers = std::sync::Arc::new(std::sync::Mutex::new(addrman.clone())); + node.peers.set_addrman(std::sync::Arc::clone(&shared_peers)); let max_out = config.max_outbound.max(1) as usize; let candidate_n = max_out.saturating_mul(2).clamp(16, 48); diff --git a/scripts/core-functional/bitcoind b/scripts/core-functional/bitcoind index a5c1452f..c75c7e8a 100755 --- a/scripts/core-functional/bitcoind +++ b/scripts/core-functional/bitcoind @@ -440,6 +440,15 @@ def translate( "--esplora-listen", f"127.0.0.1:{esplora_port(int(rpcport))}", ] + # Core `-bind=host:port=onion` → extra listen sockets (getaddr caching). + for host, bport, onion in binds: + if not onion or bport is None: + continue + listen_host = "127.0.0.1" if host in ("0.0.0.0", "::") else host + extra = f"{listen_host}:{bport}" + if extra == f"127.0.0.1:{p2pport}": + continue + cmd += ["--listen", extra] if log_level: cmd += ["--log-level", log_level] for c in uacomments: From 4494d8f4ac213f0d0cbd026379d65b7f0c9b3bbc Mon Sep 17 00:00:00 2001 From: "rbitcoin-grok[bot]" Date: Fri, 28 Aug 2026 20:30:38 -0700 Subject: [PATCH 3/5] net+rpc: unknown versionbits warnings and -alertnotify Scan completed BIP9-style periods for unassigned signalling bits and surface Core's "Unknown new rules activated (versionbit N)" in get*info warnings arrays. Shell-escape -alertnotify %s so functional alert files receive the message. --- crates/rbitcoin-net/src/lib.rs | 4 + crates/rbitcoin-net/src/versionbits_warn.rs | 125 ++++++++++++++++++++ crates/rbitcoin-node/src/cli.rs | 17 +++ crates/rbitcoin-node/src/config.rs | 3 + crates/rbitcoin-node/src/run.rs | 1 + crates/rbitcoin-rpc/src/methods.rs | 35 +++++- crates/rbitcoin-rpc/src/methods_tests.rs | 10 ++ crates/rbitcoin-rpc/src/server.rs | 10 ++ scripts/core-functional/bitcoind | 1 + scripts/core-functional/inventory.toml | 12 +- 10 files changed, 206 insertions(+), 12 deletions(-) create mode 100644 crates/rbitcoin-net/src/versionbits_warn.rs diff --git a/crates/rbitcoin-net/src/lib.rs b/crates/rbitcoin-net/src/lib.rs index 1f4722d7..8cac1ddc 100644 --- a/crates/rbitcoin-net/src/lib.rs +++ b/crates/rbitcoin-net/src/lib.rs @@ -16,6 +16,7 @@ mod seeds; mod service; mod tx_relay; mod v2; +mod versionbits_warn; pub use cache::BlockCache; pub use chain::{ @@ -54,6 +55,9 @@ pub use tx_relay::{ ElectrumMempoolItem, MempoolAnnounce, MempoolHub, MempoolPerfSample, QueryUtxoProvider, }; pub use v2::WireBytes; +pub use versionbits_warn::{ + active_unknown_bits, unknown_rules_warning, warn_period_threshold, warning_strings, +}; /// Default number of **live download peers** during IBD (`IbdConfig::target_peers` /// and node `--max-outbound` default). diff --git a/crates/rbitcoin-net/src/versionbits_warn.rs b/crates/rbitcoin-net/src/versionbits_warn.rs new file mode 100644 index 00000000..7d31a227 --- /dev/null +++ b/crates/rbitcoin-net/src/versionbits_warn.rs @@ -0,0 +1,125 @@ +//! Unknown-versionbits activation warnings (Core `WarningBitsConditionChecker`). +//! +//! Regtest/testnets: period = difficulty interval, threshold = 75% of period. +//! When a completed period has ≥threshold blocks signalling an unassigned bit +//! with BIP9 top bits, the next tip reports +//! `Unknown new rules activated (versionbit N)`. + +use rbitcoin_primitives::{Height, Network}; +use rbitcoin_query::Query; + +const VERSIONBITS_TOP_BITS: u32 = 0x2000_0000; +const VERSIONBITS_TOP_MASK: u32 = 0xe000_0000; +const VERSIONBITS_NUM_BITS: i32 = 29; + +/// Period / threshold for unknown-bit warnings (Core test-chain rule). +pub fn warn_period_threshold(network: Network) -> (u32, u32) { + let period = match network { + Network::Regtest => 144, + Network::Testnet | Network::Signet => 2016, + Network::Mainnet => 2016, + }; + let threshold = period * 3 / 4; + (period, threshold) +} + +/// Format Core's unknown-rules warning for `bit`. +pub fn unknown_rules_warning(bit: i32) -> String { + format!("Unknown new rules activated (versionbit {bit})") +} + +/// Scan best-chain headers for unknown bits that reached ACTIVE. +pub fn active_unknown_bits(query: &Query, network: Network) -> Vec { + let Some(tip_h) = query.tip_height() else { + return Vec::new(); + }; + let tip = tip_h.0; + if tip == 0 { + return Vec::new(); + } + let (period, threshold) = warn_period_threshold(network); + // Need at least two full periods after genesis to have an ACTIVE state + // (LOCKED_IN in period N, ACTIVE from start of period N+1). + if tip < period * 2 { + return Vec::new(); + } + let mut active = Vec::new(); + for bit in 0..VERSIONBITS_NUM_BITS { + if bit_is_active(query, tip, period, threshold, bit) { + active.push(bit); + } + } + active +} + +fn bit_is_active(query: &Query, tip: u32, period: u32, threshold: u32, bit: i32) -> bool { + // Walk completed periods ending at period boundaries ≤ tip. + // ACTIVE if some period P had ≥threshold signalling and tip is in a later period. + let periods_done = tip / period; + if periods_done < 2 { + return false; + } + // Check the period that ended at (periods_done-1)*period — if it locked in, + // we are ACTIVE in the current period. + // Period p covers heights [p*period, (p+1)*period). LOCKED_IN after a + // signalling period; ACTIVE once tip reaches the following period. + for p in 0..periods_done.saturating_sub(1) { + let start = p * period; + let end_excl = (p + 1) * period; + let mut count = 0u32; + for h in start..end_excl { + if h == 0 { + continue; + } + if let Ok(Some((_, rec))) = query.header_at_height(Height(h)) { + if signals_unknown(&rec.version, bit) { + count += 1; + } + } + } + if count >= threshold && tip >= (p + 2) * period { + return true; + } + } + false +} +fn signals_unknown(version: &i32, bit: i32) -> bool { + let v = *version as u32; + (v & VERSIONBITS_TOP_MASK) == VERSIONBITS_TOP_BITS && ((v >> bit) & 1) != 0 +} + +/// Warning strings for RPC `warnings` arrays. +pub fn warning_strings(query: &Query, network: Network) -> Vec { + active_unknown_bits(query, network) + .into_iter() + .map(unknown_rules_warning) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn regtest_period_matches_core_functional() { + let (p, t) = warn_period_threshold(Network::Regtest); + assert_eq!(p, 144); + assert_eq!(t, 108); + } + + #[test] + fn warning_text_matches_core() { + assert_eq!( + unknown_rules_warning(27), + "Unknown new rules activated (versionbit 27)" + ); + } + + #[test] + fn top_bits_signal_detection() { + let v = (VERSIONBITS_TOP_BITS | (1 << 27)) as i32; + assert!(signals_unknown(&v, 27)); + assert!(!signals_unknown(&v, 26)); + assert!(!signals_unknown(&(VERSIONBITS_TOP_BITS as i32), 27)); + } +} diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index c59549aa..05bca363 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -62,6 +62,7 @@ where let mut min_relay_fee_btc: Option = None; let mut mempool_expiry_hours: Option = None; let mut startup_notify: Option = None; + let mut alert_notify: Option = None; let mut permit_bare_multisig: Option = None; let mut limit_cluster_count: Option = None; let mut limit_cluster_size_kvb: Option = None; @@ -617,6 +618,19 @@ IBD: up to 1024 concurrent getdata, max 16 in transit per peer.", startup_notify = Some(args[i].to_string_lossy().into_owned()); i += 1; } + other if other.starts_with("--alertnotify=") => { + alert_notify = Some(other["--alertnotify=".len()..].to_string()); + i += 1; + } + "--alertnotify" => { + i += 1; + if i >= args.len() { + eprintln!("error: --alertnotify requires a value"); + return ExitCode::from(2); + } + alert_notify = Some(args[i].to_string_lossy().into_owned()); + i += 1; + } other if other.starts_with("--limitclustercount=") => { match other["--limitclustercount=".len()..].parse() { Ok(n) => limit_cluster_count = Some(n), @@ -1043,6 +1057,9 @@ IBD: up to 1024 concurrent getdata, max 16 in transit per peer.", if let Some(s) = startup_notify { config.startup_notify = Some(s); } + if let Some(s) = alert_notify { + config.alert_notify = Some(s); + } if let Some(b) = permit_bare_multisig { config.permit_bare_multisig = b; } diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index e07356e8..5825fc1f 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -111,6 +111,8 @@ pub struct NodeConfig { pub mempool_expiry_hours: Option, /// Core `-startupnotify` shell command (run once after start). pub startup_notify: Option, + /// Core `-alertnotify` shell command (`%s` = warning text). + pub alert_notify: Option, /// Core `-permitbaremultisig` (default true). pub permit_bare_multisig: bool, /// Core `-limitclustercount` overlay (`None` = mempool default 64). @@ -171,6 +173,7 @@ impl Default for NodeConfig { min_relay_fee_btc: None, mempool_expiry_hours: None, startup_notify: None, + alert_notify: None, permit_bare_multisig: true, limit_cluster_count: None, limit_cluster_size_kvb: None, diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index 3e8b2486..9668f39d 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -786,6 +786,7 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { .unwrap_or_else(|_| format!("/rbitcoin:{}/", env!("CARGO_PKG_VERSION"))), ), permit_bare_multisig: config.permit_bare_multisig, + alert_notify: config.alert_notify.clone(), }; let miner: Option> = if config.network == Network::Regtest { Some(Arc::new(HubRegtest(Arc::clone(&node.hub)))) diff --git a/crates/rbitcoin-rpc/src/methods.rs b/crates/rbitcoin-rpc/src/methods.rs index 02c17293..3f64b8a5 100644 --- a/crates/rbitcoin-rpc/src/methods.rs +++ b/crates/rbitcoin-rpc/src/methods.rs @@ -74,6 +74,10 @@ pub struct RpcContext { pub logpath: String, /// Core `-permitbaremultisig` (default true). `getmempoolinfo`. pub permit_bare_multisig: bool, + /// Core `-alertnotify` (`%s` = warning). Fired once when warnings appear. + pub alert_notify: Option, + /// Latches after the first alertnotify invocation. + pub alert_fired: Arc, /// In-flight RPC methods for `getrpcinfo.active_commands`. pub active: Arc>, } @@ -795,10 +799,35 @@ fn getblockchaininfo(ctx: &RpcContext) -> Result { "chainwork": chainwork_hex(ctx, ctx.query.tip_height()), "size_on_disk": ctx.query.store().datadir_bytes(), "pruned": false, - "warnings": "", + "warnings": rpc_warnings(ctx), })) } +fn rpc_warnings(ctx: &RpcContext) -> Vec { + let w = rbitcoin_net::warning_strings(ctx.query.as_ref(), ctx.network); + if !w.is_empty() + && ctx + .alert_fired + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + if let Some(cmd) = ctx.alert_notify.as_deref() { + let msg = w.join(", "); + // Core ShellEscape: single-quote so `(versionbit N)` is not a subshell. + let escaped = format!("'{}'", msg.replace('\'', "'\\''")); + let shell = cmd.replace("%s", &escaped); + match std::process::Command::new("sh").arg("-c").arg(&shell).status() { + Ok(st) if !st.success() => { + rbitcoin_log::warn!("alertnotify exited {st}: {shell}"); + } + Err(e) => rbitcoin_log::warn!("alertnotify failed: {e}: {shell}"), + _ => {} + } + } + } + w +} + fn difficulty_from_bits(bits: u32) -> f64 { // Compact target → difficulty relative to max target (same class as Core). let n_shift = ((bits >> 24) & 0xff) as i32; @@ -1331,7 +1360,7 @@ fn getnetworkinfo(ctx: &RpcContext) -> Value { "relayfee": MempoolHub::relay_fee_btc_per_kb(), "incrementalfee": MempoolHub::relay_fee_btc_per_kb(), "localaddresses": [], - "warnings": "BIP324 v2-only; not full Core networkinfo parity", + "warnings": rpc_warnings(ctx), }) } @@ -3128,7 +3157,7 @@ fn getmininginfo(ctx: &RpcContext) -> Result { "difficulty": difficulty, }), ); - m.insert("warnings".into(), json!("")); + m.insert("warnings".into(), json!(rpc_warnings(ctx))); Ok(Value::Object(m)) } diff --git a/crates/rbitcoin-rpc/src/methods_tests.rs b/crates/rbitcoin-rpc/src/methods_tests.rs index 984f9685..138e6b3b 100644 --- a/crates/rbitcoin-rpc/src/methods_tests.rs +++ b/crates/rbitcoin-rpc/src/methods_tests.rs @@ -35,6 +35,8 @@ fn ctx_empty() -> (RpcContext, PathBuf) { logpath: String::new(), active: std::sync::Arc::new(std::sync::Mutex::new(RpcActive::default())), permit_bare_multisig: true, + alert_notify: None, + alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), }; (ctx, dir) } @@ -738,6 +740,8 @@ fn all_methods_callable_empty_or_error() { logpath: String::new(), active: std::sync::Arc::new(std::sync::Mutex::new(RpcActive::default())), permit_bare_multisig: true, + alert_notify: None, + alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), }; let mem2 = dispatch(&ctx2, "getmempoolinfo", vec![]).unwrap(); assert_eq!(mem2["loaded"], true); @@ -788,6 +792,8 @@ fn chain_methods_against_mined_regtest() { logpath: String::new(), active: std::sync::Arc::new(std::sync::Mutex::new(RpcActive::default())), permit_bare_multisig: true, + alert_notify: None, + alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), }; let tip_h = chain.tip_height(); @@ -1264,6 +1270,8 @@ fn ctx_regtest_hub() -> (RpcContext, PathBuf, Arc) { logpath: String::new(), active: std::sync::Arc::new(std::sync::Mutex::new(RpcActive::default())), permit_bare_multisig: true, + alert_notify: None, + alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), }; (ctx, dir, hub) } @@ -2727,6 +2735,8 @@ fn rpc_honesty_mempool_budget_and_network_identity() { logpath: String::new(), active: std::sync::Arc::new(std::sync::Mutex::new(RpcActive::default())), permit_bare_multisig: true, + alert_notify: None, + alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), }; let mem = dispatch(&ctx, "getmempoolinfo", vec![]).unwrap(); assert_eq!( diff --git a/crates/rbitcoin-rpc/src/server.rs b/crates/rbitcoin-rpc/src/server.rs index 4ffbede6..cf445641 100644 --- a/crates/rbitcoin-rpc/src/server.rs +++ b/crates/rbitcoin-rpc/src/server.rs @@ -36,6 +36,8 @@ pub struct RpcConfig { pub work_queue: Option, /// Core `-permitbaremultisig` (default true). pub permit_bare_multisig: bool, + /// Core `-alertnotify` (`%s` = warning text). + pub alert_notify: Option, } /// Live RPC server handle. @@ -114,6 +116,8 @@ pub async fn run_rpc( logpath: config.datadir.join("debug.log").display().to_string(), active: Arc::clone(&active), permit_bare_multisig: config.permit_bare_multisig, + alert_notify: config.alert_notify.clone(), + alert_fired: Arc::new(AtomicBool::new(false)), }); let listener = TcpListener::bind(config.listen) @@ -511,6 +515,8 @@ mod tests { subversion: None, work_queue: None, permit_bare_multisig: true, + alert_notify: None, + alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), }; let handle = run_rpc(cfg, q, Some(mp), None, None, None, None, None) .await @@ -635,6 +641,8 @@ mod tests { subversion: None, work_queue: None, permit_bare_multisig: true, + alert_notify: None, + alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), }; let handle = run_rpc(cfg, q, Some(mp), None, None, None, None, None) .await @@ -719,6 +727,8 @@ mod tests { subversion: None, work_queue: Some(1), permit_bare_multisig: true, + alert_notify: None, + alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), }; let handle = run_rpc(cfg, q, Some(mp), None, None, None, None, None) .await diff --git a/scripts/core-functional/bitcoind b/scripts/core-functional/bitcoind index c75c7e8a..cc8bf236 100755 --- a/scripts/core-functional/bitcoind +++ b/scripts/core-functional/bitcoind @@ -63,6 +63,7 @@ FORWARD_FLAGS = frozenset( "minrelaytxfee", "mempoolexpiry", "startupnotify", + "alertnotify", "permitbaremultisig", "limitclustercount", "limitclustersize", diff --git a/scripts/core-functional/inventory.toml b/scripts/core-functional/inventory.toml index d62044ac..6b390e04 100644 --- a/scripts/core-functional/inventory.toml +++ b/scripts/core-functional/inventory.toml @@ -332,9 +332,7 @@ analog = "none" [[test]] name = "feature_versionbits_warning.py" -status = "skip" -reason = "rpc-missing" -analog = "follow-up: versionbits warning" +status = "run" [[test]] name = "interface_bitcoin_cli.py" @@ -594,9 +592,7 @@ reason = "core-net-policy" [[test]] name = "p2p_eviction.py" -status = "skip" -reason = "rpc-missing" -analog = "follow-up: inbound eviction" +status = "run" [[test]] name = "p2p_feefilter.py" @@ -616,9 +612,7 @@ analog = "follow-up: stale-tip serve" [[test]] name = "p2p_getaddr_caching.py" -status = "skip" -reason = "rpc-missing" -analog = "follow-up: getaddr" +status = "run" [[test]] name = "p2p_getdata.py" From b1831e2ae3a2036bc0719423d8cb965c5aaf9a12 Mon Sep 17 00:00:00 2001 From: "rbitcoin-grok[bot]" Date: Fri, 28 Aug 2026 20:35:00 -0700 Subject: [PATCH 4/5] fix: fmt, RpcConfig alert fields, shim onion-listen pin cargo fmt; drop alert_fired from RpcConfig test literals; expect onion -bind as an extra --listen in bitcoind.test.sh. --- crates/rbitcoin-net/src/eviction.rs | 18 +++++++++++++++--- crates/rbitcoin-net/src/peer.rs | 6 +++--- crates/rbitcoin-net/src/peers.rs | 19 ++++++++----------- crates/rbitcoin-node/src/cli.rs | 5 +---- crates/rbitcoin-node/src/config.rs | 4 +--- crates/rbitcoin-rpc/src/methods.rs | 6 +++++- crates/rbitcoin-rpc/src/methods_tests.rs | 20 ++++++++++---------- crates/rbitcoin-rpc/src/server.rs | 9 +++------ scripts/core-functional/bitcoind.test.sh | 6 +++--- 9 files changed, 49 insertions(+), 44 deletions(-) diff --git a/crates/rbitcoin-net/src/eviction.rs b/crates/rbitcoin-net/src/eviction.rs index 6bc2f387..a3a8869c 100644 --- a/crates/rbitcoin-net/src/eviction.rs +++ b/crates/rbitcoin-net/src/eviction.rs @@ -33,7 +33,11 @@ pub fn select_inbound_eviction(mut cands: Vec) -> Option< return None; } - cands.sort_by(|a, b| b.last_block.cmp(&a.last_block).then_with(|| a.id.cmp(&b.id))); + cands.sort_by(|a, b| { + b.last_block + .cmp(&a.last_block) + .then_with(|| a.id.cmp(&b.id)) + }); remove_first_k(&mut cands, PROTECT_BLOCKS); if cands.is_empty() { return None; @@ -57,7 +61,11 @@ pub fn select_inbound_eviction(mut cands: Vec) -> Option< } // Prefer the longest-connected remaining peer (stable id tie-break). - cands.sort_by(|a, b| a.connected_at.cmp(&b.connected_at).then_with(|| a.id.cmp(&b.id))); + cands.sort_by(|a, b| { + a.connected_at + .cmp(&b.connected_at) + .then_with(|| a.id.cmp(&b.id)) + }); Some(cands[0].id) } @@ -88,7 +96,11 @@ fn protect_by_netgroup(cands: &mut Vec, k: usize) { if protect_ids.len() >= k { break; } - if let Some(c) = cands.iter().filter(|c| c.netgroup == group).min_by_key(|c| c.id) { + if let Some(c) = cands + .iter() + .filter(|c| c.netgroup == group) + .min_by_key(|c| c.id) + { protect_ids.push(c.id); } } diff --git a/crates/rbitcoin-net/src/peer.rs b/crates/rbitcoin-net/src/peer.rs index 5a796ec5..961d4b75 100644 --- a/crates/rbitcoin-net/src/peer.rs +++ b/crates/rbitcoin-net/src/peer.rs @@ -2208,9 +2208,9 @@ async fn handle_peer_frame( return Ok(()); } NetworkMessage::GetAddr => { - let bind = session.map(|s| s.addrbind).unwrap_or_else(|| { - std::net::SocketAddr::from(([127, 0, 0, 1], 0)) - }); + let bind = session + .map(|s| s.addrbind) + .unwrap_or_else(|| std::net::SocketAddr::from(([127, 0, 0, 1], 0))); let addrs = match session.and_then(|s| s.peer_hub()) { Some(hub) => hub.addr_response_for_bind(bind), None => Vec::new(), diff --git a/crates/rbitcoin-net/src/peers.rs b/crates/rbitcoin-net/src/peers.rs index cb44f93f..1d694cf0 100644 --- a/crates/rbitcoin-net/src/peers.rs +++ b/crates/rbitcoin-net/src/peers.rs @@ -608,9 +608,8 @@ pub struct PeerHub { /// Shared addrman for GetAddr responses (optional until node wires it). addrman: Mutex>>>, /// Per-bind GetAddr response cache: bind → (cached_at_secs, addrs). - addr_response_cache: Mutex< - HashMap)>, - >, + addr_response_cache: + Mutex)>>, } impl PeerHub { @@ -636,10 +635,7 @@ impl PeerHub { /// Attach the process addrman so inbound GetAddr can sample peers. pub fn set_addrman(&self, am: std::sync::Arc>) { - *self - .addrman - .lock() - .unwrap_or_else(|e| e.into_inner()) = Some(am); + *self.addrman.lock().unwrap_or_else(|e| e.into_inner()) = Some(am); } /// Core GetAddr reply: per-bind cache (24h) of up to 1000 / 23% of addrman. @@ -686,9 +682,7 @@ impl PeerHub { ^ (bind.port() as u64) ^ bind.ip().to_string().bytes().map(|b| b as u64).sum::(); for i in (1..idxs.len()).rev() { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1); + state = state.wrapping_mul(6364136223846793005).wrapping_add(1); let j = (state as usize) % (i + 1); idxs.swap(i, j); } @@ -696,7 +690,10 @@ impl PeerHub { let mut out = Vec::with_capacity(cap); for &i in idxs.iter().take(cap) { let addr = entries[i].addr; - out.push((now as u32, bitcoin::p2p::address::Address::new(&addr, services))); + out.push(( + now as u32, + bitcoin::p2p::address::Address::new(&addr, services), + )); } self.addr_response_cache .lock() diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index 05bca363..cf70e86a 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -847,10 +847,7 @@ IBD: up to 1024 concurrent getdata, max 16 in transit per peer.", i += 1; } other if other.starts_with("--maxinbound=") || other.starts_with("--max-inbound=") => { - let raw = other - .split_once('=') - .map(|(_, v)| v) - .unwrap_or(""); + let raw = other.split_once('=').map(|(_, v)| v).unwrap_or(""); match raw.parse::() { Ok(n) if n > 0 => { max_inbound = n; diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index 5825fc1f..da00db96 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -599,9 +599,7 @@ impl NodeConfig { .parse() .map_err(|e| NodeError::Config(format!("conf maxconnections: {e}")))?; if total == 0 { - return Err(NodeError::Config( - "conf maxconnections must be >= 1".into(), - )); + return Err(NodeError::Config("conf maxconnections must be >= 1".into())); } self.max_inbound = inbound_from_maxconnections(total); self.max_inbound_explicit = true; diff --git a/crates/rbitcoin-rpc/src/methods.rs b/crates/rbitcoin-rpc/src/methods.rs index 3f64b8a5..8e87f273 100644 --- a/crates/rbitcoin-rpc/src/methods.rs +++ b/crates/rbitcoin-rpc/src/methods.rs @@ -816,7 +816,11 @@ fn rpc_warnings(ctx: &RpcContext) -> Vec { // Core ShellEscape: single-quote so `(versionbit N)` is not a subshell. let escaped = format!("'{}'", msg.replace('\'', "'\\''")); let shell = cmd.replace("%s", &escaped); - match std::process::Command::new("sh").arg("-c").arg(&shell).status() { + match std::process::Command::new("sh") + .arg("-c") + .arg(&shell) + .status() + { Ok(st) if !st.success() => { rbitcoin_log::warn!("alertnotify exited {st}: {shell}"); } diff --git a/crates/rbitcoin-rpc/src/methods_tests.rs b/crates/rbitcoin-rpc/src/methods_tests.rs index 138e6b3b..2e06024e 100644 --- a/crates/rbitcoin-rpc/src/methods_tests.rs +++ b/crates/rbitcoin-rpc/src/methods_tests.rs @@ -35,8 +35,8 @@ fn ctx_empty() -> (RpcContext, PathBuf) { logpath: String::new(), active: std::sync::Arc::new(std::sync::Mutex::new(RpcActive::default())), permit_bare_multisig: true, - alert_notify: None, - alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + alert_notify: None, + alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), }; (ctx, dir) } @@ -740,8 +740,8 @@ fn all_methods_callable_empty_or_error() { logpath: String::new(), active: std::sync::Arc::new(std::sync::Mutex::new(RpcActive::default())), permit_bare_multisig: true, - alert_notify: None, - alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + alert_notify: None, + alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), }; let mem2 = dispatch(&ctx2, "getmempoolinfo", vec![]).unwrap(); assert_eq!(mem2["loaded"], true); @@ -792,8 +792,8 @@ fn chain_methods_against_mined_regtest() { logpath: String::new(), active: std::sync::Arc::new(std::sync::Mutex::new(RpcActive::default())), permit_bare_multisig: true, - alert_notify: None, - alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + alert_notify: None, + alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), }; let tip_h = chain.tip_height(); @@ -1270,8 +1270,8 @@ fn ctx_regtest_hub() -> (RpcContext, PathBuf, Arc) { logpath: String::new(), active: std::sync::Arc::new(std::sync::Mutex::new(RpcActive::default())), permit_bare_multisig: true, - alert_notify: None, - alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + alert_notify: None, + alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), }; (ctx, dir, hub) } @@ -2735,8 +2735,8 @@ fn rpc_honesty_mempool_budget_and_network_identity() { logpath: String::new(), active: std::sync::Arc::new(std::sync::Mutex::new(RpcActive::default())), permit_bare_multisig: true, - alert_notify: None, - alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + alert_notify: None, + alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), }; let mem = dispatch(&ctx, "getmempoolinfo", vec![]).unwrap(); assert_eq!( diff --git a/crates/rbitcoin-rpc/src/server.rs b/crates/rbitcoin-rpc/src/server.rs index cf445641..6012e7b8 100644 --- a/crates/rbitcoin-rpc/src/server.rs +++ b/crates/rbitcoin-rpc/src/server.rs @@ -515,8 +515,7 @@ mod tests { subversion: None, work_queue: None, permit_bare_multisig: true, - alert_notify: None, - alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + alert_notify: None, }; let handle = run_rpc(cfg, q, Some(mp), None, None, None, None, None) .await @@ -641,8 +640,7 @@ mod tests { subversion: None, work_queue: None, permit_bare_multisig: true, - alert_notify: None, - alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + alert_notify: None, }; let handle = run_rpc(cfg, q, Some(mp), None, None, None, None, None) .await @@ -727,8 +725,7 @@ mod tests { subversion: None, work_queue: Some(1), permit_bare_multisig: true, - alert_notify: None, - alert_fired: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + alert_notify: None, }; let handle = run_rpc(cfg, q, Some(mp), None, None, None, None, None) .await diff --git a/scripts/core-functional/bitcoind.test.sh b/scripts/core-functional/bitcoind.test.sh index 3506d71a..b7c21e2e 100755 --- a/scripts/core-functional/bitcoind.test.sh +++ b/scripts/core-functional/bitcoind.test.sh @@ -111,12 +111,12 @@ else FAIL=$((FAIL + 1)) fi -# -bind=0.0.0.0:P supplies the P2P port; we still listen on 127.0.0.1. +# -bind=0.0.0.0:P supplies the P2P port; onion binds become extra --listen. OUT3="$("$SHIM" --print-cmd -datadir="$DATADIR" -regtest \ -bind=0.0.0.0:19333 -bind=127.0.0.1:19444=onion 2>/dev/null)" if printf '%s' "$OUT3" | grep -q -- "--listen 127.0.0.1:19333" \ - && ! printf '%s' "$OUT3" | grep -q -- "--listen 127.0.0.1:19444"; then - echo "ok - bind port becomes listen (onion ignored)" + && printf '%s' "$OUT3" | grep -q -- "--listen 127.0.0.1:19444"; then + echo "ok - bind port becomes listen (onion as extra listen)" PASS=$((PASS + 1)) else echo "not ok - bind port becomes listen (got: $OUT3)" From dc23f82f5d5165ae1dfb06fa02fe46521c549071 Mon Sep 17 00:00:00 2001 From: "rbitcoin-grok[bot]" Date: Fri, 28 Aug 2026 21:00:18 -0700 Subject: [PATCH 5/5] rpc: keep addpeeraddress in RAM (no per-call peers rewrite) p2p_getaddr_caching issues 10k addpeeraddress RPCs; rewriting peers on each call blew the 20m core-functional wall. Durability stays on the node's existing shutdown/catch-up save paths. --- crates/rbitcoin-rpc/src/methods.rs | 12 +++--------- crates/rbitcoin-rpc/src/methods_tests.rs | 10 ++++++---- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/crates/rbitcoin-rpc/src/methods.rs b/crates/rbitcoin-rpc/src/methods.rs index 8e87f273..1ef5f8b2 100644 --- a/crates/rbitcoin-rpc/src/methods.rs +++ b/crates/rbitcoin-rpc/src/methods.rs @@ -1255,15 +1255,9 @@ fn addpeeraddress(ctx: &RpcContext, params: &RpcParams) -> Result let Some(am) = ctx.addrman.as_ref() else { return Err(rpc_error(ERR_MISC, "addrman not available")); }; - { - let mut g = am.lock().unwrap_or_else(|e| e.into_inner()); - g.add(addr); - if let Some(path) = ctx.peers_path.as_ref() { - if let Err(e) = g.save(path) { - return Err(rpc_error(ERR_MISC, format!("peers save: {e}"))); - } - } - } + // RAM-only: do not rewrite peers on every call (p2p_getaddr_caching fills + // 10k addresses). The node still persists addrman on shutdown / catch-up. + am.lock().unwrap_or_else(|e| e.into_inner()).add(addr); Ok(json!({ "success": true })) } diff --git a/crates/rbitcoin-rpc/src/methods_tests.rs b/crates/rbitcoin-rpc/src/methods_tests.rs index 2e06024e..3ee1c879 100644 --- a/crates/rbitcoin-rpc/src/methods_tests.rs +++ b/crates/rbitcoin-rpc/src/methods_tests.rs @@ -2594,7 +2594,7 @@ fn addnode_and_disconnectnode_on_table() { } #[test] -fn addpeeraddress_adds_to_addrman_and_saves_peers() { +fn addpeeraddress_updates_addrman_without_rewriting_peers_file() { use rbitcoin_net::AddrMan; use std::sync::Mutex; @@ -2616,9 +2616,10 @@ fn addpeeraddress_adds_to_addrman_and_saves_peers() { assert_eq!(g.len(), 1); assert!(g.peers().contains(&"128.1.2.3:8333".parse().unwrap())); } - let loaded = AddrMan::load(&peers_path).unwrap(); - assert_eq!(loaded.len(), 1); - assert!(loaded.peers().contains(&"128.1.2.3:8333".parse().unwrap())); + assert!( + !peers_path.exists(), + "addpeeraddress must not rewrite peers on every call" + ); let named = RpcParams::named( json!({"address": "129.0.0.1", "port": 8334, "tried": false}) @@ -2629,6 +2630,7 @@ fn addpeeraddress_adds_to_addrman_and_saves_peers() { let out = dispatch(&ctx, "addpeeraddress", named).unwrap(); assert_eq!(out, json!({"success": true})); assert_eq!(am.lock().unwrap().len(), 2); + assert!(!peers_path.exists()); let _ = std::fs::remove_dir_all(&dir); }