Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
16b2d82
docs: record intentional fee, AEAD, and fuzz calls
rearden-grok[bot] Oct 18, 2026
025ca67
rpc: refuse a cookie wider than --rpc-cookie-perms
rearden-grok[bot] Oct 18, 2026
d031955
deps: deny duplicate crate versions in qc
rearden-grok[bot] Oct 18, 2026
22fe328
build: compile mutants skips only on the nightly feature
rearden-grok[bot] Oct 18, 2026
66fba93
net: drop unroutable addresses from seeds and gossip
rearden-grok[bot] Oct 18, 2026
3024eef
net: cap remembered fork tips at 64
rearden-grok[bot] Oct 18, 2026
28217b3
fuzz: honor FUZZ_SANITIZER and add a Sunday UBSan run
rearden-grok[bot] Oct 18, 2026
bf00661
rpc: keep the cookie mode check Unix-only
rearden-grok[bot] Oct 18, 2026
e0ae3b4
net: drop CGNAT, benchmark, reserved, and special IPv6 from gossip
reardencode Oct 18, 2026
345087f
net: keep unroutable clearnet out of steady-state gossip
reardencode Oct 18, 2026
784c536
rpc: cap cookie group and all at 0640 and 0644
reardencode Oct 18, 2026
5b27597
net: drop 0/8 and 6to4 or NAT64 addresses that embed a bogon
reardencode Oct 18, 2026
2940ca7
net: do not relay port-0 addresses except I2P
reardencode Oct 18, 2026
c559249
net: keep one slot per embedded IPv4, and drop Teredo and 3fff::/20
reardencode Oct 18, 2026
dda6212
net: do not relay Tor v2 or CJDNS outside fc00::/8
reardencode Oct 18, 2026
75daca8
net: drop the IPv6 benchmarking prefix 2001:2::/48
reardencode Oct 18, 2026
a2a3dd2
test: read the loopback dial failure from the peers file
reardencode Oct 19, 2026
6710b9e
test: accept a loopback --connect only in the peers file
reardencode Oct 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .cargo/mutants.toml
Original file line number Diff line number Diff line change
@@ -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"
46 changes: 46 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
4 changes: 2 additions & 2 deletions COMPAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,10 @@ 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% |
| 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 |
Expand Down
6 changes: 5 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
16 changes: 16 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,23 @@ 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.

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
Expand Down
9 changes: 9 additions & 0 deletions changelog.d/rpc-cookie-perms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
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 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.
1 change: 1 addition & 0 deletions crates/rbitcoin-bench/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ path = "src/main.rs"
required-features = ["cli"]

[features]
mutants = []
default = []
cli = []

Expand Down
3 changes: 3 additions & 0 deletions crates/rbitcoin-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
4 changes: 3 additions & 1 deletion crates/rbitcoin-consensus/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion crates/rbitcoin-consensus/src/script/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
3 changes: 3 additions & 0 deletions crates/rbitcoin-electrum/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
3 changes: 3 additions & 0 deletions crates/rbitcoin-esplora/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
1 change: 1 addition & 0 deletions crates/rbitcoin-log/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
1 change: 1 addition & 0 deletions crates/rbitcoin-mempool/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []

Expand Down
3 changes: 3 additions & 0 deletions crates/rbitcoin-net/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
73 changes: 68 additions & 5 deletions crates/rbitcoin-net/src/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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! {
Expand Down Expand Up @@ -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<BlockHash>,
}

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<Item = BlockHash> + '_ {
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<HashSet<BlockHash>>,
paths: RwLock<Vec<Vec<BlockHash>>>,
Expand Down Expand Up @@ -369,7 +404,7 @@ pub struct ChainHub {
held_bodies: RwLock<HeldBodies>,
precious: RwLock<Option<BlockHash>>,
/// Losing tips after a most-work reorg (hashes only). Bodies via archive.
fork_tips: RwLock<HashSet<BlockHash>>,
fork_tips: RwLock<ForkTips>,
/// Header-only tips (`submitheader` / P2P headers): hash → (prev, height).
header_tips: RwLock<HeaderTips>,
/// Set around `accept_branch` connect so each `TipEvent` carries branch length.
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2086,7 +2121,7 @@ impl ChainHub {
fn try_apply_after_invalidate(&self) -> Result<Option<AcceptOutcome>, NetError> {
let inv = self.invalidated.set.read().unwrap().clone();
let precious = *self.precious.read().unwrap();
let mut starts: Vec<BlockHash> = self.fork_tips.read().unwrap().iter().copied().collect();
let mut starts: Vec<BlockHash> = self.fork_tips.read().unwrap().iter().collect();
starts.extend(self.held_bodies.read().unwrap().keys());
if let Some(p) = precious {
if !starts.contains(&p) {
Expand Down Expand Up @@ -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<BlockHash> = (0..65u8)
.map(|i| BlockHash::from_byte_array([i; 32]))
.collect();
for hash in &hashes {
tips.insert(*hash);
}
let kept: Vec<BlockHash> = 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;
Expand Down
11 changes: 10 additions & 1 deletion crates/rbitcoin-net/src/ibd/events/confirm_reject_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions crates/rbitcoin-net/src/ibd/events/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading