From 16b2d825e81df7b2f9f7f6b465dcd95c86d7bc73 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:13:05 -0700 Subject: [PATCH 01/18] docs: record intentional fee, AEAD, and fuzz calls P2P accept has no absurd-fee cap and mempool fee math checks u64 overflow, not MAX_MONEY. BIP324 stays on upstream chacha20-poly1305 0.1. Fuzz stays nightly-only; silent-payment tweaks are not a missing bin. --- COMPAT.md | 2 +- SECURITY.md | 6 +++++- TESTING.md | 6 ++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/COMPAT.md b/COMPAT.md index fdd431621b..fd17e24f8d 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -65,7 +65,7 @@ Full `/tx/:txid` JSON still has `vin[]`. Electrum has no outspend-vin surface. | Historical blocks | Reconstruct from archive; tip via body queue + peer wire | `blocks/` blk*.dat | | Transport | **BIP324 v2 only** | v1 + v2 | | Mempool structure | Cluster graph + chunks | Cluster mempool (same lineage) | -| Admission policy | **Libre-relay-class** (0.1 sat/vB, no dust, full RBF). P2P admits any nVersion. RPC-submit (`sendrawtransaction` / `testmempoolaccept` / `submitpackage`) still rejects `"version"` outside 1/2. Feerate, RBF, min relay and `-blockmintxfee` use Core's sigop-adjusted size `max(weight, sigop_cost × 20)` (`--bytes-per-sigop`, `0` disables). Cluster and package count/size limits and the block weight budget count raw weight, unlike Core, so the adjustment never becomes a per-tx sigop cap. No 16,000 sigop-cost standardness cap; admission and template selection share a strict 80,000 minus `--block-reserved-sigops` budget (default reserve 400) | Standardness + policy knobs (nVersion 1/2, `MAX_STANDARD_TX_SIGOPS_COST` 16,000, `-bytespersigop`) | +| Admission policy | **Libre-relay-class** (0.1 sat/vB, no dust, full RBF). P2P admits any nVersion. RPC-submit (`sendrawtransaction` / `testmempoolaccept` / `submitpackage`) still rejects `"version"` outside 1/2. Feerate, RBF, min relay and `-blockmintxfee` use Core's sigop-adjusted size `max(weight, sigop_cost × 20)` (`--bytes-per-sigop`, `0` disables). Cluster and package count/size limits and the block weight budget count raw weight, unlike Core, so the adjustment never becomes a per-tx sigop cap. No 16,000 sigop-cost standardness cap; admission and template selection share a strict 80,000 minus `--block-reserved-sigops` budget (default reserve 400). P2P `accept_tx` has the minimum relay feerate and no absurd-fee or `-maxtxfee` cap; RPC submit keeps its own default of 10,000 sat/vB (`0` unlimited). That split is intentional: the signer chose the fee. Mempool fee math checks `u64` overflow (`bad-txns-txouttotal-toolarge`), not `MAX_MONEY`. Block connect rejects an output or an output sum above `Amount::MAX_MONEY`. A mainnet input sum cannot exceed that cap, because those coins were rejected at connect | Standardness + policy knobs (nVersion 1/2, `MAX_STANDARD_TX_SIGOPS_COST` 16,000, `-bytespersigop`) | | Compact blocks | BIP152 **v2** receive + reconstruct + `getblocktxn` serve. Fill is live mempool + orphanage + `extra_compact` (cap 100). Outbound extra prefill is **on** unless `--prefill-compact=0` (10 KiB cap, extra-pool last; generate / submit / NewPoWValid pack txs not in the live mempool without delaying forward) | v1/v2 high-bandwidth + `extra_txn` cache (`-blockreconstructionextratxn`); Core #35558 prefill still unmerged | | WTx inventory | BIP339 when peer also sends `wtxidrelay` | BIP339 | | GetAddr | Core `MAX_ADDR_TO_SEND` / `MAX_PCT_ADDR_TO_SEND` (**1000** / **23%**), 24h per-bind cache. Named copies in `rbitcoin-net` — do not “improve” without a named reason to diverge. `MAX_ADDR_MAN` (8192) is **our** HashMap DoS cap and must stay above `1000/0.23` | Core new/tried buckets (~80k); same 1000 / 23% | diff --git a/SECURITY.md b/SECURITY.md index f3ce23aa11..c077fe1c8a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -53,7 +53,11 @@ that affect consensus, P2P attack surface, or Electrum/query integrity. - **P2P:** BIP324 v2-only. DoS parity with Bitcoin Core is **not** claimed, but mitigations are intentional operator surface: max inbound sessions (`--max-inbound`, default 125), per-session message/byte - rate windows, misbehavior score disconnect. + rate windows, misbehavior score disconnect. Peer AEAD is the + `chacha20-poly1305` 0.1 that `bip324` 0.11 links. That is current + `rust-bitcoin/bip324` master, not a local pin. `noise_sv2` already uses + the renamed `chacha20poly1305` crate. No version bump until upstream + publishes one. - **Electrum and Esplora:** plain TCP/HTTP; TLS is an operator reverse-proxy concern. Wallet clients and electrs HTTP (except address-prefix) ([`COMPAT.md`](./COMPAT.md)). diff --git a/TESTING.md b/TESTING.md index f85174f42b..f86b7adaf5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -429,7 +429,13 @@ New features: add a high-level scenario; remove obsolete lower-level tests in th ## Core differential Nightly (not a required PR check) `fuzz.yml` runs **21** cargo-fuzz jobs. +That nightly run is the fuzz signal on purpose. Pull requests do not run +a short fuzz job: a 30-second smoke would not catch what the nightly +differential run is for, and it is not a required check. `fuzz/` is not a default workspace member. +Silent-payment tweaks are not a missing fuzz bin. They are libsecp +`combine_keys` and `mul_tweak` plus a tagged hash, covered by unit tests +and the Electrum spend test. Treat crate-root `pub` that exists only so a fuzz target can call it as the same smell as a test-only export: prefer `pub(crate)` plus an in-crate harness, or the published `rbitcoin-node` / CLI binary, even if that costs From 025ca67ab21f69c1c2e163f4eb9c6f103c13fb8d Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:19:25 -0700 Subject: [PATCH 02/18] rpc: refuse a cookie wider than --rpc-cookie-perms Default owner rejects group and world bits at startup. group allows a 0640 file and all allows any mode. The node still does not create or chmod the cookie. NixOS services.rbitcoin.rpc.cookiePerms passes the flag only when set. --- changelog.d/rpc-cookie-perms.md | 8 ++ crates/rbitcoin-node/src/cli.rs | 21 ++++- crates/rbitcoin-node/src/config.rs | 28 ++++++- crates/rbitcoin-node/src/run.rs | 1 + crates/rbitcoin-rpc/src/auth.rs | 119 ++++++++++++++++++++++++++--- crates/rbitcoin-rpc/src/lib.rs | 4 +- crates/rbitcoin-rpc/src/server.rs | 27 ++++++- docs/operator/interfaces.md | 8 +- docs/operator/operations.md | 1 + docs/rpc.md | 1 + docs/wallets.md | 7 +- nix/modules/rbitcoin.nix | 20 +++++ nix/tests/nixos-module-eval.nix | 37 +++++++++ 13 files changed, 263 insertions(+), 19 deletions(-) create mode 100644 changelog.d/rpc-cookie-perms.md diff --git a/changelog.d/rpc-cookie-perms.md b/changelog.d/rpc-cookie-perms.md new file mode 100644 index 0000000000..9504bfac72 --- /dev/null +++ b/changelog.d/rpc-cookie-perms.md @@ -0,0 +1,8 @@ +Changed + +- **`--rpc-cookie-perms owner|group|all`** (conf `rpc_cookie_perms=`, NixOS + `services.rbitcoin.rpc.cookiePerms`). Default `owner` refuses a cookie + with group or world bits at startup. `group` allows a 0640 file shared + with another service. `all` allows any mode. The node still does not + create or chmod the cookie. A group- or world-readable cookie used to + warn once and start. diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index e2821552fc..f67b4b4e0a 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -312,7 +312,7 @@ fn operator_usage() -> String { [--electrum-listen ADDR] [--esplora-listen ADDR] [--esplora-onion[=0|1]] [--health-listen [ADDR]] [--metrics] \\\n\ [--sv2-tp-listen ADDR] [--sv2-tp-authority-sec KEY] [--sv2-tp-authority-sec-file PATH] [--sv2-tp-cert-validity SECS] [--sv2-tp-stale-grace SECS] [--sv2-tp-fee-delta SATS] [--sv2-tp-template-interval SECS] \\\n\ [--sh-index] [--block-filter-index] [--prune-seqsigwit] [--prune-seqsigwit-ram-threshold-bytes N] [--datadir-hdd] [--sp-tweaks] [--sp-tweaks-dust SATS] [--max-sh-creates N] [--electrum-max-subs N] [--esplora-block-template] \\\n\ - [--rpc] [--rpc-listen [ADDR]] [--rest] [--rpc-socket PATH] [--rpc-token-file PATH] [--rpc-cookie-file PATH] [--rpc-work-queue N] \\\n\ + [--rpc] [--rpc-listen [ADDR]] [--rest] [--rpc-socket PATH] [--rpc-token-file PATH] [--rpc-cookie-file PATH] [--rpc-cookie-perms owner|group|all] [--rpc-work-queue N] \\\n\ [--milestone HEIGHT] \\\n\ [--max-outbound N] [--max-inbound N] \\\n\ [--mempool-size-mb N] [--mempool-expiry HOURS] \\\n\ @@ -374,7 +374,7 @@ SV2: --sv2-tp-listen ADDR serves the Stratum v2 Template Distribution Protocol ( --sv2-tp-stale-grace SECS keeps a replaced tip's templates answering for SECS, at most 86400 (default 10).\n\ --sv2-tp-fee-delta SATS pushes a same-tip template once its fees gain SATS over the last one sent (default 1000).\n\ --sv2-tp-template-interval SECS checks for that gain every SECS, 1 to 86400 (default 5).\n\ -RPC: --rpc unix socket {{datadir}}/rpc.sock; --rpc-listen [ADDR] adds TCP (default 127.0.0.1 and Core-matching port). Token {{datadir}}/rpc.token (Bearer); --rpc-cookie-file opts TCP into Core cookie HTTP Basic. No --rpcuser. --rest turns on unauthenticated /rest/ on those listeners (off unless set).\n\ +RPC: --rpc unix socket {{datadir}}/rpc.sock; --rpc-listen [ADDR] adds TCP (default 127.0.0.1 and Core-matching port). Token {{datadir}}/rpc.token (Bearer); --rpc-cookie-file opts TCP into Core cookie HTTP Basic. --rpc-cookie-perms owner|group|all (default owner) is the widest mode that file may have; the node never creates or chmods it. No --rpcuser. --rest turns on unauthenticated /rest/ on those listeners (off unless set).\n\ Cold files: --datadir-cold PATH puts append-only IBD files under PATH/store\n\ (seqsigwit, txstat, input, and blockfilter / sp_tweaks once enabled).\n\ Default (flag omitted): those files live under --datadir. --prune-seqsigwit\n\ @@ -660,6 +660,7 @@ mod tests { "--rpc-socket", "--rpc-token-file", "--rpc-cookie-file", + "--rpc-cookie-perms", "--proxy", "--onion", "--proxy-randomize", @@ -1194,6 +1195,22 @@ mod tests { std::fs::write(&cookie, "__cookie__:secret").unwrap(); // Socket-only RPC would silently ignore the cookie (message pinned in config.rs). assert_exit(run(&["--rpc"]), ExitCode::FAILURE); + assert_exit( + run(&["--rpc-listen", "--rpc-cookie-perms", "0640"]), + ExitCode::from(2), + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert_exit(run(&["--rpc-listen"]), ExitCode::FAILURE); + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o640)).unwrap(); + assert_exit( + run(&["--rpc-listen", "--rpc-cookie-perms", "group"]), + ExitCode::SUCCESS, + ); + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o600)).unwrap(); + } assert_exit(run(&["--rpc-listen"]), ExitCode::SUCCESS); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index 0c99da0c43..8e0a41cf0b 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -261,6 +261,8 @@ pub struct RpcOpts { pub token_file: Option, /// Opt-in Core-format `username:password` cookie accepted as TCP HTTP Basic. pub cookie_file: Option, + /// Widest mode `--rpc-cookie-perms` allows. Default owner (0600). + pub cookie_perms: rbitcoin_rpc::RpcCookiePerms, pub work_queue: Option, /// Core REST on the RPC listener. Off unless `--rest` / `rest=`. pub rest: bool, @@ -275,6 +277,7 @@ impl Default for RpcOpts { socket_path: None, token_file: None, cookie_file: None, + cookie_perms: rbitcoin_rpc::RpcCookiePerms::Owner, work_queue: Some(rbitcoin_rpc::DEFAULT_RPC_WORK_QUEUE), rest: false, } @@ -679,7 +682,7 @@ impl NodeConfig { "--rpc-cookie-file applies to TCP only; add --rpc-listen".into(), )); } - rbitcoin_rpc::read_cookie_file(path) + rbitcoin_rpc::read_cookie_file(path, self.rpc.cookie_perms) .map(drop) .map_err(NodeError::Config) } @@ -1245,6 +1248,10 @@ impl NodeConfig { } self.rpc.cookie_file = Some(PathBuf::from(val)); } + "rpc_cookie_perms" => { + self.rpc.cookie_perms = + rbitcoin_rpc::RpcCookiePerms::parse(val).map_err(NodeError::Config)?; + } "ua_comment" => self.uacomments.push(val.to_string()), "test_activation_height" => { let (name, height) = ChainParams::parse_test_activation_height(val) @@ -2302,6 +2309,25 @@ mod tests { let err = cfg.validate().unwrap_err().to_string(); assert!(err.contains("line ending"), "{err}"); std::fs::write(&cookie, "__cookie__:secret").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o644)).unwrap(); + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("--rpc-cookie-perms owner"), "{err}"); + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o640)).unwrap(); + cfg.rpc.cookie_perms = rbitcoin_rpc::RpcCookiePerms::Group; + cfg.validate() + .expect("0640 is within --rpc-cookie-perms group"); + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o644)).unwrap(); + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("--rpc-cookie-perms group"), "{err}"); + cfg.rpc.cookie_perms = rbitcoin_rpc::RpcCookiePerms::All; + cfg.validate() + .expect("0644 is within --rpc-cookie-perms all"); + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o600)).unwrap(); + cfg.rpc.cookie_perms = rbitcoin_rpc::RpcCookiePerms::Owner; + } cfg.validate() .expect("Core-format cookie with --rpc-listen"); cfg.rpc.listen_default = false; diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index edcc944e78..d886ac012b 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -170,6 +170,7 @@ async fn start_rpc_listener( network: config.network, token_path: Some(config.rpc_token_path()), cookie_path: config.rpc_cookie_path(), + cookie_perms: config.rpc.cookie_perms, work_queue: config.rpc.work_queue, rest: config.rpc.rest, subversion: Some( diff --git a/crates/rbitcoin-rpc/src/auth.rs b/crates/rbitcoin-rpc/src/auth.rs index 5746d2905a..e1095b078c 100644 --- a/crates/rbitcoin-rpc/src/auth.rs +++ b/crates/rbitcoin-rpc/src/auth.rs @@ -57,6 +57,49 @@ pub fn default_socket_path(datadir: &Path) -> PathBuf { datadir.join("rpc.sock") } +/// Widest mode an operator declared for `--rpc-cookie-file`. +/// +/// The node never creates or chmods the cookie. `owner` is mode 0600. +/// `group` also allows group bits (0640). `all` allows any mode. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RpcCookiePerms { + #[default] + Owner, + Group, + All, +} + +impl RpcCookiePerms { + pub fn parse(raw: &str) -> Result { + match raw { + "owner" => Ok(Self::Owner), + "group" => Ok(Self::Group), + "all" => Ok(Self::All), + _ => Err(format!( + "rpc_cookie_perms must be owner, group, or all (got {raw:?})" + )), + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Owner => "owner", + Self::Group => "group", + Self::All => "all", + } + } + + /// Unix permission bits no wider than this declaration. + fn allows_unix_mode(self, mode: u32) -> bool { + let mode = mode & 0o777; + match self { + Self::Owner => mode & 0o077 == 0, + Self::Group => mode & 0o007 == 0, + Self::All => true, + } + } +} + /// Read a Core-format RPC cookie. The configured file is never created or logged. /// /// The file must be exactly `username:password` with no line ending, as Core @@ -64,7 +107,7 @@ pub fn default_socket_path(datadir: &Path) -> PathBuf { /// raw file bytes as Basic credentials, so a trailing newline would make the two /// clients disagree. Refusing it here turns mempool's endless 401 into a startup /// error. -pub fn read_cookie_file(path: &Path) -> Result { +pub fn read_cookie_file(path: &Path, perms: RpcCookiePerms) -> Result { let credentials = fs::read_to_string(path).map_err(|e| format!("read RPC cookie {}: {e}", path.display()))?; if credentials.ends_with(['\n', '\r']) { @@ -85,15 +128,15 @@ pub fn read_cookie_file(path: &Path) -> Result { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - use std::sync::atomic::{AtomicBool, Ordering}; - static WARNED: AtomicBool = AtomicBool::new(false); if let Ok(meta) = fs::metadata(path) { let mode = meta.permissions().mode() & 0o777; - if mode & 0o077 != 0 && !WARNED.swap(true, Ordering::Relaxed) { - rbitcoin_log::warn!( - "rpc: cookie {} mode {mode:o} is group or world accessible", - path.display() - ); + if !perms.allows_unix_mode(mode) { + return Err(format!( + "RPC cookie {}: mode {mode:o} is wider than --rpc-cookie-perms {} \ + (owner is 0600, group allows group bits, all allows any mode)", + path.display(), + perms.as_str() + )); } } } @@ -304,7 +347,12 @@ mod tests { fs::create_dir_all(&dir).unwrap(); let path = dir.join(".cookie"); fs::write(&path, "mempool:secret").unwrap(); - let cookie = read_cookie_file(&path).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + } + let cookie = read_cookie_file(&path, RpcCookiePerms::Owner).unwrap(); assert!(cookie.matches_credentials("mempool:secret")); assert!(!cookie.matches_credentials("mempool:wrong")); for contents in [ @@ -316,18 +364,67 @@ mod tests { "mempool:secret\nnext", ] { fs::write(&path, contents).unwrap(); - assert!(read_cookie_file(&path).is_err(), "{contents:?}"); + assert!( + read_cookie_file(&path, RpcCookiePerms::Owner).is_err(), + "{contents:?}" + ); } // mempool sends the file verbatim, so a line ending would never match: // refuse it with an error that names the fix. for contents in ["mempool:secret\n", "mempool:secret\r\n", "mempool:secret\r"] { fs::write(&path, contents).unwrap(); - let err = read_cookie_file(&path).err().expect("line ending rejected"); + let err = read_cookie_file(&path, RpcCookiePerms::Owner) + .err() + .expect("line ending rejected"); assert!(err.contains("line ending"), "{contents:?}: {err}"); } let _ = fs::remove_dir_all(&dir); } + #[cfg(unix)] + #[test] + fn cookie_mode_must_match_declared_perms() { + use std::os::unix::fs::PermissionsExt; + let dir = tmp(); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join(".cookie"); + fs::write(&path, "mempool:secret").unwrap(); + let chmod = |mode: u32| { + fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap(); + }; + chmod(0o644); + let err = read_cookie_file(&path, RpcCookiePerms::Owner) + .err() + .expect("0644 is wider than owner"); + assert!(err.contains("644"), "{err}"); + assert!(err.contains("--rpc-cookie-perms owner"), "{err}"); + chmod(0o600); + assert!(read_cookie_file(&path, RpcCookiePerms::Owner).is_ok()); + chmod(0o640); + assert!(read_cookie_file(&path, RpcCookiePerms::Group).is_ok()); + let err = read_cookie_file(&path, RpcCookiePerms::Owner) + .err() + .expect("0640 is wider than owner"); + assert!(err.contains("--rpc-cookie-perms owner"), "{err}"); + chmod(0o644); + let err = read_cookie_file(&path, RpcCookiePerms::Group) + .err() + .expect("0644 is wider than group"); + assert!(err.contains("--rpc-cookie-perms group"), "{err}"); + assert!(read_cookie_file(&path, RpcCookiePerms::All).is_ok()); + assert_eq!( + RpcCookiePerms::parse("owner").unwrap(), + RpcCookiePerms::Owner + ); + assert_eq!( + RpcCookiePerms::parse("group").unwrap(), + RpcCookiePerms::Group + ); + assert_eq!(RpcCookiePerms::parse("all").unwrap(), RpcCookiePerms::All); + assert!(RpcCookiePerms::parse("0640").is_err()); + let _ = fs::remove_dir_all(&dir); + } + #[cfg(unix)] #[test] fn token_file_is_created_owner_only() { diff --git a/crates/rbitcoin-rpc/src/lib.rs b/crates/rbitcoin-rpc/src/lib.rs index 7dbe5aa099..2cc1a0e34c 100644 --- a/crates/rbitcoin-rpc/src/lib.rs +++ b/crates/rbitcoin-rpc/src/lib.rs @@ -7,7 +7,9 @@ mod blockstats; mod methods; mod server; -pub use auth::{default_socket_path, default_token_path, read_cookie_file, RpcAuth}; +pub use auth::{ + default_socket_path, default_token_path, read_cookie_file, RpcAuth, RpcCookiePerms, +}; pub use methods::{ difficulty_rpc_f64, gbt_template, submit_received_block, HubRegtest, RpcActive, RpcContext, RpcRegtest, SubmitBlockOutcome, diff --git a/crates/rbitcoin-rpc/src/server.rs b/crates/rbitcoin-rpc/src/server.rs index f2d104e3f2..26985b9a06 100644 --- a/crates/rbitcoin-rpc/src/server.rs +++ b/crates/rbitcoin-rpc/src/server.rs @@ -2,6 +2,7 @@ use crate::auth::{ parse_basic_auth, parse_bearer_auth, read_cookie_file, resolve_rpc_auth, RpcAuth, RpcCookie, + RpcCookiePerms, }; use crate::methods::{RpcContext, RpcRegtest}; use axum::body::Bytes; @@ -134,6 +135,8 @@ pub struct RpcConfig { pub token_path: Option, /// Optional Core-format `username:password` file accepted as HTTP Basic on TCP. pub cookie_path: Option, + /// Widest mode `--rpc-cookie-perms` allows for [`Self::cookie_path`]. + pub cookie_perms: RpcCookiePerms, /// `getnetworkinfo.subversion`. Empty → `/rbitcoin:VERSION/`. pub subversion: Option, /// HTTP occupancy cap. `None` and `0` are [`DEFAULT_RPC_WORK_QUEUE`]. @@ -213,7 +216,7 @@ pub async fn run_rpc( let cookie = config .cookie_path .as_deref() - .map(read_cookie_file) + .map(|path| read_cookie_file(path, config.cookie_perms)) .transpose()?; let stop = Arc::new(AtomicBool::new(false)); @@ -946,6 +949,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -984,6 +988,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: true, @@ -1084,6 +1089,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -1152,6 +1158,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -1234,6 +1241,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -1275,6 +1283,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -1342,6 +1351,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -1475,6 +1485,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -1539,6 +1550,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -1602,6 +1614,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -1671,6 +1684,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: Some(1), rest: false, @@ -1728,6 +1742,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: Some(1), rest: false, @@ -1812,6 +1827,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -1983,6 +1999,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -2024,6 +2041,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: Some(cookie_path.clone()), + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -2071,6 +2089,11 @@ mod tests { assert!(err.contains("line ending"), "{err}"); std::fs::write(&cookie_path, "mempool:secret").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&cookie_path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } let handle = run_rpc(with_cookie(), query, None, None, None, None, None) .await .unwrap(); @@ -2173,6 +2196,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, @@ -2201,6 +2225,7 @@ mod tests { network: Network::Regtest, token_path: None, cookie_path: None, + cookie_perms: RpcCookiePerms::Owner, subversion: None, work_queue: None, rest: false, diff --git a/docs/operator/interfaces.md b/docs/operator/interfaces.md index 5997624477..e814c34d06 100644 --- a/docs/operator/interfaces.md +++ b/docs/operator/interfaces.md @@ -249,6 +249,7 @@ printf '__cookie__:%s' "$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')" | --sh-index \ --rpc-listen 127.0.0.1:8332 \ --rpc-cookie-file /run/rbitcoin/rpc.cookie \ + --rpc-cookie-perms group \ --esplora-listen /run/rbitcoin/esplora.sock \ --log-level info ``` @@ -366,8 +367,11 @@ listener. The node reads that file and never creates it, so write it before each start (the [mempool.space](#mempoolspace) command above does). Keep it readable only by the rbitcoin and mempool service identities. -1. Start the node with `--rpc-listen 127.0.0.1:` and - `--rpc-cookie-file /run/rbitcoin/rpc.cookie`. +1. Start the node with `--rpc-listen 127.0.0.1:`, + `--rpc-cookie-file /run/rbitcoin/rpc.cookie`, and + `--rpc-cookie-perms group` (the 0640 file above). Default `owner` + refuses that mode. NixOS `services.rbitcoin.rpc.cookiePerms = "group"` + passes the flag; leave it unset to keep the unit argv on the node default. 2. Point mempool at the same file: ```json diff --git a/docs/operator/operations.md b/docs/operator/operations.md index f791b03454..f329d071ad 100644 --- a/docs/operator/operations.md +++ b/docs/operator/operations.md @@ -110,6 +110,7 @@ Clean smoke: | `--rest` | `rest=` | **off** — unauthenticated `/rest/` on the RPC listener. Own queue. Needs `--rpc` or `--rpc-listen` | | `--rpc-token-file PATH` | `rpc_token_file=` | `{datadir}/rpc.token` (CSPRNG hex; TCP Bearer) | | `--rpc-cookie-file PATH` | `rpc_cookie_file=` | disabled — existing Core `username:password` cookie (no trailing newline) enables TCP HTTP Basic alongside Bearer | +| `--rpc-cookie-perms owner\|group\|all` | `rpc_cookie_perms=` | **owner** — refuse a cookie wider than this. `group` allows 0640. `all` allows any mode. The node does not chmod the file. NixOS `services.rbitcoin.rpc.cookiePerms` | | `--rpc-work-queue N` | `rpc_work_queue=` | **16** in-flight HTTP RPC (Core `-rpcworkqueue`). One POST is one slot (array batches still run). Full permit is HTTP **503** `Work queue depth exceeded`. **0** is the default queue of 16. | | `--min-relay-tx-fee BTC` | `min_relay_tx_fee=` | unset — Libre default 100 sat/kvB; `0` = no floor; garbage/negatives fail start | | `--mempool-expiry HOURS` | `mempool_expiry=` | unset — hub default; min 1 | diff --git a/docs/rpc.md b/docs/rpc.md index c89fbf69b6..f55cc43c67 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -52,6 +52,7 @@ later Core RPC is present, including descriptor-wallet calls from 0.21 | `--rpc-listen [ADDR]` / conf `rpc_listen=` | **off** | TCP JSON-RPC; omit ADDR → `127.0.0.1` and Core-matching port (8332 / 18332 / 38332 / 18443). Implies `--rpc`. | | `--rpc-token-file PATH` | `{datadir}/rpc.token` | CSPRNG hex token; TCP `Authorization: Bearer` | | `--rpc-cookie-file PATH` / conf `rpc_cookie_file=` | **off** | Existing Core-format `username:password` file (no trailing newline) accepted as TCP HTTP Basic in addition to Bearer; it is never created or logged | +| `--rpc-cookie-perms owner\|group\|all` / conf `rpc_cookie_perms=` | **owner** | Widest mode the cookie file may have. `owner` refuses group or world bits. `group` allows group bits (0640) and refuses world bits. `all` allows any mode. The node never chmods the file. NixOS `services.rbitcoin.rpc.cookiePerms` (default unset, so the unit argv omits the flag) | | `--sh-index` | **off** | Class B scripthash for Electrum/Esplora address history. RPC by height/hash/txid does not need it | | `--block-filter-index` | **off** | BIP158 basic. IBD seals them when the flag is on from the start. A later enable still materializes after catch-up. `NODE_COMPACT_FILTERS` is advertised once filters first reach the tip (`getnetworkinfo` lists `COMPACT_FILTERS`), then for the life of the process. `getblockfilter` serves a sealed height, one block whose parent filter is already sealed, and a stale branch back to that sealed fork point. An unsealed best-chain gap is refused. `/rest/blockfilter/` and P2P still serve only heights the watermark covers. Independent of `--sh-index` | | `--rpc-work-queue N` | **16** | In-flight HTTP RPC (Core `-rpcworkqueue`). One POST is one slot (a JSON-RPC array is still one slot). Full permit is HTTP **503** `Work queue depth exceeded`. **0** is the default queue of 16. | diff --git a/docs/wallets.md b/docs/wallets.md index b3825783ca..1f9b60f70c 100644 --- a/docs/wallets.md +++ b/docs/wallets.md @@ -70,9 +70,14 @@ printf '__cookie__:%s' "$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')" | --datadir ./datadir-mainnet \ --network mainnet \ --rpc-listen 127.0.0.1:8332 \ - --rpc-cookie-file /run/rbitcoin/rpc.cookie + --rpc-cookie-file /run/rbitcoin/rpc.cookie \ + --rpc-cookie-perms group ``` +`--rpc-cookie-perms` defaults to `owner` (mode 0600). `group` is what makes +the 0640 file above acceptable. `all` allows any mode. The node never +chmods the file. NixOS: `services.rbitcoin.rpc.cookiePerms`. + The mempool.space group ownership and unix-socket variant are in [`operator/interfaces.md`](./operator/interfaces.md#mempoolspace-core_rpc). Unix `{datadir}/rpc.sock` (`--rpc`, mode 0600) needs no header. diff --git a/nix/modules/rbitcoin.nix b/nix/modules/rbitcoin.nix index 2918359b79..58b5d66871 100644 --- a/nix/modules/rbitcoin.nix +++ b/nix/modules/rbitcoin.nix @@ -83,6 +83,8 @@ let ++ optional (cfg.rpc.socketPath != null) cfg.rpc.socketPath ++ optional (cfg.rpc.cookieFile != null) "--rpc-cookie-file" ++ optional (cfg.rpc.cookieFile != null) (toString cfg.rpc.cookieFile) + ++ optional (cfg.rpc.cookiePerms != null) "--rpc-cookie-perms" + ++ optional (cfg.rpc.cookiePerms != null) cfg.rpc.cookiePerms ++ optional cfg.electrum.enable "--electrum-listen" ++ optional cfg.electrum.enable (socket cfg.electrum.address cfg.electrum.port) ++ optional (cfg.electrum.enable && cfg.electrum.maxSubs != null) "--electrum-max-subs" @@ -433,6 +435,20 @@ in read it, and mempool must be able to traverse its directory and read the file. The unit's PrivateTmp and ProtectHome hide /tmp and /home, so keep it elsewhere (e.g. /run/rbitcoin). Format: docs/operator/interfaces.md. + A group-readable cookie (mode 0640) also needs `cookiePerms = "group"`. + ''; + }; + + cookiePerms = mkOption { + type = types.nullOr (types.enum [ "owner" "group" "all" ]); + default = null; + example = "group"; + description = '' + Pass `--rpc-cookie-perms`. The node never creates or chmods the cookie. + `null` leaves the node default (`owner`): group or world bits refuse startup. + `group` allows group bits, such as a 0640 file shared with mempool. + `all` allows any mode. Requires `cookieFile`. Omitted from the unit + argv when null, so the default service command does not grow a flag. ''; }; }; @@ -580,6 +596,10 @@ in assertion = cfg.rpc.cookieFile == null || cfg.rpc.enable; message = "services.rbitcoin.rpc.cookieFile requires rpc.enable (the cookie is accepted on TCP only)"; } + { + assertion = cfg.rpc.cookiePerms == null || cfg.rpc.cookieFile != null; + message = "services.rbitcoin.rpc.cookiePerms requires rpc.cookieFile"; + } { assertion = !cfg.metrics || cfg.health.enable; message = "services.rbitcoin.metrics requires health.enable (--metrics needs --health-listen)"; diff --git a/nix/tests/nixos-module-eval.nix b/nix/tests/nixos-module-eval.nix index 2d1179bf77..87225a742b 100644 --- a/nix/tests/nixos-module-eval.nix +++ b/nix/tests/nixos-module-eval.nix @@ -147,6 +147,37 @@ let cookieDataDirMode = cookieInDataDir.config.systemd.tmpfiles.settings."10-rbitcoin"."/var/lib/rbitcoin".d.mode; cookieTcpOnly = "services.rbitcoin.rpc.cookieFile requires rpc.enable (the cookie is accepted on TCP only)"; + cookiePermsNeedFile = "services.rbitcoin.rpc.cookiePerms requires rpc.cookieFile"; + cookieGroup = nixpkgs.lib.nixosSystem { + inherit (pkgs.stdenv.hostPlatform) system; + modules = [ + module + { + services.rbitcoin = { + enable = true; + package = fakePackage; + rpc.enable = true; + rpc.cookieFile = "/run/rbitcoin/rpc.cookie"; + rpc.cookiePerms = "group"; + }; + } + ]; + }; + cookieGroupExec = cookieGroup.config.systemd.services.rbitcoin.serviceConfig.ExecStart; + cookiePermsAlone = nixpkgs.lib.nixosSystem { + inherit (pkgs.stdenv.hostPlatform) system; + modules = [ + module + { + services.rbitcoin = { + enable = true; + package = fakePackage; + rpc.enable = true; + rpc.cookiePerms = "group"; + }; + } + ]; + }; metricsNeedHealth = "services.rbitcoin.metrics requires health.enable (--metrics needs --health-listen)"; metricsWithoutHealth = nixpkgs.lib.nixosSystem { inherit (pkgs.stdenv.hostPlatform) system; @@ -227,6 +258,7 @@ assert defaultCfg.p2p.listenOnion == false; assert defaultCfg.rpc.port == 8332; assert defaultCfg.rpc.socketPath == null; assert defaultCfg.rpc.cookieFile == null; +assert defaultCfg.rpc.cookiePerms == null; assert defaultCfg.proxy == null; assert defaultCfg.onionProxy == null; assert defaultCfg.proxyRandomize == true; @@ -291,6 +323,9 @@ assert builtins.match ".*--milestone 100.*" ( ) != null; assert builtins.match ".*--rpc-socket /run/rbitcoin/rpc.sock.*" execStart != null; assert builtins.match ".*--rpc-cookie-file /run/rbitcoin/rpc.cookie.*" execStart != null; +assert builtins.match ".*--rpc-cookie-perms.*" execStart == null; +assert builtins.match ".*--rpc-cookie-perms group.*" cookieGroupExec != null; +assert builtins.match ".*--rpc-cookie-file /run/rbitcoin/rpc.cookie.*" cookieGroupExec != null; assert builtins.elem "/run/rbitcoin" service.serviceConfig.ReadWritePaths; assert cfg.systemd.tmpfiles.settings."10-rbitcoin"."/run/rbitcoin".d.mode == "0750"; assert builtins.match ".*--electrum-listen 127.0.0.1:50001.*" execStart != null; @@ -348,6 +383,8 @@ assert builtins.match ".*--rpc-cookie-file.*" listenOffExec == null; assert !builtins.elem cookieTcpOnly (failedAssertions system); assert !builtins.elem metricsNeedHealth (failedAssertions system); assert builtins.elem cookieTcpOnly (failedAssertions cookieWithoutTcp); +assert builtins.elem cookiePermsNeedFile (failedAssertions cookiePermsAlone); +assert !builtins.elem cookiePermsNeedFile (failedAssertions cookieGroup); assert builtins.elem metricsNeedHealth (failedAssertions metricsWithoutHealth); assert builtins.elem "127.0.0.1:9332" (builtins.head rbitcoinScrape.static_configs).targets; assert !builtins.elem sv2KeyNotInStore (failedAssertions system); From d03195515d7f2adfe62f07dcec382164dd839108 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:23:01 -0700 Subject: [PATCH 03/18] deps: deny duplicate crate versions in qc cargo deny now fails a new duplicate. The copies that noise_sv2 and the hashes lines still require are skipped with a reason. workspace = true stays allowed: that is the wildcards lint, and the tree has no version star. --- deny.toml | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/deny.toml b/deny.toml index 145a523683..78b875e339 100644 --- a/deny.toml +++ b/deny.toml @@ -29,9 +29,29 @@ allow = [ ] [bans] -multiple-versions = "warn" +multiple-versions = "deny" +# `workspace = true` is this lint. The tree has no `version = "*"`. wildcards = "allow" deny = [] +skip = [ + # noise_sv2 aes/chacha still use cpufeatures 0.2. sha3 0.12 uses 0.3. + { name = "cpufeatures", version = "=0.2.17" }, + # Same split: noise_sv2 aead/cipher vs digest 0.11 (sha3). + { name = "crypto-common", version = "=0.1.7" }, + # noise_sv2 2.0.0 still pins secp256k1 0.28. bitcoin and bip324 use 0.29. + { name = "secp256k1", version = "=0.28.2" }, + # Follows the noise_sv2 secp256k1 0.28 pin (sys 0.9). bitcoin uses 0.10. + { name = "secp256k1-sys", version = "=0.9.2" }, + # secp256k1 0.28 pulls hashes 0.13. bitcoin is 0.14. bip324 is 0.16. + { name = "bitcoin_hashes", version = "=0.13.1" }, + { name = "bitcoin_hashes", version = "=0.14.101" }, + # Each hashes line above carries its own hex-conservative. 1.x stays checked. + { name = "hex-conservative", version = "=0.1.2" }, + { name = "hex-conservative", version = "=0.2.3" }, + { name = "hex-conservative", version = "=0.3.2" }, + # rand 0.8 (via noise_sv2) still needs getrandom 0.2. Direct deps are 0.4. + { name = "getrandom", version = "=0.2.17" }, +] [sources] unknown-registry = "deny" From 22fe3288c44de800a238f24eabefe5affc953d62 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:24:52 -0700 Subject: [PATCH 04/18] build: compile mutants skips only on the nightly feature The attribute crate is optional. Default node builds do not run the proc-macro. Nightly cargo-mutants passes --features mutants, and every workspace member has that feature so the workspace flag resolves. --- .cargo/mutants.toml | 2 ++ crates/rbitcoin-bench/Cargo.toml | 1 + crates/rbitcoin-cli/Cargo.toml | 3 ++ crates/rbitcoin-consensus/Cargo.toml | 4 ++- .../src/script/interpreter.rs | 2 +- crates/rbitcoin-electrum/Cargo.toml | 3 ++ crates/rbitcoin-esplora/Cargo.toml | 3 ++ crates/rbitcoin-log/Cargo.toml | 1 + crates/rbitcoin-mempool/Cargo.toml | 1 + crates/rbitcoin-net/Cargo.toml | 3 ++ crates/rbitcoin-node/Cargo.toml | 3 ++ crates/rbitcoin-primitives/Cargo.toml | 3 ++ crates/rbitcoin-query/Cargo.toml | 4 ++- crates/rbitcoin-query/src/stamp.rs | 2 +- crates/rbitcoin-rpc/Cargo.toml | 3 ++ crates/rbitcoin-store/Cargo.toml | 1 + crates/rbitcoin-sv2/Cargo.toml | 3 ++ crates/rbitcoin-test/Cargo.toml | 1 + scripts/mutants-nightly.sh | 4 +-- scripts/mutants-queue.test.sh | 34 +++++++++++++++++++ 20 files changed, 75 insertions(+), 6 deletions(-) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index f90c5a1d2f..84d5fc5f0b 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -1,3 +1,5 @@ # Optional host client. Not a mutants gate. Same stance as coverage. # cargo-mutants ignores this file when the command line passes --exclude. exclude_globs = ["crates/rbitcoin-bench/**/*.rs"] +# The skip attributes compile only with this feature. Default node builds leave it off. +additional_cargo_args = "--features mutants" diff --git a/crates/rbitcoin-bench/Cargo.toml b/crates/rbitcoin-bench/Cargo.toml index 70cdd92b56..f0ea9fc97a 100644 --- a/crates/rbitcoin-bench/Cargo.toml +++ b/crates/rbitcoin-bench/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" required-features = ["cli"] [features] +mutants = [] default = [] cli = [] diff --git a/crates/rbitcoin-cli/Cargo.toml b/crates/rbitcoin-cli/Cargo.toml index a77465dcf8..07dc048762 100644 --- a/crates/rbitcoin-cli/Cargo.toml +++ b/crates/rbitcoin-cli/Cargo.toml @@ -11,6 +11,9 @@ description = "rbitcoin RPC CLI client" name = "rbitcoin-cli" path = "src/main.rs" +[features] +mutants = [] + [dependencies] rbitcoin-primitives = { workspace = true } mimalloc = { workspace = true } diff --git a/crates/rbitcoin-consensus/Cargo.toml b/crates/rbitcoin-consensus/Cargo.toml index b8c38c8d03..cd85a764a3 100644 --- a/crates/rbitcoin-consensus/Cargo.toml +++ b/crates/rbitcoin-consensus/Cargo.toml @@ -10,6 +10,8 @@ description = "Block and transaction validation / confirmability" [features] # Lets script-oracle call the Core script assembler. Off for the node. corpus = ["dep:serde_json"] +# Nightly cargo-mutants only. Off for the node build. +mutants = ["dep:mutants"] [dependencies] rbitcoin-primitives = { workspace = true } @@ -19,7 +21,7 @@ bitcoin_hashes = { workspace = true } # Corpus rows are JSON. The node build leaves this feature off. serde_json = { workspace = true, optional = true } # Attribute only. cargo-mutants reads it; it does not change compiled code. -mutants = "0.0.4" +mutants = { version = "0.0.4", optional = true } # Parallel script checks use in-crate `script_pool` (std threads) — no rayon. [dev-dependencies] diff --git a/crates/rbitcoin-consensus/src/script/interpreter.rs b/crates/rbitcoin-consensus/src/script/interpreter.rs index 08f366f524..706b9e65bd 100644 --- a/crates/rbitcoin-consensus/src/script/interpreter.rs +++ b/crates/rbitcoin-consensus/src/script/interpreter.rs @@ -932,7 +932,7 @@ pub(crate) fn eval_script( } /// The type bit does not overlap the low 16, so `|` and `^` are the same mask. -#[mutants::skip] +#[cfg_attr(feature = "mutants", mutants::skip)] fn csv_locktime_mask(type_bit: u32) -> u32 { 0x0000_ffff | type_bit } diff --git a/crates/rbitcoin-electrum/Cargo.toml b/crates/rbitcoin-electrum/Cargo.toml index 4a5d785e8a..a7260e6cc0 100644 --- a/crates/rbitcoin-electrum/Cargo.toml +++ b/crates/rbitcoin-electrum/Cargo.toml @@ -7,6 +7,9 @@ rust-version.workspace = true repository.workspace = true description = "Electrum protocol server (scripthash JSON-RPC over TCP)" +[features] +mutants = [] + [dependencies] rbitcoin-primitives = { workspace = true } rbitcoin-log = { workspace = true } diff --git a/crates/rbitcoin-esplora/Cargo.toml b/crates/rbitcoin-esplora/Cargo.toml index a90971ba16..22341a077b 100644 --- a/crates/rbitcoin-esplora/Cargo.toml +++ b/crates/rbitcoin-esplora/Cargo.toml @@ -7,6 +7,9 @@ rust-version.workspace = true repository.workspace = true description = "Esplora-compatible REST (electrs HTTP drop-in except address-prefix)" +[features] +mutants = [] + [dependencies] rbitcoin-primitives = { workspace = true } rbitcoin-log = { workspace = true } diff --git a/crates/rbitcoin-log/Cargo.toml b/crates/rbitcoin-log/Cargo.toml index 1e355ac796..583792e1ce 100644 --- a/crates/rbitcoin-log/Cargo.toml +++ b/crates/rbitcoin-log/Cargo.toml @@ -16,6 +16,7 @@ workspace = true # product graph. These targets exist so `cargo fmt --all` still formats it; # required-features keeps cargo test / clippy from compiling those files. [features] +mutants = [] fmt-fuzz-anchor = [] [[test]] diff --git a/crates/rbitcoin-mempool/Cargo.toml b/crates/rbitcoin-mempool/Cargo.toml index 957057beff..a930d05abf 100644 --- a/crates/rbitcoin-mempool/Cargo.toml +++ b/crates/rbitcoin-mempool/Cargo.toml @@ -15,6 +15,7 @@ rbitcoin-store = { workspace = true } bitcoin = { workspace = true } [features] +mutants = [] # Net tests pin a full slot table. A production build leaves this off. test-hooks = [] diff --git a/crates/rbitcoin-net/Cargo.toml b/crates/rbitcoin-net/Cargo.toml index 86d35fefeb..7b8d356e72 100644 --- a/crates/rbitcoin-net/Cargo.toml +++ b/crates/rbitcoin-net/Cargo.toml @@ -7,6 +7,9 @@ rust-version.workspace = true repository.workspace = true description = "Bitcoin P2P (BIP324, IBD, tip follow, compact v2, tx relay)" +[features] +mutants = [] + [dependencies] rbitcoin-primitives = { workspace = true } rbitcoin-log = { workspace = true } diff --git a/crates/rbitcoin-node/Cargo.toml b/crates/rbitcoin-node/Cargo.toml index bedc9dab58..35749196db 100644 --- a/crates/rbitcoin-node/Cargo.toml +++ b/crates/rbitcoin-node/Cargo.toml @@ -11,6 +11,9 @@ description = "rbitcoin full node binary and lifecycle" name = "rbitcoin-node" path = "src/main.rs" +[features] +mutants = [] + [dependencies] rbitcoin-primitives = { workspace = true } rbitcoin-log = { workspace = true } diff --git a/crates/rbitcoin-primitives/Cargo.toml b/crates/rbitcoin-primitives/Cargo.toml index 0b783369af..188f5b480f 100644 --- a/crates/rbitcoin-primitives/Cargo.toml +++ b/crates/rbitcoin-primitives/Cargo.toml @@ -7,6 +7,9 @@ rust-version.workspace = true repository.workspace = true description = "Shared types for the rbitcoin node" +[features] +mutants = [] + [dependencies] bitcoin_hashes = { workspace = true } diff --git a/crates/rbitcoin-query/Cargo.toml b/crates/rbitcoin-query/Cargo.toml index 42f15acea8..30c9066441 100644 --- a/crates/rbitcoin-query/Cargo.toml +++ b/crates/rbitcoin-query/Cargo.toml @@ -15,10 +15,12 @@ rbitcoin-log = { workspace = true } bitcoin = { workspace = true } arc-swap = { workspace = true } # Attribute only. cargo-mutants reads it; it does not change compiled code. -mutants = "0.0.4" +mutants = { version = "0.0.4", optional = true } [features] test-hooks = [] +# Nightly cargo-mutants only. Off for the node build. +mutants = ["dep:mutants"] [dev-dependencies] # Height-index tests write a raw confirmed slot. Dependents of this crate diff --git a/crates/rbitcoin-query/src/stamp.rs b/crates/rbitcoin-query/src/stamp.rs index e5ecdc5e9e..c5c57b1599 100644 --- a/crates/rbitcoin-query/src/stamp.rs +++ b/crates/rbitcoin-query/src/stamp.rs @@ -162,7 +162,7 @@ fn stamp_inflight_hits<'a>( } // Capacity is not an observable result: empty maps insert the same keys. -#[mutants::skip] +#[cfg_attr(feature = "mutants", mutants::skip)] fn external_parent_stamp(need_len: usize) -> ExternalParentStamp { ExternalParentStamp { resolved: TxidFkMap::with_capacity_and_hasher(need_len / 2, Default::default()), diff --git a/crates/rbitcoin-rpc/Cargo.toml b/crates/rbitcoin-rpc/Cargo.toml index c3d8a861fd..811ceba3d4 100644 --- a/crates/rbitcoin-rpc/Cargo.toml +++ b/crates/rbitcoin-rpc/Cargo.toml @@ -7,6 +7,9 @@ rust-version.workspace = true repository.workspace = true description = "Core-class JSON-RPC HTTP server (subset; not full Core parity)" +[features] +mutants = [] + [dependencies] rbitcoin-primitives = { workspace = true } rbitcoin-log = { workspace = true } diff --git a/crates/rbitcoin-store/Cargo.toml b/crates/rbitcoin-store/Cargo.toml index 25ddec1df7..bebfdb2263 100644 --- a/crates/rbitcoin-store/Cargo.toml +++ b/crates/rbitcoin-store/Cargo.toml @@ -18,6 +18,7 @@ libc = "0.2" getrandom = "0.4" [features] +mutants = [] # Downstream tests call hooks a production build does not compile: # spend-checkpoint interleaving, and a raw confirmed slot. # A crate's dev-dependency turns this on. A production build leaves it off. diff --git a/crates/rbitcoin-sv2/Cargo.toml b/crates/rbitcoin-sv2/Cargo.toml index 3127751c00..1327dd4e0e 100644 --- a/crates/rbitcoin-sv2/Cargo.toml +++ b/crates/rbitcoin-sv2/Cargo.toml @@ -7,6 +7,9 @@ rust-version.workspace = true repository.workspace = true description = "Stratum v2 Template Distribution Protocol server (Noise over TCP)" +[features] +mutants = [] + [dependencies] rbitcoin-log = { workspace = true } rbitcoin-consensus = { workspace = true } diff --git a/crates/rbitcoin-test/Cargo.toml b/crates/rbitcoin-test/Cargo.toml index 69f44dc173..30314ce946 100644 --- a/crates/rbitcoin-test/Cargo.toml +++ b/crates/rbitcoin-test/Cargo.toml @@ -60,6 +60,7 @@ name = "asmap_journey" path = "tests/asmap_journey.rs" [features] +mutants = [] # Live private-mesh journeys. Default `cargo test` stays fake SOCKS/SAM/control. overlay = [] diff --git a/scripts/mutants-nightly.sh b/scripts/mutants-nightly.sh index d3705ba7bf..11d526a3ba 100755 --- a/scripts/mutants-nightly.sh +++ b/scripts/mutants-nightly.sh @@ -59,7 +59,7 @@ git diff "$new_base"..HEAD --unified=0 -- crates >"$OUT/new.diff" || true echo "mutants-nightly: listing workspace mutants" # rbitcoin-bench is an optional host client, not a mutants gate. # CLI --exclude replaces exclude_globs in .cargo/mutants.toml (same glob). -cargo mutants --workspace --exclude 'crates/rbitcoin-bench/**/*.rs' --list >"$OUT/all.txt" +cargo mutants --features mutants --workspace --exclude 'crates/rbitcoin-bench/**/*.rs' --list >"$OUT/all.txt" read -r n_new n_old < <(python3 "$ROOT/scripts/mutants_queue.py" split \ --list "$OUT/all.txt" --diff "$OUT/new.diff" \ @@ -124,7 +124,7 @@ while ((SECONDS < deadline)); do echo "mutants-nightly: batch side=$side at $offset ($requested mutants in $file, ${remain}s left)" set +e timeout --signal=TERM --kill-after=60s "$remain" \ - cargo mutants --workspace --exclude 'crates/rbitcoin-bench/**/*.rs' \ + cargo mutants --features mutants --workspace --exclude 'crates/rbitcoin-bench/**/*.rs' \ --test-workspace=true --baseline=skip --caught --unviable \ -j 1 --timeout "$MUTANT_TIMEOUT" \ "${RE_ARGS[@]}" \ diff --git a/scripts/mutants-queue.test.sh b/scripts/mutants-queue.test.sh index 52a47e8619..c85caea448 100755 --- a/scripts/mutants-queue.test.sh +++ b/scripts/mutants-queue.test.sh @@ -435,5 +435,39 @@ assert_ok "missing state branch falls back to the artifact" \ assert_ok "missing branch and artifact start from an empty cursor" \ grep -E -q '"old_index": ?0' "$tmp/restored-empty.json" +assert_ok "consensus mutants dependency is optional" \ + grep -q 'mutants = { version = "0.0.4", optional = true }' "$ROOT/crates/rbitcoin-consensus/Cargo.toml" +assert_ok "query mutants dependency is optional" \ + grep -q 'mutants = { version = "0.0.4", optional = true }' "$ROOT/crates/rbitcoin-query/Cargo.toml" +assert_ok "consensus mutants feature is the optional dep" \ + grep -q 'mutants = \["dep:mutants"\]' "$ROOT/crates/rbitcoin-consensus/Cargo.toml" +assert_ok "query mutants feature is the optional dep" \ + grep -q 'mutants = \["dep:mutants"\]' "$ROOT/crates/rbitcoin-query/Cargo.toml" +assert_ok "csv mask skip is behind the mutants feature" \ + grep -q 'cfg_attr(feature = "mutants", mutants::skip)' \ + "$ROOT/crates/rbitcoin-consensus/src/script/interpreter.rs" +assert_ok "parent stamp skip is behind the mutants feature" \ + grep -q 'cfg_attr(feature = "mutants", mutants::skip)' \ + "$ROOT/crates/rbitcoin-query/src/stamp.rs" +assert_ok "nightly list builds the mutants feature" \ + grep -q -- '--features mutants --workspace' "$NIGHTLY" +assert_ok "mutants.toml passes the feature to cargo" \ + grep -q 'additional_cargo_args = "--features mutants"' "$TOML" +for toml in "$ROOT"/crates/*/Cargo.toml; do + assert_ok "mutants feature in $(basename "$(dirname "$toml")")" \ + grep -q '^mutants = ' "$toml" +done +set +e +cargo tree -p rbitcoin-consensus -i mutants --prefix none >"$tmp/mutants-tree.txt" 2>&1 +tree_ec=$? +set -e +assert_ok "default consensus build does not link mutants" test "$tree_ec" -ne 0 +set +e +cargo tree -p rbitcoin-consensus --features mutants -i mutants --prefix none >"$tmp/mutants-on.txt" 2>&1 +tree_on=$? +set -e +assert_ok "mutants feature links the attribute crate" test "$tree_on" -eq 0 +assert_ok "mutants feature tree names the crate" grep -q '^mutants ' "$tmp/mutants-on.txt" + echo "$PASS passed, $FAIL failed" test "$FAIL" -eq 0 From 66fba93e637bfabe81e0d264d2be60b0e33b5cc6 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:27:57 -0700 Subject: [PATCH 05/18] net: drop unroutable addresses from seeds and gossip DNS and fixed-seed inject, and peer addr messages, skip loopback, private, link-local, documentation, and other non-routable IPs. addpeeraddress and --connect still accept them. Onion, I2P, and CJDNS stay. --- COMPAT.md | 2 +- .../src/ibd/events/confirm_reject_tests.rs | 11 ++++- crates/rbitcoin-net/src/ibd/events/mod.rs | 4 +- crates/rbitcoin-net/src/netaddr.rs | 47 ++++++++++++++++++- crates/rbitcoin-net/src/seeds.rs | 33 +++++++++++++ 5 files changed, 92 insertions(+), 5 deletions(-) diff --git a/COMPAT.md b/COMPAT.md index fd17e24f8d..a59909acca 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -68,7 +68,7 @@ Full `/tx/:txid` JSON still has `vin[]`. Electrum has no outspend-vin surface. | Admission policy | **Libre-relay-class** (0.1 sat/vB, no dust, full RBF). P2P admits any nVersion. RPC-submit (`sendrawtransaction` / `testmempoolaccept` / `submitpackage`) still rejects `"version"` outside 1/2. Feerate, RBF, min relay and `-blockmintxfee` use Core's sigop-adjusted size `max(weight, sigop_cost × 20)` (`--bytes-per-sigop`, `0` disables). Cluster and package count/size limits and the block weight budget count raw weight, unlike Core, so the adjustment never becomes a per-tx sigop cap. No 16,000 sigop-cost standardness cap; admission and template selection share a strict 80,000 minus `--block-reserved-sigops` budget (default reserve 400). P2P `accept_tx` has the minimum relay feerate and no absurd-fee or `-maxtxfee` cap; RPC submit keeps its own default of 10,000 sat/vB (`0` unlimited). That split is intentional: the signer chose the fee. Mempool fee math checks `u64` overflow (`bad-txns-txouttotal-toolarge`), not `MAX_MONEY`. Block connect rejects an output or an output sum above `Amount::MAX_MONEY`. A mainnet input sum cannot exceed that cap, because those coins were rejected at connect | Standardness + policy knobs (nVersion 1/2, `MAX_STANDARD_TX_SIGOPS_COST` 16,000, `-bytespersigop`) | | Compact blocks | BIP152 **v2** receive + reconstruct + `getblocktxn` serve. Fill is live mempool + orphanage + `extra_compact` (cap 100). Outbound extra prefill is **on** unless `--prefill-compact=0` (10 KiB cap, extra-pool last; generate / submit / NewPoWValid pack txs not in the live mempool without delaying forward) | v1/v2 high-bandwidth + `extra_txn` cache (`-blockreconstructionextratxn`); Core #35558 prefill still unmerged | | WTx inventory | BIP339 when peer also sends `wtxidrelay` | BIP339 | -| GetAddr | Core `MAX_ADDR_TO_SEND` / `MAX_PCT_ADDR_TO_SEND` (**1000** / **23%**), 24h per-bind cache. Named copies in `rbitcoin-net` — do not “improve” without a named reason to diverge. `MAX_ADDR_MAN` (8192) is **our** HashMap DoS cap and must stay above `1000/0.23` | Core new/tried buckets (~80k); same 1000 / 23% | +| GetAddr | Core `MAX_ADDR_TO_SEND` / `MAX_PCT_ADDR_TO_SEND` (**1000** / **23%**), 24h per-bind cache. Named copies in `rbitcoin-net` — do not “improve” without a named reason to diverge. `MAX_ADDR_MAN` (8192) is **our** HashMap DoS cap and must stay above `1000/0.23`. Learned gossip and DNS/fixed inject ignore unroutable IPs; `addpeeraddress` and `--connect` do not. Onion, I2P, and CJDNS are not IP bogons | Core new/tried buckets (~80k); same 1000 / 23% | | Package submit | RPC `submitpackage` / Esplora `POST /txs/package` (no P2P package command) | BIP331 wire | | Pruning / GUI | Unpruned: `seqsigwit.body`. `--prune-seqsigwit`: watermark + `NETWORK_LIMITED`, kept witness is 288 height files under `store/seqsigwit.window/` plus a RAM cap (`--prune-seqsigwit-ram-threshold-bytes`, `0` = files only). Not a rolling stem. Not Core `-prune` of headers/txout | Supported | | Mining template RPC | `getblocktemplate` / `getmininginfo` / `prioritisetransaction` (selector; no stratum) | GBT + stratum / pool stack | diff --git a/crates/rbitcoin-net/src/ibd/events/confirm_reject_tests.rs b/crates/rbitcoin-net/src/ibd/events/confirm_reject_tests.rs index 4035c741cf..844755eeb5 100644 --- a/crates/rbitcoin-net/src/ibd/events/confirm_reject_tests.rs +++ b/crates/rbitcoin-net/src/ibd/events/confirm_reject_tests.rs @@ -742,17 +742,26 @@ fn apply_peer_event_body_and_control_surface() { // Addrs + inject filter. inject_learned_addrs(&mut book, &[], local, 1); + let routable = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333); inject_learned_addrs( &mut book, &[ crate::NetAddr::Ip(addr(2)), + crate::NetAddr::Ip(routable), crate::NetAddr::Ip(local), crate::NetAddr::Ip(SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 1)), ], local, 1, ); - assert!(book.entry(&addr(2)).is_some()); + assert!( + book.entry(&addr(2)).is_none(), + "gossip drops RFC1918; addpeeraddress still uses add_learned" + ); + assert!( + book.entry(&routable).is_some(), + "gossip keeps a public address" + ); let onion: crate::NetAddr = "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333" .parse() diff --git a/crates/rbitcoin-net/src/ibd/events/mod.rs b/crates/rbitcoin-net/src/ibd/events/mod.rs index d2a6d85874..d1eedeab4b 100644 --- a/crates/rbitcoin-net/src/ibd/events/mod.rs +++ b/crates/rbitcoin-net/src/ibd/events/mod.rs @@ -935,11 +935,11 @@ pub(crate) fn inject_learned_addrs( } let mut added = 0usize; for &a in addrs { - if a.port() == 0 { + if a.port() == 0 || crate::netaddr::netaddr_unroutable_gossip(a) { continue; } match a { - crate::NetAddr::Ip(s) if s == local_addr || s.ip().is_unspecified() => continue, + crate::NetAddr::Ip(s) if s == local_addr => continue, _ => {} } if book.add_learned_addr(a, MAX_PEER_POOL) { diff --git a/crates/rbitcoin-net/src/netaddr.rs b/crates/rbitcoin-net/src/netaddr.rs index a4a5508021..cdd273aa98 100644 --- a/crates/rbitcoin-net/src/netaddr.rs +++ b/crates/rbitcoin-net/src/netaddr.rs @@ -4,7 +4,7 @@ use crate::error::NetError; use bitcoin::p2p::address::{AddrV2, AddrV2Message}; use sha3::{Digest, Sha3_256}; use std::fmt; -use std::net::{IpAddr, Ipv6Addr, SocketAddr}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::str::FromStr; const B32: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567"; @@ -50,6 +50,51 @@ pub(crate) fn addr_allowed(addr: NetAddr, only: &[OnlyNet]) -> bool { only.is_empty() || only.iter().any(|n| n.matches_addr(addr)) } +/// `2001:db8::/32`. `Ipv6Addr::is_documentation` is still unstable on 1.95. +fn ipv6_documentation(v: Ipv6Addr) -> bool { + let o = v.octets(); + o[0] == 0x20 && o[1] == 0x01 && o[2] == 0x0d && o[3] == 0xb8 +} + +fn ipv4_unroutable(v: Ipv4Addr) -> bool { + v.is_unspecified() + || v.is_loopback() + || v.is_private() + || v.is_link_local() + || v.is_broadcast() + || v.is_multicast() + || v.is_documentation() +} + +/// Clearnet bogon. CJDNS `fc00::/8` is an overlay, not a unique-local bogon. +pub(crate) fn clearnet_unroutable(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v) => ipv4_unroutable(v), + IpAddr::V6(v) => { + if is_cjdns_ip(v) { + return false; + } + if let Some(v4) = v.to_ipv4_mapped() { + return ipv4_unroutable(v4); + } + v.is_unspecified() + || v.is_loopback() + || v.is_unicast_link_local() + || v.is_unique_local() + || v.is_multicast() + || ipv6_documentation(v) + } + } +} + +/// Seed inject and peer addr gossip. Onion, I2P, and CJDNS are not IP bogons. +pub(crate) fn netaddr_unroutable_gossip(addr: NetAddr) -> bool { + match addr { + NetAddr::Ip(s) => clearnet_unroutable(s.ip()), + NetAddr::Onion { .. } | NetAddr::I2p { .. } | NetAddr::Cjdns { .. } => false, + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum NetAddr { Ip(SocketAddr), diff --git a/crates/rbitcoin-net/src/seeds.rs b/crates/rbitcoin-net/src/seeds.rs index 5101df767b..03aecd1088 100644 --- a/crates/rbitcoin-net/src/seeds.rs +++ b/crates/rbitcoin-net/src/seeds.rs @@ -333,6 +333,9 @@ impl AddrMan { pub fn inject(&mut self, addrs: impl IntoIterator) { for a in addrs { + if crate::netaddr::clearnet_unroutable(a.ip()) { + continue; + } self.add(a); } self.sort_order_ipv4_first(); @@ -1004,6 +1007,36 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, o)), 8333) } + #[test] + fn inject_drops_unroutable_and_add_learned_keeps_it() { + use std::net::Ipv6Addr; + let mut book = AddrMan::new(); + let public = addr(4); + book.inject([ + "10.0.0.1:8333".parse().unwrap(), + "127.0.0.1:8333".parse().unwrap(), + "192.0.2.1:8333".parse().unwrap(), + "0.0.0.0:8333".parse().unwrap(), + "[2001:db8::1]:8333".parse().unwrap(), + public, + ]); + assert_eq!(book.len(), 1); + assert!(book.entry(&public).is_some()); + let lan: SocketAddr = "10.0.0.1:8333".parse().unwrap(); + assert!(book.add_learned(lan, 64)); + assert!(book.entry(&lan).is_some()); + let cjdns: SocketAddr = + SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1)), 8333); + book.set_cjdns_reachable(true); + book.inject([cjdns]); + assert!( + book.entries() + .iter() + .any(|e| matches!(e.addr, NetAddr::Cjdns { .. })), + "fc00::/8 is CJDNS, not a unique-local bogon" + ); + } + fn addr_n(i: u32) -> SocketAddr { SocketAddr::new( IpAddr::V4(Ipv4Addr::new( From 3024eef7d5571f36890c5968fa086e67a278eac7 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:29:40 -0700 Subject: [PATCH 06/18] net: cap remembered fork tips at 64 The oldest hint drops when a heavier branch replaces the tip. Invalidated stays uncapped: forgetting one of those hashes would accept the block again. --- crates/rbitcoin-net/src/chain.rs | 73 +++++++++++++++++++++++++++++--- docs/ibd-memory.md | 5 +++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/crates/rbitcoin-net/src/chain.rs b/crates/rbitcoin-net/src/chain.rs index 4e9fc8b727..d765078bc1 100644 --- a/crates/rbitcoin-net/src/chain.rs +++ b/crates/rbitcoin-net/src/chain.rs @@ -23,7 +23,7 @@ use rbitcoin_query::{ confirm_wire_load_phase_pipelined, confirm_write_phase, header_to_record, validate_header, PlanStampOutcome, ScriptOkBatch, ScriptPreverified, WireLoadPipeline, }; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, AtomicU64, Ordering}; thread_local! { @@ -170,7 +170,42 @@ impl HeldBodies { } } +/// Dial and reconsider hints. Not a validity set: the oldest drops at the cap. +const FORK_TIP_CAP: usize = 64; + +struct ForkTips { + tips: VecDeque, +} + +impl ForkTips { + fn new() -> Self { + Self { + tips: VecDeque::new(), + } + } + + fn insert(&mut self, hash: BlockHash) { + if self.tips.contains(&hash) { + return; + } + if self.tips.len() == FORK_TIP_CAP { + self.tips.pop_front(); + } + self.tips.push_back(hash); + } + + fn remove(&mut self, hash: &BlockHash) { + self.tips.retain(|h| h != hash); + } + + fn iter(&self) -> impl Iterator + '_ { + self.tips.iter().copied() + } +} + /// Operator-invalidated hashes and best-chain paths (hashes only, height order). +/// +/// Uncapped. Forgetting an invalid hash would make that block acceptable again. struct Invalidated { set: RwLock>, paths: RwLock>>, @@ -369,7 +404,7 @@ pub struct ChainHub { held_bodies: RwLock, precious: RwLock>, /// Losing tips after a most-work reorg (hashes only). Bodies via archive. - fork_tips: RwLock>, + fork_tips: RwLock, /// Header-only tips (`submitheader` / P2P headers): hash → (prev, height). header_tips: RwLock, /// Set around `accept_branch` connect so each `TipEvent` carries branch length. @@ -496,7 +531,7 @@ impl ChainHub { invalidated: Invalidated::new(), held_bodies: RwLock::new(HeldBodies::new()), precious: RwLock::new(None), - fork_tips: RwLock::new(HashSet::new()), + fork_tips: RwLock::new(ForkTips::new()), header_tips: RwLock::new(HeaderTips::new()), announce_reorg_len: AtomicU32::new(0), minimum_chain_work: RwLock::new(None), @@ -1007,7 +1042,7 @@ impl ChainHub { } }; - for h in self.fork_tips.read().unwrap().iter().copied() { + for h in self.fork_tips.read().unwrap().iter() { record(&mut out, h, "valid-fork"); } // The header and held walks below re-read `header_tips` and @@ -2086,7 +2121,7 @@ impl ChainHub { fn try_apply_after_invalidate(&self) -> Result, NetError> { let inv = self.invalidated.set.read().unwrap().clone(); let precious = *self.precious.read().unwrap(); - let mut starts: Vec = self.fork_tips.read().unwrap().iter().copied().collect(); + let mut starts: Vec = self.fork_tips.read().unwrap().iter().collect(); starts.extend(self.held_bodies.read().unwrap().keys()); if let Some(p) = precious { if !starts.contains(&p) { @@ -3936,6 +3971,34 @@ pub(crate) fn sixty_four_byte_body(prev: BlockHash, time: u32) -> Block { #[cfg(test)] mod tests { use super::*; + + #[test] + fn fork_tips_drop_the_oldest_at_the_cap() { + let mut tips = ForkTips::new(); + let hashes: Vec = (0..65u8) + .map(|i| BlockHash::from_byte_array([i; 32])) + .collect(); + for hash in &hashes { + tips.insert(*hash); + } + let kept: Vec = tips.iter().collect(); + assert_eq!(kept.len(), FORK_TIP_CAP); + assert!(!kept.contains(&hashes[0])); + assert_eq!(kept[0], hashes[1]); + assert_eq!(*kept.last().unwrap(), hashes[64]); + tips.remove(&hashes[1]); + assert!(!tips.iter().any(|h| h == hashes[1])); + let before = tips.iter().count(); + tips.insert(hashes[64]); + assert_eq!( + tips.iter().count(), + before, + "a hash already kept is not moved" + ); + tips.insert(hashes[1]); + assert_eq!(tips.iter().count(), before + 1); + assert_eq!(tips.iter().last(), Some(hashes[1])); + } use crate::most_work::sum_work; use bitcoin::absolute::LockTime; use bitcoin::block::Version; diff --git a/docs/ibd-memory.md b/docs/ibd-memory.md index e1fa7b9728..32f05bb43e 100644 --- a/docs/ibd-memory.md +++ b/docs/ibd-memory.md @@ -1,5 +1,10 @@ # IBD memory: intentional caches vs process leaks +`Invalidated` is not capped. Each entry is a validated header or an operator +`invalidateblock`. Dropping one would make that block acceptable again. +`fork_tips` keeps 64 insertion-ordered hashes and drops the oldest. Those +are dial and reconsider hints, not a validity set. + This document is the developer/AI contract for **process-owned** memory on the IBD path. It is **not** about kernel page cache under FdOnly store tables (those count in RSS when faulted but are not Rust heap leaks). Sealed `.fuse8` From 28217b3a312fea7907c168336744523471ac72fa Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:31:03 -0700 Subject: [PATCH 07/18] fuzz: honor FUZZ_SANITIZER and add a Sunday UBSan run Unset keeps the current sanitizer for each target. A weekly job, not a pull request, runs asmap and block_wire for 120 seconds with undefined. The nightly AddressSanitizer matrix stays the long run. --- .github/workflows/fuzz.yml | 46 ++++++++++++++++++++++++++++++++++++++ TESTING.md | 10 +++++++++ scripts/fuzz-run.sh | 22 ++++++++++-------- scripts/fuzz-run.test.sh | 16 +++++++++++++ 4 files changed, 85 insertions(+), 9 deletions(-) diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index ec19c5f8c1..429ad1f15c 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -103,3 +103,49 @@ jobs: name: fuzz-crashers-${{ matrix.bin }} path: fuzz/crashers/ if-no-files-found: ignore + + # Sunday, or a manual dispatch. The daily cron checks out and exits. + ubsan: + name: ubsan ${{ matrix.bin }} + runs-on: ubuntu-latest + timeout-minutes: 40 + env: + RUSTUP_TOOLCHAIN: nightly + RUSTFLAGS: "-A warnings" + strategy: + fail-fast: false + matrix: + bin: [asmap, block_wire] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + submodules: false + + - name: sunday or dispatch + id: gate + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" || "$(date -u +%u)" == "7" ]]; then + echo "run=1" >> "$GITHUB_OUTPUT" + fi + + - if: steps.gate.outputs.run == '1' + uses: dtolnay/rust-toolchain@be39649afda95dbf70f87cce95f68b8d5797b296 # nightly + with: + components: rust-src + + - if: steps.gate.outputs.run == '1' + name: clang (libFuzzer) + run: sudo apt-get update && sudo apt-get install -y clang + + - if: steps.gate.outputs.run == '1' + uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2.87.27 + with: + tool: cargo-fuzz@0.13.2 + + - if: steps.gate.outputs.run == '1' + name: UBSan ${{ matrix.bin }} + env: + FUZZ_SANITIZER: undefined + FUZZ_MAX_TOTAL_TIME: "120" + run: ./scripts/fuzz-run.sh ${{ matrix.bin }} diff --git a/TESTING.md b/TESTING.md index f86b7adaf5..3b4844bd20 100644 --- a/TESTING.md +++ b/TESTING.md @@ -436,6 +436,16 @@ differential run is for, and it is not a required check. Silent-payment tweaks are not a missing fuzz bin. They are libsecp `combine_keys` and `mul_tweak` plus a tagged hash, covered by unit tests and the Electrum spend test. + +Sanitizers, nightly only: AddressSanitizer on the wire parsers +(`block_wire`, `v2_contents`, `addrv2_wire`, `inv_getdata_wire`, +`electrum_json`, `asmap`), and on `store_reorg`, `v2_session`, +`cmpct_differential`, and `script_kernel_differential`. The heavy Core +differentials run with `--sanitizer none`. `block_spend_differential` +uses AddressSanitizer on Sunday and none on other days. `FUZZ_SANITIZER` +overrides that choice; unset keeps it. A Sunday (or `workflow_dispatch`) +job runs `asmap` and `block_wire` for 120s with `--sanitizer undefined`. +Miri stays `rbitcoin-primitives` only. Treat crate-root `pub` that exists only so a fuzz target can call it as the same smell as a test-only export: prefer `pub(crate)` plus an in-crate harness, or the published `rbitcoin-node` / CLI binary, even if that costs diff --git a/scripts/fuzz-run.sh b/scripts/fuzz-run.sh index 202e722d0d..d4e125f882 100755 --- a/scripts/fuzz-run.sh +++ b/scripts/fuzz-run.sh @@ -160,6 +160,10 @@ elif [[ "$BIN" == "p2p_sequence_differential" || "$BIN" == "chain_review_differe sanitizer="none" timeout=180 fi +# Unset keeps the choice above. A set value overrides it, including `none`. +if [[ -n "${FUZZ_SANITIZER:-}" ]]; then + sanitizer="$FUZZ_SANITIZER" +fi WRAP="$ROOT/scripts/fuzz-rustc-allow-warnings.sh" export RUSTC_WRAPPER="$WRAP" @@ -219,7 +223,7 @@ if [[ "$BIN" == "block_wire" ]]; then merge_seed fuzz/corpus/block_wire \ crates/rbitcoin-consensus/tests/fixtures/signet_block_90719.bin set +e - env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" block_wire -- \ + env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer "$sanitizer" block_wire -- \ -max_total_time="$(fuzz_max_total_time)" \ -timeout="$timeout" \ -max_len=1048576 \ @@ -238,7 +242,7 @@ if [[ "$BIN" == "v2_contents" ]]; then merge_seed fuzz/corpus/v2_contents crates/rbitcoin-net/tests/fixtures/v2_sendcmpct.bin merge_seed fuzz/corpus/v2_contents crates/rbitcoin-net/tests/fixtures/v2_getheaders.bin set +e - env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" v2_contents -- \ + env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer "$sanitizer" v2_contents -- \ -max_total_time="$(fuzz_max_total_time)" \ -timeout="$timeout" \ -max_len=65536 \ @@ -274,7 +278,7 @@ if [[ "$BIN" == "addrv2_wire" || "$BIN" == "inv_getdata_wire" || "$BIN" == "elec dict_args=(-dict="$dict") fi set +e - env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" "$BIN" -- \ + env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer "$sanitizer" "$BIN" -- \ -max_total_time="$(fuzz_max_total_time)" \ -timeout="$timeout" \ -max_len=65536 \ @@ -292,7 +296,7 @@ if [[ "$BIN" == "v2_session" ]]; then merge_seed fuzz/corpus/v2_session crates/rbitcoin-net/tests/fixtures/v2_verack.bin log="${TMPDIR:-/tmp}/rbtc-fuzz-v2.$$.log" set +e - env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" v2_session -- \ + env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer "$sanitizer" v2_session -- \ -max_total_time="$(fuzz_max_total_time)" \ -timeout="$timeout" \ -max_len=65536 \ @@ -325,7 +329,7 @@ if [[ "$BIN" == "cmpct_differential" ]]; then fuzz/fixtures/cmpct_fuzz_raw.bin log="${TMPDIR:-/tmp}/rbtc-fuzz-cmpct.$$.log" set +e - env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" cmpct_differential -- \ + env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer "$sanitizer" cmpct_differential -- \ -max_total_time="$(fuzz_max_total_time)" \ -timeout="$timeout" \ -max_len=65536 \ @@ -348,7 +352,7 @@ if [[ "$BIN" == "store_reorg" ]]; then crates/rbitcoin-net/tests/fixtures/store_reorg_ops.bin log="${TMPDIR:-/tmp}/rbtc-fuzz-store-reorg.$$.log" set +e - env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" store_reorg -- \ + env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer "$sanitizer" store_reorg -- \ -max_total_time="$(fuzz_max_total_time)" \ -timeout="$timeout" \ -max_len=64 \ @@ -386,7 +390,7 @@ if [[ "$BIN" == "script_kernel_differential" ]]; then done log="${TMPDIR:-/tmp}/rbtc-fuzz-script-kernel.$$.log" set +e - env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" script_kernel_differential -- \ + env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer "$sanitizer" script_kernel_differential -- \ -max_total_time="$(fuzz_max_total_time)" \ -timeout="$timeout" \ -max_len=2000 \ @@ -410,7 +414,7 @@ if [[ "$BIN" == "chain_review_differential" ]]; then fuzz/fixtures/chain_review_milestone.bin log="${TMPDIR:-/tmp}/rbtc-fuzz-chain-review.$$.log" set +e - env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer none chain_review_differential -- \ + env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer "$sanitizer" chain_review_differential -- \ -max_total_time="$(fuzz_max_total_time)" \ -timeout="$timeout" \ -max_len=64 \ @@ -433,7 +437,7 @@ if [[ "$BIN" == "p2p_sequence_differential" ]]; then crates/rbitcoin-net/tests/fixtures/p2p_seq_two_ping.bin log="${TMPDIR:-/tmp}/rbtc-fuzz-p2p-seq.$$.log" set +e - env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer none p2p_sequence_differential -- \ + env -u CARGO_TARGET_DIR cargo fuzz run --target "$target" --sanitizer "$sanitizer" p2p_sequence_differential -- \ -max_total_time="$(fuzz_max_total_time)" \ -timeout="$timeout" \ -max_len=256 \ diff --git a/scripts/fuzz-run.test.sh b/scripts/fuzz-run.test.sh index f1fce6b695..097f95a6b5 100755 --- a/scripts/fuzz-run.test.sh +++ b/scripts/fuzz-run.test.sh @@ -187,6 +187,18 @@ assert_ok "asmap dry-run bin" \ grep -qx "FUZZ_BIN=asmap" <<<"$out" assert_ok "asmap dry-run sanitizer address" \ grep -qx "FUZZ_SANITIZER=address" <<<"$out" +over="$(FUZZ_DRY_RUN=1 FUZZ_SANITIZER=undefined "$RUN" asmap)" +assert_ok "FUZZ_SANITIZER overrides asmap" \ + grep -qx "FUZZ_SANITIZER=undefined" <<<"$over" +wire="$(FUZZ_DRY_RUN=1 FUZZ_SANITIZER=undefined "$RUN" block_wire)" +assert_ok "FUZZ_SANITIZER overrides block_wire" \ + grep -qx "FUZZ_SANITIZER=undefined" <<<"$wire" +plain="$(FUZZ_DRY_RUN=1 "$RUN" block_wire)" +assert_ok "block_wire stays address when FUZZ_SANITIZER is unset" \ + grep -qx "FUZZ_SANITIZER=address" <<<"$plain" +heavy="$(FUZZ_DRY_RUN=1 FUZZ_SANITIZER=undefined "$RUN" block_differential)" +assert_ok "FUZZ_SANITIZER overrides an unsanitized differential" \ + grep -qx "FUZZ_SANITIZER=undefined" <<<"$heavy" assert_ok "asmap dry-run no Core" \ grep -qx "FUZZ_NO_CORE=1" <<<"$out" assert_ok "asmap listed in fuzz Cargo.toml" \ @@ -197,6 +209,10 @@ assert_ok "asmap two-prefix-plus-ip seed" \ test -s "$ROOT/crates/rbitcoin-net/tests/fixtures/asmap_two_prefix_plus_ip.bin" assert_ok "asmap listed in fuzz.yml matrix" \ grep -q '{ bin: asmap, core: false }' "$ROOT/.github/workflows/fuzz.yml" +assert_ok "weekly ubsan job names asmap and block_wire" \ + grep -q 'bin: \[asmap, block_wire\]' "$ROOT/.github/workflows/fuzz.yml" +assert_ok "weekly ubsan sets undefined" \ + grep -q 'FUZZ_SANITIZER: undefined' "$ROOT/.github/workflows/fuzz.yml" t6="$(FUZZ_WEEKDAY=6 "$RUN" --default-time)" assert_ok "FUZZ_WEEKDAY=6 is 600" \ From bf006613ea39b46dd58b5ae16f7c0061be1427ef Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:34:43 -0700 Subject: [PATCH 08/18] rpc: keep the cookie mode check Unix-only Windows has no permission bits, so the declared perms are unused there. The argument stays on the signature so every caller still passes it. --- crates/rbitcoin-rpc/src/auth.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/rbitcoin-rpc/src/auth.rs b/crates/rbitcoin-rpc/src/auth.rs index e1095b078c..8b9ddcc16f 100644 --- a/crates/rbitcoin-rpc/src/auth.rs +++ b/crates/rbitcoin-rpc/src/auth.rs @@ -90,6 +90,7 @@ impl RpcCookiePerms { } /// Unix permission bits no wider than this declaration. + #[cfg(unix)] fn allows_unix_mode(self, mode: u32) -> bool { let mode = mode & 0o777; match self { @@ -108,6 +109,9 @@ impl RpcCookiePerms { /// clients disagree. Refusing it here turns mempool's endless 401 into a startup /// error. pub fn read_cookie_file(path: &Path, perms: RpcCookiePerms) -> Result { + // Mode bits exist only on Unix. The argument stays so every caller passes it. + #[cfg(not(unix))] + let _ = perms; let credentials = fs::read_to_string(path).map_err(|e| format!("read RPC cookie {}: {e}", path.display()))?; if credentials.ends_with(['\n', '\r']) { From e0ae3b40313d7e1cf86316b535f0027d8367b1b1 Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Sun, 18 Oct 2026 14:02:59 -0700 Subject: [PATCH 09/18] net: drop CGNAT, benchmark, reserved, and special IPv6 from gossip Those ranges are not globally routable, and the std helpers that name them are still unstable on 1.95. DNS and IBD inject now skip them the same way as RFC1918. Co-authored-by: Cursor --- crates/rbitcoin-net/src/netaddr.rs | 40 +++++++++++++++++++++++++++--- crates/rbitcoin-net/src/seeds.rs | 6 +++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/crates/rbitcoin-net/src/netaddr.rs b/crates/rbitcoin-net/src/netaddr.rs index cdd273aa98..6ae0349682 100644 --- a/crates/rbitcoin-net/src/netaddr.rs +++ b/crates/rbitcoin-net/src/netaddr.rs @@ -50,10 +50,39 @@ pub(crate) fn addr_allowed(addr: NetAddr, only: &[OnlyNet]) -> bool { only.is_empty() || only.iter().any(|n| n.matches_addr(addr)) } -/// `2001:db8::/32`. `Ipv6Addr::is_documentation` is still unstable on 1.95. -fn ipv6_documentation(v: Ipv6Addr) -> bool { +/// Prefixes `Ipv6Addr` does not classify on 1.95. +/// +/// `2001:db8::/32` documentation, `2001:10::/28` ORCHID, `2001:20::/28` +/// ORCHIDv2, and `100::/64` discard. `is_documentation` is still unstable. +fn ipv6_special_unroutable(v: Ipv6Addr) -> bool { let o = v.octets(); - o[0] == 0x20 && o[1] == 0x01 && o[2] == 0x0d && o[3] == 0xb8 + if o[0] == 0x20 && o[1] == 0x01 && o[2] == 0x0d && o[3] == 0xb8 { + return true; + } + if o[0] == 0x20 && o[1] == 0x01 && o[2] == 0x00 && (o[3] & 0xf0) == 0x10 { + return true; + } + if o[0] == 0x20 && o[1] == 0x01 && o[2] == 0x00 && (o[3] & 0xf0) == 0x20 { + return true; + } + o[0] == 0x01 && o[1] == 0x00 && o[2..8].iter().all(|&b| b == 0) +} + +/// `100.64.0.0/10`. `Ipv4Addr::is_shared` is still unstable on 1.95. +fn ipv4_shared(v: Ipv4Addr) -> bool { + let o = v.octets(); + o[0] == 100 && (o[1] & 0xc0) == 64 +} + +/// `198.18.0.0/15`. `Ipv4Addr::is_benchmarking` is still unstable on 1.95. +fn ipv4_benchmarking(v: Ipv4Addr) -> bool { + let o = v.octets(); + o[0] == 198 && (o[1] & 0xfe) == 18 +} + +/// `240.0.0.0/4`. `Ipv4Addr::is_reserved` is still unstable on 1.95. +fn ipv4_reserved(v: Ipv4Addr) -> bool { + v.octets()[0] & 0xf0 == 240 } fn ipv4_unroutable(v: Ipv4Addr) -> bool { @@ -64,6 +93,9 @@ fn ipv4_unroutable(v: Ipv4Addr) -> bool { || v.is_broadcast() || v.is_multicast() || v.is_documentation() + || ipv4_shared(v) + || ipv4_benchmarking(v) + || ipv4_reserved(v) } /// Clearnet bogon. CJDNS `fc00::/8` is an overlay, not a unique-local bogon. @@ -82,7 +114,7 @@ pub(crate) fn clearnet_unroutable(ip: IpAddr) -> bool { || v.is_unicast_link_local() || v.is_unique_local() || v.is_multicast() - || ipv6_documentation(v) + || ipv6_special_unroutable(v) } } } diff --git a/crates/rbitcoin-net/src/seeds.rs b/crates/rbitcoin-net/src/seeds.rs index 03aecd1088..0e4f35c38c 100644 --- a/crates/rbitcoin-net/src/seeds.rs +++ b/crates/rbitcoin-net/src/seeds.rs @@ -1017,7 +1017,13 @@ mod tests { "127.0.0.1:8333".parse().unwrap(), "192.0.2.1:8333".parse().unwrap(), "0.0.0.0:8333".parse().unwrap(), + "100.64.0.1:8333".parse().unwrap(), + "198.18.0.1:8333".parse().unwrap(), + "240.0.0.1:8333".parse().unwrap(), "[2001:db8::1]:8333".parse().unwrap(), + "[2001:10::1]:8333".parse().unwrap(), + "[2001:20::1]:8333".parse().unwrap(), + "[100::1]:8333".parse().unwrap(), public, ]); assert_eq!(book.len(), 1); From 345087f0ab986a1380a7f9a2276106e35f5ebd22 Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Sun, 18 Oct 2026 14:10:23 -0700 Subject: [PATCH 10/18] net: keep unroutable clearnet out of steady-state gossip learn_addrv2 and addr relay used the book without the bogon check, so a peer could fill it and forward those addresses. getaddr and peers-file load now drop them too. addpeeraddress and --connect still can. Co-authored-by: Cursor --- COMPAT.md | 2 +- crates/rbitcoin-net/src/netaddr.rs | 4 +- crates/rbitcoin-net/src/peer.rs | 5 ++ .../rbitcoin-net/src/peer_hostile_journey.rs | 76 +++++++++++++++++++ crates/rbitcoin-net/src/peers.rs | 24 +++++- crates/rbitcoin-net/src/seeds.rs | 49 +++++++++++- 6 files changed, 155 insertions(+), 5 deletions(-) diff --git a/COMPAT.md b/COMPAT.md index a59909acca..7027f910c5 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -68,7 +68,7 @@ Full `/tx/:txid` JSON still has `vin[]`. Electrum has no outspend-vin surface. | Admission policy | **Libre-relay-class** (0.1 sat/vB, no dust, full RBF). P2P admits any nVersion. RPC-submit (`sendrawtransaction` / `testmempoolaccept` / `submitpackage`) still rejects `"version"` outside 1/2. Feerate, RBF, min relay and `-blockmintxfee` use Core's sigop-adjusted size `max(weight, sigop_cost × 20)` (`--bytes-per-sigop`, `0` disables). Cluster and package count/size limits and the block weight budget count raw weight, unlike Core, so the adjustment never becomes a per-tx sigop cap. No 16,000 sigop-cost standardness cap; admission and template selection share a strict 80,000 minus `--block-reserved-sigops` budget (default reserve 400). P2P `accept_tx` has the minimum relay feerate and no absurd-fee or `-maxtxfee` cap; RPC submit keeps its own default of 10,000 sat/vB (`0` unlimited). That split is intentional: the signer chose the fee. Mempool fee math checks `u64` overflow (`bad-txns-txouttotal-toolarge`), not `MAX_MONEY`. Block connect rejects an output or an output sum above `Amount::MAX_MONEY`. A mainnet input sum cannot exceed that cap, because those coins were rejected at connect | Standardness + policy knobs (nVersion 1/2, `MAX_STANDARD_TX_SIGOPS_COST` 16,000, `-bytespersigop`) | | Compact blocks | BIP152 **v2** receive + reconstruct + `getblocktxn` serve. Fill is live mempool + orphanage + `extra_compact` (cap 100). Outbound extra prefill is **on** unless `--prefill-compact=0` (10 KiB cap, extra-pool last; generate / submit / NewPoWValid pack txs not in the live mempool without delaying forward) | v1/v2 high-bandwidth + `extra_txn` cache (`-blockreconstructionextratxn`); Core #35558 prefill still unmerged | | WTx inventory | BIP339 when peer also sends `wtxidrelay` | BIP339 | -| GetAddr | Core `MAX_ADDR_TO_SEND` / `MAX_PCT_ADDR_TO_SEND` (**1000** / **23%**), 24h per-bind cache. Named copies in `rbitcoin-net` — do not “improve” without a named reason to diverge. `MAX_ADDR_MAN` (8192) is **our** HashMap DoS cap and must stay above `1000/0.23`. Learned gossip and DNS/fixed inject ignore unroutable IPs; `addpeeraddress` and `--connect` do not. Onion, I2P, and CJDNS are not IP bogons | Core new/tried buckets (~80k); same 1000 / 23% | +| GetAddr | Core `MAX_ADDR_TO_SEND` / `MAX_PCT_ADDR_TO_SEND` (**1000** / **23%**), 24h per-bind cache. Named copies in `rbitcoin-net` — do not “improve” without a named reason to diverge. `MAX_ADDR_MAN` (8192) is **our** HashMap DoS cap and must stay above `1000/0.23`. DNS and fixed inject, IBD and steady-state addr gossip, getaddr replies, and peers-file load ignore unroutable clearnet (loopback, private, CGNAT, link-local, documentation, benchmarking, class E, and the matching IPv6 ranges). `addpeeraddress` and `--connect` do not. Onion, I2P, and CJDNS are not IP bogons | Core new/tried buckets (~80k); same 1000 / 23% | | Package submit | RPC `submitpackage` / Esplora `POST /txs/package` (no P2P package command) | BIP331 wire | | Pruning / GUI | Unpruned: `seqsigwit.body`. `--prune-seqsigwit`: watermark + `NETWORK_LIMITED`, kept witness is 288 height files under `store/seqsigwit.window/` plus a RAM cap (`--prune-seqsigwit-ram-threshold-bytes`, `0` = files only). Not a rolling stem. Not Core `-prune` of headers/txout | Supported | | Mining template RPC | `getblocktemplate` / `getmininginfo` / `prioritisetransaction` (selector; no stratum) | GBT + stratum / pool stack | diff --git a/crates/rbitcoin-net/src/netaddr.rs b/crates/rbitcoin-net/src/netaddr.rs index 6ae0349682..2e631a0323 100644 --- a/crates/rbitcoin-net/src/netaddr.rs +++ b/crates/rbitcoin-net/src/netaddr.rs @@ -119,7 +119,9 @@ pub(crate) fn clearnet_unroutable(ip: IpAddr) -> bool { } } -/// Seed inject and peer addr gossip. Onion, I2P, and CJDNS are not IP bogons. +/// Seed inject, IBD and steady-state addr gossip, getaddr, and peers-file +/// load. Onion, I2P, and CJDNS are not IP bogons. `add_learned` stays open +/// for `addpeeraddress` and `--connect`. pub(crate) fn netaddr_unroutable_gossip(addr: NetAddr) -> bool { match addr { NetAddr::Ip(s) => clearnet_unroutable(s.ip()), diff --git a/crates/rbitcoin-net/src/peer.rs b/crates/rbitcoin-net/src/peer.rs index 58170cdc1e..8a800f8e9d 100644 --- a/crates/rbitcoin-net/src/peer.rs +++ b/crates/rbitcoin-net/src/peer.rs @@ -3062,6 +3062,11 @@ fn on_addrv2( let mut batches: Vec> = vec![Vec::new(); neighbors.len()]; for addr in list.iter().take(allow) { + if crate::NetAddr::from_addrv2(addr) + .is_some_and(crate::netaddr::netaddr_unroutable_gossip) + { + continue; + } let key = addr_relay_key(addr); let n_dest = if neighbors.len() <= 1 { neighbors.len() diff --git a/crates/rbitcoin-net/src/peer_hostile_journey.rs b/crates/rbitcoin-net/src/peer_hostile_journey.rs index cea7cadb99..3ada84e448 100644 --- a/crates/rbitcoin-net/src/peer_hostile_journey.rs +++ b/crates/rbitcoin-net/src/peer_hostile_journey.rs @@ -19,6 +19,82 @@ fn hostile_ver(addr: SocketAddr) -> VersionMessage { } } +#[tokio::test] +async fn addrv2_gossip_drops_unroutable_clearnet() { + use bitcoin::p2p::message::NetworkMessage; + use std::sync::{Arc, Mutex}; + + let peers = crate::peers::PeerHub::new(); + let book = Arc::new(Mutex::new(crate::seeds::AddrMan::new())); + peers.set_addrman(Arc::clone(&book)); + let src_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333); + let ver = hostile_ver(src_addr); + let peer = peers.register( + src_addr, + src_addr, + &ver, + true, + crate::peers::PeerConnType::Inbound, + ); + peer.set_wants_addrv2(); + let dst_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(5, 6, 7, 8)), 8333); + let dst = peers.register( + dst_addr, + dst_addr, + &ver, + true, + crate::peers::PeerConnType::Inbound, + ); + dst.set_wants_addrv2(); + let (_dst_tx, mut dst_rx) = mpsc::unbounded_channel(); + dst.attach_out(_dst_tx); + peers.set_mock_now(peer.connected_at_secs() + 10_000); + + let public = AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK, + addr: AddrV2::Ipv4(Ipv4Addr::new(9, 9, 9, 9)), + port: 8333, + }; + let lan = AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK, + addr: AddrV2::Ipv4(Ipv4Addr::new(10, 0, 0, 1)), + port: 8333, + }; + let cgnat = AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK, + addr: AddrV2::Ipv4(Ipv4Addr::new(100, 64, 0, 1)), + port: 8333, + }; + let mut follow = PeerFollowState::new(); + on_addrv2( + &mut follow, + Some(peer.as_ref()), + &[lan, public.clone(), cgnat], + ) + .unwrap(); + + let got = dst_rx + .try_recv() + .expect("the public address is relayed") + .expect_msg(); + let NetworkMessage::AddrV2(sent) = got else { + panic!("expected addrv2, got {got:?}"); + }; + assert_eq!(sent, vec![public]); + assert!(dst_rx.try_recv().is_err(), "unroutable rows are not relayed"); + let book = book.lock().unwrap_or_else(|e| e.into_inner()); + let public_sock = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 8333); + let lan_sock = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 8333); + assert!(book.entry(&public_sock).is_some()); + assert!( + book.entry(&lan_sock).is_none(), + "learn_addrv2 does not store RFC1918" + ); +} + fn hostile_addr(port: u16) -> AddrV2Message { AddrV2Message { time: 1_700_000_000, diff --git a/crates/rbitcoin-net/src/peers.rs b/crates/rbitcoin-net/src/peers.rs index b7636defd7..aa5379cc3d 100644 --- a/crates/rbitcoin-net/src/peers.rs +++ b/crates/rbitcoin-net/src/peers.rs @@ -1744,8 +1744,8 @@ impl PeerHub { /// Learn IPv4/IPv6/Tor v3/I2P/CJDNS rows from BIP155 `addrv2`. /// - /// Rows for a net this process cannot dial are not stored. Relaying the - /// message is separate and does not consult the book. + /// Rows for a net this process cannot dial are not stored. Unroutable + /// clearnet is not stored. Relaying is separate and drops the same rows. pub fn learn_addrv2(&self, list: &[bitcoin::p2p::address::AddrV2Message]) { let g = self.addrman.lock().unwrap_or_else(|e| e.into_inner()); let Some(am) = g.as_ref() else { @@ -1754,6 +1754,9 @@ impl PeerHub { let mut book = am.lock().unwrap_or_else(|e| e.into_inner()); for a in list { if let Some(addr) = crate::NetAddr::from_addrv2(a) { + if crate::netaddr::netaddr_unroutable_gossip(addr) { + continue; + } book.add_learned_addr(addr, crate::seeds::MAX_ADDR_MAN); } } @@ -1794,6 +1797,7 @@ impl PeerHub { .iter() .map(|e| e.addr) .filter(|a| v2 || matches!(a, crate::NetAddr::Ip(_))) + .filter(|a| !crate::netaddr::netaddr_unroutable_gossip(*a)) .collect(); let n = addrs.len(); let pct_cap = (n * crate::peer::MAX_PCT_ADDR_TO_SEND / 100).max(1); @@ -4002,5 +4006,21 @@ mod tests { assert_ne!(a, expired); } + #[test] + fn getaddr_omits_unroutable_clearnet() { + let hub = PeerHub::new(); + hub.set_mock_now(1_700_000_000); + let mut am = crate::seeds::AddrMan::new(); + for addr in ["10.0.0.1:8333", "100.64.0.1:8333", "127.0.0.1:8333"] { + assert!(am.add_learned(addr.parse().unwrap(), 64)); + } + hub.set_addrman(Arc::new(Mutex::new(am))); + let got = hub.addr_response_for_bind(SocketAddr::from(([127, 0, 0, 1], 18444))); + assert!( + got.is_empty(), + "getaddr must not advertise unroutable clearnet: {got:?}" + ); + } + include!("overlay_addrman_journey.rs"); } diff --git a/crates/rbitcoin-net/src/seeds.rs b/crates/rbitcoin-net/src/seeds.rs index 0e4f35c38c..03b0189247 100644 --- a/crates/rbitcoin-net/src/seeds.rs +++ b/crates/rbitcoin-net/src/seeds.rs @@ -964,6 +964,9 @@ impl AddrMan { ) })? }; + if crate::netaddr::netaddr_unroutable_gossip(addr) { + continue; + } am.remember(addr, PeerFlags(flags_u)); } if !saw_magic && am.is_empty() { @@ -1046,7 +1049,7 @@ mod tests { fn addr_n(i: u32) -> SocketAddr { SocketAddr::new( IpAddr::V4(Ipv4Addr::new( - 10, + 11, ((i >> 16) & 0xff) as u8, ((i >> 8) & 0xff) as u8, (i & 0xff) as u8, @@ -1752,6 +1755,50 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn load_drops_unroutable_clearnet_and_keeps_cjdns() { + let dir = std::env::temp_dir().join(format!( + "rbitcoin-peers-bogon-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("peers"); + std::fs::write( + &path, + "\ +rbitcoin-peers-v2 +1.2.3.4:8333 0x01 +10.0.0.1:8333 0x01 +100.64.0.1:8333 0x00 +[fc00::1]:8333 0x00 +", + ) + .unwrap(); + let loaded = AddrMan::load(&path).unwrap(); + let public: SocketAddr = "1.2.3.4:8333".parse().unwrap(); + assert!(loaded.entry(&public).is_some(), "a public address still loads"); + assert!(loaded.flags(&public).has_connected()); + let lan: SocketAddr = "10.0.0.1:8333".parse().unwrap(); + assert!( + loaded.entry(&lan).is_none(), + "a remembered RFC1918 address is not dialed again" + ); + let cgnat: SocketAddr = "100.64.0.1:8333".parse().unwrap(); + assert!(loaded.entry(&cgnat).is_none()); + assert!( + loaded + .entries() + .iter() + .any(|e| matches!(e.addr, NetAddr::Cjdns { .. })), + "fc00::/8 stays a CJDNS row" + ); + let _ = std::fs::remove_dir_all(&dir); + } + fn onion() -> NetAddr { "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333" .parse() From 784c53660f44e48812a36c5c01a0481e006a8b94 Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Sun, 18 Oct 2026 14:24:05 -0700 Subject: [PATCH 11/18] rpc: cap cookie group and all at 0640 and 0644 group used to allow a group-writable cookie, and all allowed any mode. The mode is now read from the same file handle that supplies the secret, so a failed stat refuses startup. Co-authored-by: Cursor --- changelog.d/rpc-cookie-perms.md | 7 ++-- crates/rbitcoin-net/src/seeds.rs | 5 ++- crates/rbitcoin-node/src/cli.rs | 2 +- crates/rbitcoin-node/src/config.rs | 15 +++++++- crates/rbitcoin-rpc/src/auth.rs | 62 ++++++++++++++++++++---------- crates/rbitcoin-rpc/src/server.rs | 6 +++ docs/operator/operations.md | 2 +- docs/rpc.md | 2 +- docs/wallets.md | 5 ++- nix/modules/rbitcoin.nix | 5 ++- 10 files changed, 78 insertions(+), 33 deletions(-) diff --git a/changelog.d/rpc-cookie-perms.md b/changelog.d/rpc-cookie-perms.md index 9504bfac72..255397cbc8 100644 --- a/changelog.d/rpc-cookie-perms.md +++ b/changelog.d/rpc-cookie-perms.md @@ -3,6 +3,7 @@ Changed - **`--rpc-cookie-perms owner|group|all`** (conf `rpc_cookie_perms=`, NixOS `services.rbitcoin.rpc.cookiePerms`). Default `owner` refuses a cookie with group or world bits at startup. `group` allows a 0640 file shared - with another service. `all` allows any mode. The node still does not - create or chmod the cookie. A group- or world-readable cookie used to - warn once and start. + with another service and refuses group write. `all` allows world read + (0644) and refuses group or world write. The check is Unix mode bits; + Windows ignores the flag. The node still does not create or chmod the + cookie. A group- or world-readable cookie used to warn once and start. diff --git a/crates/rbitcoin-net/src/seeds.rs b/crates/rbitcoin-net/src/seeds.rs index 03b0189247..80dd2820fb 100644 --- a/crates/rbitcoin-net/src/seeds.rs +++ b/crates/rbitcoin-net/src/seeds.rs @@ -1780,7 +1780,10 @@ rbitcoin-peers-v2 .unwrap(); let loaded = AddrMan::load(&path).unwrap(); let public: SocketAddr = "1.2.3.4:8333".parse().unwrap(); - assert!(loaded.entry(&public).is_some(), "a public address still loads"); + assert!( + loaded.entry(&public).is_some(), + "a public address still loads" + ); assert!(loaded.flags(&public).has_connected()); let lan: SocketAddr = "10.0.0.1:8333".parse().unwrap(); assert!( diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index f67b4b4e0a..caeed6564a 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -374,7 +374,7 @@ SV2: --sv2-tp-listen ADDR serves the Stratum v2 Template Distribution Protocol ( --sv2-tp-stale-grace SECS keeps a replaced tip's templates answering for SECS, at most 86400 (default 10).\n\ --sv2-tp-fee-delta SATS pushes a same-tip template once its fees gain SATS over the last one sent (default 1000).\n\ --sv2-tp-template-interval SECS checks for that gain every SECS, 1 to 86400 (default 5).\n\ -RPC: --rpc unix socket {{datadir}}/rpc.sock; --rpc-listen [ADDR] adds TCP (default 127.0.0.1 and Core-matching port). Token {{datadir}}/rpc.token (Bearer); --rpc-cookie-file opts TCP into Core cookie HTTP Basic. --rpc-cookie-perms owner|group|all (default owner) is the widest mode that file may have; the node never creates or chmods it. No --rpcuser. --rest turns on unauthenticated /rest/ on those listeners (off unless set).\n\ +RPC: --rpc unix socket {{datadir}}/rpc.sock; --rpc-listen [ADDR] adds TCP (default 127.0.0.1 and Core-matching port). Token {{datadir}}/rpc.token (Bearer); --rpc-cookie-file opts TCP into Core cookie HTTP Basic. --rpc-cookie-perms owner|group|all (default owner) is the widest Unix mode that file may have (owner 0600, group 0640, all 0644); the node never creates or chmods it. Windows has no mode bits. No --rpcuser. --rest turns on unauthenticated /rest/ on those listeners (off unless set).\n\ Cold files: --datadir-cold PATH puts append-only IBD files under PATH/store\n\ (seqsigwit, txstat, input, and blockfilter / sp_tweaks once enabled).\n\ Default (flag omitted): those files live under --datadir. --prune-seqsigwit\n\ diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index 8e0a41cf0b..a90f270938 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -261,7 +261,8 @@ pub struct RpcOpts { pub token_file: Option, /// Opt-in Core-format `username:password` cookie accepted as TCP HTTP Basic. pub cookie_file: Option, - /// Widest mode `--rpc-cookie-perms` allows. Default owner (0600). + /// Widest Unix mode `--rpc-cookie-perms` allows. Default owner (0600). + /// `group` is at most 0640. `all` is at most 0644. pub cookie_perms: rbitcoin_rpc::RpcCookiePerms, pub work_queue: Option, /// Core REST on the RPC listener. Off unless `--rest` / `rest=`. @@ -2306,6 +2307,11 @@ mod tests { let err = cfg.validate().unwrap_err().to_string(); assert!(err.contains("read RPC cookie"), "missing file: {err}"); std::fs::write(&cookie, "__cookie__:secret\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o600)).unwrap(); + } let err = cfg.validate().unwrap_err().to_string(); assert!(err.contains("line ending"), "{err}"); std::fs::write(&cookie, "__cookie__:secret").unwrap(); @@ -2322,7 +2328,14 @@ mod tests { std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o644)).unwrap(); let err = cfg.validate().unwrap_err().to_string(); assert!(err.contains("--rpc-cookie-perms group"), "{err}"); + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o660)).unwrap(); + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("--rpc-cookie-perms group"), "{err}"); cfg.rpc.cookie_perms = rbitcoin_rpc::RpcCookiePerms::All; + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o666)).unwrap(); + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("--rpc-cookie-perms all"), "{err}"); + std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o644)).unwrap(); cfg.validate() .expect("0644 is within --rpc-cookie-perms all"); std::fs::set_permissions(&cookie, std::fs::Permissions::from_mode(0o600)).unwrap(); diff --git a/crates/rbitcoin-rpc/src/auth.rs b/crates/rbitcoin-rpc/src/auth.rs index 8b9ddcc16f..bf63b1dab1 100644 --- a/crates/rbitcoin-rpc/src/auth.rs +++ b/crates/rbitcoin-rpc/src/auth.rs @@ -1,7 +1,7 @@ //! RPC authentication: Bearer token plus optional Core cookie credentials. use std::fs; -use std::io::Write; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; /// Bearer token accepted by the RPC server. @@ -60,7 +60,8 @@ pub fn default_socket_path(datadir: &Path) -> PathBuf { /// Widest mode an operator declared for `--rpc-cookie-file`. /// /// The node never creates or chmods the cookie. `owner` is mode 0600. -/// `group` also allows group bits (0640). `all` allows any mode. +/// `group` is at most 0640 (group read). `all` is at most 0644 (world read). +/// The check is Unix mode bits. Windows ignores the flag. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum RpcCookiePerms { #[default] @@ -95,8 +96,10 @@ impl RpcCookiePerms { let mode = mode & 0o777; match self { Self::Owner => mode & 0o077 == 0, - Self::Group => mode & 0o007 == 0, - Self::All => true, + // Group write/exec and every world bit. 0640 stays. + Self::Group => mode & 0o037 == 0, + // Group and world write/exec. 0644 stays. + Self::All => mode & 0o033 == 0, } } } @@ -112,8 +115,29 @@ pub fn read_cookie_file(path: &Path, perms: RpcCookiePerms) -> Result Result Date: Sun, 18 Oct 2026 15:56:44 -0700 Subject: [PATCH 12/18] net: drop 0/8 and 6to4 or NAT64 addresses that embed a bogon 0.0.0.0/8 was only caught at 0.0.0.0, and a private IPv4 wrapped in 2002::/16 or 64:ff9b::/96 still entered the dial set. A public embedded address stays. Co-authored-by: Cursor --- COMPAT.md | 2 +- crates/rbitcoin-net/src/netaddr.rs | 25 +++++++++++++++++++++++++ crates/rbitcoin-net/src/seeds.rs | 14 ++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/COMPAT.md b/COMPAT.md index 7027f910c5..2c182ffe03 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -68,7 +68,7 @@ Full `/tx/:txid` JSON still has `vin[]`. Electrum has no outspend-vin surface. | Admission policy | **Libre-relay-class** (0.1 sat/vB, no dust, full RBF). P2P admits any nVersion. RPC-submit (`sendrawtransaction` / `testmempoolaccept` / `submitpackage`) still rejects `"version"` outside 1/2. Feerate, RBF, min relay and `-blockmintxfee` use Core's sigop-adjusted size `max(weight, sigop_cost × 20)` (`--bytes-per-sigop`, `0` disables). Cluster and package count/size limits and the block weight budget count raw weight, unlike Core, so the adjustment never becomes a per-tx sigop cap. No 16,000 sigop-cost standardness cap; admission and template selection share a strict 80,000 minus `--block-reserved-sigops` budget (default reserve 400). P2P `accept_tx` has the minimum relay feerate and no absurd-fee or `-maxtxfee` cap; RPC submit keeps its own default of 10,000 sat/vB (`0` unlimited). That split is intentional: the signer chose the fee. Mempool fee math checks `u64` overflow (`bad-txns-txouttotal-toolarge`), not `MAX_MONEY`. Block connect rejects an output or an output sum above `Amount::MAX_MONEY`. A mainnet input sum cannot exceed that cap, because those coins were rejected at connect | Standardness + policy knobs (nVersion 1/2, `MAX_STANDARD_TX_SIGOPS_COST` 16,000, `-bytespersigop`) | | Compact blocks | BIP152 **v2** receive + reconstruct + `getblocktxn` serve. Fill is live mempool + orphanage + `extra_compact` (cap 100). Outbound extra prefill is **on** unless `--prefill-compact=0` (10 KiB cap, extra-pool last; generate / submit / NewPoWValid pack txs not in the live mempool without delaying forward) | v1/v2 high-bandwidth + `extra_txn` cache (`-blockreconstructionextratxn`); Core #35558 prefill still unmerged | | WTx inventory | BIP339 when peer also sends `wtxidrelay` | BIP339 | -| GetAddr | Core `MAX_ADDR_TO_SEND` / `MAX_PCT_ADDR_TO_SEND` (**1000** / **23%**), 24h per-bind cache. Named copies in `rbitcoin-net` — do not “improve” without a named reason to diverge. `MAX_ADDR_MAN` (8192) is **our** HashMap DoS cap and must stay above `1000/0.23`. DNS and fixed inject, IBD and steady-state addr gossip, getaddr replies, and peers-file load ignore unroutable clearnet (loopback, private, CGNAT, link-local, documentation, benchmarking, class E, and the matching IPv6 ranges). `addpeeraddress` and `--connect` do not. Onion, I2P, and CJDNS are not IP bogons | Core new/tried buckets (~80k); same 1000 / 23% | +| GetAddr | Core `MAX_ADDR_TO_SEND` / `MAX_PCT_ADDR_TO_SEND` (**1000** / **23%**), 24h per-bind cache. Named copies in `rbitcoin-net` — do not “improve” without a named reason to diverge. `MAX_ADDR_MAN` (8192) is **our** HashMap DoS cap and must stay above `1000/0.23`. DNS and fixed inject, IBD and steady-state addr gossip, getaddr replies, and peers-file load ignore unroutable clearnet (loopback, private, `0.0.0.0/8`, CGNAT, link-local, documentation, benchmarking, class E, the matching IPv6 ranges, and 6to4 or NAT64 `64:ff9b::/96` whose embedded IPv4 is unroutable). `addpeeraddress` and `--connect` do not. Onion, I2P, and CJDNS are not IP bogons | Core new/tried buckets (~80k); same 1000 / 23% | | Package submit | RPC `submitpackage` / Esplora `POST /txs/package` (no P2P package command) | BIP331 wire | | Pruning / GUI | Unpruned: `seqsigwit.body`. `--prune-seqsigwit`: watermark + `NETWORK_LIMITED`, kept witness is 288 height files under `store/seqsigwit.window/` plus a RAM cap (`--prune-seqsigwit-ram-threshold-bytes`, `0` = files only). Not a rolling stem. Not Core `-prune` of headers/txout | Supported | | Mining template RPC | `getblocktemplate` / `getmininginfo` / `prioritisetransaction` (selector; no stratum) | GBT + stratum / pool stack | diff --git a/crates/rbitcoin-net/src/netaddr.rs b/crates/rbitcoin-net/src/netaddr.rs index 2e631a0323..9a7950f7aa 100644 --- a/crates/rbitcoin-net/src/netaddr.rs +++ b/crates/rbitcoin-net/src/netaddr.rs @@ -68,6 +68,24 @@ fn ipv6_special_unroutable(v: Ipv6Addr) -> bool { o[0] == 0x01 && o[1] == 0x00 && o[2..8].iter().all(|&b| b == 0) } +/// 6to4 `2002::/16` and well-known NAT64 `64:ff9b::/96` when the embedded +/// IPv4 is itself unroutable. A public embedded address stays. +fn ipv6_embedded_bogon(v: Ipv6Addr) -> bool { + let o = v.octets(); + if o[0] == 0x20 && o[1] == 0x02 { + return ipv4_unroutable(Ipv4Addr::new(o[2], o[3], o[4], o[5])); + } + if o[0] == 0x00 + && o[1] == 0x64 + && o[2] == 0xff + && o[3] == 0x9b + && o[4..12].iter().all(|&b| b == 0) + { + return ipv4_unroutable(Ipv4Addr::new(o[12], o[13], o[14], o[15])); + } + false +} + /// `100.64.0.0/10`. `Ipv4Addr::is_shared` is still unstable on 1.95. fn ipv4_shared(v: Ipv4Addr) -> bool { let o = v.octets(); @@ -85,6 +103,11 @@ fn ipv4_reserved(v: Ipv4Addr) -> bool { v.octets()[0] & 0xf0 == 240 } +/// `0.0.0.0/8`. `is_unspecified` is only `0.0.0.0`. +fn ipv4_this_network(v: Ipv4Addr) -> bool { + v.octets()[0] == 0 +} + fn ipv4_unroutable(v: Ipv4Addr) -> bool { v.is_unspecified() || v.is_loopback() @@ -96,6 +119,7 @@ fn ipv4_unroutable(v: Ipv4Addr) -> bool { || ipv4_shared(v) || ipv4_benchmarking(v) || ipv4_reserved(v) + || ipv4_this_network(v) } /// Clearnet bogon. CJDNS `fc00::/8` is an overlay, not a unique-local bogon. @@ -115,6 +139,7 @@ pub(crate) fn clearnet_unroutable(ip: IpAddr) -> bool { || v.is_unique_local() || v.is_multicast() || ipv6_special_unroutable(v) + || ipv6_embedded_bogon(v) } } } diff --git a/crates/rbitcoin-net/src/seeds.rs b/crates/rbitcoin-net/src/seeds.rs index 80dd2820fb..cced2ec750 100644 --- a/crates/rbitcoin-net/src/seeds.rs +++ b/crates/rbitcoin-net/src/seeds.rs @@ -1027,10 +1027,24 @@ mod tests { "[2001:10::1]:8333".parse().unwrap(), "[2001:20::1]:8333".parse().unwrap(), "[100::1]:8333".parse().unwrap(), + "0.1.2.3:8333".parse().unwrap(), + "[2002:0a00:1::]:8333".parse().unwrap(), + "[64:ff9b::a00:1]:8333".parse().unwrap(), public, ]); assert_eq!(book.len(), 1); assert!(book.entry(&public).is_some()); + let sixto4_public: SocketAddr = "[2002:0102:0304::]:8333".parse().unwrap(); + let nat64_public: SocketAddr = "[64:ff9b::102:304]:8333".parse().unwrap(); + book.inject([sixto4_public, nat64_public]); + assert!( + book.entry(&sixto4_public).is_some(), + "6to4 of a public IPv4 stays" + ); + assert!( + book.entry(&nat64_public).is_some(), + "NAT64 of a public IPv4 stays" + ); let lan: SocketAddr = "10.0.0.1:8333".parse().unwrap(); assert!(book.add_learned(lan, 64)); assert!(book.entry(&lan).is_some()); From 2940ca743a90403e20f2d0eb14d0366405eb6967 Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Sun, 18 Oct 2026 16:02:49 -0700 Subject: [PATCH 13/18] net: do not relay port-0 addresses except I2P from_addrv2 returns None for port 0, so the unroutable gossip guard never saw the row and it was forwarded anyway. I2P stays, because Core advertises it with port 0. Co-authored-by: Cursor --- COMPAT.md | 2 +- crates/rbitcoin-net/src/peer.rs | 7 +++++++ .../rbitcoin-net/src/peer_hostile_journey.rs | 21 ++++++++++++++++--- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/COMPAT.md b/COMPAT.md index 2c182ffe03..7115c842a7 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -68,7 +68,7 @@ Full `/tx/:txid` JSON still has `vin[]`. Electrum has no outspend-vin surface. | Admission policy | **Libre-relay-class** (0.1 sat/vB, no dust, full RBF). P2P admits any nVersion. RPC-submit (`sendrawtransaction` / `testmempoolaccept` / `submitpackage`) still rejects `"version"` outside 1/2. Feerate, RBF, min relay and `-blockmintxfee` use Core's sigop-adjusted size `max(weight, sigop_cost × 20)` (`--bytes-per-sigop`, `0` disables). Cluster and package count/size limits and the block weight budget count raw weight, unlike Core, so the adjustment never becomes a per-tx sigop cap. No 16,000 sigop-cost standardness cap; admission and template selection share a strict 80,000 minus `--block-reserved-sigops` budget (default reserve 400). P2P `accept_tx` has the minimum relay feerate and no absurd-fee or `-maxtxfee` cap; RPC submit keeps its own default of 10,000 sat/vB (`0` unlimited). That split is intentional: the signer chose the fee. Mempool fee math checks `u64` overflow (`bad-txns-txouttotal-toolarge`), not `MAX_MONEY`. Block connect rejects an output or an output sum above `Amount::MAX_MONEY`. A mainnet input sum cannot exceed that cap, because those coins were rejected at connect | Standardness + policy knobs (nVersion 1/2, `MAX_STANDARD_TX_SIGOPS_COST` 16,000, `-bytespersigop`) | | Compact blocks | BIP152 **v2** receive + reconstruct + `getblocktxn` serve. Fill is live mempool + orphanage + `extra_compact` (cap 100). Outbound extra prefill is **on** unless `--prefill-compact=0` (10 KiB cap, extra-pool last; generate / submit / NewPoWValid pack txs not in the live mempool without delaying forward) | v1/v2 high-bandwidth + `extra_txn` cache (`-blockreconstructionextratxn`); Core #35558 prefill still unmerged | | WTx inventory | BIP339 when peer also sends `wtxidrelay` | BIP339 | -| GetAddr | Core `MAX_ADDR_TO_SEND` / `MAX_PCT_ADDR_TO_SEND` (**1000** / **23%**), 24h per-bind cache. Named copies in `rbitcoin-net` — do not “improve” without a named reason to diverge. `MAX_ADDR_MAN` (8192) is **our** HashMap DoS cap and must stay above `1000/0.23`. DNS and fixed inject, IBD and steady-state addr gossip, getaddr replies, and peers-file load ignore unroutable clearnet (loopback, private, `0.0.0.0/8`, CGNAT, link-local, documentation, benchmarking, class E, the matching IPv6 ranges, and 6to4 or NAT64 `64:ff9b::/96` whose embedded IPv4 is unroutable). `addpeeraddress` and `--connect` do not. Onion, I2P, and CJDNS are not IP bogons | Core new/tried buckets (~80k); same 1000 / 23% | +| GetAddr | Core `MAX_ADDR_TO_SEND` / `MAX_PCT_ADDR_TO_SEND` (**1000** / **23%**), 24h per-bind cache. Named copies in `rbitcoin-net` — do not “improve” without a named reason to diverge. `MAX_ADDR_MAN` (8192) is **our** HashMap DoS cap and must stay above `1000/0.23`. DNS and fixed inject, IBD and steady-state addr gossip, getaddr replies, and peers-file load ignore unroutable clearnet (loopback, private, `0.0.0.0/8`, CGNAT, link-local, documentation, benchmarking, class E, the matching IPv6 ranges, and 6to4 or NAT64 `64:ff9b::/96` whose embedded IPv4 is unroutable). Addr gossip also drops port 0 except I2P, which is advertised with port 0. `addpeeraddress` and `--connect` do not. Onion, I2P, and CJDNS are not IP bogons | Core new/tried buckets (~80k); same 1000 / 23% | | Package submit | RPC `submitpackage` / Esplora `POST /txs/package` (no P2P package command) | BIP331 wire | | Pruning / GUI | Unpruned: `seqsigwit.body`. `--prune-seqsigwit`: watermark + `NETWORK_LIMITED`, kept witness is 288 height files under `store/seqsigwit.window/` plus a RAM cap (`--prune-seqsigwit-ram-threshold-bytes`, `0` = files only). Not a rolling stem. Not Core `-prune` of headers/txout | Supported | | Mining template RPC | `getblocktemplate` / `getmininginfo` / `prioritisetransaction` (selector; no stratum) | GBT + stratum / pool stack | diff --git a/crates/rbitcoin-net/src/peer.rs b/crates/rbitcoin-net/src/peer.rs index 8a800f8e9d..5197f5c5a3 100644 --- a/crates/rbitcoin-net/src/peer.rs +++ b/crates/rbitcoin-net/src/peer.rs @@ -3062,6 +3062,13 @@ fn on_addrv2( let mut batches: Vec> = vec![Vec::new(); neighbors.len()]; for addr in list.iter().take(allow) { + // Port 0 is not a dialable endpoint. `from_addrv2` returns + // None for it (so it is not stored), except I2P, which Core + // advertises with port 0. That None skips the unroutable + // guard, so drop the row here before it is forwarded. + if addr.port == 0 && !matches!(addr.addr, bitcoin::p2p::address::AddrV2::I2p(_)) { + continue; + } if crate::NetAddr::from_addrv2(addr) .is_some_and(crate::netaddr::netaddr_unroutable_gossip) { diff --git a/crates/rbitcoin-net/src/peer_hostile_journey.rs b/crates/rbitcoin-net/src/peer_hostile_journey.rs index 3ada84e448..5801c8d782 100644 --- a/crates/rbitcoin-net/src/peer_hostile_journey.rs +++ b/crates/rbitcoin-net/src/peer_hostile_journey.rs @@ -68,11 +68,23 @@ async fn addrv2_gossip_drops_unroutable_clearnet() { addr: AddrV2::Ipv4(Ipv4Addr::new(100, 64, 0, 1)), port: 8333, }; + let zero_port = AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK, + addr: AddrV2::Ipv4(Ipv4Addr::new(9, 9, 9, 8)), + port: 0, + }; + let i2p = AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK, + addr: AddrV2::I2p([0x11; 32]), + port: 0, + }; let mut follow = PeerFollowState::new(); on_addrv2( &mut follow, Some(peer.as_ref()), - &[lan, public.clone(), cgnat], + &[lan, public.clone(), cgnat, zero_port, i2p.clone()], ) .unwrap(); @@ -83,8 +95,11 @@ async fn addrv2_gossip_drops_unroutable_clearnet() { let NetworkMessage::AddrV2(sent) = got else { panic!("expected addrv2, got {got:?}"); }; - assert_eq!(sent, vec![public]); - assert!(dst_rx.try_recv().is_err(), "unroutable rows are not relayed"); + assert_eq!(sent, vec![public, i2p]); + assert!( + dst_rx.try_recv().is_err(), + "unroutable rows and port-0 clearnet are not relayed" + ); let book = book.lock().unwrap_or_else(|e| e.into_inner()); let public_sock = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 8333); let lan_sock = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 8333); From c5592496302803cdd79241f372573ef8426a8b7d Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Sun, 18 Oct 2026 16:29:21 -0700 Subject: [PATCH 14/18] net: keep one slot per embedded IPv4, and drop Teredo and 3fff::/20 A public 6to4 or local NAT64 suffix was a distinct dialable key, so one IPv4 could fill the address book. Teredo's server field is the same kind of free bits. 3fff::/20 is documentation. Co-authored-by: Cursor --- COMPAT.md | 2 +- crates/rbitcoin-net/src/netaddr.rs | 42 ++++++++++++++++++++++++++---- crates/rbitcoin-net/src/seeds.rs | 27 +++++++++++++++++-- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/COMPAT.md b/COMPAT.md index 7115c842a7..11e01ce1ca 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -68,7 +68,7 @@ Full `/tx/:txid` JSON still has `vin[]`. Electrum has no outspend-vin surface. | Admission policy | **Libre-relay-class** (0.1 sat/vB, no dust, full RBF). P2P admits any nVersion. RPC-submit (`sendrawtransaction` / `testmempoolaccept` / `submitpackage`) still rejects `"version"` outside 1/2. Feerate, RBF, min relay and `-blockmintxfee` use Core's sigop-adjusted size `max(weight, sigop_cost × 20)` (`--bytes-per-sigop`, `0` disables). Cluster and package count/size limits and the block weight budget count raw weight, unlike Core, so the adjustment never becomes a per-tx sigop cap. No 16,000 sigop-cost standardness cap; admission and template selection share a strict 80,000 minus `--block-reserved-sigops` budget (default reserve 400). P2P `accept_tx` has the minimum relay feerate and no absurd-fee or `-maxtxfee` cap; RPC submit keeps its own default of 10,000 sat/vB (`0` unlimited). That split is intentional: the signer chose the fee. Mempool fee math checks `u64` overflow (`bad-txns-txouttotal-toolarge`), not `MAX_MONEY`. Block connect rejects an output or an output sum above `Amount::MAX_MONEY`. A mainnet input sum cannot exceed that cap, because those coins were rejected at connect | Standardness + policy knobs (nVersion 1/2, `MAX_STANDARD_TX_SIGOPS_COST` 16,000, `-bytespersigop`) | | Compact blocks | BIP152 **v2** receive + reconstruct + `getblocktxn` serve. Fill is live mempool + orphanage + `extra_compact` (cap 100). Outbound extra prefill is **on** unless `--prefill-compact=0` (10 KiB cap, extra-pool last; generate / submit / NewPoWValid pack txs not in the live mempool without delaying forward) | v1/v2 high-bandwidth + `extra_txn` cache (`-blockreconstructionextratxn`); Core #35558 prefill still unmerged | | WTx inventory | BIP339 when peer also sends `wtxidrelay` | BIP339 | -| GetAddr | Core `MAX_ADDR_TO_SEND` / `MAX_PCT_ADDR_TO_SEND` (**1000** / **23%**), 24h per-bind cache. Named copies in `rbitcoin-net` — do not “improve” without a named reason to diverge. `MAX_ADDR_MAN` (8192) is **our** HashMap DoS cap and must stay above `1000/0.23`. DNS and fixed inject, IBD and steady-state addr gossip, getaddr replies, and peers-file load ignore unroutable clearnet (loopback, private, `0.0.0.0/8`, CGNAT, link-local, documentation, benchmarking, class E, the matching IPv6 ranges, and 6to4 or NAT64 `64:ff9b::/96` whose embedded IPv4 is unroutable). Addr gossip also drops port 0 except I2P, which is advertised with port 0. `addpeeraddress` and `--connect` do not. Onion, I2P, and CJDNS are not IP bogons | Core new/tried buckets (~80k); same 1000 / 23% | +| GetAddr | Core `MAX_ADDR_TO_SEND` / `MAX_PCT_ADDR_TO_SEND` (**1000** / **23%**), 24h per-bind cache. Named copies in `rbitcoin-net` — do not “improve” without a named reason to diverge. `MAX_ADDR_MAN` (8192) is **our** HashMap DoS cap and must stay above `1000/0.23`. DNS and fixed inject, IBD and steady-state addr gossip, getaddr replies, and peers-file load ignore unroutable clearnet (loopback, private, `0.0.0.0/8`, CGNAT, link-local, documentation including `3fff::/20`, benchmarking, class E, and the matching IPv6 ranges). 6to4, well-known NAT64 `64:ff9b::/96`, local NAT64 `64:ff9b:1::/48`, IPv4-compatible `::/96`, and SIIT `::ffff:0:0:0/96` stay only when the embedded IPv4 is routable and the non-IPv4 bits are zero. Teredo `2001::/32` is dropped. Addr gossip also drops port 0 except I2P, which is advertised with port 0. `addpeeraddress` and `--connect` do not. Onion, I2P, and CJDNS are not IP bogons | Core new/tried buckets (~80k); same 1000 / 23% | | Package submit | RPC `submitpackage` / Esplora `POST /txs/package` (no P2P package command) | BIP331 wire | | Pruning / GUI | Unpruned: `seqsigwit.body`. `--prune-seqsigwit`: watermark + `NETWORK_LIMITED`, kept witness is 288 height files under `store/seqsigwit.window/` plus a RAM cap (`--prune-seqsigwit-ram-threshold-bytes`, `0` = files only). Not a rolling stem. Not Core `-prune` of headers/txout | Supported | | Mining template RPC | `getblocktemplate` / `getmininginfo` / `prioritisetransaction` (selector; no stratum) | GBT + stratum / pool stack | diff --git a/crates/rbitcoin-net/src/netaddr.rs b/crates/rbitcoin-net/src/netaddr.rs index 9a7950f7aa..fea7679e87 100644 --- a/crates/rbitcoin-net/src/netaddr.rs +++ b/crates/rbitcoin-net/src/netaddr.rs @@ -52,13 +52,18 @@ pub(crate) fn addr_allowed(addr: NetAddr, only: &[OnlyNet]) -> bool { /// Prefixes `Ipv6Addr` does not classify on 1.95. /// -/// `2001:db8::/32` documentation, `2001:10::/28` ORCHID, `2001:20::/28` -/// ORCHIDv2, and `100::/64` discard. `is_documentation` is still unstable. +/// `2001:db8::/32` and `3fff::/20` documentation, `2001:10::/28` ORCHID, +/// `2001:20::/28` ORCHIDv2, and `100::/64` discard. `is_documentation` is +/// still unstable. fn ipv6_special_unroutable(v: Ipv6Addr) -> bool { let o = v.octets(); if o[0] == 0x20 && o[1] == 0x01 && o[2] == 0x0d && o[3] == 0xb8 { return true; } + // RFC 9637 documentation. Core `IsRFC9637` rejects this in `IsValid`. + if o[0] == 0x3f && o[1] == 0xff && (o[2] & 0xf0) == 0 { + return true; + } if o[0] == 0x20 && o[1] == 0x01 && o[2] == 0x00 && (o[3] & 0xf0) == 0x10 { return true; } @@ -68,13 +73,26 @@ fn ipv6_special_unroutable(v: Ipv6Addr) -> bool { o[0] == 0x01 && o[1] == 0x00 && o[2..8].iter().all(|&b| b == 0) } -/// 6to4 `2002::/16` and well-known NAT64 `64:ff9b::/96` when the embedded -/// IPv4 is itself unroutable. A public embedded address stays. +/// Tunnel and translation prefixes. +/// +/// A public embedded IPv4 stays only in its canonical form: 6to4 and local +/// NAT64 with the non-IPv4 bits zero, well-known NAT64, IPv4-compatible +/// `::/96`, and SIIT `::ffff:0:0:0/96`. Any other suffix is a distinct map +/// key and would fill the flat 8192 book. Teredo `2001::/32` is dropped +/// entirely: the server address is 32 free bits, so a canonical suffix +/// still fills the book. IPv4-mapped `::ffff:0:0/96` is unwrapped earlier. fn ipv6_embedded_bogon(v: Ipv6Addr) -> bool { let o = v.octets(); + // Teredo. Client IPv4 is bit-flipped; server, flags, and port are free. + if o[0] == 0x20 && o[1] == 0x01 && o[2] == 0x00 && o[3] == 0x00 { + return true; + } + // 6to4. Bytes 2–5 are the IPv4. Bytes 6–15 must be zero. if o[0] == 0x20 && o[1] == 0x02 { - return ipv4_unroutable(Ipv4Addr::new(o[2], o[3], o[4], o[5])); + return ipv4_unroutable(Ipv4Addr::new(o[2], o[3], o[4], o[5])) + || o[6..].iter().any(|&b| b != 0); } + // Well-known NAT64 `64:ff9b::/96`. if o[0] == 0x00 && o[1] == 0x64 && o[2] == 0xff @@ -83,6 +101,20 @@ fn ipv6_embedded_bogon(v: Ipv6Addr) -> bool { { return ipv4_unroutable(Ipv4Addr::new(o[12], o[13], o[14], o[15])); } + // Local NAT64 `64:ff9b:1::/48`. Bytes 6–11 must be zero. + if o[0] == 0x00 && o[1] == 0x64 && o[2] == 0xff && o[3] == 0x9b && o[4] == 0x00 && o[5] == 0x01 + { + return ipv4_unroutable(Ipv4Addr::new(o[12], o[13], o[14], o[15])) + || o[6..12].iter().any(|&b| b != 0); + } + // SIIT `::ffff:0:0:0/96`. Not IPv4-mapped `::ffff:0:0/96`. + if o[..8].iter().all(|&b| b == 0) && o[8] == 0xff && o[9] == 0xff && o[10] == 0 && o[11] == 0 { + return ipv4_unroutable(Ipv4Addr::new(o[12], o[13], o[14], o[15])); + } + // Deprecated IPv4-compatible `::/96`. + if o[..12].iter().all(|&b| b == 0) { + return ipv4_unroutable(Ipv4Addr::new(o[12], o[13], o[14], o[15])); + } false } diff --git a/crates/rbitcoin-net/src/seeds.rs b/crates/rbitcoin-net/src/seeds.rs index cced2ec750..1acab07e1e 100644 --- a/crates/rbitcoin-net/src/seeds.rs +++ b/crates/rbitcoin-net/src/seeds.rs @@ -1029,22 +1029,45 @@ mod tests { "[100::1]:8333".parse().unwrap(), "0.1.2.3:8333".parse().unwrap(), "[2002:0a00:1::]:8333".parse().unwrap(), + "[2002:0102:0304::1]:8333".parse().unwrap(), "[64:ff9b::a00:1]:8333".parse().unwrap(), + "[::a00:1]:8333".parse().unwrap(), + "[::ffff:0:a00:1]:8333".parse().unwrap(), + "[::ffff:a00:1]:8333".parse().unwrap(), + "[64:ff9b:1::a00:1]:8333".parse().unwrap(), + "[64:ff9b:1:1::102:304]:8333".parse().unwrap(), + "[2001:0:102:304::f5ff:fffe]:8333".parse().unwrap(), + "[3fff::1]:8333".parse().unwrap(), public, ]); assert_eq!(book.len(), 1); assert!(book.entry(&public).is_some()); let sixto4_public: SocketAddr = "[2002:0102:0304::]:8333".parse().unwrap(); let nat64_public: SocketAddr = "[64:ff9b::102:304]:8333".parse().unwrap(); - book.inject([sixto4_public, nat64_public]); + let compatible_public: SocketAddr = "[::102:304]:8333".parse().unwrap(); + let siit_public: SocketAddr = "[::ffff:0:102:304]:8333".parse().unwrap(); + let nat64_local_public: SocketAddr = "[64:ff9b:1::102:304]:8333".parse().unwrap(); + let mapped_public: SocketAddr = "[::ffff:102:304]:8333".parse().unwrap(); + book.inject([ + sixto4_public, + nat64_public, + compatible_public, + siit_public, + nat64_local_public, + mapped_public, + ]); assert!( book.entry(&sixto4_public).is_some(), - "6to4 of a public IPv4 stays" + "6to4 of a public IPv4 stays when the suffix is zero" ); assert!( book.entry(&nat64_public).is_some(), "NAT64 of a public IPv4 stays" ); + assert!(book.entry(&compatible_public).is_some()); + assert!(book.entry(&siit_public).is_some()); + assert!(book.entry(&nat64_local_public).is_some()); + assert!(book.entry(&mapped_public).is_some()); let lan: SocketAddr = "10.0.0.1:8333".parse().unwrap(); assert!(book.add_learned(lan, 64)); assert!(book.entry(&lan).is_some()); From dda62123b3f2a3be6646476040ac56eddfe0826e Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Sun, 18 Oct 2026 16:34:40 -0700 Subject: [PATCH 15/18] net: do not relay Tor v2 or CJDNS outside fc00::/8 Those rows never become a NetAddr, so the unroutable check did not see them and the relay loop forwarded them. Unknown BIP155 network ids are still forwarded. Co-authored-by: Cursor --- COMPAT.md | 2 +- crates/rbitcoin-net/src/netaddr.rs | 16 +++++++ crates/rbitcoin-net/src/peer.rs | 11 +---- .../rbitcoin-net/src/peer_hostile_journey.rs | 42 +++++++++++++++++-- 4 files changed, 56 insertions(+), 15 deletions(-) diff --git a/COMPAT.md b/COMPAT.md index 11e01ce1ca..aa624d2572 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -68,7 +68,7 @@ Full `/tx/:txid` JSON still has `vin[]`. Electrum has no outspend-vin surface. | Admission policy | **Libre-relay-class** (0.1 sat/vB, no dust, full RBF). P2P admits any nVersion. RPC-submit (`sendrawtransaction` / `testmempoolaccept` / `submitpackage`) still rejects `"version"` outside 1/2. Feerate, RBF, min relay and `-blockmintxfee` use Core's sigop-adjusted size `max(weight, sigop_cost × 20)` (`--bytes-per-sigop`, `0` disables). Cluster and package count/size limits and the block weight budget count raw weight, unlike Core, so the adjustment never becomes a per-tx sigop cap. No 16,000 sigop-cost standardness cap; admission and template selection share a strict 80,000 minus `--block-reserved-sigops` budget (default reserve 400). P2P `accept_tx` has the minimum relay feerate and no absurd-fee or `-maxtxfee` cap; RPC submit keeps its own default of 10,000 sat/vB (`0` unlimited). That split is intentional: the signer chose the fee. Mempool fee math checks `u64` overflow (`bad-txns-txouttotal-toolarge`), not `MAX_MONEY`. Block connect rejects an output or an output sum above `Amount::MAX_MONEY`. A mainnet input sum cannot exceed that cap, because those coins were rejected at connect | Standardness + policy knobs (nVersion 1/2, `MAX_STANDARD_TX_SIGOPS_COST` 16,000, `-bytespersigop`) | | Compact blocks | BIP152 **v2** receive + reconstruct + `getblocktxn` serve. Fill is live mempool + orphanage + `extra_compact` (cap 100). Outbound extra prefill is **on** unless `--prefill-compact=0` (10 KiB cap, extra-pool last; generate / submit / NewPoWValid pack txs not in the live mempool without delaying forward) | v1/v2 high-bandwidth + `extra_txn` cache (`-blockreconstructionextratxn`); Core #35558 prefill still unmerged | | WTx inventory | BIP339 when peer also sends `wtxidrelay` | BIP339 | -| GetAddr | Core `MAX_ADDR_TO_SEND` / `MAX_PCT_ADDR_TO_SEND` (**1000** / **23%**), 24h per-bind cache. Named copies in `rbitcoin-net` — do not “improve” without a named reason to diverge. `MAX_ADDR_MAN` (8192) is **our** HashMap DoS cap and must stay above `1000/0.23`. DNS and fixed inject, IBD and steady-state addr gossip, getaddr replies, and peers-file load ignore unroutable clearnet (loopback, private, `0.0.0.0/8`, CGNAT, link-local, documentation including `3fff::/20`, benchmarking, class E, and the matching IPv6 ranges). 6to4, well-known NAT64 `64:ff9b::/96`, local NAT64 `64:ff9b:1::/48`, IPv4-compatible `::/96`, and SIIT `::ffff:0:0:0/96` stay only when the embedded IPv4 is routable and the non-IPv4 bits are zero. Teredo `2001::/32` is dropped. Addr gossip also drops port 0 except I2P, which is advertised with port 0. `addpeeraddress` and `--connect` do not. Onion, I2P, and CJDNS are not IP bogons | Core new/tried buckets (~80k); same 1000 / 23% | +| GetAddr | Core `MAX_ADDR_TO_SEND` / `MAX_PCT_ADDR_TO_SEND` (**1000** / **23%**), 24h per-bind cache. Named copies in `rbitcoin-net` — do not “improve” without a named reason to diverge. `MAX_ADDR_MAN` (8192) is **our** HashMap DoS cap and must stay above `1000/0.23`. DNS and fixed inject, IBD and steady-state addr gossip, getaddr replies, and peers-file load ignore unroutable clearnet (loopback, private, `0.0.0.0/8`, CGNAT, link-local, documentation including `3fff::/20`, benchmarking, class E, and the matching IPv6 ranges). 6to4, well-known NAT64 `64:ff9b::/96`, local NAT64 `64:ff9b:1::/48`, IPv4-compatible `::/96`, and SIIT `::ffff:0:0:0/96` stay only when the embedded IPv4 is routable and the non-IPv4 bits are zero. Teredo `2001::/32` is dropped. Addr gossip also drops port 0 except I2P (advertised with port 0), Tor v2, and CJDNS addresses outside `fc00::/8`. Unknown BIP155 network ids are still relayed. `addpeeraddress` and `--connect` do not. Onion, I2P, and CJDNS are not IP bogons | Core new/tried buckets (~80k); same 1000 / 23% | | Package submit | RPC `submitpackage` / Esplora `POST /txs/package` (no P2P package command) | BIP331 wire | | Pruning / GUI | Unpruned: `seqsigwit.body`. `--prune-seqsigwit`: watermark + `NETWORK_LIMITED`, kept witness is 288 height files under `store/seqsigwit.window/` plus a RAM cap (`--prune-seqsigwit-ram-threshold-bytes`, `0` = files only). Not a rolling stem. Not Core `-prune` of headers/txout | Supported | | Mining template RPC | `getblocktemplate` / `getmininginfo` / `prioritisetransaction` (selector; no stratum) | GBT + stratum / pool stack | diff --git a/crates/rbitcoin-net/src/netaddr.rs b/crates/rbitcoin-net/src/netaddr.rs index fea7679e87..161fef96a8 100644 --- a/crates/rbitcoin-net/src/netaddr.rs +++ b/crates/rbitcoin-net/src/netaddr.rs @@ -186,6 +186,22 @@ pub(crate) fn netaddr_unroutable_gossip(addr: NetAddr) -> bool { } } +/// Rows the addrv2 relay loop must not forward. +/// +/// Port 0 is not a dialable endpoint except I2P, which Core advertises with +/// port 0. Tor v2 and CJDNS outside `fc00::/8` never become a [`NetAddr`], +/// so the unroutable check would not see them. Unknown BIP155 network ids +/// are still forwarded. +pub(crate) fn addrv2_skip_relay(msg: &AddrV2Message) -> bool { + match &msg.addr { + AddrV2::I2p(_) => false, + AddrV2::TorV2(_) => true, + AddrV2::Cjdns(ip) if !is_cjdns_ip(*ip) => true, + _ if msg.port == 0 => true, + _ => NetAddr::from_addrv2(msg).is_some_and(netaddr_unroutable_gossip), + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum NetAddr { Ip(SocketAddr), diff --git a/crates/rbitcoin-net/src/peer.rs b/crates/rbitcoin-net/src/peer.rs index 5197f5c5a3..e9ea942572 100644 --- a/crates/rbitcoin-net/src/peer.rs +++ b/crates/rbitcoin-net/src/peer.rs @@ -3062,16 +3062,7 @@ fn on_addrv2( let mut batches: Vec> = vec![Vec::new(); neighbors.len()]; for addr in list.iter().take(allow) { - // Port 0 is not a dialable endpoint. `from_addrv2` returns - // None for it (so it is not stored), except I2P, which Core - // advertises with port 0. That None skips the unroutable - // guard, so drop the row here before it is forwarded. - if addr.port == 0 && !matches!(addr.addr, bitcoin::p2p::address::AddrV2::I2p(_)) { - continue; - } - if crate::NetAddr::from_addrv2(addr) - .is_some_and(crate::netaddr::netaddr_unroutable_gossip) - { + if crate::netaddr::addrv2_skip_relay(addr) { continue; } let key = addr_relay_key(addr); diff --git a/crates/rbitcoin-net/src/peer_hostile_journey.rs b/crates/rbitcoin-net/src/peer_hostile_journey.rs index 5801c8d782..4f2042bc4a 100644 --- a/crates/rbitcoin-net/src/peer_hostile_journey.rs +++ b/crates/rbitcoin-net/src/peer_hostile_journey.rs @@ -3,7 +3,7 @@ use bitcoin::p2p::message_blockdata::GetHeadersMessage; use bitcoin::p2p::message_network::VersionMessage; use bitcoin::p2p::ServiceFlags; use bitcoin::ScriptBuf; -use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; fn hostile_ver(addr: SocketAddr) -> VersionMessage { VersionMessage { @@ -80,11 +80,45 @@ async fn addrv2_gossip_drops_unroutable_clearnet() { addr: AddrV2::I2p([0x11; 32]), port: 0, }; + let tor_v2 = AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK, + addr: AddrV2::TorV2([0xab; 10]), + port: 8333, + }; + let cjdns_bad = AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK, + addr: AddrV2::Cjdns(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)), + port: 8333, + }; + let cjdns = AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK, + addr: AddrV2::Cjdns(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1)), + port: 8333, + }; + let unknown = AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK, + addr: AddrV2::Unknown(0x99, vec![1, 2, 3, 4]), + port: 8333, + }; let mut follow = PeerFollowState::new(); on_addrv2( &mut follow, Some(peer.as_ref()), - &[lan, public.clone(), cgnat, zero_port, i2p.clone()], + &[ + lan, + public.clone(), + cgnat, + zero_port, + i2p.clone(), + tor_v2, + cjdns_bad, + cjdns.clone(), + unknown.clone(), + ], ) .unwrap(); @@ -95,10 +129,10 @@ async fn addrv2_gossip_drops_unroutable_clearnet() { let NetworkMessage::AddrV2(sent) = got else { panic!("expected addrv2, got {got:?}"); }; - assert_eq!(sent, vec![public, i2p]); + assert_eq!(sent, vec![public, i2p, cjdns, unknown]); assert!( dst_rx.try_recv().is_err(), - "unroutable rows and port-0 clearnet are not relayed" + "unroutable rows, port-0 clearnet, Tor v2, and non-CJDNS are not relayed" ); let book = book.lock().unwrap_or_else(|e| e.into_inner()); let public_sock = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 8333); From 75daca8ca3cc6178f639a875726e25cee3574f55 Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Sun, 18 Oct 2026 16:47:46 -0700 Subject: [PATCH 16/18] net: drop the IPv6 benchmarking prefix 2001:2::/48 198.18.0.0/15 was already ignored. The IPv6 range was not, and its last 80 bits are free, so one peer could fill the address book with addresses that are not globally routed. Co-authored-by: Cursor --- COMPAT.md | 2 +- crates/rbitcoin-net/src/netaddr.rs | 12 +++++++++--- crates/rbitcoin-net/src/seeds.rs | 8 ++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/COMPAT.md b/COMPAT.md index aa624d2572..632640d869 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -68,7 +68,7 @@ Full `/tx/:txid` JSON still has `vin[]`. Electrum has no outspend-vin surface. | Admission policy | **Libre-relay-class** (0.1 sat/vB, no dust, full RBF). P2P admits any nVersion. RPC-submit (`sendrawtransaction` / `testmempoolaccept` / `submitpackage`) still rejects `"version"` outside 1/2. Feerate, RBF, min relay and `-blockmintxfee` use Core's sigop-adjusted size `max(weight, sigop_cost × 20)` (`--bytes-per-sigop`, `0` disables). Cluster and package count/size limits and the block weight budget count raw weight, unlike Core, so the adjustment never becomes a per-tx sigop cap. No 16,000 sigop-cost standardness cap; admission and template selection share a strict 80,000 minus `--block-reserved-sigops` budget (default reserve 400). P2P `accept_tx` has the minimum relay feerate and no absurd-fee or `-maxtxfee` cap; RPC submit keeps its own default of 10,000 sat/vB (`0` unlimited). That split is intentional: the signer chose the fee. Mempool fee math checks `u64` overflow (`bad-txns-txouttotal-toolarge`), not `MAX_MONEY`. Block connect rejects an output or an output sum above `Amount::MAX_MONEY`. A mainnet input sum cannot exceed that cap, because those coins were rejected at connect | Standardness + policy knobs (nVersion 1/2, `MAX_STANDARD_TX_SIGOPS_COST` 16,000, `-bytespersigop`) | | Compact blocks | BIP152 **v2** receive + reconstruct + `getblocktxn` serve. Fill is live mempool + orphanage + `extra_compact` (cap 100). Outbound extra prefill is **on** unless `--prefill-compact=0` (10 KiB cap, extra-pool last; generate / submit / NewPoWValid pack txs not in the live mempool without delaying forward) | v1/v2 high-bandwidth + `extra_txn` cache (`-blockreconstructionextratxn`); Core #35558 prefill still unmerged | | WTx inventory | BIP339 when peer also sends `wtxidrelay` | BIP339 | -| GetAddr | Core `MAX_ADDR_TO_SEND` / `MAX_PCT_ADDR_TO_SEND` (**1000** / **23%**), 24h per-bind cache. Named copies in `rbitcoin-net` — do not “improve” without a named reason to diverge. `MAX_ADDR_MAN` (8192) is **our** HashMap DoS cap and must stay above `1000/0.23`. DNS and fixed inject, IBD and steady-state addr gossip, getaddr replies, and peers-file load ignore unroutable clearnet (loopback, private, `0.0.0.0/8`, CGNAT, link-local, documentation including `3fff::/20`, benchmarking, class E, and the matching IPv6 ranges). 6to4, well-known NAT64 `64:ff9b::/96`, local NAT64 `64:ff9b:1::/48`, IPv4-compatible `::/96`, and SIIT `::ffff:0:0:0/96` stay only when the embedded IPv4 is routable and the non-IPv4 bits are zero. Teredo `2001::/32` is dropped. Addr gossip also drops port 0 except I2P (advertised with port 0), Tor v2, and CJDNS addresses outside `fc00::/8`. Unknown BIP155 network ids are still relayed. `addpeeraddress` and `--connect` do not. Onion, I2P, and CJDNS are not IP bogons | Core new/tried buckets (~80k); same 1000 / 23% | +| GetAddr | Core `MAX_ADDR_TO_SEND` / `MAX_PCT_ADDR_TO_SEND` (**1000** / **23%**), 24h per-bind cache. Named copies in `rbitcoin-net` — do not “improve” without a named reason to diverge. `MAX_ADDR_MAN` (8192) is **our** HashMap DoS cap and must stay above `1000/0.23`. DNS and fixed inject, IBD and steady-state addr gossip, getaddr replies, and peers-file load ignore unroutable clearnet (loopback, private, `0.0.0.0/8`, CGNAT, link-local, documentation including `3fff::/20`, benchmarking (`198.18.0.0/15` and `2001:2::/48`), class E, and the matching IPv6 ranges). 6to4, well-known NAT64 `64:ff9b::/96`, local NAT64 `64:ff9b:1::/48`, IPv4-compatible `::/96`, and SIIT `::ffff:0:0:0/96` stay only when the embedded IPv4 is routable and the non-IPv4 bits are zero. Teredo `2001::/32` is dropped. Addr gossip also drops port 0 except I2P (advertised with port 0), Tor v2, and CJDNS addresses outside `fc00::/8`. Unknown BIP155 network ids are still relayed. `addpeeraddress` and `--connect` do not. Onion, I2P, and CJDNS are not IP bogons | Core new/tried buckets (~80k); same 1000 / 23% | | Package submit | RPC `submitpackage` / Esplora `POST /txs/package` (no P2P package command) | BIP331 wire | | Pruning / GUI | Unpruned: `seqsigwit.body`. `--prune-seqsigwit`: watermark + `NETWORK_LIMITED`, kept witness is 288 height files under `store/seqsigwit.window/` plus a RAM cap (`--prune-seqsigwit-ram-threshold-bytes`, `0` = files only). Not a rolling stem. Not Core `-prune` of headers/txout | Supported | | Mining template RPC | `getblocktemplate` / `getmininginfo` / `prioritisetransaction` (selector; no stratum) | GBT + stratum / pool stack | diff --git a/crates/rbitcoin-net/src/netaddr.rs b/crates/rbitcoin-net/src/netaddr.rs index 161fef96a8..b92e9ff040 100644 --- a/crates/rbitcoin-net/src/netaddr.rs +++ b/crates/rbitcoin-net/src/netaddr.rs @@ -52,9 +52,9 @@ pub(crate) fn addr_allowed(addr: NetAddr, only: &[OnlyNet]) -> bool { /// Prefixes `Ipv6Addr` does not classify on 1.95. /// -/// `2001:db8::/32` and `3fff::/20` documentation, `2001:10::/28` ORCHID, -/// `2001:20::/28` ORCHIDv2, and `100::/64` discard. `is_documentation` is -/// still unstable. +/// `2001:db8::/32` and `3fff::/20` documentation, `2001:2::/48` benchmarking, +/// `2001:10::/28` ORCHID, `2001:20::/28` ORCHIDv2, and `100::/64` discard. +/// `is_documentation` is still unstable. fn ipv6_special_unroutable(v: Ipv6Addr) -> bool { let o = v.octets(); if o[0] == 0x20 && o[1] == 0x01 && o[2] == 0x0d && o[3] == 0xb8 { @@ -70,6 +70,12 @@ fn ipv6_special_unroutable(v: Ipv6Addr) -> bool { if o[0] == 0x20 && o[1] == 0x01 && o[2] == 0x00 && (o[3] & 0xf0) == 0x20 { return true; } + // RFC 5180 benchmarking. The IPv4 twin is `198.18.0.0/15`. The last + // 80 bits are free, so the whole prefix has to go. + if o[0] == 0x20 && o[1] == 0x01 && o[2] == 0x00 && o[3] == 0x02 && o[4] == 0x00 && o[5] == 0x00 + { + return true; + } o[0] == 0x01 && o[1] == 0x00 && o[2..8].iter().all(|&b| b == 0) } diff --git a/crates/rbitcoin-net/src/seeds.rs b/crates/rbitcoin-net/src/seeds.rs index 1acab07e1e..e818096c34 100644 --- a/crates/rbitcoin-net/src/seeds.rs +++ b/crates/rbitcoin-net/src/seeds.rs @@ -1038,6 +1038,8 @@ mod tests { "[64:ff9b:1:1::102:304]:8333".parse().unwrap(), "[2001:0:102:304::f5ff:fffe]:8333".parse().unwrap(), "[3fff::1]:8333".parse().unwrap(), + "[2001:2::1]:8333".parse().unwrap(), + "[2001:2:0:ffff:ffff:ffff:ffff:ffff]:8333".parse().unwrap(), public, ]); assert_eq!(book.len(), 1); @@ -1048,6 +1050,7 @@ mod tests { let siit_public: SocketAddr = "[::ffff:0:102:304]:8333".parse().unwrap(); let nat64_local_public: SocketAddr = "[64:ff9b:1::102:304]:8333".parse().unwrap(); let mapped_public: SocketAddr = "[::ffff:102:304]:8333".parse().unwrap(); + let bench_outside: SocketAddr = "[2001:2:1::1]:8333".parse().unwrap(); book.inject([ sixto4_public, nat64_public, @@ -1055,6 +1058,7 @@ mod tests { siit_public, nat64_local_public, mapped_public, + bench_outside, ]); assert!( book.entry(&sixto4_public).is_some(), @@ -1068,6 +1072,10 @@ mod tests { assert!(book.entry(&siit_public).is_some()); assert!(book.entry(&nat64_local_public).is_some()); assert!(book.entry(&mapped_public).is_some()); + assert!( + book.entry(&bench_outside).is_some(), + "2001:2:1::/48 is outside the benchmarking prefix" + ); let lan: SocketAddr = "10.0.0.1:8333".parse().unwrap(); assert!(book.add_learned(lan, 64)); assert!(book.entry(&lan).is_some()); From a2a3dd202a83e30c24ab1b842f74f207e4a8cad1 Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Sun, 18 Oct 2026 17:07:44 -0700 Subject: [PATCH 17/18] test: read the loopback dial failure from the peers file Load drops 127.0.0.1, so the refused-connect test no longer sees the fail bit after AddrMan::load. The flush is still in the file. Co-authored-by: Cursor --- crates/rbitcoin-node/src/run.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index d886ac012b..c62395eed7 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -3023,10 +3023,28 @@ mod tests { !target_hit.load(Ordering::SeqCst), "SOCKS dial must not open a direct TCP connection to the target" ); - let book = AddrMan::load(&dir.join("peers")).expect("peers saved"); + let peers_path = dir.join("peers"); + let text = std::fs::read_to_string(&peers_path).expect("peers saved"); + let row = text + .lines() + .find(|line| line.starts_with(&format!("{connect} "))) + .unwrap_or_else(|| { + panic!("IBD must flush the dial failure into the saved peer book\n{text}") + }); + let flags = row + .split_whitespace() + .nth(1) + .and_then(|s| s.strip_prefix("0x")) + .and_then(|hex| u8::from_str_radix(hex, 16).ok()) + .unwrap_or(0); assert!( - book.flags(&connect).failed_last_connect(), - "IBD must flush the dial failure into the saved peer book" + flags & rbitcoin_net::PeerFlags::FAILED_LAST_CONNECT != 0, + "IBD must flush the dial failure into the saved peer book, row {row}" + ); + let book = AddrMan::load(&peers_path).expect("peers saved"); + assert!( + book.entry(&connect).is_none(), + "loopback is not restored from the peers file" ); let _ = std::fs::remove_dir_all(&dir); } From 6710b9e34b8631696d576780e2aeeeccb3ef1af4 Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Sun, 18 Oct 2026 17:17:02 -0700 Subject: [PATCH 18/18] test: accept a loopback --connect only in the peers file node_listen_and_exit loaded 127.0.0.1:1 and expected the fail bit. Load drops loopback, so the flush is checked on the file row instead. Co-authored-by: Cursor --- crates/rbitcoin-test/tests/cross_surface.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/rbitcoin-test/tests/cross_surface.rs b/crates/rbitcoin-test/tests/cross_surface.rs index d5f3660eda..8abd4424fd 100644 --- a/crates/rbitcoin-test/tests/cross_surface.rs +++ b/crates/rbitcoin-test/tests/cross_surface.rs @@ -2580,8 +2580,23 @@ async fn node_listen_and_exit() { assert!(mempool.exists(), "run_p2p opens the mempool"); let book = rbitcoin_net::AddrMan::load(&peers).expect("junk book replaced"); assert!( - book.flags(&dead).failed_last_connect(), - "the refused --connect is in the saved book" + book.entry(&dead).is_none(), + "loopback is not restored from the peers file" + ); + let text = std::fs::read_to_string(&peers).expect("peers saved"); + let row = text + .lines() + .find(|line| line.starts_with(&format!("{dead} "))) + .unwrap_or_else(|| panic!("the refused --connect is in the saved book\n{text}")); + let flags = row + .split_whitespace() + .nth(1) + .and_then(|s| s.strip_prefix("0x")) + .and_then(|hex| u8::from_str_radix(hex, 16).ok()) + .unwrap_or(0); + assert!( + flags & rbitcoin_net::PeerFlags::FAILED_LAST_CONNECT != 0, + "the refused --connect is in the saved book, row {row}" ); // The asmap is in place now, the saved book loads, and the wallet servers