From c2cb86b227f27266af5c7278402a2db87b0803bd Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Mon, 28 Sep 2026 07:03:51 -0700 Subject: [PATCH 1/5] test: one run_p2p datadir for the startup arms node_listen_and_exit restarts one operator datadir through run_p2p while its one --connect peer refuses. A junk peers file and a missing asmap start an empty book, and the saved book then records the refused peer. The next start loads that book and a valid ip_asn.dat, and Esplora, Electrum and RPC answer at genesis until stop. An Electrum port another process holds only warns. Without --connect and with seeds on, regtest resolves none and the node exits short of tip mode. After a --prune-seqsigwit start, an unpruned start refuses. Ten run_p2p_* tests each opened a fresh datadir for one of those arms and asserted only that run_p2p returned. The prune refuse called apply_startup_index_mode directly; it now goes through run_p2p. Co-Authored-By: Claude Opus 5.5 --- TESTING.md | 3 +- crates/rbitcoin-node/src/run.rs | 241 -------------------- crates/rbitcoin-test/tests/cross_surface.rs | 111 +++++++++ 3 files changed, 113 insertions(+), 242 deletions(-) diff --git a/TESTING.md b/TESTING.md index ebd210cd6..740548e4a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -344,6 +344,7 @@ Prefer **one high-level scenario** per behavior cluster. Delete lower-level test | `electrum_idle_timeout_disconnects_quiet_client` | Electrum | Idle timeout closes a quiet socket | | `esplora_broadcast_visible_in_rpc_and_electrum` | Node + Electrum + Esplora + RPC | One `run_p2p` datadir: HTTP `sendrawtransaction` / `testmempoolaccept` (allowed, missing-or-spent, exact 100 sat/kvB min-relay accept + one-sat-under reject, RBF one-sat-short incremental reject + exact incremental accept); Esplora `POST /tx` parent and mempool child appear in `getrawmempool` and Electrum mempool/history (`fee` on unconfirmed, including child `height = -1`); Electrum `listunspent` of that child is `height=-1` and the parent UTXO drops; process `gettxout` / `getchaintips`; Esplora `POST /txs/package` 1p1c (including parent-alone below min-relay + paying child), 25-tx accept, 26-tx and over-weight `package too large`; serving-only `submitpackage` refuses (relay off); live `GET /mempool` / `/mempool/txids` / `/mempool/recent` / `/fee-estimates` (depths 1/5/144/504/1008 present and > 0; under-full near can be min-relay 0.1 sat/vB); process `getmempoolancestors` / descendants / cluster / `gettxspendingprevout` / feerate diagram / verbose `getrawmempool` on that 1p1c; `waitforblockheight` timeout=0 while behind returns the live tip; GBT stale `longpollid` is immediate; current id / `waitfornewblock` / `waitforblockheight` wake on the pad `generate`; `getblockhash` tip ok / tip+1 `-8`; unknown `getblock` `-5`; verbosity 0 hex and 2 vin/vout; `GET /blocks` 10 newest, `/blocks/0` and `/blocks/:tip` (start past tip clamps); `/block/:hash/txs/:start` last page shorter than 25, one-past last page `[]` (not 404), unknown hash 404; `/block/:hash/txids` + coinbase merkle-proof + unspent `outspend/0`; `/tx/:id/outspends`; `/block` JSON/raw/status/`txid/0` (OOB 404); `/tx/:id/raw` vs hex; merkleblock-proof; `/block-height` (missing 404); `/block/:hash/header` 160 hex; `/tx/:id/status` + full JSON (`unknown` OP_TRUE type, coinbase vin) and missing-tx 404s; OP_TRUE scripthash info/summary/utxo/`txs/chain` cursor and combined `/txs`. Keep crate no-hub mempool/fees/POST 503, reconstruct meters, header wire match, and `tx_status_json`; Esplora `/tx/:id/status` confirms the package parent on generate; `generate` includes those txs (parent before child) then leaves IBD (relay on); `scantxoutset` drops the spent coinbase and still sees a non-coinbase unspent; `submitpackage` maxfeerate reject, 1p1c success, already-in-mempool continue, below-min-relay parent + paying child success, 26-tx / over-weight `package too large`; immature coinbase sendraw rejects. Keep `accept.rs` reject units, package JSON errors, RPC dry-run orphan-count, leftover `gettxout` include_mempool / disconnected / leftover, `generate_selects_chained_mempool_parent_first`, `submitpackage_child_fail_keeps_parent`, maxburn `submitpackage`, and wait-on-stop units. Unix `--rpc-socket` (mode 0660, no datadir `rpc.sock`) `getblockcount` without Authorization; TCP `GET`/`POST /internal/mempool/txs`; `GET /internal/block/:hash/txs` full list vs public 25/page; `POST /internal/txs/outspends/by-txid` same-length unknown `[]` slot; `GET /address-prefix/bc1` **404**; unauthenticated Core REST on the RPC listener (`chaininfo`, block hash/headers/block/tx, mempool info/contents, `getutxos`, `deploymentinfo`, basic `blockfilter` bin/hex/json) and `getblockfilter` with `--block-filter-index` | | `fee_history_backfills_from_the_chain_when_relay_starts` | Node + RPC | `run_p2p` over a datadir whose only fee-paying block predates startup; `generate` leaves IBD and turns relay on; `estimatesmartfee 144` answers that block's rate from the chain backfill, not from blocks mined after start | +| `node_listen_and_exit` | Node + Electrum + Esplora + RPC | One `run_p2p` datadir, restarted with its one `--connect` refusing (a pinned connect at genesis still enters tip mode): a junk `peers` file and a missing `--asmap` start an empty book and exit, and the saved book records the refused connect; the next start loads that book and a valid `ip_asn.dat`, and Esplora, Electrum, and RPC answer at genesis until `stop`; an Electrum port another process holds warns and the node still exits; without `--connect` and with seeds on, regtest resolves none and the node exits short of tip mode; after a `--prune-seqsigwit` start, an unpruned start refuses. Live peers are `node_run_p2p_short` | | `two_node_header_and_block_sync` | P2P (**default**) | Seeder → peer genesis+1 IBD; peer `last_write` meter. Empty `headers` lag keep-sync is `apply_peer_event_body_and_control_surface`; drained-path EOF `headers_done` is `apply_peer_event_block_framed_bq_horizon_and_headers_done`. 8-block dual-seeder stays `ibd_two_peers` | | `p2p_timeout_getaddr_and_keepalive_ping` | P2P (**default**) | One pad: v1-magic inbound drops at `peertimeout=1`, obsolete VERSION and pre-verack ping close the peer, full-relay GetAddr cache 1000, headers-sync stall replace, self-connect refuses, AddrFetch `getaddr`/`addrv2` (no `getheaders`), one keepalive ping/pong. Handshake **format** needles stay. Sole-preferred stall KEEP stays a PeerHub unit (`noban_headers_timeout_clears_awaiting_so_a_new_getheaders_can_send`: CIDR `noban@127.0.0.1` and hub `--trusted`). | | `hostile_peer_session` | P2P (**default**, crate) | One lib entry in `peer::tests`, one in `chain::tests`, one in `assign::tests`. Header cap, send budget, one-shot `getaddr`, addr relay to one or two peers, zero-prev not held, witness padding and time-too-new not cached invalid, getdata stops at the byte budget. `tip_script_pres_skips_only_matching_wtxid` stays its own test (mempool graph, not the peer session). | @@ -361,7 +362,7 @@ Prefer **one high-level scenario** per behavior cluster. Delete lower-level test | `ibd_two_peers` | P2P (**default**) | Dual live seeders, 8-block IBD | | `tip_follow_after_ibd` | P2P (**default**) | After IBD, follow + one new tip via inv/headers. With the filter index on, IBD confirm writes no basic filters; `rbtc-idx-wb` materializes them to the tip (its caught-up callback fires once, at the tip), then seals the followed block. With `--sp-tweaks` too: the builder brings both indexes to 5 and is stopped; a followed block with no builder running moves neither (no confirm path writes index data); a new builder seals 6, then follows 7 | | `tip_follow_getheaders_catches_missed_blocks` | P2P (**default**) | Blocks mined while disconnected fill via post-connect `getheaders` | -| `node_run_p2p_short` | Node (**default**) | Product `run_p2p` `--blocks-only` `--connect` to a live seeder (`--max-tip-age` so the 3-block pad is not stale IBD); process `getpeerinfo` / `getconnectioncount` / `getnetworkinfo` / `getnettotals` / `ping` while connected (v2 outbound-full-relay; handshake `startingheight` equals the seeder tip; `timeoffset` present; `synced_headers`/`synced_blocks` stay `-1` until the peer announces a header hash (empty getheaders at tip does not copy VERSION height); `servicesnames` present; `getnetworkinfo.timeoffset` present); after catch-up `localrelay` / mempool `relay_enabled` stay false and `sendrawtransaction` is not `relay disabled`; Electrum `broadcast` and Esplora `POST /tx` admit decode/consensus errors (not hub-missing / not `relay disabled`); `addconnection inbound` refuses; `disconnectnode` unknown `nodeid` / empty params error then a real addr clears `getpeerinfo`; `addnode onetry` reconnects as `manual`; seeder inbound `tx` then disconnects. Exit via `stop`. `max_run_secs=0` stays a node-crate unit. Mock-clock `timeoffset` median (odd N, even N upper-middle, inbound-only 0, peer clock behind), connecting dummy `-1`, header-only vs connected `synced_blocks`, query-without-chain, `pingwait` / `NETWORK_LIMITED` / `noban` stay RPC guts | +| `node_run_p2p_short` | Node (**default**) | Product `run_p2p` `--blocks-only` `--connect` to a live seeder (`--max-tip-age` so the 3-block pad is not stale IBD); process `getpeerinfo` / `getconnectioncount` / `getnetworkinfo` / `getnettotals` / `ping` while connected (v2 outbound-full-relay; handshake `startingheight` equals the seeder tip; `timeoffset` present; `synced_headers`/`synced_blocks` stay `-1` until the peer announces a header hash (empty getheaders at tip does not copy VERSION height); `servicesnames` present; `getnetworkinfo.timeoffset` present); after catch-up `localrelay` / mempool `relay_enabled` stay false and `sendrawtransaction` is not `relay disabled`; Electrum `broadcast` and Esplora `POST /tx` admit decode/consensus errors (not hub-missing / not `relay disabled`); `addconnection inbound` refuses; `disconnectnode` unknown `nodeid` / empty params error then a real addr clears `getpeerinfo`; `addnode onetry` reconnects as `manual`; seeder inbound `tx` then disconnects. Exit via `stop`. `max_run_secs=0` is `node_listen_and_exit`. Mock-clock `timeoffset` median (odd N, even N upper-middle, inbound-only 0, peer clock behind), connecting dummy `-1`, header-only vs connected `synced_blocks`, query-without-chain, `pingwait` / `NETWORK_LIMITED` / `noban` stay RPC guts | Removed (covered by the rows above): `confirm_cross_block_prevout_without_tx_head`, `double_archive_keeps_tx_height_for_coinbase_maturity`, `mega_batch_duplicate_header_is_idempotent`, diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index 29c698401..02aba01a4 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -2002,31 +2002,6 @@ mod tests { .with_tiny_heads() } - #[test] - fn startup_refuses_non_pruned_config_on_pruned_datadir() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("rbitcoin-prune-refuse-{nanos}")); - std::fs::create_dir_all(&dir).unwrap(); - let store = dir.join("store"); - let q = Query::open_or_create_tiny(&store).unwrap(); - q.set_prune_seqsigwit(true).unwrap(); - q.set_pruneheight(Some(rbitcoin_primitives::Height(0))) - .unwrap(); - let mut cfg = tiny_regtest(&dir); - cfg.prune_seqsigwit = false; - let err = apply_startup_index_mode(&q, &cfg, 0) - .unwrap_err() - .to_string(); - assert!( - err.contains("pruned-seqsigwit") || err.contains("--prune-seqsigwit"), - "{err}" - ); - let _ = std::fs::remove_dir_all(&dir); - } - /// Perf (5s) and RPC-stop (50ms) ticks must still evaluate stale redial. /// A one-shot sleep in the same `select!` is reset on every such wake. #[test] @@ -2662,29 +2637,6 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - #[tokio::test] - async fn run_p2p_no_peers_exits_after_catchup() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("rbitcoin-run-p2p-{nanos}")); - let mut cfg = tiny_regtest(&dir).with_p2p_listen("127.0.0.1:0".parse().unwrap()); - cfg.listen.use_seeds = false; - cfg.listen.connect.clear(); - cfg.max_run_secs = Some(0); // exit after catch-up / tip mode - cfg.smoke = false; - cfg.mempool.bytes_per_sigop = Some(0); - let mempool_path = cfg.mempool_path(); - // Bound runtime so a hang fails the test suite instead of blocking. - // max_run_secs=0 should exit immediately after catch-up; keep bound tight. - let result = tokio::time::timeout(Duration::from_secs(15), run_p2p(cfg)).await; - assert!(result.is_ok(), "run_p2p timed out"); - result.unwrap().expect("run_p2p ok with no peers"); - assert!(mempool_path.exists(), "run_p2p opens the mempool"); - let _ = std::fs::remove_dir_all(&dir); - } - #[tokio::test] async fn cancelled_completes_after_request() { let sd = Shutdown::new(); @@ -2703,67 +2655,6 @@ mod tests { j.await.unwrap(); } - #[tokio::test] - async fn run_p2p_milestone_and_electrum() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("rbitcoin-run-p2p-el-{nanos}")); - let mut cfg = tiny_regtest(&dir).with_p2p_listen("127.0.0.1:0".parse().unwrap()); - cfg.listen.use_seeds = false; - cfg.listen.connect.clear(); - cfg.milestone_height = 100; // exercise milestone log branch - cfg.shindex = true; - cfg.listen.electrum = Some("127.0.0.1:0".parse().unwrap()); - // max_run_secs=0 exits after catch-up/tip (tip-follow loop uses 60s poll sleeps). - cfg.max_run_secs = Some(0); - let result = tokio::time::timeout(Duration::from_secs(15), run_p2p(cfg)).await; - assert!(result.is_ok(), "run_p2p timed out"); - result.unwrap().expect("run_p2p with electrum"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[tokio::test] - async fn run_p2p_with_esplora_listen() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("rbitcoin-run-p2p-esp-{nanos}")); - let mut cfg = tiny_regtest(&dir).with_p2p_listen("127.0.0.1:0".parse().unwrap()); - cfg.listen.use_seeds = false; - cfg.listen.connect.clear(); - cfg.shindex = true; - cfg.listen.esplora = Some(EsploraListen::Tcp("127.0.0.1:0".parse().unwrap())); - cfg.max_run_secs = Some(0); - let result = tokio::time::timeout(Duration::from_secs(15), run_p2p(cfg)).await; - assert!(result.is_ok(), "run_p2p timed out"); - result.unwrap().expect("run_p2p with esplora"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[tokio::test] - async fn run_p2p_bad_connect_peer_still_exits() { - // Explicit dead --connect so IBD/follow attempts are exercised, then exit. - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("rbitcoin-run-p2p-conn-{nanos}")); - let mut cfg = tiny_regtest(&dir).with_p2p_listen("127.0.0.1:0".parse().unwrap()); - cfg.listen.use_seeds = false; - // Blackhole / closed port: connect fails fast under FOLLOW_CONNECT_SECS. - cfg.listen.connect = vec!["127.0.0.1:1".parse().unwrap()]; - cfg.max_run_secs = Some(0); - // Dead connect should fail fast (FOLLOW_CONNECT_SECS); 20s bound for hang detection. - let result = tokio::time::timeout(Duration::from_secs(20), run_p2p(cfg)).await; - assert!(result.is_ok(), "run_p2p timed out"); - // Incomplete IBD is ok (warn path); should not hang. - let _ = result.unwrap(); - let _ = std::fs::remove_dir_all(&dir); - } - /// The `IbdConfig` literal in `run_ibd_or_skip` is not built by the config /// journey, so this drives `run_p2p`. A refused connect must flush a fail /// mark into the saved book, and the SOCKS proxy must be the socket dialed. @@ -2826,43 +2717,6 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - #[tokio::test] - async fn run_p2p_missing_asmap_still_starts() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("rbitcoin-run-p2p-asmap-miss-{nanos}")); - let mut cfg = tiny_regtest(&dir).with_p2p_listen("127.0.0.1:0".parse().unwrap()); - cfg.listen.use_seeds = false; - cfg.listen.connect.clear(); - cfg.asmap = Some(dir.join("no-such-asmap")); - cfg.max_run_secs = Some(0); - let result = tokio::time::timeout(Duration::from_secs(15), run_p2p(cfg)).await; - assert!(result.is_ok(), "run_p2p timed out"); - result.unwrap().expect("missing asmap must not panic"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[tokio::test] - async fn run_p2p_valid_asmap_starts() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("rbitcoin-run-p2p-asmap-ok-{nanos}")); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("ip_asn.dat"), rbitcoin_net::TWO_PREFIX_ASMAP).unwrap(); - let mut cfg = tiny_regtest(&dir).with_p2p_listen("127.0.0.1:0".parse().unwrap()); - cfg.listen.use_seeds = false; - cfg.listen.connect.clear(); - cfg.max_run_secs = Some(0); - let result = tokio::time::timeout(Duration::from_secs(15), run_p2p(cfg)).await; - assert!(result.is_ok(), "run_p2p timed out"); - result.unwrap().expect("valid asmap start"); - let _ = std::fs::remove_dir_all(&dir); - } - #[test] fn enter_tip_mode_warns_on_leftover_runs_dir() { use rbitcoin_query::IndexMode; @@ -2902,57 +2756,6 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - #[tokio::test] - async fn run_p2p_with_peers_file_and_electrum() { - use rbitcoin_net::AddrMan; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("rbitcoin-run-p2p-peers-{nanos}")); - std::fs::create_dir_all(&dir).unwrap(); - // Non-empty peers book so load path logs address count. - let mut am = AddrMan::new(); - am.add("127.0.0.1:18444".parse().unwrap()); - am.add("127.0.0.1:18445".parse().unwrap()); - am.save(&dir.join("peers")).unwrap(); - - let mut cfg = tiny_regtest(&dir).with_p2p_listen("127.0.0.1:0".parse().unwrap()); - cfg.listen.use_seeds = false; - // Peers file is loaded for bookkeeping; do not dial those addrs as --connect - // (would stall IBD). Empty connect + no seeds → catch-up complete immediately. - cfg.listen.connect.clear(); - cfg.max_run_secs = Some(0); - cfg.shindex = true; - cfg.listen.electrum = Some("127.0.0.1:0".parse().unwrap()); - cfg.milestone_height = 50; - let result = tokio::time::timeout(Duration::from_secs(15), run_p2p(cfg)).await; - assert!(result.is_ok(), "run_p2p timed out"); - result.unwrap().expect("run_p2p peers+electrum"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[tokio::test] - async fn run_p2p_corrupt_peers_and_dead_connect() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("rbitcoin-run-p2p-badpeers-{nanos}")); - std::fs::create_dir_all(&dir).unwrap(); - // Corrupt peers file → load error branch starts empty book. - std::fs::write(dir.join("peers"), b"not-a-valid-peers-blob\xff\x00").unwrap(); - - let mut cfg = tiny_regtest(&dir).with_p2p_listen("127.0.0.1:0".parse().unwrap()); - cfg.listen.use_seeds = false; - cfg.listen.connect = vec!["127.0.0.1:1".parse().unwrap()]; - cfg.max_run_secs = Some(0); - let result = tokio::time::timeout(Duration::from_secs(20), run_p2p(cfg)).await; - assert!(result.is_ok(), "run_p2p timed out"); - let _ = result.unwrap(); // incomplete IBD ok - let _ = std::fs::remove_dir_all(&dir); - } - #[tokio::test] async fn cancelled_waits_for_request_race() { // Cover the while !requested re-check after spurious notify. @@ -2968,50 +2771,6 @@ mod tests { j.await.unwrap(); } - /// `use_seeds=true` on regtest resolves empty seed set (covers seed inject path). - #[tokio::test] - async fn run_p2p_use_seeds_regtest_empty() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("rbitcoin-run-p2p-seeds-{nanos}")); - let mut cfg = tiny_regtest(&dir).with_p2p_listen("127.0.0.1:0".parse().unwrap()); - cfg.listen.use_seeds = true; // regtest: resolve_all_seeds → empty - cfg.listen.connect.clear(); - cfg.max_run_secs = Some(0); - cfg.milestone_height = 1; // log milestone branch - let result = tokio::time::timeout(Duration::from_secs(15), run_p2p(cfg)).await; - assert!(result.is_ok(), "run_p2p timed out"); - result.unwrap().expect("run_p2p seeds regtest"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Electrum bind failure (port already taken / invalid) → warn path, still exits. - #[tokio::test] - async fn run_p2p_electrum_bind_fail_warns() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("rbitcoin-run-p2p-el-fail-{nanos}")); - // Hold a port so electrum bind fails. - let held = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = held.local_addr().unwrap(); - let mut cfg = tiny_regtest(&dir).with_p2p_listen("127.0.0.1:0".parse().unwrap()); - cfg.listen.use_seeds = false; - cfg.listen.connect.clear(); - cfg.shindex = true; - cfg.listen.electrum = Some(addr); // already bound → fail - cfg.max_run_secs = Some(0); - let result = tokio::time::timeout(Duration::from_secs(15), run_p2p(cfg)).await; - assert!(result.is_ok(), "run_p2p timed out"); - // Bind fail is non-fatal warn; run should still complete. - result.unwrap().expect("run_p2p despite electrum fail"); - drop(held); - let _ = std::fs::remove_dir_all(&dir); - } - #[tokio::test] async fn electrum_i2p_forward_when_sam_incoming() { use std::sync::{Arc, Mutex}; diff --git a/crates/rbitcoin-test/tests/cross_surface.rs b/crates/rbitcoin-test/tests/cross_surface.rs index 6519f0449..782b6e05f 100644 --- a/crates/rbitcoin-test/tests/cross_surface.rs +++ b/crates/rbitcoin-test/tests/cross_surface.rs @@ -1948,3 +1948,114 @@ async fn esplora_broadcast_visible_in_rpc_and_electrum() { Err(_) => panic!("run_p2p did not exit after stop"), } } + +/// Nothing listens here. A pinned `--connect` at genesis still enters tip mode. +const DEAD_CONNECT: &str = "127.0.0.1:1"; + +/// `run_p2p` on regtest with an ephemeral P2P bind, no seeds, the one +/// `--connect` down, and `--max-run-secs 0`: exit once tip entry ends. +fn listen_and_exit_cfg(datadir: &std::path::Path) -> NodeConfig { + let mut cfg = NodeConfig::default() + .with_datadir(datadir) + .with_network(Network::Regtest) + .with_tiny_heads() + .with_p2p_listen("127.0.0.1:0".parse().unwrap()); + cfg.listen.use_seeds = false; + cfg.listen.connect = vec![DEAD_CONNECT.parse().unwrap()]; + cfg.max_run_secs = Some(0); + cfg +} + +async fn start_and_exit(cfg: NodeConfig) -> Result<(), rbitcoin_node::NodeError> { + tokio::time::timeout(Duration::from_secs(30), run_p2p(cfg)) + .await + .expect("run_p2p did not exit") +} + +/// One operator datadir restarted through the startup arms `run_p2p` owns +/// while its one `--connect` peer is down: a junk peer book, a missing then a +/// valid asmap, the wallet servers up until `stop`, an Electrum port someone +/// else holds, and a pruned datadir that refuses an unpruned start. +#[tokio::test(flavor = "multi_thread")] +async fn node_listen_and_exit() { + let td = TestDatadir::new().unwrap(); + let dir = td.path(); + let peers = dir.join("peers"); + + // A copied-in datadir: the peer book is junk and the configured asmap is + // not there. Catch-up gives up on the refused peer instead of hanging, + // and the book on disk is a real book again. + std::fs::write(&peers, b"not-a-valid-peers-blob\xff\x00").unwrap(); + let dead: SocketAddr = DEAD_CONNECT.parse().unwrap(); + let mut cfg = listen_and_exit_cfg(&dir); + cfg.asmap = Some(dir.join("no-such-asmap")); + let mempool = cfg.mempool_path(); + start_and_exit(cfg) + .await + .expect("a refused peer is not fatal"); + 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" + ); + + // The asmap is in place now, the saved book loads, and the wallet servers + // answer until the operator stops the node. + std::fs::write(dir.join("ip_asn.dat"), rbitcoin_net::TWO_PREFIX_ASMAP).unwrap(); + std::fs::write(dir.join("rpc.token"), "pass").unwrap(); + let (electrum_addr, esplora_addr, rpc_addr) = + (ephemeral_addr(), ephemeral_addr(), ephemeral_addr()); + let mut cfg = listen_and_exit_cfg(&dir); + cfg.milestone_height = 100; + cfg.shindex = true; + cfg.listen.electrum = Some(electrum_addr); + cfg.listen.esplora = Some(rbitcoin_esplora::EsploraListen::Tcp(esplora_addr)); + cfg.rpc.listen = Some(rpc_addr); + cfg.max_run_secs = Some(60); + let node = tokio::spawn(run_p2p(cfg)); + wait_listeners(&[electrum_addr, esplora_addr, rpc_addr]).await; + let (st, height) = http_get(esplora_addr, "/blocks/tip/height").await; + assert_eq!((st, height.as_str()), (200, "0"), "esplora on genesis"); + let mut el = TcpStream::connect(electrum_addr).await.unwrap(); + let tip = electrum_rpc(&mut el, 1, "blockchain.headers.subscribe", json!([])).await; + assert_eq!(tip["result"]["height"], 0, "{tip}"); + let count = jsonrpc(rpc_addr, "getblockcount", json!([])).await; + assert_eq!(count["result"], 0, "{count}"); + let _ = jsonrpc(rpc_addr, "stop", json!([])).await; + let stopped = tokio::time::timeout(Duration::from_secs(15), node).await; + assert!( + matches!(stopped, Ok(Ok(Ok(())))), + "run_p2p did not stop cleanly" + ); + + // Another process holds the Electrum port. The bind fails with a warning + // and the node still starts and exits. + let held = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut cfg = listen_and_exit_cfg(&dir); + cfg.shindex = true; + cfg.listen.electrum = Some(held.local_addr().unwrap()); + start_and_exit(cfg) + .await + .expect("an Electrum bind failure is not fatal"); + drop(held); + + // Without `--connect` and with seeds on: regtest resolves none, the one + // saved peer still refuses, and the node exits short of tip mode. + let mut cfg = listen_and_exit_cfg(&dir); + cfg.listen.connect.clear(); + cfg.listen.use_seeds = true; + start_and_exit(cfg) + .await + .expect("no reachable peer is not fatal"); + + // Once pruned, the datadir refuses a start without --prune-seqsigwit. + let mut cfg = listen_and_exit_cfg(&dir); + cfg.prune_seqsigwit = true; + start_and_exit(cfg).await.expect("pruned start"); + let err = start_and_exit(listen_and_exit_cfg(&dir)) + .await + .expect_err("unpruned start on a pruned datadir") + .to_string(); + assert!(err.contains("--prune-seqsigwit"), "{err}"); +} From a0cfff2b5a3334c95fb4dcbf91f5e6e2015f059f Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Mon, 28 Sep 2026 07:35:21 -0700 Subject: [PATCH 2/5] test: one Tor control and SAM session through run_p2p tor_control_onion_lifecycle starts one datadir against a fake Tor control port and SAM bridge. A cookie from another Tor, a 2-byte cookie, and a Tor that offers only plain COOKIE each refuse the start without sending AUTHENTICATE. Password auth with --listen-onion, --i2p-accept-incoming, Electrum, and Esplora mints one onion key per service. Each key is saved 0600, each SAM destination is saved, a STREAM FORWARD goes to each port, and getnetworkinfo lists every address. A SAFECOOKIE restart reuses the saved keys and destinations. The ten tor_control tests and the two I2P forward tests called TorControl, I2pSam, or start_i2p_named_forward directly, with no node behind them. Nothing checked that run_p2p asks Tor for the right port, or that the address lands in getnetworkinfo. Live Tor and i2pd stay overlay-functional. Co-Authored-By: Claude Opus 5.5 --- TESTING.md | 1 + crates/rbitcoin-node/src/cli.rs | 70 --- crates/rbitcoin-node/src/run.rs | 75 --- crates/rbitcoin-node/src/tor_control.rs | 508 ------------------ crates/rbitcoin-test/tests/cross_surface.rs | 382 +++++++++++++ .../024-talip-review-index.md | 2 +- docs/external_findings/044-local-auth.md | 6 +- docs/external_findings/README.md | 2 +- 8 files changed, 388 insertions(+), 658 deletions(-) diff --git a/TESTING.md b/TESTING.md index 740548e4a..e3c264ab3 100644 --- a/TESTING.md +++ b/TESTING.md @@ -345,6 +345,7 @@ Prefer **one high-level scenario** per behavior cluster. Delete lower-level test | `esplora_broadcast_visible_in_rpc_and_electrum` | Node + Electrum + Esplora + RPC | One `run_p2p` datadir: HTTP `sendrawtransaction` / `testmempoolaccept` (allowed, missing-or-spent, exact 100 sat/kvB min-relay accept + one-sat-under reject, RBF one-sat-short incremental reject + exact incremental accept); Esplora `POST /tx` parent and mempool child appear in `getrawmempool` and Electrum mempool/history (`fee` on unconfirmed, including child `height = -1`); Electrum `listunspent` of that child is `height=-1` and the parent UTXO drops; process `gettxout` / `getchaintips`; Esplora `POST /txs/package` 1p1c (including parent-alone below min-relay + paying child), 25-tx accept, 26-tx and over-weight `package too large`; serving-only `submitpackage` refuses (relay off); live `GET /mempool` / `/mempool/txids` / `/mempool/recent` / `/fee-estimates` (depths 1/5/144/504/1008 present and > 0; under-full near can be min-relay 0.1 sat/vB); process `getmempoolancestors` / descendants / cluster / `gettxspendingprevout` / feerate diagram / verbose `getrawmempool` on that 1p1c; `waitforblockheight` timeout=0 while behind returns the live tip; GBT stale `longpollid` is immediate; current id / `waitfornewblock` / `waitforblockheight` wake on the pad `generate`; `getblockhash` tip ok / tip+1 `-8`; unknown `getblock` `-5`; verbosity 0 hex and 2 vin/vout; `GET /blocks` 10 newest, `/blocks/0` and `/blocks/:tip` (start past tip clamps); `/block/:hash/txs/:start` last page shorter than 25, one-past last page `[]` (not 404), unknown hash 404; `/block/:hash/txids` + coinbase merkle-proof + unspent `outspend/0`; `/tx/:id/outspends`; `/block` JSON/raw/status/`txid/0` (OOB 404); `/tx/:id/raw` vs hex; merkleblock-proof; `/block-height` (missing 404); `/block/:hash/header` 160 hex; `/tx/:id/status` + full JSON (`unknown` OP_TRUE type, coinbase vin) and missing-tx 404s; OP_TRUE scripthash info/summary/utxo/`txs/chain` cursor and combined `/txs`. Keep crate no-hub mempool/fees/POST 503, reconstruct meters, header wire match, and `tx_status_json`; Esplora `/tx/:id/status` confirms the package parent on generate; `generate` includes those txs (parent before child) then leaves IBD (relay on); `scantxoutset` drops the spent coinbase and still sees a non-coinbase unspent; `submitpackage` maxfeerate reject, 1p1c success, already-in-mempool continue, below-min-relay parent + paying child success, 26-tx / over-weight `package too large`; immature coinbase sendraw rejects. Keep `accept.rs` reject units, package JSON errors, RPC dry-run orphan-count, leftover `gettxout` include_mempool / disconnected / leftover, `generate_selects_chained_mempool_parent_first`, `submitpackage_child_fail_keeps_parent`, maxburn `submitpackage`, and wait-on-stop units. Unix `--rpc-socket` (mode 0660, no datadir `rpc.sock`) `getblockcount` without Authorization; TCP `GET`/`POST /internal/mempool/txs`; `GET /internal/block/:hash/txs` full list vs public 25/page; `POST /internal/txs/outspends/by-txid` same-length unknown `[]` slot; `GET /address-prefix/bc1` **404**; unauthenticated Core REST on the RPC listener (`chaininfo`, block hash/headers/block/tx, mempool info/contents, `getutxos`, `deploymentinfo`, basic `blockfilter` bin/hex/json) and `getblockfilter` with `--block-filter-index` | | `fee_history_backfills_from_the_chain_when_relay_starts` | Node + RPC | `run_p2p` over a datadir whose only fee-paying block predates startup; `generate` leaves IBD and turns relay on; `estimatesmartfee 144` answers that block's rate from the chain backfill, not from blocks mined after start | | `node_listen_and_exit` | Node + Electrum + Esplora + RPC | One `run_p2p` datadir, restarted with its one `--connect` refusing (a pinned connect at genesis still enters tip mode): a junk `peers` file and a missing `--asmap` start an empty book and exit, and the saved book records the refused connect; the next start loads that book and a valid `ip_asn.dat`, and Esplora, Electrum, and RPC answer at genesis until `stop`; an Electrum port another process holds warns and the node still exits; without `--connect` and with seeds on, regtest resolves none and the node exits short of tip mode; after a `--prune-seqsigwit` start, an unpruned start refuses. Live peers are `node_run_p2p_short` | +| `tor_control_onion_lifecycle` | Node + RPC | One `run_p2p` datadir against a fake Tor control port and SAM bridge (live Tor and i2pd are overlay-functional). A cookie from another Tor (SAFECOOKIE server hash mismatch), a 2-byte cookie, and a Tor that offers only plain COOKIE each refuse the start, and none sends `AUTHENTICATE`. Password auth with `--listen-onion`, `--i2p-accept-incoming`, Electrum, and Esplora: `ADD_ONION NEW` per service with the P2P virtual port on the loopback bind, each key saved `0600` under `onion/`, each SAM destination under `i2p/`, `STREAM FORWARD` to each port, and `getnetworkinfo.localaddresses` lists the three onions and the I2P address. A SAFECOOKIE restart reuses every saved key and destination | | `two_node_header_and_block_sync` | P2P (**default**) | Seeder → peer genesis+1 IBD; peer `last_write` meter. Empty `headers` lag keep-sync is `apply_peer_event_body_and_control_surface`; drained-path EOF `headers_done` is `apply_peer_event_block_framed_bq_horizon_and_headers_done`. 8-block dual-seeder stays `ibd_two_peers` | | `p2p_timeout_getaddr_and_keepalive_ping` | P2P (**default**) | One pad: v1-magic inbound drops at `peertimeout=1`, obsolete VERSION and pre-verack ping close the peer, full-relay GetAddr cache 1000, headers-sync stall replace, self-connect refuses, AddrFetch `getaddr`/`addrv2` (no `getheaders`), one keepalive ping/pong. Handshake **format** needles stay. Sole-preferred stall KEEP stays a PeerHub unit (`noban_headers_timeout_clears_awaiting_so_a_new_getheaders_can_send`: CIDR `noban@127.0.0.1` and hub `--trusted`). | | `hostile_peer_session` | P2P (**default**, crate) | One lib entry in `peer::tests`, one in `chain::tests`, one in `assign::tests`. Header cap, send budget, one-shot `getaddr`, addr relay to one or two peers, zero-prev not held, witness padding and time-too-new not cached invalid, getdata stops at the byte budget. `tip_script_pres_skips_only_matching_wtxid` stays its own test (mempool graph, not the peer session). | diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index 626b72058..2b05b3d4b 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -811,76 +811,6 @@ mod tests { ); } - #[tokio::test] - async fn i2p_accept_incoming_forwards_to_loopback() { - use std::sync::{Arc, Mutex}; - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - use tokio::net::{TcpListener, TcpStream}; - - async fn write_line(s: &mut TcpStream, line: &str) { - s.write_all(line.as_bytes()).await.unwrap(); - s.write_all(b"\n").await.unwrap(); - s.flush().await.unwrap(); - } - async fn read_line(s: &mut TcpStream) -> Option { - let mut reader = BufReader::new(s); - let mut line = String::new(); - let n = reader.read_line(&mut line).await.ok()?; - if n == 0 { - return None; - } - Some(line.trim_end_matches(['\r', '\n']).to_string()) - } - - let log = Arc::new(Mutex::new(Vec::new())); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let log_acc = Arc::clone(&log); - tokio::spawn(async move { - loop { - let Ok((mut s, _)) = listener.accept().await else { - break; - }; - let log = Arc::clone(&log_acc); - tokio::spawn(async move { - loop { - let Some(line) = read_line(&mut s).await else { - break; - }; - let up = line.to_ascii_uppercase(); - if up.starts_with("HELLO VERSION") { - write_line(&mut s, "HELLO REPLY RESULT=OK VERSION=3.1").await; - } else if up.starts_with("SESSION CREATE") { - write_line(&mut s, "SESSION STATUS RESULT=OK DESTINATION=fakeprivdest") - .await; - } else if up.starts_with("STREAM FORWARD") { - log.lock().unwrap().push(line); - write_line(&mut s, "STREAM STATUS RESULT=OK").await; - } else if up.starts_with("STREAM CONNECT") { - write_line(&mut s, "STREAM STATUS RESULT=OK").await; - break; - } - } - }); - } - }); - - let dir = tmp_datadir(); - let dest_path = dir.join("i2p").join("p2p.priv"); - let mut sam = rbitcoin_net::I2pSam::connect_persistent(addr, &dest_path) - .await - .unwrap(); - sam.stream_forward(18444).await.unwrap(); - assert_eq!( - std::fs::read_to_string(&dest_path).unwrap().trim(), - "fakeprivdest" - ); - let fw = log.lock().unwrap().clone(); - assert_eq!(fw.len(), 1, "{fw:?}"); - assert!(fw[0].contains("PORT=18444"), "{}", fw[0]); - let _ = std::fs::remove_dir_all(&dir); - } - include!("overlay_config_journey.rs"); #[test] diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index 02aba01a4..66b419262 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -2770,79 +2770,4 @@ mod tests { sd.request(); j.await.unwrap(); } - - #[tokio::test] - async fn electrum_i2p_forward_when_sam_incoming() { - use std::sync::{Arc, Mutex}; - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - use tokio::net::{TcpListener, TcpStream}; - - async fn write_line(s: &mut TcpStream, line: &str) { - s.write_all(line.as_bytes()).await.unwrap(); - s.write_all(b"\n").await.unwrap(); - s.flush().await.unwrap(); - } - async fn read_line(s: &mut TcpStream) -> Option { - let mut reader = BufReader::new(s); - let mut line = String::new(); - let n = reader.read_line(&mut line).await.ok()?; - if n == 0 { - return None; - } - Some(line.trim_end_matches(['\r', '\n']).to_string()) - } - - let log = Arc::new(Mutex::new(Vec::new())); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let log_acc = Arc::clone(&log); - tokio::spawn(async move { - loop { - let Ok((mut s, _)) = listener.accept().await else { - break; - }; - let log = Arc::clone(&log_acc); - tokio::spawn(async move { - loop { - let Some(line) = read_line(&mut s).await else { - break; - }; - let up = line.to_ascii_uppercase(); - if up.starts_with("HELLO VERSION") { - write_line(&mut s, "HELLO REPLY RESULT=OK VERSION=3.1").await; - } else if up.starts_with("SESSION CREATE") { - write_line(&mut s, "SESSION STATUS RESULT=OK DESTINATION=walletfake") - .await; - } else if up.starts_with("STREAM FORWARD") { - log.lock().unwrap().push(line); - write_line(&mut s, "STREAM STATUS RESULT=OK").await; - } - } - }); - } - }); - - let dir = std::env::temp_dir().join(format!( - "rbtc-i2p-wallet-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let sam = start_i2p_named_forward(addr, &dir, "electrum", 50001) - .await - .unwrap(); - drop(sam); - assert_eq!( - std::fs::read_to_string(dir.join("i2p").join("electrum.priv")) - .unwrap() - .trim(), - "walletfake" - ); - let fw = log.lock().unwrap().clone(); - assert_eq!(fw.len(), 1, "{fw:?}"); - assert!(fw[0].contains("PORT=50001"), "{}", fw[0]); - let _ = std::fs::remove_dir_all(&dir); - } } diff --git a/crates/rbitcoin-node/src/tor_control.rs b/crates/rbitcoin-node/src/tor_control.rs index 8afe6c0b7..710108c4d 100644 --- a/crates/rbitcoin-node/src/tor_control.rs +++ b/crates/rbitcoin-node/src/tor_control.rs @@ -423,511 +423,3 @@ fn write_key_file(path: &Path, key: &str) -> Result<(), NodeError> { .map_err(|e| NodeError::Init(format!("tor control key {}: {e}", path.display())))?; Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use bitcoin::hex::DisplayHex; - use std::sync::{Arc, Mutex}; - use std::time::{SystemTime, UNIX_EPOCH}; - use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; - use tokio::net::TcpListener; - - const FAKE_SID: &str = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcd"; - const FAKE_PK: &str = "ED25519-V3:dGVzdGtleWJsb2I"; - - async fn fake_control( - cookie: Option>, - password: Option, - ) -> (SocketAddr, Arc>>) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let log = Arc::new(Mutex::new(Vec::new())); - let log_task = Arc::clone(&log); - tokio::spawn(async move { - let (mut s, _) = listener.accept().await.unwrap(); - let (r, mut w) = s.split(); - let mut reader = BufReader::new(r); - let mut safecookie_expected: Option<[u8; 32]> = None; - loop { - let mut line = String::new(); - if reader.read_line(&mut line).await.unwrap() == 0 { - break; - } - let line = line.trim_end_matches(['\r', '\n']).to_string(); - log_task.lock().unwrap().push(line.clone()); - if line.eq_ignore_ascii_case("PROTOCOLINFO 1") { - if cookie.is_some() { - w.write_all( - format!( - "250-PROTOCOLINFO 1\r\n250-AUTH METHODS=SAFECOOKIE COOKIEFILE=\"{}\"\r\n250-VERSION Tor=\"0.4.8.10\"\r\n250 OK\r\n", - DEFAULT_COOKIE_PATH - ) - .as_bytes(), - ) - .await - .unwrap(); - } else if password.is_some() { - w.write_all( - b"250-PROTOCOLINFO 1\r\n250-AUTH METHODS=HASHEDPASSWORD\r\n250-VERSION Tor=\"0.4.8.10\"\r\n250 OK\r\n", - ) - .await - .unwrap(); - } else { - w.write_all( - b"250-PROTOCOLINFO 1\r\n250-AUTH METHODS=NULL\r\n250-VERSION Tor=\"0.4.8.10\"\r\n250 OK\r\n", - ) - .await - .unwrap(); - } - } else if let Some(rest) = line.strip_prefix("AUTHCHALLENGE SAFECOOKIE ") { - let Some(ref cookie_bytes) = cookie else { - w.write_all(b"515 Authentication failed\r\n").await.unwrap(); - continue; - }; - let client_nonce = match hex32(rest, "CLIENTNONCE") { - Ok(v) => v, - Err(_) => { - w.write_all(b"512 Bad client nonce\r\n").await.unwrap(); - continue; - } - }; - let server_nonce = [0x5au8; 32]; - let mut mat = Vec::with_capacity(96); - mat.extend_from_slice(cookie_bytes); - mat.extend_from_slice(&client_nonce); - mat.extend_from_slice(&server_nonce); - let server_hash = hmac_sha256(SAFECOOKIE_SERVER_KEY, &mat); - safecookie_expected = Some(hmac_sha256(SAFECOOKIE_CLIENT_KEY, &mat)); - w.write_all( - format!( - "250 AUTHCHALLENGE SERVERHASH={} SERVERNONCE={}\r\n", - server_hash.to_lower_hex_string(), - server_nonce.to_lower_hex_string() - ) - .as_bytes(), - ) - .await - .unwrap(); - } else if let Some(rest) = line.strip_prefix("AUTHENTICATE ") { - let ok = if let Some(ref want) = cookie { - rest.eq_ignore_ascii_case(&want.to_lower_hex_string()) - || safecookie_expected.as_ref().is_some_and(|v| { - rest.eq_ignore_ascii_case(&v.to_lower_hex_string()) - }) - } else if let Some(ref want) = password { - rest == format!("\"{}\"", escape_quoted(want)) - } else { - false - }; - if ok { - w.write_all(b"250 OK\r\n").await.unwrap(); - } else { - w.write_all(b"515 Authentication failed\r\n").await.unwrap(); - } - } else if line.eq_ignore_ascii_case("GETINFO version") { - w.write_all(b"250-version=0.4.8.10\r\n250 OK\r\n") - .await - .unwrap(); - } else if let Some(rest) = line.strip_prefix("ADD_ONION ") { - let spec = rest.split_once(" Port=").map(|(s, _)| s).unwrap_or(rest); - if spec == "NEW:ED25519-V3" { - w.write_all( - format!("250-ServiceID={FAKE_SID}\r\n250-PrivateKey={FAKE_PK}\r\n250 OK\r\n") - .as_bytes(), - ) - .await - .unwrap(); - } else if spec == FAKE_PK { - w.write_all(format!("250-ServiceID={FAKE_SID}\r\n250 OK\r\n").as_bytes()) - .await - .unwrap(); - } else { - w.write_all(b"512 Invalid onion key\r\n").await.unwrap(); - } - } else { - w.write_all(b"510 Unrecognized command\r\n").await.unwrap(); - } - } - }); - (addr, log) - } - - async fn fake_control_bad_safecookie() -> SocketAddr { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - let (mut s, _) = listener.accept().await.unwrap(); - let (r, mut w) = s.split(); - let mut reader = BufReader::new(r); - loop { - let mut line = String::new(); - if reader.read_line(&mut line).await.unwrap() == 0 { - break; - } - let line = line.trim_end_matches(['\r', '\n']).to_string(); - if line.eq_ignore_ascii_case("PROTOCOLINFO 1") { - w.write_all( - b"250-PROTOCOLINFO 1\r\n250-AUTH METHODS=SAFECOOKIE\r\n250-VERSION Tor=\"0.4.8.10\"\r\n250 OK\r\n", - ) - .await - .unwrap(); - } else if line.starts_with("AUTHCHALLENGE SAFECOOKIE ") { - let bad_hash = [0u8; 32]; - let nonce = [1u8; 32]; - w.write_all( - format!( - "250 AUTHCHALLENGE SERVERHASH={} SERVERNONCE={}\r\n", - bad_hash.to_lower_hex_string(), - nonce.to_lower_hex_string() - ) - .as_bytes(), - ) - .await - .unwrap(); - } else if line.starts_with("AUTHENTICATE ") { - w.write_all(b"515 Authentication failed\r\n").await.unwrap(); - } else { - w.write_all(b"510 Unrecognized command\r\n").await.unwrap(); - } - } - }); - addr - } - - async fn fake_control_cookie_only(cookie: Vec) -> (SocketAddr, Arc>>) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let log = Arc::new(Mutex::new(Vec::new())); - let log_task = Arc::clone(&log); - tokio::spawn(async move { - let (mut s, _) = listener.accept().await.unwrap(); - let (r, mut w) = s.split(); - let mut reader = BufReader::new(r); - loop { - let mut line = String::new(); - if reader.read_line(&mut line).await.unwrap() == 0 { - break; - } - let line = line.trim_end_matches(['\r', '\n']).to_string(); - log_task.lock().unwrap().push(line.clone()); - if line.eq_ignore_ascii_case("PROTOCOLINFO 1") { - w.write_all( - format!( - "250-PROTOCOLINFO 1\r\n250-AUTH METHODS=COOKIE COOKIEFILE=\"{}\"\r\n250 OK\r\n", - DEFAULT_COOKIE_PATH - ) - .as_bytes(), - ) - .await - .unwrap(); - } else if let Some(rest) = line.strip_prefix("AUTHENTICATE ") { - if rest.eq_ignore_ascii_case(&cookie.to_lower_hex_string()) { - w.write_all(b"250 OK\r\n").await.unwrap(); - } else { - w.write_all(b"515 Authentication failed\r\n").await.unwrap(); - } - } else { - w.write_all(b"510 Unrecognized command\r\n").await.unwrap(); - } - } - }); - (addr, log) - } - - fn tmp_cookie(bytes: &[u8]) -> PathBuf { - let p = std::env::temp_dir().join(format!( - "rbtc-tor-cookie-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::write(&p, bytes).unwrap(); - p - } - - fn tmp_key_path() -> PathBuf { - std::env::temp_dir().join(format!( - "rbtc-tor-onion-{}-{}/electrum.priv", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos() - )) - } - - #[tokio::test] - async fn tor_control_auth_cookie_and_password() { - let cookie = vec![0x2a; 32]; - let (addr, log) = fake_control(Some(cookie.clone()), None).await; - let path = tmp_cookie(&cookie); - TorControl::connect_and_auth(addr, TorAuth::Cookie(path.clone())) - .await - .unwrap(); - let cmds = log.lock().unwrap().clone(); - assert!(cmds.iter().any(|c| c == "PROTOCOLINFO 1"), "{cmds:?}"); - let _ = std::fs::remove_file(&path); - - let (addr, _) = fake_control(None, Some("s3cret".into())).await; - TorControl::connect_and_auth(addr, TorAuth::Password("s3cret".into())) - .await - .unwrap(); - - let (addr, _) = fake_control(Some(cookie.clone()), None).await; - let bad = tmp_cookie(&[0x00; 32]); - let err = match TorControl::connect_and_auth(addr, TorAuth::Cookie(bad.clone())).await { - Err(e) => e, - Ok(_) => panic!("wrong cookie must not authenticate"), - }; - let msg = format!("{err}"); - assert!( - msg.contains("515") - || msg.contains("Authentication failed") - || msg.contains("server hash mismatch"), - "{msg}" - ); - let _ = std::fs::remove_file(&bad); - } - - #[tokio::test] - async fn tor_add_onion_new_persists_key() { - let cookie = vec![0x11; 32]; - let (addr, _) = fake_control(Some(cookie.clone()), None).await; - let cookie_path = tmp_cookie(&cookie); - let mut ctl = TorControl::connect_and_auth(addr, TorAuth::Cookie(cookie_path.clone())) - .await - .unwrap(); - let key_path = tmp_key_path(); - let target: SocketAddr = "127.0.0.1:50001".parse().unwrap(); - let hs = ctl - .add_onion_persistent(&key_path, 50001, target) - .await - .unwrap(); - assert_eq!(hs.service_id, FAKE_SID); - let stored = std::fs::read_to_string(&key_path).unwrap(); - assert_eq!(stored.trim(), FAKE_PK); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(&key_path).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o600); - } - let _ = std::fs::remove_file(&cookie_path); - let _ = std::fs::remove_dir_all(key_path.parent().unwrap()); - } - - #[tokio::test] - async fn tor_add_onion_reuse_key_same_id() { - let cookie = vec![0x33; 32]; - let (addr, log) = fake_control(Some(cookie.clone()), None).await; - let cookie_path = tmp_cookie(&cookie); - let mut ctl = TorControl::connect_and_auth(addr, TorAuth::Cookie(cookie_path.clone())) - .await - .unwrap(); - let key_path = tmp_key_path(); - let target: SocketAddr = "127.0.0.1:50001".parse().unwrap(); - let first = ctl - .add_onion_persistent(&key_path, 50001, target) - .await - .unwrap(); - let second = ctl - .add_onion_persistent(&key_path, 50001, target) - .await - .unwrap(); - assert_eq!(first.service_id, second.service_id); - assert_eq!(second.service_id, FAKE_SID); - let cmds = log.lock().unwrap().clone(); - let onions: Vec<_> = cmds - .iter() - .filter(|c| c.starts_with("ADD_ONION ")) - .cloned() - .collect(); - assert_eq!(onions.len(), 2, "{onions:?}"); - assert!( - onions[0].starts_with("ADD_ONION NEW:ED25519-V3 "), - "{}", - onions[0] - ); - assert!( - onions[1].starts_with(&format!("ADD_ONION {FAKE_PK} ")), - "{}", - onions[1] - ); - let _ = std::fs::remove_file(&cookie_path); - let _ = std::fs::remove_dir_all(key_path.parent().unwrap()); - } - - #[tokio::test] - async fn tor_control_auth_fail_is_start_error() { - let cookie = vec![0xaa; 32]; - let (addr, _) = fake_control(Some(cookie.clone()), None).await; - let bad = tmp_cookie(&[0x00; 32]); - let err = - match TorControl::connect_if_configured(Some(addr), Some(bad.as_path()), None).await { - Err(e) => e, - Ok(_) => panic!("bad cookie must fail start"), - }; - let msg = format!("{err}"); - assert!( - msg.contains("515") - || msg.contains("Authentication failed") - || msg.contains("tor control"), - "{msg}" - ); - let none = TorControl::connect_if_configured(None, None, None) - .await - .unwrap(); - assert!(none.is_none()); - let _ = std::fs::remove_file(&bad); - } - - #[tokio::test] - async fn tor_control_safecookie_serverhash_mismatch_fails() { - let cookie = vec![0x11; 32]; - let addr = fake_control_bad_safecookie().await; - let path = tmp_cookie(&cookie); - let err = match TorControl::connect_and_auth(addr, TorAuth::Cookie(path.clone())).await { - Ok(_) => panic!("bad SAFECOOKIE server hash must fail"), - Err(e) => e, - }; - let msg = format!("{err}"); - assert!(msg.contains("server hash mismatch"), "{msg}"); - let _ = std::fs::remove_file(&path); - } - - #[tokio::test] - async fn electrum_hidden_service_add_onion_when_listening() { - let cookie = vec![0x55; 32]; - let (addr, log) = fake_control(Some(cookie.clone()), None).await; - let cookie_path = tmp_cookie(&cookie); - let mut ctl = TorControl::connect_and_auth(addr, TorAuth::Cookie(cookie_path.clone())) - .await - .unwrap(); - let dir = std::env::temp_dir().join(format!( - "rbtc-tor-electrum-hs-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let bound: SocketAddr = "127.0.0.1:50001".parse().unwrap(); - let hs = ctl.add_electrum_onion(&dir, bound).await.unwrap(); - assert_eq!(hs.service_id, FAKE_SID); - let cmds = log.lock().unwrap().clone(); - let onion = cmds - .iter() - .find(|c| c.starts_with("ADD_ONION ")) - .cloned() - .expect("ADD_ONION"); - assert!(onion.contains("Port=50001,127.0.0.1:50001"), "{onion}"); - assert!(std::path::Path::new(&dir.join("onion").join("electrum.priv")).is_file()); - let _ = std::fs::remove_file(&cookie_path); - let _ = std::fs::remove_dir_all(&dir); - } - - #[tokio::test] - async fn esplora_hidden_service_add_onion() { - let cookie = vec![0x77; 32]; - let (addr, log) = fake_control(Some(cookie.clone()), None).await; - let cookie_path = tmp_cookie(&cookie); - let mut ctl = TorControl::connect_and_auth(addr, TorAuth::Cookie(cookie_path.clone())) - .await - .unwrap(); - let dir = std::env::temp_dir().join(format!( - "rbtc-tor-esplora-hs-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let bound: SocketAddr = "127.0.0.1:3000".parse().unwrap(); - let hs = ctl.add_esplora_onion(&dir, bound).await.unwrap(); - assert_eq!(hs.service_id, FAKE_SID); - let cmds = log.lock().unwrap().clone(); - let onion = cmds - .iter() - .find(|c| c.starts_with("ADD_ONION ")) - .cloned() - .expect("ADD_ONION"); - assert!(onion.contains("Port=3000,127.0.0.1:3000"), "{onion}"); - assert!(std::path::Path::new(&dir.join("onion").join("esplora.priv")).is_file()); - let _ = std::fs::remove_file(&cookie_path); - let _ = std::fs::remove_dir_all(&dir); - } - - #[tokio::test] - async fn p2p_add_onion_persists_key() { - let cookie = vec![0x21; 32]; - let (addr, log) = fake_control(Some(cookie.clone()), None).await; - let cookie_path = tmp_cookie(&cookie); - let mut ctl = TorControl::connect_and_auth(addr, TorAuth::Cookie(cookie_path.clone())) - .await - .unwrap(); - let dir = std::env::temp_dir().join(format!( - "rbtc-tor-p2p-hs-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let bound: SocketAddr = "127.0.0.1:23456".parse().unwrap(); - let hs = ctl.add_p2p_onion(&dir, bound, 18444).await.unwrap(); - assert_eq!(hs.service_id, FAKE_SID); - let cmds = log.lock().unwrap().clone(); - let onion = cmds - .iter() - .find(|c| c.starts_with("ADD_ONION ")) - .cloned() - .expect("ADD_ONION"); - assert!(onion.contains("Port=18444,127.0.0.1:23456"), "{onion}"); - assert!(std::path::Path::new(&dir.join("onion").join("p2p.priv")).is_file()); - let hs2 = ctl.add_p2p_onion(&dir, bound, 18444).await.unwrap(); - assert_eq!(hs2.service_id, FAKE_SID); - let _ = std::fs::remove_file(&cookie_path); - let _ = std::fs::remove_dir_all(&dir); - } - - #[tokio::test] - async fn tor_plain_cookie_is_not_sent_when_safecookie_is_absent() { - let cookie = vec![0xab; 32]; - let (addr, log) = fake_control_cookie_only(cookie.clone()).await; - let path = tmp_cookie(&cookie); - let err = match TorControl::connect_and_auth(addr, TorAuth::Cookie(path.clone())).await { - Err(e) => e, - Ok(_) => panic!("COOKIE without SAFECOOKIE must fail closed"), - }; - let msg = format!("{err}"); - assert!(msg.contains("SAFECOOKIE"), "{msg}"); - let cmds = log.lock().unwrap().clone(); - assert!( - cmds.iter().all(|c| !c.starts_with("AUTHENTICATE ")), - "must not send the raw cookie: {cmds:?}" - ); - let _ = std::fs::remove_file(&path); - } - - #[tokio::test] - async fn tor_short_cookie_does_not_fall_back_to_raw_hex() { - let cookie = vec![0x11, 0x22]; - let (addr, log) = fake_control(Some(cookie.clone()), None).await; - let path = tmp_cookie(&cookie); - let err = match TorControl::connect_and_auth(addr, TorAuth::Cookie(path.clone())).await { - Err(e) => e, - Ok(_) => panic!("a short SAFECOOKIE cookie must fail closed"), - }; - let msg = format!("{err}"); - assert!(msg.contains("32 bytes"), "{msg}"); - let cmds = log.lock().unwrap().clone(); - assert!( - cmds.iter().all(|c| !c.starts_with("AUTHENTICATE ")), - "must not send the raw cookie: {cmds:?}" - ); - let _ = std::fs::remove_file(&path); - } -} diff --git a/crates/rbitcoin-test/tests/cross_surface.rs b/crates/rbitcoin-test/tests/cross_surface.rs index 782b6e05f..10f707419 100644 --- a/crates/rbitcoin-test/tests/cross_surface.rs +++ b/crates/rbitcoin-test/tests/cross_surface.rs @@ -15,6 +15,8 @@ use rbitcoin_test::{build_mature_regtest_with_spend, TestDatadir}; use serde_json::{json, Value}; use std::net::SocketAddr; use std::str::FromStr; +use std::sync::atomic::Ordering; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpStream; @@ -2059,3 +2061,383 @@ async fn node_listen_and_exit() { .to_string(); assert!(err.contains("--prune-seqsigwit"), "{err}"); } + +/// Tor control port stand-in: PROTOCOLINFO advertises `methods`, SAFECOOKIE +/// answers from `cookie`, and ADD_ONION NEW mints `key{n}` for service `n`. +/// Every command line lands in `log`. Live Tor is overlay-functional. +struct FakeTor { + addr: SocketAddr, + methods: Arc>, + log: Arc>>, + minted: Arc>>, +} + +const TOR_COOKIE: [u8; 32] = [0x2a; 32]; +const TOR_PASSWORD: &str = "s3cret"; + +fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] { + use bitcoin::hashes::{hmac, sha256, HashEngine}; + let mut engine = hmac::HmacEngine::::new(key); + engine.input(data); + hmac::Hmac::::from_engine(engine).to_byte_array() +} + +fn fake_onion_service_id(n: usize) -> String { + let pk = [u8::try_from(n + 1).unwrap(); 32]; + let name = rbitcoin_net::NetAddr::Onion { pk, port: 0 }.to_string(); + name.trim_end_matches(".onion:0").to_string() +} + +impl FakeTor { + async fn start() -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let tor = Self { + addr: listener.local_addr().unwrap(), + methods: Arc::new(Mutex::new("SAFECOOKIE")), + log: Arc::default(), + minted: Arc::default(), + }; + let (methods, log, minted) = ( + Arc::clone(&tor.methods), + Arc::clone(&tor.log), + Arc::clone(&tor.minted), + ); + tokio::spawn(async move { + while let Ok((s, _)) = listener.accept().await { + tokio::spawn(fake_tor_session( + s, + Arc::clone(&methods), + Arc::clone(&log), + Arc::clone(&minted), + )); + } + }); + tor + } + + fn take_log(&self) -> Vec { + std::mem::take(&mut *self.log.lock().unwrap()) + } +} + +async fn fake_tor_session( + s: TcpStream, + methods: Arc>, + log: Arc>>, + minted: Arc>>, +) { + use bitcoin::hex::{DisplayHex, FromHex}; + let (r, mut w) = s.into_split(); + let mut lines = BufReader::new(r).lines(); + let mut client_hash: Option<[u8; 32]> = None; + while let Ok(Some(line)) = lines.next_line().await { + log.lock().unwrap().push(line.clone()); + let reply = if line == "PROTOCOLINFO 1" { + let methods = *methods.lock().unwrap(); + format!("250-PROTOCOLINFO 1\r\n250-AUTH METHODS={methods}\r\n250 OK\r\n") + } else if let Some(nonce) = line.strip_prefix("AUTHCHALLENGE SAFECOOKIE ") { + let server_nonce = [0x5a; 32]; + let mut mat = TOR_COOKIE.to_vec(); + mat.extend(Vec::::from_hex(nonce).unwrap()); + mat.extend(server_nonce); + client_hash = Some(hmac_sha256( + b"Tor safe cookie authentication controller-to-server hash", + &mat, + )); + let server_hash = hmac_sha256( + b"Tor safe cookie authentication server-to-controller hash", + &mat, + ); + format!( + "250 AUTHCHALLENGE SERVERHASH={} SERVERNONCE={}\r\n", + server_hash.to_lower_hex_string(), + server_nonce.to_lower_hex_string() + ) + } else if let Some(auth) = line.strip_prefix("AUTHENTICATE ") { + let ok = auth == format!("\"{TOR_PASSWORD}\"") + || client_hash.is_some_and(|h| auth == h.to_lower_hex_string()); + if ok { + "250 OK\r\n" + } else { + "515 Authentication failed\r\n" + } + .to_string() + } else if line == "GETINFO version" { + "250-version=0.4.8.10\r\n250 OK\r\n".to_string() + } else if let Some(rest) = line.strip_prefix("ADD_ONION ") { + let spec = rest.split_once(" Port=").map_or(rest, |(s, _)| s); + let mut minted = minted.lock().unwrap(); + if spec == "NEW:ED25519-V3" { + let n = minted.len(); + minted.push(format!("ED25519-V3:key{n}")); + format!( + "250-ServiceID={}\r\n250-PrivateKey={}\r\n250 OK\r\n", + fake_onion_service_id(n), + minted[n] + ) + } else if let Some(n) = minted.iter().position(|k| k == spec) { + format!("250-ServiceID={}\r\n250 OK\r\n", fake_onion_service_id(n)) + } else { + "512 Invalid onion key\r\n".to_string() + } + } else { + "510 Unrecognized command\r\n".to_string() + }; + if w.write_all(reply.as_bytes()).await.is_err() { + break; + } + } +} + +/// I2P SAM stand-in: SESSION CREATE TRANSIENT hands out destination `n`, a +/// stored destination comes back as itself, and STREAM FORWARD is accepted. +struct FakeSam { + addr: SocketAddr, + log: Arc>>, +} + +/// A 387-byte public destination with no certificate, distinct per `n`: +/// I2P base64 of `fake_i2p_public(n)`. +fn fake_i2p_destination(n: usize) -> String { + let first = char::from(b'B' + u8::try_from(n).unwrap()); + format!("{first}{}", "A".repeat(515)) +} + +fn fake_i2p_public(n: usize) -> Vec { + let mut raw = vec![0u8; 387]; + raw[0] = (u8::try_from(n).unwrap() + 1) << 2; + raw +} + +impl FakeSam { + async fn start() -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let sam = Self { + addr: listener.local_addr().unwrap(), + log: Arc::default(), + }; + let log = Arc::clone(&sam.log); + let transient = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + tokio::spawn(async move { + while let Ok((s, _)) = listener.accept().await { + let (log, transient) = (Arc::clone(&log), Arc::clone(&transient)); + tokio::spawn(async move { + let (r, mut w) = s.into_split(); + let mut lines = BufReader::new(r).lines(); + while let Ok(Some(line)) = lines.next_line().await { + log.lock().unwrap().push(line.clone()); + let reply = if line.starts_with("HELLO VERSION") { + "HELLO REPLY RESULT=OK VERSION=3.1".to_string() + } else if line.starts_with("SESSION CREATE") { + let asked = line + .split_whitespace() + .find_map(|t| t.strip_prefix("DESTINATION=")) + .unwrap_or("TRANSIENT"); + let dest = if asked == "TRANSIENT" { + let n = transient.fetch_add(1, Ordering::SeqCst); + fake_i2p_destination(n) + } else { + asked.to_string() + }; + format!("SESSION STATUS RESULT=OK DESTINATION={dest}") + } else if line.starts_with("STREAM FORWARD") { + "STREAM STATUS RESULT=OK".to_string() + } else { + "SESSION STATUS RESULT=I2P_ERROR".to_string() + }; + if w.write_all(format!("{reply}\n").as_bytes()).await.is_err() { + break; + } + } + }); + } + }); + sam + } + + fn take_log(&self) -> Vec { + std::mem::take(&mut *self.log.lock().unwrap()) + } +} + +fn starts_with_any(lines: &[String], prefix: &str) -> Vec { + lines + .iter() + .filter(|l| l.starts_with(prefix)) + .cloned() + .collect() +} + +/// An operator publishes P2P, Electrum, and Esplora as onion and I2P +/// services through a local Tor control port and SAM bridge. A bad or short +/// cookie, or a Tor that does not offer SAFECOOKIE, stops the start before +/// any AUTHENTICATE; password auth mints and saves a key per service; a +/// SAFECOOKIE restart reuses every saved key and destination. +#[tokio::test(flavor = "multi_thread")] +async fn tor_control_onion_lifecycle() { + let td = TestDatadir::new().unwrap(); + let dir = td.path(); + let tor = FakeTor::start().await; + let sam = FakeSam::start().await; + let cookie = dir.join("control.authcookie"); + let cookie_cfg = |cookie: &std::path::Path| { + let mut cfg = listen_and_exit_cfg(&dir); + cfg.tor.control = Some(tor.addr); + cfg.tor.cookie = Some(cookie.to_path_buf()); + cfg + }; + + // A cookie from another Tor, a truncated cookie, and a Tor that only + // offers plain COOKIE all refuse the start, and none sends AUTHENTICATE: + // the node never hands the raw cookie to whatever owns the port. + std::fs::write(&cookie, [0u8; 32]).unwrap(); + let err = start_and_exit(cookie_cfg(&cookie)) + .await + .expect_err("wrong cookie") + .to_string(); + assert!(err.contains("server hash mismatch"), "{err}"); + std::fs::write(&cookie, [0x2a; 2]).unwrap(); + let err = start_and_exit(cookie_cfg(&cookie)) + .await + .expect_err("short cookie") + .to_string(); + assert!(err.contains("32 bytes"), "{err}"); + std::fs::write(&cookie, TOR_COOKIE).unwrap(); + *tor.methods.lock().unwrap() = "COOKIE"; + let err = start_and_exit(cookie_cfg(&cookie)) + .await + .expect_err("plain COOKIE only") + .to_string(); + assert!(err.contains("SAFECOOKIE"), "{err}"); + let refused = tor.take_log(); + assert!( + starts_with_any(&refused, "AUTHENTICATE ").is_empty(), + "{refused:?}" + ); + assert!( + starts_with_any(&refused, "ADD_ONION ").is_empty(), + "{refused:?}" + ); + + // Password auth. Each service gets a fresh onion key, saved 0600 under + // the datadir, and an I2P destination; getnetworkinfo lists them all. + *tor.methods.lock().unwrap() = "HASHEDPASSWORD"; + std::fs::write(dir.join("rpc.token"), "pass").unwrap(); + let (p2p, electrum, esplora, rpc) = ( + ephemeral_addr(), + ephemeral_addr(), + ephemeral_addr(), + ephemeral_addr(), + ); + let services_cfg = || { + let mut cfg = listen_and_exit_cfg(&dir).with_p2p_listen(p2p); + cfg.tor.control = Some(tor.addr); + cfg.listen.listen_onion = true; + cfg.listen.i2p_sam = Some(sam.addr); + cfg.listen.i2p_accept_incoming = true; + cfg.shindex = true; + cfg.listen.electrum = Some(electrum); + cfg.listen.esplora = Some(rbitcoin_esplora::EsploraListen::Tcp(esplora)); + cfg + }; + let mut cfg = services_cfg(); + cfg.tor.password = Some(TOR_PASSWORD.into()); + cfg.rpc.listen = Some(rpc); + cfg.max_run_secs = Some(60); + let node = tokio::spawn(run_p2p(cfg)); + wait_listeners(&[electrum, esplora, rpc]).await; + let info = jsonrpc(rpc, "getnetworkinfo", json!([])).await; + let local: Vec<(String, u64)> = info["result"]["localaddresses"] + .as_array() + .unwrap_or_else(|| panic!("{info}")) + .iter() + .map(|r| { + ( + r["address"].as_str().unwrap().to_string(), + r["port"].as_u64().unwrap(), + ) + }) + .collect(); + let regtest_port = Network::Regtest.default_p2p_port(); + for (n, port) in [(0, regtest_port), (1, electrum.port()), (2, esplora.port())] { + let host = format!("{}.onion", fake_onion_service_id(n)); + assert!(local.contains(&(host, u64::from(port))), "{local:?}"); + } + let i2p_p2p = rbitcoin_net::NetAddr::I2p { + dest: bitcoin::hashes::sha256::Hash::hash(&fake_i2p_public(0)).to_byte_array(), + port: 0, + } + .host_str(); + assert!(local.iter().any(|(a, _)| *a == i2p_p2p), "{local:?}"); + let _ = jsonrpc(rpc, "stop", json!([])).await; + let stopped = tokio::time::timeout(Duration::from_secs(15), node).await; + assert!( + matches!(stopped, Ok(Ok(Ok(())))), + "run_p2p did not stop cleanly" + ); + + let first = tor.take_log(); + assert!(first.contains(&format!("AUTHENTICATE \"{TOR_PASSWORD}\""))); + let onions = starts_with_any(&first, "ADD_ONION "); + let targets = [ + format!("Port={regtest_port},127.0.0.1:{}", p2p.port()), + format!("Port={0},127.0.0.1:{0}", electrum.port()), + format!("Port={0},127.0.0.1:{0}", esplora.port()), + ]; + assert_eq!(onions.len(), 3, "{onions:?}"); + for (line, target) in onions.iter().zip(&targets) { + assert_eq!(*line, format!("ADD_ONION NEW:ED25519-V3 {target}")); + } + for (n, name) in ["p2p", "electrum", "esplora"].into_iter().enumerate() { + let key = dir.join("onion").join(format!("{name}.priv")); + assert_eq!( + std::fs::read_to_string(&key).unwrap().trim(), + format!("ED25519-V3:key{n}") + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&key).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "{name} onion key mode"); + } + let dest = dir.join("i2p").join(format!("{name}.priv")); + assert_eq!( + std::fs::read_to_string(&dest).unwrap().trim(), + fake_i2p_destination(n) + ); + } + let forwards = starts_with_any(&sam.take_log(), "STREAM FORWARD"); + for port in [p2p.port(), electrum.port(), esplora.port()] { + assert!( + forwards + .iter() + .any(|f| f.contains(&format!("PORT={port} "))), + "{forwards:?}" + ); + } + + // SAFECOOKIE restart: the same three services under the saved keys, and + // each SAM session under its saved destination. + *tor.methods.lock().unwrap() = "SAFECOOKIE"; + let mut cfg = services_cfg(); + cfg.tor.cookie = Some(cookie.clone()); + start_and_exit(cfg).await.expect("SAFECOOKIE restart"); + let second = tor.take_log(); + assert_eq!( + starts_with_any(&second, "AUTHCHALLENGE SAFECOOKIE ").len(), + 1, + "{second:?}" + ); + let onions = starts_with_any(&second, "ADD_ONION "); + assert_eq!(onions.len(), 3, "{onions:?}"); + for (n, (line, target)) in onions.iter().zip(&targets).enumerate() { + assert_eq!(*line, format!("ADD_ONION ED25519-V3:key{n} {target}")); + } + assert_eq!(tor.minted.lock().unwrap().len(), 3, "no key minted twice"); + let creates = starts_with_any(&sam.take_log(), "SESSION CREATE"); + assert_eq!(creates.len(), 3, "{creates:?}"); + for n in 0..3 { + let dest = format!("DESTINATION={} ", fake_i2p_destination(n)); + assert!(creates.iter().any(|c| c.contains(&dest)), "{creates:?}"); + } +} diff --git a/docs/external_findings/024-talip-review-index.md b/docs/external_findings/024-talip-review-index.md index 17e0c57b2..bf1720f61 100644 --- a/docs/external_findings/024-talip-review-index.md +++ b/docs/external_findings/024-talip-review-index.md @@ -26,7 +26,7 @@ board. Each fixed row names the regression. No reproduction steps. | M-7 | medium | Addr relay fanout | fixed | `hostile_peer_session` ([045](./045-addr-relay.md)) | | #14 / C17 / #19 | low | Flag parity, token, inv cap | fixed | `peer_command_logs_have_no_raw_newline` ([041](./041-hygiene.md)) | | C14 / M-4 | medium | Milestone hash and signet default | fixed | `low_work_fork_does_not_skip_even_at_the_milestone_height` ([042](./042-milestone-anchor.md)) | -| L-1 / L-3 / L-12 | low | Local auth and datadir mode | fixed | `tor_plain_cookie_is_not_sent_when_safecookie_is_absent` ([044](./044-local-auth.md)) | +| L-1 / L-3 / L-12 | low | Local auth and datadir mode | fixed | `tor_control_onion_lifecycle` ([044](./044-local-auth.md)) | | L-9 | low | BIP30 after the exception window | fixed | `buried_rules_and_a_lying_header_path` ([035](./035-bip30-bip34-ancestry.md)) | | C16 | low | Regtest BIP34 height | won't-fix | `s7_regtest_does_not_activate_bip34_early` (`bip34_height` stays rust-bitcoin's value, above 1_000_000). Signet is already height 1. Production networks are unaffected. | | #03 / #13 / #16 | — | Witness reserved value, RBFR, admin RPC | rejected | not defects | diff --git a/docs/external_findings/044-local-auth.md b/docs/external_findings/044-local-auth.md index 853870a7d..ec2f0936d 100644 --- a/docs/external_findings/044-local-auth.md +++ b/docs/external_findings/044-local-auth.md @@ -21,9 +21,9 @@ address is onion or I2P. Inbound Tor and I2P still arrive as the loopback TCP peer on the shared P2P socket, so that socket address is what the grant sees. -**Regression:** `rbitcoin-node` `tor_control::tests::tor_plain_cookie_is_not_sent_when_safecookie_is_absent`, -`tor_short_cookie_does_not_fall_back_to_raw_hex`, -`config::tests::builders_paths_milestone_and_ensure`, +**Regression:** `rbitcoin-test` `tor_control_onion_lifecycle` (plain COOKIE +and a short cookie refuse the start before any `AUTHENTICATE`), +`rbitcoin-node` `config::tests::builders_paths_milestone_and_ensure`, `rbitcoin-rpc` `server::tests::unix_socket_needs_no_http_auth`, `rbitcoin-esplora` `internal::tests::esplora_unix_internal`, `rbitcoin-net` `net_permissions::tests::loopback_grant_does_not_cover_onion_or_i2p`. diff --git a/docs/external_findings/README.md b/docs/external_findings/README.md index fd07c04a1..bcebd9432 100644 --- a/docs/external_findings/README.md +++ b/docs/external_findings/README.md @@ -53,7 +53,7 @@ rbitcoin reference, or redteam static analysis). Numbered reports live beside th | [051](./051-rbfr-direct-set.md) | low | RBFR uses the direct conflict set | won't-fix | `mempool_accept_life` | | [046](./046-spend-durability.md) | high | Spend slot missing after the tip seal is unspent | fixed | `zeroed_spend_slot_after_tip_seal_rejects_respend` | | [045](./045-addr-relay.md) | medium | Addr relay is one or two peers, not every peer | fixed | `hostile_peer_session` | -| [044](./044-local-auth.md) | low | Tor SAFECOOKIE, datadir `0700`, socket mode, overlay permissions | fixed | `tor_plain_cookie_is_not_sent_when_safecookie_is_absent` | +| [044](./044-local-auth.md) | low | Tor SAFECOOKIE, datadir `0700`, socket mode, overlay permissions | fixed | `tor_control_onion_lifecycle` | | [043](./043-peer-send-buffer.md) | high | Unbounded per-peer outbound queue | fixed | `hostile_peer_session` | | [040](./040-corrupt-bounds.md) | medium | Corrupt uleb128, seqsigwit lengths, and BDZ modulus | fixed | `read_packed_zero_modulus_or_vertices_is_corrupt` | From 53d7f41791b7172556bd00687f173313719d9a69 Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Mon, 28 Sep 2026 07:56:13 -0700 Subject: [PATCH 3/5] test: one datadir turns the scripthash index on and off enter_tip_mode_indexes restarts one run_p2p datadir with --sh-index off, on, off, on. Blocks mined while the index is off are collected from Class A on the first start with it on, and Electrum serves their history. Turning it off closes Electrum. A crash leaves a collect run and a lagging include high-water mark behind. Turning the index back on resumes the durable index under write-behind: the run is discarded, not merged, and the next block lands in history. The six enter_tip_mode_* tests each opened a store and called enter_tip_mode against a synthetic chain to read IndexMode and the gate flags. run_p2p is spawned off the runtime workers, as cli_main blocks on it, because an empty datadir connects genesis through tip-accept. Co-Authored-By: Claude Opus 5.5 --- TESTING.md | 1 + crates/rbitcoin-node/src/run.rs | 257 -------------------- crates/rbitcoin-test/tests/cross_surface.rs | 132 ++++++++++ 3 files changed, 133 insertions(+), 257 deletions(-) diff --git a/TESTING.md b/TESTING.md index e3c264ab3..d858dfb97 100644 --- a/TESTING.md +++ b/TESTING.md @@ -346,6 +346,7 @@ Prefer **one high-level scenario** per behavior cluster. Delete lower-level test | `fee_history_backfills_from_the_chain_when_relay_starts` | Node + RPC | `run_p2p` over a datadir whose only fee-paying block predates startup; `generate` leaves IBD and turns relay on; `estimatesmartfee 144` answers that block's rate from the chain backfill, not from blocks mined after start | | `node_listen_and_exit` | Node + Electrum + Esplora + RPC | One `run_p2p` datadir, restarted with its one `--connect` refusing (a pinned connect at genesis still enters tip mode): a junk `peers` file and a missing `--asmap` start an empty book and exit, and the saved book records the refused connect; the next start loads that book and a valid `ip_asn.dat`, and Esplora, Electrum, and RPC answer at genesis until `stop`; an Electrum port another process holds warns and the node still exits; without `--connect` and with seeds on, regtest resolves none and the node exits short of tip mode; after a `--prune-seqsigwit` start, an unpruned start refuses. Live peers are `node_run_p2p_short` | | `tor_control_onion_lifecycle` | Node + RPC | One `run_p2p` datadir against a fake Tor control port and SAM bridge (live Tor and i2pd are overlay-functional). A cookie from another Tor (SAFECOOKIE server hash mismatch), a 2-byte cookie, and a Tor that offers only plain COOKIE each refuse the start, and none sends `AUTHENTICATE`. Password auth with `--listen-onion`, `--i2p-accept-incoming`, Electrum, and Esplora: `ADD_ONION NEW` per service with the P2P virtual port on the loopback bind, each key saved `0600` under `onion/`, each SAM destination under `i2p/`, `STREAM FORWARD` to each port, and `getnetworkinfo.localaddresses` lists the three onions and the I2P address. A SAFECOOKIE restart reuses every saved key and destination | +| `enter_tip_mode_indexes` | Node + Electrum + RPC | One `run_p2p` datadir restarted with `--sh-index` off, on, off, on. Off: RPC and tip follow run, Electrum does not listen, and `generateblock` mines three OP_TRUE coinbases. First start on: the index is collected from Class A before Electrum opens, and the OP_TRUE history has three rows. Off again: Electrum closed. On after a crash that left a collect run and a lagging include high-water mark: the durable index resumes under write-behind, so the run is discarded (not merged) and Electrum opens, and the next block lands in history | | `two_node_header_and_block_sync` | P2P (**default**) | Seeder → peer genesis+1 IBD; peer `last_write` meter. Empty `headers` lag keep-sync is `apply_peer_event_body_and_control_surface`; drained-path EOF `headers_done` is `apply_peer_event_block_framed_bq_horizon_and_headers_done`. 8-block dual-seeder stays `ibd_two_peers` | | `p2p_timeout_getaddr_and_keepalive_ping` | P2P (**default**) | One pad: v1-magic inbound drops at `peertimeout=1`, obsolete VERSION and pre-verack ping close the peer, full-relay GetAddr cache 1000, headers-sync stall replace, self-connect refuses, AddrFetch `getaddr`/`addrv2` (no `getheaders`), one keepalive ping/pong. Handshake **format** needles stay. Sole-preferred stall KEEP stays a PeerHub unit (`noban_headers_timeout_clears_awaiting_so_a_new_getheaders_can_send`: CIDR `noban@127.0.0.1` and hub `--trusted`). | | `hostile_peer_session` | P2P (**default**, crate) | One lib entry in `peer::tests`, one in `chain::tests`, one in `assign::tests`. Header cap, send budget, one-shot `getaddr`, addr relay to one or two peers, zero-prev not held, witness padding and time-too-new not cached invalid, getdata stops at the byte budget. `tip_script_pres_skips_only_matching_wtxid` stays its own test (mempool graph, not the peer session). | diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index 66b419262..0f7c8123c 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -1993,8 +1993,6 @@ fn resolve_seednode(raw: &str, network: Network) -> Result { mod tests { use super::*; use std::time::{SystemTime, UNIX_EPOCH}; - - use rbitcoin_query::testutil::FixtureChain; fn tiny_regtest(dir: impl AsRef) -> NodeConfig { NodeConfig::default() .with_datadir(dir.as_ref()) @@ -2371,243 +2369,6 @@ mod tests { } } - fn coinbase_block( - h: u32, - prev: rbitcoin_primitives::Fk, - parent_hash: Option<[u8; 32]>, - ) -> (rbitcoin_store::HeaderRecord, rbitcoin_query::TxApply) { - use rbitcoin_primitives::Fk; - use rbitcoin_query::TxApply; - use rbitcoin_store::{HeaderRecord, InputRecord, OutputRecord, TxRecord}; - - let version = 1; - let timestamp = h + 1; - let bits = 0x207fffff; - let nonce = h; - let mut merkle = [0u8; 32]; - merkle[0..4].copy_from_slice(&h.to_le_bytes()); - merkle[4] = 0xcd; - let hash = match parent_hash { - None => merkle, - Some(ph) => { - rbitcoin_store::block_header_hash(version, &ph, &merkle, timestamp, bits, nonce) - } - }; - let header = HeaderRecord { - prev_fk: prev, - version, - timestamp, - bits, - nonce, - merkle_root: merkle, - hash, - size: 0, - weight: 0, - }; - let mut txid = [0u8; 32]; - txid[0..4].copy_from_slice(&h.to_le_bytes()); - txid[31] = 0xcb; - let ta = TxApply { - tx: TxRecord { - txid, - version: 1, - locktime: 0, - input_start_fk: Fk::NULL, - input_count: 1, - output_start_fk: Fk::NULL, - output_count: 1, - }, - inputs: vec![InputRecord { - prev_txid: [0u8; 32], - create_fk: Fk::NULL, - prev_index: u32::MAX, - sequence: u32::MAX, - script_sig: vec![h as u8], - witness: vec![], - }], - outputs: vec![OutputRecord::unspent(50_0000_0000, vec![0x51, h as u8])], - }; - (header, ta) - } - - fn seed_direct_chain(q: &Query, n: u32) { - use rbitcoin_primitives::{Fk, Height}; - q.enter_direct_index_mode().unwrap(); - let mut prev = Fk::NULL; - let mut parent_hash: Option<[u8; 32]> = None; - for h in 0..n { - let (header, ta) = coinbase_block(h, prev, parent_hash); - parent_hash = Some(header.hash); - prev = q.connect_block(Height(h), &header, &[ta]).unwrap(); - } - } - - #[test] - fn enter_tip_mode_reenables_indexes() { - use rbitcoin_query::IndexMode; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("rbitcoin-tip-mode-{nanos}")); - std::fs::create_dir_all(&dir).unwrap(); - let q = Query::open_or_create_tiny(dir.join("store")).unwrap(); - q.enter_direct_index_mode().unwrap(); - assert_eq!(q.index_mode(), IndexMode::Direct); - assert!(q.spend_index_enabled()); - assert!(q.tx_index_enabled()); - - let g = enter_tip_mode(&q, None, true); - assert!(g.tip_follow_ready); - // Empty store: SH not "tip-ready" by watermark metric, but follow is on. - assert_eq!(q.index_mode(), IndexMode::Tip); - assert!(q.spend_index_enabled()); - assert!(q.tx_index_enabled()); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// Durable head + lagging HWM: enter_tip_mode must not collect; Electrum on. - #[test] - fn enter_tip_mode_durable_head_hwm_lag_writebehind() { - use rbitcoin_query::IndexMode; - use rbitcoin_store::{next_run_path, write_sorted_run}; - - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("rbitcoin-tip-wb-{nanos}")); - std::fs::create_dir_all(&dir).unwrap(); - let store = dir.join("store"); - let q = Query::open_or_create_tiny(&store).unwrap(); - seed_direct_chain(&q, 5); - assert_eq!(q.index_mode(), IndexMode::Direct); - let _ = q.finalize_sh_runs().unwrap(); - assert!(q.sh_use_writebehind()); - let count_before = q.scripthash_entry_count(); - let tip_max = q.store().txs.count(); - let lag = tip_max.saturating_sub(2).max(1); - std::fs::write( - store.join(rbitcoin_store::INCLUDE_HWM_NAME), - lag.to_le_bytes(), - ) - .unwrap(); - - let runs_dir = store.join("scripthash.runs"); - std::fs::create_dir_all(&runs_dir).unwrap(); - let mut body = Vec::new(); - let mut rec = [0u8; 40]; - rec[..32].fill(0xee); - rec[32..40].copy_from_slice(&99u64.to_le_bytes()); - body.extend_from_slice(&rec); - write_sorted_run(&next_run_path(&runs_dir, 50), 40, 40, &body).unwrap(); - - let g = enter_tip_mode(&q, None, true); - assert!(g.tip_follow_ready); - assert!( - g.sh_tip_ready, - "durable head chooses write-behind; Electrum must not wait on HWM==tip" - ); - assert_eq!(q.index_mode(), IndexMode::Tip); - assert_eq!(q.scripthash_entry_count(), count_before); - assert_eq!(q.scripthash_run_count(), 0); - assert_eq!(q.store().scripthash.include_hwm(), lag); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// First-time SH: collect while Direct, then Tip. - #[test] - fn enter_tip_mode_collects_while_direct_then_tip() { - use rbitcoin_query::IndexMode; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("rbitcoin-tip-collect-{nanos}")); - std::fs::create_dir_all(&dir).unwrap(); - let q = Query::open_or_create_tiny(dir.join("store")).unwrap(); - seed_direct_chain(&q, 4); - assert_eq!(q.index_mode(), IndexMode::Direct); - assert!(!q.store().scripthash.has_durable_index()); - assert!(!q.sh_use_writebehind()); - - let g = enter_tip_mode(&q, None, true); - assert!(g.tip_follow_ready); - assert!(g.sh_tip_ready); - assert_eq!(q.index_mode(), IndexMode::Tip); - assert!(q.store().scripthash.has_durable_index()); - assert_eq!(q.scripthash_run_count(), 0); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn enter_tip_mode_shindex_off_skips_sh_and_enables_follow() { - use rbitcoin_query::IndexMode; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("rbitcoin-tip-nosh-{nanos}")); - std::fs::create_dir_all(&dir).unwrap(); - let q = Query::open_or_create_tiny(dir.join("store")).unwrap(); - q.enter_direct_index_mode_sh(false).unwrap(); - assert!(!q.sh_index_enabled()); - assert!(!q.sh_run_enabled()); - - let g = enter_tip_mode(&q, None, false); - assert!(g.tip_follow_ready, "tip follow must not wait on SH"); - assert!( - !g.sh_tip_ready, - "Electrum gate stays closed without shindex" - ); - assert_eq!(q.index_mode(), IndexMode::Tip); - assert!(!q.sh_index_enabled()); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn enter_tip_mode_disable_after_on_leaves_sh_tables() { - use rbitcoin_query::IndexMode; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("rbitcoin-tip-sh-off-{nanos}")); - std::fs::create_dir_all(&dir).unwrap(); - let store = dir.join("store"); - let q = Query::open_or_create_tiny(&store).unwrap(); - q.enter_direct_index_mode_sh(true).unwrap(); - assert!(q.sh_index_enabled()); - let on = enter_tip_mode(&q, None, true); - assert!(on.tip_follow_ready); - let sh_body = store.join("scripthash.body"); - assert!( - sh_body.is_file() || sh_body.join("00").is_file(), - "tip SH materialize must leave a body" - ); - - let off = enter_tip_mode(&q, None, false); - assert!(off.tip_follow_ready, "follow stays on after disable"); - assert!(!off.sh_tip_ready, "Electrum gate closes when shindex off"); - assert!(!q.sh_index_enabled()); - assert_eq!(q.index_mode(), IndexMode::Tip); - assert!( - sh_body.is_file() || sh_body.join("00").is_file(), - "disable must not purge SH tables" - ); - - let again = enter_tip_mode(&q, None, true); - assert!(again.tip_follow_ready); - assert!(q.sh_index_enabled()); - assert!(sh_body.is_file() || sh_body.join("00").is_file()); - - let _ = std::fs::remove_dir_all(&dir); - } - #[test] fn shutdown_flag_and_node_handle_smoke() { let sd = Shutdown::new(); @@ -2717,24 +2478,6 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - #[test] - fn enter_tip_mode_warns_on_leftover_runs_dir() { - use rbitcoin_query::IndexMode; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("rbitcoin-tip-leftover-{nanos}")); - std::fs::create_dir_all(dir.join("store")).unwrap(); - let q = Query::open_or_create_tiny(dir.join("store")).unwrap(); - q.enter_direct_index_mode().unwrap(); - // Empty store: finalize has no runs; still flips to tip. - let g = enter_tip_mode(&q, None, true); - assert!(g.tip_follow_ready); - assert_eq!(q.index_mode(), IndexMode::Tip); - let _ = std::fs::remove_dir_all(&dir); - } - #[test] fn node_handle_shutdown_with_mempool() { use rbitcoin_net::MempoolHub; diff --git a/crates/rbitcoin-test/tests/cross_surface.rs b/crates/rbitcoin-test/tests/cross_surface.rs index 10f707419..8f803f214 100644 --- a/crates/rbitcoin-test/tests/cross_surface.rs +++ b/crates/rbitcoin-test/tests/cross_surface.rs @@ -2441,3 +2441,135 @@ async fn tor_control_onion_lifecycle() { assert!(creates.iter().any(|c| c.contains(&dest)), "{creates:?}"); } } + +/// `run_p2p` off the runtime workers, as `cli_main` blocks on it: an empty +/// datadir connects genesis through tip-accept, which refuses a worker. +fn spawn_run_p2p(cfg: NodeConfig) -> tokio::task::JoinHandle> { + tokio::task::spawn_blocking(move || { + let _block = rbitcoin_net::BlockingRegion::enter(); + tokio::runtime::Handle::current().block_on(run_p2p(cfg)) + }) +} + +async fn stop_run_p2p( + rpc_addr: SocketAddr, + node: tokio::task::JoinHandle>, +) { + let _ = jsonrpc(rpc_addr, "stop", json!([])).await; + match tokio::time::timeout(Duration::from_secs(15), node).await { + Ok(Ok(Ok(()))) => {} + Ok(Ok(Err(e))) => panic!("run_p2p error after stop: {e}"), + Ok(Err(e)) => panic!("run_p2p join: {e}"), + Err(_) => panic!("run_p2p did not exit after stop"), + } +} + +async fn history_len(electrum_addr: SocketAddr, scripthash: &str) -> usize { + let mut el = TcpStream::connect(electrum_addr).await.unwrap(); + let hist = electrum_rpc( + &mut el, + 1, + "blockchain.scripthash.get_history", + json!([scripthash]), + ) + .await; + hist["result"] + .as_array() + .unwrap_or_else(|| panic!("{hist}")) + .len() +} + +/// One datadir whose operator turns `--sh-index` on and off across +/// restarts. Blocks mined while it is off are collected from the archive +/// on the first start with it on; turning it off closes Electrum and keeps +/// the index; turning it back on after a crash that left a collect run and +/// a lagging high-water mark resumes the write-behind, discards the run, +/// and follows the next block. +#[tokio::test(flavor = "multi_thread")] +async fn enter_tip_mode_indexes() { + let td = TestDatadir::new().unwrap(); + let dir = td.path(); + let store = td.store_path(); + std::fs::write(dir.join("rpc.token"), "pass").unwrap(); + let (electrum_addr, rpc_addr) = (ephemeral_addr(), ephemeral_addr()); + let start = |shindex: bool| { + let mut cfg = NodeConfig::default() + .with_datadir(&dir) + .with_network(Network::Regtest) + .with_tiny_heads() + .with_p2p_listen("127.0.0.1:0".parse().unwrap()); + cfg.listen.use_seeds = false; + cfg.listen.connect.clear(); + cfg.shindex = shindex; + cfg.listen.electrum = Some(electrum_addr); + cfg.rpc.listen = Some(rpc_addr); + cfg.max_run_secs = Some(60); + spawn_run_p2p(cfg) + }; + let op_true = electrum_scripthash_hex(&[0x51]); + + // No scripthash index: tip follow and RPC run, Electrum does not. + let node = start(false); + wait_listeners(&[rpc_addr]).await; + for _ in 0..3 { + let mined = jsonrpc(rpc_addr, "generateblock", json!(["raw(51)", []])).await; + assert!(mined["result"]["hash"].is_string(), "{mined}"); + } + assert!(TcpStream::connect(electrum_addr).await.is_err()); + stop_run_p2p(rpc_addr, node).await; + + // First start with the index: the three coinbases are collected from the + // archive before Electrum opens. + let node = start(true); + wait_listeners(&[electrum_addr, rpc_addr]).await; + assert_eq!(history_len(electrum_addr, &op_true).await, 3); + stop_run_p2p(rpc_addr, node).await; + + // Index off again: Electrum stays closed. + let node = start(false); + wait_listeners(&[rpc_addr]).await; + assert!(TcpStream::connect(electrum_addr).await.is_err()); + stop_run_p2p(rpc_addr, node).await; + + // A crash left a collect run behind and the write-behind mark short of + // the tip. Turning the index off kept it, so it resumes under + // write-behind: a recollect would merge the stale run, and instead the + // run is discarded, Electrum opens, and the next block lands in history. + let runs = store.join("scripthash.runs"); + std::fs::create_dir_all(&runs).unwrap(); + let stale_sh = [0xee; 32]; + let mut rec = [0u8; 40]; + rec[..32].copy_from_slice(&stale_sh); + rec[32..].copy_from_slice(&99u64.to_le_bytes()); + rbitcoin_store::write_sorted_run(&rbitcoin_store::next_run_path(&runs, 50), 40, 40, &rec) + .unwrap(); + let hwm_path = store.join(rbitcoin_store::INCLUDE_HWM_NAME); + let hwm = u64::from_le_bytes(std::fs::read(&hwm_path).unwrap().try_into().unwrap()); + std::fs::write(&hwm_path, (hwm - 2).to_le_bytes()).unwrap(); + let node = start(true); + wait_listeners(&[electrum_addr, rpc_addr]).await; + assert_eq!( + rbitcoin_store::list_runs(&runs).unwrap().len(), + 0, + "leftover run discarded" + ); + assert_eq!( + history_len( + electrum_addr, + &bitcoin::hex::DisplayHex::to_lower_hex_string(&stale_sh[..]) + ) + .await, + 0 + ); + let mined = jsonrpc(rpc_addr, "generateblock", json!(["raw(51)", []])).await; + assert!(mined["result"]["hash"].is_string(), "{mined}"); + let deadline = Instant::now() + Duration::from_secs(10); + while history_len(electrum_addr, &op_true).await < 4 { + assert!( + Instant::now() < deadline, + "write-behind did not reach the tip" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + stop_run_p2p(rpc_addr, node).await; +} From 519de9b65cb8425bbc48200f2d32e45d44ff913f Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Mon, 28 Sep 2026 08:12:26 -0700 Subject: [PATCH 4/5] test: fold the node CLI and conf twins into the smoke journey node_cli_and_surface_smoke now owns what an operator sees from argv and a conf file. Usage errors, including every concatenated and Core spelling, exit 2 before a datadir is touched. A conf file that is missing, has a bad line, a bad log_level, rpcuser, rpcpassword, or max_outbound=0 exits 2 and opens no store. Flags that describe a node that cannot run exit 1: non-hex --min-chain-work, a signet challenge off signet, a signet block time with no challenge, and tweaks with seqsigwit pruning. One smoke takes the native flag set together. CLI --datadir wins over conf datadir. --datadir-cold puts seqsigwit on the cold store. rbitcoin-cli help and version do not dial. Twenty cli.rs, config.rs, and rbitcoin-cli tests covered those needles, and nine of them opened their own store. The values argv and the conf assemble (defaults, milestone anchor, listen ports, help text) are not reported by any running surface. They stay as one node-crate test, operator_conf_and_argv, in place of fifteen parse tests. Co-Authored-By: Claude Opus 5.5 --- TESTING.md | 2 +- crates/rbitcoin-cli/src/lib.rs | 7 - crates/rbitcoin-node/src/cli.rs | 899 +++++------------- crates/rbitcoin-node/src/config.rs | 202 ---- crates/rbitcoin-test/tests/scenarios.rs | 361 +++++-- .../external_findings/042-milestone-anchor.md | 2 +- 6 files changed, 520 insertions(+), 953 deletions(-) diff --git a/TESTING.md b/TESTING.md index d858dfb97..618f0d138 100644 --- a/TESTING.md +++ b/TESTING.md @@ -309,7 +309,7 @@ Prefer **one high-level scenario** per behavior cluster. Delete lower-level test | ID | Layer | Description | |----|-------|-------------| | `overlay_config` | Node CLI + net | In-process onion / I2P / cjdns matrix: `onlynet`, proxy, SAM, reachable, accept-incoming, and one parse or learn check per network. Live Tor, i2pd, and cjdns stay in overlay-functional. | -| `node_cli_and_surface_smoke` | Lifecycle/CLI | Networks, `run_node`, config errors, CLI flags (incl. `--conf`, `--peer-timeout=0` refuse / `=1` smoke, unknown conf key ignored, `min_relay_tx_fee=-1` and `network=nope` conf fail), dropped Core `--rpcuser`/`--rpcpassword`/`--rpcport`/`--rpcconnect`/`-rpcport` refuse, help/version. Signet: genesis header plus height-1 BIP325 connect | +| `node_cli_and_surface_smoke` | Lifecycle/CLI | Networks, `run_node`, config errors, CLI flags (incl. `--conf`, `--peer-timeout=0` refuse / `=1` smoke, unknown conf key ignored, `min_relay_tx_fee=-1` and `network=nope` conf fail), dropped Core `--rpcuser`/`--rpcpassword`/`--rpcport`/`--rpcconnect`/`-rpcport` refuse, help/version (`rbitcoin-cli` help and version do not dial). Usage errors exit 2 before a datadir is touched: unknown flags, missing or bad values, and every concatenated, one-dash, or Core spelling of a native kebab flag. A missing conf, a bad line, a bad `log_level`, conf `rpcuser`/`rpcpassword`, and `max_outbound=0` exit 2 and open no store. Non-hex `--min-chain-work`, a signet challenge or block time off a custom signet, and `--sp-tweaks` with `--prune-seqsigwit` exit 1. One smoke takes the native flag set together; bare network conf lines parse and CLI `--datadir` wins over conf `datadir`; `--datadir-cold` puts seqsigwit on the cold store; a custom signet smokes. Signet: genesis header plus height-1 BIP325 connect. The assembled `NodeConfig` values and help text no surface reports are the node-crate `operator_conf_and_argv` | | `three_stage_confirm_and_parent_pin_surface` | Consensus+query | Split load→scripts→write of pad+spend from genesis (header-plan BIP68 MTP); parent pin; load ready timeout/cancel; instance-owned `last_write` / `last_pin` / `take_window` meters (a second engine's window stays empty); txstat fee / size / weight from the load assemble, restamp and its refuses; same-run create then spend; 546-shaped 2-vout merge + same-block chain + cross-batch head resolve; an already-at-height retry finishes a spend annotate | | `mempool_under_pressure` | Mempool + RPC (crate) | One entry in `orphanage`, `accept`, `tx_relay`, and `methods_tests`: orphan reserve and expiry, sigops before script, rolling fee floor, cluster cap, parked min-relay orphan, and the package RPC rejects (unsorted, missing inputs, conflict, min-relay parent with maxfeerate child). | | `mempool_accept_life` | Mempool (crate) | One `ActiveMempool` against one chain view that blocks move. Orphan parks and re-announce, dry run not parked, parent promotes the child; missing vout, invalid parent, and block-spent coin reject without parking. Full RBF and no return, the staged commit failing closed on a conflict that landed after prepare, pure RBFR unpinning a child, replaced txs out of the cluster count; a ~30 kvB single tx under the vsize cap and the ten-way merge over it. Package order, CPFP, child fail restoring the RBF victim. A block evicts double-spent txs with descendants; a reorg readmits the parent and evicts the BIP68 and coinbase-maturity spends. Raised `-minrelaytxfee`: 1p1c needs a paying child, an unrelated tx does not ride the waiver, child fail takes a promoted spender down. Full pool: a protected lone worst chunk evicts nothing and leaves the floor, the next arrival evicts the CPFP pair together. | diff --git a/crates/rbitcoin-cli/src/lib.rs b/crates/rbitcoin-cli/src/lib.rs index 79dda52d6..a4feaa982 100644 --- a/crates/rbitcoin-cli/src/lib.rs +++ b/crates/rbitcoin-cli/src/lib.rs @@ -382,13 +382,6 @@ mod tests { assert_eq!(param_value("abc"), serde_json::json!("abc")); } - #[test] - fn help_and_version_do_not_dial() { - assert!(exit_ok(cli_main(["rbitcoin-cli", "--help"]))); - assert!(exit_ok(cli_main(["rbitcoin-cli", "-V"]))); - assert!(exit_ok(cli_main(["rbitcoin-cli", "help"]))); - } - #[test] fn equals_form_datadir_is_accepted() { let dir = tmp_datadir(); diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index 2b05b3d4b..c1f05b57d 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -564,7 +564,62 @@ mod tests { } #[test] - fn help_advertises_kebab_not_concatenated_core_names() { + fn bytes_per_sigop_flag_sets_mempool_overlay() { + assert_eq!( + ready_config(["rbitcoin-node"]).mempool.bytes_per_sigop, + None + ); + let cfg = ready_config(["rbitcoin-node", "--bytes-per-sigop", "0"]); + assert_eq!(cfg.mempool.bytes_per_sigop, Some(0)); + let cfg = ready_config(["rbitcoin-node", "--bytes-per-sigop=40"]); + assert_eq!(cfg.mempool.bytes_per_sigop, Some(40)); + assert_exit( + cli_main(["rbitcoin-node", "--bytes-per-sigop=x"]), + ExitCode::from(2), + ); + } + + #[test] + fn block_reserved_sigops_flag_configures_shared_budget() { + let cfg = ready_config(["rbitcoin-node", "--block-reserved-sigops", "0"]); + assert_eq!(cfg.mempool.block_reserved_sigops, Some(0)); + let cfg = ready_config(["rbitcoin-node", "--block-reserved-sigops=400"]); + assert_eq!(cfg.mempool.block_reserved_sigops, Some(400)); + let mut cfg = ready_config(["rbitcoin-node", "--block-reserved-sigops", "80001"]); + assert!(cfg.validate().is_err()); + cfg.mempool.block_reserved_sigops = Some(80_000); + cfg.validate().expect("consensus maximum is accepted"); + } + + /// Tweaks need the scriptSig and witness data seqsigwit pruning drops, + /// so the pair is refused from the CLI and from the conf file alike. + #[test] + fn sp_tweaks_refuses_prune_seqsigwit() { + let cli = ready_config(["rbitcoin-node", "--sp-tweaks", "--prune-seqsigwit"]); + let err = cli.validate().unwrap_err().to_string(); + assert!( + err.contains("--sp-tweaks") && err.contains("--prune-seqsigwit"), + "{err}" + ); + let mut conf = NodeConfig::default(); + conf.apply_kv("prune_seqsigwit", "1").unwrap(); + conf.apply_kv("sp_tweaks", "1").unwrap(); + assert!(conf.validate().is_err()); + } + + /// What argv and the conf file assemble before `run_node`. The smoke + /// journey (`node_cli_and_surface_smoke`) sees only the exit code and + /// the datadir; these values and the help text are not reported by any + /// running surface, so this is the one place they are read. + #[allow(clippy::cognitive_complexity)] // one operator's conf and argv + #[test] + fn operator_conf_and_argv() { + use crate::config::P2pListen; + use rbitcoin_consensus::Milestone; + let _g = OPERATOR_ENV_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let h = operator_usage(); for flag in [ "--prefill-compact", @@ -597,6 +652,7 @@ mod tests { "--sh-index", "--block-filter-index", "--prune-seqsigwit", + "--prune-seqsigwit-ram-threshold-bytes", "--sp-tweaks", "--sp-tweaks-dust", "--esplora-block-template", @@ -620,13 +676,12 @@ mod tests { ] { assert!(h.contains(flag), "help must list {flag}"); } - for concat in ["--shindex", "--sptweaks", "-shindex", "-sptweaks"] { - assert!( - !h.contains(concat), - "help must not advertise concatenated or one-dash long {concat}" - ); - } for concat in [ + "--shindex", + "--sptweaks", + "-shindex", + "-sptweaks", + "--pruneseqsigwit", "--prefillcompact", "--limitclustercount", "--limitclustersize", @@ -655,247 +710,215 @@ mod tests { ] { assert!(!h.contains(concat), "help must not advertise {concat}"); } - assert!( - h.contains("Networks: mainnet|testnet|signet|regtest."), - "network list must end with a period" - ); - assert!( - h.contains("[--signet-block-time SECS]"), - "duration placeholder must be SECS" - ); - } + assert!(h.contains("Networks: mainnet|testnet|signet|regtest.")); + assert!(h.contains("[--signet-block-time SECS]")); + assert!(matches!( + operator_config_from_args(["rbitcoin-node", "-V"]), + Ok(OperatorArgs::Version) + )); + assert!(matches!( + operator_config_from_args(["rbitcoin-node", "--log-level=off"]), + Ok(OperatorArgs::Ready { + log_level_cli: Some(None), + .. + }) + )); - #[test] - fn bytes_per_sigop_flag_sets_mempool_overlay() { + // No argv: mainnet with the anchored milestone and the chainwork floor. + let omitted = ready_config(["rbitcoin-node"]); + assert_eq!(omitted.network, Network::Mainnet); assert_eq!( - ready_config(["rbitcoin-node"]).mempool.bytes_per_sigop, - None - ); - let cfg = ready_config(["rbitcoin-node", "--bytes-per-sigop", "0"]); - assert_eq!(cfg.mempool.bytes_per_sigop, Some(0)); - let cfg = ready_config(["rbitcoin-node", "--bytes-per-sigop=40"]); - assert_eq!(cfg.mempool.bytes_per_sigop, Some(40)); - assert_exit( - cli_main(["rbitcoin-node", "--bytes-per-sigop=x"]), - ExitCode::from(2), - ); - } - - #[test] - fn block_reserved_sigops_flag_configures_shared_budget() { - let cfg = ready_config(["rbitcoin-node", "--block-reserved-sigops", "0"]); - assert_eq!(cfg.mempool.block_reserved_sigops, Some(0)); - let cfg = ready_config(["rbitcoin-node", "--block-reserved-sigops=400"]); - assert_eq!(cfg.mempool.block_reserved_sigops, Some(400)); - let mut cfg = ready_config(["rbitcoin-node", "--block-reserved-sigops", "80001"]); - assert!(cfg.validate().is_err()); - cfg.mempool.block_reserved_sigops = Some(80_000); - cfg.validate().expect("consensus maximum is accepted"); - } - - #[test] - fn sh_index_and_sp_tweaks_are_kebab_not_concat() { - let on = ready_config(["rbitcoin-node", "--sh-index", "--sp-tweaks"]); - assert!(on.shindex); - assert!(on.sptweaks); - let eq = ready_config(["rbitcoin-node", "--sh-index=1", "--sp-tweaks-dust=546"]); - assert!(eq.shindex); - assert_eq!(eq.sptweaks_dust, 546); - assert_exit(cli_main(["rbitcoin-node", "--shindex"]), ExitCode::from(2)); - assert_exit(cli_main(["rbitcoin-node", "-shindex"]), ExitCode::from(2)); - assert_exit(cli_main(["rbitcoin-node", "--sptweaks"]), ExitCode::from(2)); - assert_exit( - cli_main(["rbitcoin-node", "--sptweaks-dust=1"]), - ExitCode::from(2), - ); - } - - /// Tweaks need the scriptSig and witness data seqsigwit pruning drops, - /// so the pair is refused from the CLI and from the conf file alike. - #[test] - fn sp_tweaks_refuses_prune_seqsigwit() { - let cli = ready_config(["rbitcoin-node", "--sp-tweaks", "--prune-seqsigwit"]); - let err = cli.validate().unwrap_err().to_string(); - assert!( - err.contains("--sp-tweaks") && err.contains("--prune-seqsigwit"), - "{err}" + omitted.milestone_height, + default_milestone_height(Network::Mainnet) ); - let mut conf = NodeConfig::default(); - conf.apply_kv("prune_seqsigwit", "1").unwrap(); - conf.apply_kv("sp_tweaks", "1").unwrap(); - assert!(conf.validate().is_err()); - } - - #[test] - fn max_inbound_zero_is_allowed() { - let _g = OPERATOR_ENV_TEST_LOCK.lock().unwrap(); - let cfg = ready_config(["rbitcoin-node", "--max-inbound", "0"]); - assert_eq!(cfg.listen.max_inbound, 0); - assert!(cfg.listen.max_inbound_explicit); - cfg.validate() - .expect("CLI --max-inbound 0 must assemble and validate"); - let out = NodeConfig::default() - .apply_kv("max_outbound", "0") - .unwrap_err(); - assert!(format!("{out}").contains("max_outbound")); - } - - #[test] - fn listen_zero_does_not_default_loopback() { - let _g = OPERATOR_ENV_TEST_LOCK.lock().unwrap(); - let mut c = NodeConfig::default(); - assert_eq!(c.apply_kv("listen", "0").unwrap(), ConfApply::Applied); - assert_eq!(c.listen.p2p, crate::config::P2pListen::Off); - assert!(c.listen.p2p_bind_addr(Network::Regtest).is_none()); - - let n = ready_config(["rbitcoin-node", "--no-listen"]); - assert_eq!(n.listen.p2p, crate::config::P2pListen::Off); - assert!(n.listen.p2p_bind_addr(Network::Regtest).is_none()); - - let eq = ready_config(["rbitcoin-node", "--listen=0"]); - assert_eq!(eq.listen.p2p, crate::config::P2pListen::Off); - - let bound = ready_config(["rbitcoin-node", "--listen", "127.0.0.1:18444"]); + assert!(omitted.milestone().anchor.is_some()); + assert!(!omitted.milestone().skips_scripts_at(1)); + assert!(!omitted.meets_minimum_chain_work([0; 32])); + assert!(omitted.prefill_compact); + assert_eq!(omitted.check_blocks, None); assert_eq!( - bound.listen.p2p, - crate::config::P2pListen::Socket("127.0.0.1:18444".parse().unwrap()) + omitted.check_blocks_window(), + rbitcoin_store::VERIFY_TIP_BLOCKS ); + assert!(omitted.listen.discover); + assert_eq!(omitted.listen.p2p, P2pListen::Auto); assert_eq!( - bound.listen.p2p_bind_addr(Network::Regtest), + omitted.listen.p2p_bind_addr(Network::Regtest), Some("127.0.0.1:18444".parse().unwrap()) ); - let auto = NodeConfig::default(); - assert_eq!(auto.listen.p2p, crate::config::P2pListen::Auto); + // A custom signet operator's conf file. + let dir = tmp_datadir(); + std::fs::create_dir_all(&dir).unwrap(); + let knobs = dir.join("rbitcoin.conf"); + std::fs::write( + &knobs, + "# custom signet\n\ + network=signet\n\ + signet_challenge=51\n\ + signet_block_time=60\n\ + max_inbound=40\n\ + max_outbound=8\n\ + mempool_size_mb=50\n\ + milestone=100\n\ + log_level=debug\n\ + api_log=/tmp/rbitcoin-api.jsonl\n\ + asmap=/tmp/ip_asn.dat\n\ + connect=127.0.0.1:38333\n\ + datadir-cold=/mnt/hdd/rbtc-cold\n", + ) + .unwrap(); + let cfg = ready_config(["rbitcoin-node", "--conf", knobs.to_str().unwrap()]); + assert_eq!(cfg.network, Network::Signet); + let params = cfg.chain_params().unwrap(); + assert_eq!(params.btc.pow_target_spacing, 60); + assert_eq!(params.signet_challenge.unwrap().as_bytes(), &[0x51]); + assert_eq!(cfg.listen.max_inbound, 40); + assert!(cfg.listen.max_inbound_explicit); + assert_eq!(cfg.listen.max_outbound, 8); + assert_eq!(cfg.mempool.max_weight, 50_000_000); + assert_eq!(cfg.milestone_height, 100); + assert_eq!(cfg.conf_log_level.as_deref(), Some("debug")); assert_eq!( - auto.listen.p2p_bind_addr(Network::Regtest), - Some("127.0.0.1:18444".parse().unwrap()) + cfg.api_log.as_deref(), + Some(std::path::Path::new("/tmp/rbitcoin-api.jsonl")) ); - - let h = operator_usage(); - assert!( - h.contains("--no-listen"), - "help must list kebab --no-listen" + assert_eq!( + cfg.asmap.as_deref(), + Some(std::path::Path::new("/tmp/ip_asn.dat")) ); - assert!( - !h.contains("--nolisten"), - "help must not advertise concatenated --nolisten" + assert_eq!(cfg.listen.connect.len(), 1); + assert!(cfg.listen.connect_dns.is_empty()); + assert_eq!( + cfg.datadir.cold.as_deref(), + Some(std::path::Path::new("/mnt/hdd/rbtc-cold")) ); - } - #[test] - fn prune_seqsigwit_is_kebab() { - let on = ready_config([ + // Bare network lines, and an explicit milestone 0 that the network + // default does not replace, from the conf and from argv alike. + for (bare, net) in [ + ("regtest", Network::Regtest), + ("signet", Network::Signet), + ("testnet", Network::Testnet), + ] { + let conf = dir.join(format!("{bare}.conf")); + std::fs::write(&conf, format!("{bare}\n; also a comment\n\nno_seeds=1\n")).unwrap(); + let eq = format!("--conf={}", conf.to_str().unwrap()); + let cfg = ready_config(["rbitcoin-node", eq.as_str()]); + assert_eq!(cfg.network, net); + assert!(!cfg.listen.use_seeds); + } + let zero = dir.join("milestone.conf"); + std::fs::write(&zero, "milestone=0\n").unwrap(); + for cfg in [ + ready_config(["rbitcoin-node", "--conf", zero.to_str().unwrap()]), + ready_config(["rbitcoin-node", "--milestone", "0"]), + ] { + assert_eq!(cfg.network, Network::Mainnet); + assert_eq!(cfg.milestone(), Milestone::NONE); + } + let _ = std::fs::remove_dir_all(&dir); + let explicit = ready_config(["rbitcoin-node", "--milestone", "840000"]); + assert!(explicit.milestone().anchor.is_none()); + assert!(explicit.milestone().skips_scripts_at(1)); + let signet = ready_config(["rbitcoin-node", "--network=signet"]); + assert_eq!(signet.milestone_height, 0); + assert!(!signet.milestone().skips_scripts_at(1)); + let signet_skip = ready_config([ "rbitcoin-node", - "--prune-seqsigwit", - "--prune-seqsigwit-ram-threshold-bytes=4096", + "--network=signet", + "--milestone", + "2000000", ]); - assert!(on.prune_seqsigwit); - assert_eq!(on.prune_seqsigwit_ram_threshold_bytes, 4096); - let mut conf = NodeConfig::default(); - conf.apply_kv("prune_seqsigwit", "1").unwrap(); - conf.apply_kv("prune_seqsigwit_ram_threshold_bytes", "8192") - .unwrap(); - assert!(conf.prune_seqsigwit); - assert_eq!(conf.prune_seqsigwit_ram_threshold_bytes, 8192); - conf.apply_kv("prune_seqsigwit_ram_threshold_bytes", "0") - .unwrap(); - assert_eq!(conf.prune_seqsigwit_ram_threshold_bytes, 0); - let h = operator_usage(); - assert!(h.contains("--prune-seqsigwit")); - assert!(h.contains("--prune-seqsigwit-ram-threshold-bytes")); - assert!(!h.contains("--pruneseqsigwit")); - assert_exit( - cli_main(["rbitcoin-node", "--pruneseqsigwit"]), - ExitCode::from(2), - ); - } - - include!("overlay_config_journey.rs"); - - #[test] - fn no_discover_conf() { - let _g = OPERATOR_ENV_TEST_LOCK.lock().unwrap(); - assert!(NodeConfig::default().listen.discover); - let off = ready_config(["rbitcoin-node", "--no-discover"]); - assert!(!off.listen.discover); - let mut c = NodeConfig::default(); - assert_eq!(c.apply_kv("no_discover", "1").unwrap(), ConfApply::Applied); - assert!(!c.listen.discover); - c.apply_kv("no_discover", "0").unwrap(); - assert!(c.listen.discover); - } + assert!(signet_skip.milestone().skips_scripts_at(1)); + let work = ready_config(["rbitcoin-node", "--min-chain-work=0x65"]); + assert_eq!(work.minimum_chain_work.unwrap()[31], 0x65); - #[test] - fn kebab_seed_node_and_min_relay_tx_fee_parse() { - let seeds = ready_config(["rbitcoin-node", "--seed-node", "127.0.0.1:8333"]); - assert_eq!(seeds.listen.seednodes, vec!["127.0.0.1:8333".to_string()]); - let fee = ready_config(["rbitcoin-node", "--min-relay-tx-fee", "0.00001000"]); - assert_eq!(fee.mempool.min_relay_fee_btc.as_deref(), Some("0.00001000")); - let compact = ready_config(["rbitcoin-node", "--prefill-compact=0"]); - assert!(!compact.prefill_compact); - let rpc = ready_config(["rbitcoin-node", "--network", "regtest", "--rpc-listen"]); - assert!(rpc.rpc.socket); - assert_eq!(rpc.rpc.listen.unwrap().port(), 18443); - assert_eq!(rpc.rpc.listen.unwrap().ip().to_string(), "127.0.0.1"); - let sock = ready_config(["rbitcoin-node", "--rpc"]); - assert!(sock.rpc.socket); - assert!(sock.rpc.listen.is_none()); - let el = ready_config(["rbitcoin-node", "--sh-index", "--electrum-listen"]); - assert_eq!(el.listen.electrum.unwrap().port(), 50001); - let es = ready_config(["rbitcoin-node", "--sh-index", "--esplora-listen"]); - match es.listen.esplora.unwrap() { + // Kebab flags set the knob the conf key of the same name sets. + let cfg = ready_config([ + "rbitcoin-node", + "--network", + "regtest", + "--sh-index", + "--sp-tweaks", + "--sp-tweaks-dust=546", + "--max-inbound", + "0", + "--no-discover", + "--seed-node", + "127.0.0.1:8333", + "--min-relay-tx-fee", + "0.00001000", + "--rpc-listen", + "--electrum-listen", + "--esplora-listen", + ]); + assert!(cfg.shindex && cfg.sptweaks); + assert_eq!(cfg.sptweaks_dust, 546); + assert_eq!(cfg.listen.max_inbound, 0); + assert!(cfg.listen.max_inbound_explicit); + assert!(!cfg.listen.discover); + assert_eq!(cfg.listen.seednodes, vec!["127.0.0.1:8333".to_string()]); + assert_eq!(cfg.mempool.min_relay_fee_btc.as_deref(), Some("0.00001000")); + assert!(cfg.rpc.socket); + assert_eq!(cfg.rpc.listen, Some("127.0.0.1:18443".parse().unwrap())); + assert_eq!(cfg.listen.electrum.unwrap().port(), 50001); + match cfg.listen.esplora.unwrap() { rbitcoin_esplora::EsploraListen::Tcp(a) => assert_eq!(a.port(), 3000), #[cfg(unix)] rbitcoin_esplora::EsploraListen::Unix(_) => panic!("default esplora-listen is TCP"), } #[cfg(unix)] + match ready_config(["rbitcoin-node", "--esplora-listen", "/tmp/esplora.sock"]) + .listen + .esplora + .unwrap() { - let es_unix = ready_config([ - "rbitcoin-node", - "--sh-index", - "--esplora-listen", - "/tmp/esplora.sock", - ]); - match es_unix.listen.esplora.unwrap() { - rbitcoin_esplora::EsploraListen::Unix(p) => { - assert_eq!(p, std::path::PathBuf::from("/tmp/esplora.sock")) - } - rbitcoin_esplora::EsploraListen::Tcp(_) => panic!("path must be unix"), + rbitcoin_esplora::EsploraListen::Unix(p) => { + assert_eq!(p, PathBuf::from("/tmp/esplora.sock")) } + rbitcoin_esplora::EsploraListen::Tcp(_) => panic!("path must be unix"), } - } - - #[test] - fn check_blocks_cli_parses_zero_and_negative() { - let omitted = ready_config(["rbitcoin-node"]); - assert_eq!(omitted.check_blocks, None); + let sock = ready_config(["rbitcoin-node", "--rpc"]); + assert!(sock.rpc.socket && sock.rpc.listen.is_none()); + let pruned = ready_config([ + "rbitcoin-node", + "--prune-seqsigwit", + "--prune-seqsigwit-ram-threshold-bytes=4096", + ]); + assert!(pruned.prune_seqsigwit); + assert_eq!(pruned.prune_seqsigwit_ram_threshold_bytes, 4096); + for (argv, want) in [ + (&["--prefill-compact=0"][..], false), + (&["--prefill-compact"], true), + (&["--prefill-compact=1"], true), + ] { + let mut args = vec!["rbitcoin-node"]; + args.extend_from_slice(argv); + assert_eq!(ready_config(args).prefill_compact, want, "{argv:?}"); + } + for (arg, stored, window) in [ + ("--check-blocks=6", 6, 6), + ("--check-blocks=0", 0, 0), + ("--check-blocks=-1", -1, 0), + ] { + let cfg = ready_config(["rbitcoin-node", arg]); + assert_eq!(cfg.check_blocks, Some(stored)); + assert_eq!(cfg.check_blocks_window(), window, "{arg}"); + } + for off in ["--no-listen", "--listen=0"] { + let cfg = ready_config(["rbitcoin-node", off]); + assert_eq!(cfg.listen.p2p, P2pListen::Off, "{off}"); + assert!(cfg.listen.p2p_bind_addr(Network::Regtest).is_none()); + } + let bound = ready_config(["rbitcoin-node", "--listen", "127.0.0.1:18445"]); assert_eq!( - omitted.check_blocks_window(), - rbitcoin_store::VERIFY_TIP_BLOCKS + bound.listen.p2p_bind_addr(Network::Regtest), + Some("127.0.0.1:18445".parse().unwrap()) ); - let six = ready_config(["rbitcoin-node", "--check-blocks=6"]); - assert_eq!(six.check_blocks, Some(6)); - assert_eq!(six.check_blocks_window(), 6); - let all = ready_config(["rbitcoin-node", "--check-blocks", "0"]); - assert_eq!(all.check_blocks, Some(0)); - assert_eq!(all.check_blocks_window(), 0); - let neg = ready_config(["rbitcoin-node", "--check-blocks=-1"]); - assert_eq!(neg.check_blocks, Some(-1)); - assert_eq!(neg.check_blocks_window(), 0); } - #[test] - fn prefillcompact_omitted_is_on_zero_disables() { - let omitted = ready_config(["rbitcoin-node"]); - assert!(omitted.prefill_compact); - let off = ready_config(["rbitcoin-node", "--prefill-compact=0"]); - assert!(!off.prefill_compact); - let on = ready_config(["rbitcoin-node", "--prefill-compact"]); - assert!(on.prefill_compact); - let on_eq = ready_config(["rbitcoin-node", "--prefill-compact=1"]); - assert!(on_eq.prefill_compact); - } + include!("overlay_config_journey.rs"); #[test] fn max_sh_creates_and_esplora_block_template_cli_hyphens() { @@ -920,444 +943,6 @@ mod tests { assert!(!onion_off.esplora_onion); } - #[test] - fn explicit_milestone_zero_sticks_on_mainnet() { - use rbitcoin_consensus::{default_milestone_height, Milestone}; - - let omitted = ready_config(["rbitcoin-node"]); - assert_eq!(omitted.network, Network::Mainnet); - assert_eq!( - omitted.milestone_height, - default_milestone_height(Network::Mainnet) - ); - assert!(omitted.milestone().anchor.is_some()); - assert!(!omitted.milestone().skips_scripts_at(1)); - - let cli0 = ready_config(["rbitcoin-node", "--milestone", "0"]); - assert_eq!(cli0.network, Network::Mainnet); - assert_eq!(cli0.milestone_height, 0); - assert_eq!(cli0.milestone(), Milestone::NONE); - assert!(!cli0.milestone().skips_scripts_at(1)); - - assert!(operator_config_from_args(["rbitcoin-node", "--assumevalid-height=0"]).is_err()); - - assert!( - matches!( - operator_config_from_args(["rbitcoin-node", "-V"]), - Ok(OperatorArgs::Version) - ), - "-V must assemble Version before run" - ); - assert!( - operator_config_from_args(["rbitcoin-node", "--conf="]).is_err(), - "empty --conf= must fail" - ); - match operator_config_from_args(["rbitcoin-node", "--log-level=off"]) { - Ok(OperatorArgs::Ready { - log_level_cli: Some(None), - .. - }) => {} - other => panic!("--log-level=off must be Ready with log off, got {other:?}"), - } - assert!(operator_config_from_args(["rbitcoin-node", "--log-level"]).is_err()); - - let dir = tmp_datadir(); - std::fs::create_dir_all(&dir).unwrap(); - let conf = dir.join("m.conf"); - std::fs::write(&conf, "milestone=0\n").unwrap(); - let from_conf = ready_config(["rbitcoin-node", "--conf", conf.to_str().unwrap()]); - assert_eq!(from_conf.network, Network::Mainnet); - assert_eq!(from_conf.milestone_height, 0); - assert_eq!(from_conf.milestone(), Milestone::NONE); - let eq = format!("--conf={}", conf.to_str().unwrap()); - let from_eq = ready_config(["rbitcoin-node", eq.as_str()]); - assert_eq!(from_eq.milestone_height, 0); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn default_milestone_is_anchored_and_signet_is_full_scripts() { - let omitted = ready_config(["rbitcoin-node"]); - assert!(omitted.milestone().anchor.is_some()); - assert!(!omitted.milestone().skips_scripts_at(1)); - assert!(omitted.minimum_chain_work.is_some()); - assert!(!omitted.meets_minimum_chain_work([0; 32])); - let explicit = ready_config(["rbitcoin-node", "--milestone", "840000"]); - assert!(explicit.milestone().anchor.is_none()); - assert!(explicit.milestone().skips_scripts_at(1)); - let signet = ready_config(["rbitcoin-node", "--network=signet"]); - assert_eq!(signet.milestone_height, 0); - assert!(!signet.milestone().skips_scripts_at(1)); - let signet_skip = ready_config([ - "rbitcoin-node", - "--network=signet", - "--milestone", - "2000000", - ]); - assert!(signet_skip.milestone().skips_scripts_at(1)); - let custom_work = ready_config(["rbitcoin-node", "--min-chain-work=0x65"]); - assert_eq!(custom_work.minimum_chain_work.unwrap()[31], 0x65); - } - - #[test] - fn flag_matrix_cli_equals_conf_apply_kv() { - let _g = OPERATOR_ENV_TEST_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let mut cfg = crate::config::NodeConfig::default(); - assert_eq!( - cfg.apply_kv("network", "regtest").unwrap(), - crate::config::ConfApply::Applied - ); - assert_eq!(cfg.network, Network::Regtest); - assert_eq!( - cfg.apply_kv("chain", "signet").unwrap(), - crate::config::ConfApply::Unknown("chain".into()) - ); - assert_eq!(cfg.network, Network::Regtest); - - let dir = tmp_datadir(); - assert_exit( - cli_main([ - "rbitcoin-node", - "--smoke", - "--network=regtest", - "--datadir", - dir.to_str().unwrap(), - "--no-seeds=1", - "--log-level", - "error", - "--milestone", - "0", - ]), - ExitCode::SUCCESS, - ); - let _ = std::fs::remove_dir_all(&dir); - - assert_exit( - cli_main(["rbitcoin-node", "--chain=regtest"]), - ExitCode::from(2), - ); - - let dir = tmp_datadir(); - let conf = dir.join("node.conf"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(&conf, "network=testnet\n").unwrap(); - let smoke = dir.join("smoke"); - assert_exit( - cli_main([ - "rbitcoin-node", - "--smoke", - "--conf", - conf.to_str().unwrap(), - "--network=regtest", - "--datadir", - smoke.to_str().unwrap(), - "--no-seeds", - "--log-level", - "error", - "--milestone", - "0", - ]), - ExitCode::SUCCESS, - ); - assert!( - smoke.join("store").exists(), - "CLI datadir must win over conf" - ); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn testactivationheight_cli_smoke_regtest() { - let _g = OPERATOR_ENV_TEST_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let dir = tmp_datadir(); - let code = cli_main([ - "rbitcoin-node", - "--smoke", - "--network", - "regtest", - "--datadir", - dir.to_str().unwrap(), - "--test-activation-height=csv@102", - "--test-activation-height=dersig@50", - "--trusted", - "--limit-cluster-count=10", - "--min-chain-work=0x65", - "--no-seeds", - "--log-level", - "error", - "--milestone", - "0", - ]); - assert_exit(code, ExitCode::SUCCESS); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn minimumchainwork_rejects_non_hex() { - let dir = tmp_datadir(); - let code = cli_main([ - "rbitcoin-node", - "--smoke", - "--network", - "regtest", - "--datadir", - dir.to_str().unwrap(), - "--min-chain-work=test", - "--log-level", - "error", - ]); - assert_exit(code, ExitCode::from(1)); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn unknown_and_missing_value_errors() { - assert_exit(cli_main(["rbitcoin-node", "--nope"]), ExitCode::from(2)); - assert_exit(cli_main(["rbitcoin-node", "--network"]), ExitCode::from(2)); - assert_exit( - cli_main(["rbitcoin-node", "--network", "bogus"]), - ExitCode::from(2), - ); - assert_exit(cli_main(["rbitcoin-node", "--datadir"]), ExitCode::from(2)); - assert_exit( - cli_main(["rbitcoin-node", "--datadir-cold"]), - ExitCode::from(2), - ); - assert_exit( - cli_main(["rbitcoin-node", "--listen", "not-an-addr"]), - ExitCode::from(2), - ); - assert_exit( - cli_main(["rbitcoin-node", "--log-level", "wat"]), - ExitCode::from(2), - ); - assert_exit(cli_main(["rbitcoin-node", "--api-log"]), ExitCode::from(2)); - assert_exit(cli_main(["rbitcoin-node", "--asmap"]), ExitCode::from(2)); - assert_exit( - cli_main(["rbitcoin-node", "--max-outbound", "0"]), - ExitCode::from(2), - ); - assert_exit( - cli_main(["rbitcoin-node", "--mempool-size-mb", "0"]), - ExitCode::from(2), - ); - // Missing values / parse rejects for advanced knobs. - assert_exit(cli_main(["rbitcoin-node", "--conf"]), ExitCode::from(2)); - assert_exit( - cli_main(["rbitcoin-node", "--max-inbound"]), - ExitCode::from(2), - ); - assert_exit( - cli_main(["rbitcoin-node", "--max-inbound", "nope"]), - ExitCode::from(2), - ); - assert_exit( - cli_main(["rbitcoin-node", "--sp-tweaks-dust"]), - ExitCode::from(2), - ); - assert_exit( - cli_main(["rbitcoin-node", "--sp-tweaks-dust", "nope"]), - ExitCode::from(2), - ); - // Bad conf path / invalid conf log_level. - let dir = tmp_datadir(); - std::fs::create_dir_all(&dir).unwrap(); - assert_exit( - cli_main([ - "rbitcoin-node", - "--conf", - dir.join("missing.conf").to_str().unwrap(), - "--datadir", - dir.join("d").to_str().unwrap(), - ]), - ExitCode::from(2), - ); - let conf = dir.join("badlog.conf"); - std::fs::write(&conf, "log_level=notalevel\nnetwork=regtest\n").unwrap(); - assert_exit( - cli_main([ - "rbitcoin-node", - "--smoke", - "--conf", - conf.to_str().unwrap(), - "--datadir", - dir.join("d2").to_str().unwrap(), - "--no-seeds", - ]), - ExitCode::from(2), - ); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn smoke_datadir_cold_puts_seqsigwit_on_cold_store() { - let _g = OPERATOR_ENV_TEST_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let dir = tmp_datadir(); - let hot = dir.join("hot"); - let cold = dir.join("cold"); - let code = cli_main([ - "rbitcoin-node", - "--smoke", - "--network", - "regtest", - "--datadir", - hot.to_str().unwrap(), - "--datadir-cold", - cold.to_str().unwrap(), - "--no-seeds", - "--log-level", - "error", - "--milestone", - "0", - ]); - assert_exit(code, ExitCode::SUCCESS); - assert!(hot.join("store").is_dir()); - assert!(hot.join("store/txout.body").is_file()); - assert!(!hot.join("store/seqsigwit.body").exists()); - assert!(cold.join("store/seqsigwit.body").is_file()); - assert!(cold.join("store/seqsigwit.loc").is_file()); - assert!(hot.join("store").join("seqsigwit.reloc").is_file()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn custom_signet_cli_smoke() { - let dir = tmp_datadir(); - let code = cli_main([ - "rbitcoin-node", - "--smoke", - "--network", - "signet", - "--datadir", - dir.to_str().unwrap(), - "--signet-challenge", - "51", - "--signet-block-time", - "60", - "--no-seeds", - "--log-level", - "error", - "--milestone", - "0", - ]); - assert_exit(code, ExitCode::SUCCESS); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn native_cli_flags_reject_core_aliases() { - let _g = OPERATOR_ENV_TEST_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let dir = tmp_datadir(); - let code = cli_main([ - "rbitcoin-node", - "--smoke", - "--network", - "regtest", - "--datadir", - dir.to_str().unwrap(), - "--milestone", - "0", - "--max-inbound", - "5", - "--mempool-size-mb", - "8", - "--log-level", - "error", - "--no-seeds", - ]); - assert_exit(code, ExitCode::SUCCESS); - for flag in [ - "--chain=regtest", - "--assumevalid-height=0", - "--maxconnections=5", - "--maxmempool=8", - "--whitelist=noban@127.0.0.1", - "--blocksonly", - "--minimumchainwork=0x65", - "--maxtipage=3600", - "--uacomment=x", - "--peertimeout=1", - "--prefillcompact=0", - "--limitclustercount=10", - "--limitclustersize=10", - "--minrelaytxfee=0.0001", - "--mempoolexpiry=1", - "--externalip=1.2.3.4", - "--seednode=127.0.0.1:1", - "--mocktime=1", - "--blockversion=1", - "--blockmintxfee=0.00000001", - "--bytespersigop=20", - "--blockreservedsigops=400", - "--alertnotify=echo", - "--startupnotify=echo", - "--testactivationheight=csv@102", - "--rpcworkqueue=1", - "--datadircold=/tmp/x", - "--electrumlisten=127.0.0.1:1", - "--esploralisten=127.0.0.1:1", - "--maxshcreates=1", - "--esplorablocktemplate=1", - "--apilog=/tmp/x", - "--maxrunsecs=1", - "--inhibitsuspend=1", - "--rpclisten=127.0.0.1:1", - "--rpc-user=u", - "--rpcuser=u", - "--rpcpassword=p", - "--checkblocks=6", - "--blocksdir=/tmp/x", - "--blocks-dir=/tmp/x", - "--whitelist-relay=0", - "--whitelist-forcerelay=1", - "--shindex", - "--sptweaks", - "-shindex", - "-sptweaks", - "-datadir=/tmp/x", - ] { - assert_exit(cli_main(["rbitcoin-node", flag]), ExitCode::from(2)); - } - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn conf_file_then_cli_override() { - let _g = OPERATOR_ENV_TEST_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let dir = tmp_datadir(); - std::fs::create_dir_all(&dir).unwrap(); - let conf = dir.join("node.conf"); - std::fs::write(&conf, "network=signet\nmax_inbound=33\n").unwrap(); - let data = dir.join("data"); - let code = cli_main([ - "rbitcoin-node", - "--smoke", - "--conf", - conf.to_str().unwrap(), - "--datadir", - data.to_str().unwrap(), - "--network", - "regtest", // CLI overrides conf network - "--log-level", - "error", - "--no-seeds", - "--milestone", - "0", - ]); - assert_exit(code, ExitCode::SUCCESS); - let _ = std::fs::remove_dir_all(&dir); - } - /// CLI omit of inbound must not clobber pre-set advanced envs. #[test] fn cli_omit_preserves_advanced_env() { diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index 1301d409c..ec9127eeb 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -1472,30 +1472,6 @@ mod tests { } } - #[test] - fn rpc_listen_and_dropped_user_apply_kv() { - let err = NodeConfig::default() - .apply_kv("rpcuser", "u") - .unwrap_err() - .to_string(); - assert!(err.contains("rpc.token"), "{err}"); - let err = NodeConfig::default() - .apply_kv("rpcpassword", "p") - .unwrap_err() - .to_string(); - assert!(err.contains("rpc.token"), "{err}"); - let mut rpc = NodeConfig { - network: Network::Regtest, - ..NodeConfig::default() - }; - assert_eq!(rpc.apply_kv("rpc", "1").unwrap(), ConfApply::Applied); - assert!(rpc.rpc.socket); - assert_eq!(rpc.apply_kv("rpc_listen", "").unwrap(), ConfApply::Applied); - rpc.resolve_listen_defaults(); - assert_eq!(rpc.rpc.listen.unwrap().port(), 18443); - assert_eq!(rpc.rpc.listen.unwrap().ip().to_string(), "127.0.0.1"); - } - #[test] fn rpc_socket_moves_the_socket_out_of_the_datadir() { let mut c = NodeConfig::default().with_datadir(Path::new("/var/lib/rbitcoin")); @@ -1571,33 +1547,6 @@ mod tests { } } - #[test] - fn minrelaytxfee_garbage_and_negative_are_config_errors() { - let mut c = NodeConfig::default(); - let bad = c.apply_kv("min_relay_tx_fee", "nope").unwrap_err(); - assert!( - format!("{bad}").contains("min_relay_tx_fee"), - "garbage must name the knob: {bad}" - ); - let neg = c.apply_kv("min_relay_tx_fee", "-0.0001").unwrap_err(); - assert!( - format!("{neg}").contains("min_relay_tx_fee"), - "negative must name the knob: {neg}" - ); - assert_eq!( - c.apply_kv("min_relay_tx_fee", "0").unwrap(), - ConfApply::Applied - ); - assert_eq!( - c.apply_kv("min_relay_tx_fee", "0.00000001").unwrap(), - ConfApply::Applied - ); - match c.apply_kv("minrelaytxfee", "0").unwrap() { - ConfApply::Unknown(k) => assert_eq!(k, "minrelaytxfee"), - other => panic!("{other:?}"), - } - } - #[test] fn check_blocks_apply_kv_zero_is_all() { let mut c = NodeConfig::default(); @@ -1837,52 +1786,6 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - #[test] - fn conf_file_maps_operator_knobs() { - let dir = tmp(); - std::fs::create_dir_all(&dir).unwrap(); - let conf = dir.join("rbitcoin.conf"); - std::fs::write( - &conf, - "# test conf\n\ - network=signet\n\ - max_inbound=40\n\ - max_outbound=8\n\ - mempool_size_mb=50\n\ - milestone=100\n\ - log_level=debug\n\ - api_log=/tmp/rbitcoin-api.jsonl\n\ - asmap=/tmp/ip_asn.dat\n\ - connect=127.0.0.1:38333\n\ - datadir-cold=/mnt/hdd/rbtc-cold\n", - ) - .unwrap(); - let mut cfg = NodeConfig::default().with_datadir(dir.join("data")); - cfg.merge_conf_file(&conf).unwrap(); - assert_eq!(cfg.network, Network::Signet); - assert_eq!(cfg.listen.max_inbound, 40); - assert!(cfg.listen.max_inbound_explicit); - assert_eq!(cfg.listen.max_outbound, 8); - assert_eq!(cfg.mempool.max_weight, 50_000_000); - assert_eq!(cfg.milestone_height, 100); - assert_eq!(cfg.conf_log_level.as_deref(), Some("debug")); - assert_eq!( - cfg.api_log.as_deref(), - Some(std::path::Path::new("/tmp/rbitcoin-api.jsonl")) - ); - assert_eq!( - cfg.asmap.as_deref(), - Some(std::path::Path::new("/tmp/ip_asn.dat")) - ); - assert_eq!(cfg.listen.connect.len(), 1); - assert!(cfg.listen.connect_dns.is_empty()); - assert_eq!( - cfg.datadir.cold.as_deref(), - Some(std::path::Path::new("/mnt/hdd/rbtc-cold")) - ); - let _ = std::fs::remove_dir_all(&dir); - } - /// Env is an input when inbound was not explicit; never published back. #[test] fn absorb_inbound_env_reads_but_does_not_write() { @@ -1996,45 +1899,6 @@ mod tests { ); } - #[test] - fn custom_signet_conf_builds_params() { - let dir = tmp(); - std::fs::create_dir_all(&dir).unwrap(); - let conf = dir.join("custom-signet.conf"); - std::fs::write( - &conf, - "network=signet\n\ - signet_challenge=51\n\ - signet_block_time=60\n", - ) - .unwrap(); - - let mut cfg = NodeConfig::default(); - cfg.merge_conf_file(&conf).unwrap(); - cfg.validate().unwrap(); - let params = cfg.chain_params().unwrap(); - assert_eq!(params.btc.pow_target_spacing, 60); - assert_eq!(params.signet_challenge.unwrap().as_bytes(), &[0x51]); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn custom_signet_options_require_signet_and_challenge() { - let challenge = bitcoin::ScriptBuf::from_bytes(vec![0x51]); - let mainnet = NodeConfig { - signet_challenge: Some(challenge), - ..NodeConfig::default() - }; - assert!(mainnet.validate().is_err()); - - let missing_challenge = NodeConfig { - network: Network::Signet, - signet_block_time: Some(30), - ..NodeConfig::default() - }; - assert!(missing_challenge.validate().is_err()); - } - #[test] fn ensure_datadir_rejects_file_path_after_parent_exists() { let dir = tmp(); @@ -2079,72 +1943,6 @@ mod tests { assert_eq!(cfg.milestone().height, 10); } - #[test] - fn max_inbound_zero_is_allowed() { - let mut c = NodeConfig::default().with_datadir(tmp()); - assert_eq!(c.apply_kv("max_inbound", "0").unwrap(), ConfApply::Applied); - assert_eq!(c.listen.max_inbound, 0); - assert!(c.listen.max_inbound_explicit); - c.validate() - .expect("max_inbound=0 is outbound-only, not an error"); - - let err = NodeConfig::default() - .apply_kv("max_outbound", "0") - .unwrap_err(); - assert!( - format!("{err}").contains("max_outbound"), - "max_outbound=0 must still fail: {err}" - ); - let mut o = NodeConfig::default().with_datadir(tmp()); - o.listen.max_outbound = 0; - let verr = o.validate().unwrap_err().to_string(); - assert!( - verr.contains("max-outbound"), - "validate must still reject max_outbound=0: {verr}" - ); - } - - #[test] - fn conf_bare_network_flags_and_bad_line() { - let dir = tmp(); - std::fs::create_dir_all(&dir).unwrap(); - let conf = dir.join("flags.conf"); - std::fs::write( - &conf, - "regtest\n\ - # comment\n\ - ; also\n\ - \n\ - no_seeds=1\n", - ) - .unwrap(); - let mut cfg = NodeConfig::default().with_datadir(dir.join("d")); - cfg.merge_conf_file(&conf).unwrap(); - assert_eq!(cfg.network, Network::Regtest); - assert!(!cfg.listen.use_seeds); - - let conf2 = dir.join("signet.conf"); - std::fs::write(&conf2, "signet\n").unwrap(); - let mut cfg2 = NodeConfig::default().with_datadir(dir.join("d2")); - cfg2.merge_conf_file(&conf2).unwrap(); - assert_eq!(cfg2.network, Network::Signet); - - let conf3 = dir.join("testnet.conf"); - std::fs::write(&conf3, "testnet\n").unwrap(); - let mut cfg3 = NodeConfig::default().with_datadir(dir.join("d3")); - cfg3.merge_conf_file(&conf3).unwrap(); - assert_eq!(cfg3.network, Network::Testnet); - - let conf_bad = dir.join("bad.conf"); - std::fs::write(&conf_bad, "not_a_key_value\n").unwrap(); - let mut cfg_bad = NodeConfig::default().with_datadir(dir.join("db")); - assert!(cfg_bad.merge_conf_file(&conf_bad).is_err()); - - let missing = dir.join("nope.conf"); - assert!(cfg_bad.merge_conf_file(&missing).is_err()); - let _ = std::fs::remove_dir_all(&dir); - } - #[test] fn electrum_without_shindex_validates() { let mut cfg = NodeConfig::default().with_datadir(tmp()); diff --git a/crates/rbitcoin-test/tests/scenarios.rs b/crates/rbitcoin-test/tests/scenarios.rs index 568229345..4f242ab44 100644 --- a/crates/rbitcoin-test/tests/scenarios.rs +++ b/crates/rbitcoin-test/tests/scenarios.rs @@ -78,6 +78,271 @@ fn pin_conf_unknown_key_and_peertimeout(td: &TestDatadir) { ); } +fn node(args: &[&str]) -> ExitCode { + node_cli_main(std::iter::once("rbitcoin-node").chain(args.iter().copied())) +} + +fn exit_is(c: ExitCode, want: u8) -> bool { + format!("{c:?}") == format!("{:?}", ExitCode::from(want)) +} + +/// `--smoke` a regtest node under `td/name` with extra argv. +fn smoke(td: &TestDatadir, name: &str, extra: &[&str]) -> ExitCode { + let d = td.path().join(name); + let mut args = vec!["--datadir", d.to_str().unwrap(), "--network", "regtest"]; + args.extend_from_slice(extra); + args.extend_from_slice(&["--no-seeds", "--log-level", "error", "--smoke"]); + node(&args) +} + +/// Usage errors exit 2 before a datadir is touched: unknown flags, missing or +/// unparsable values, and every concatenated, one-dash, or Core spelling of a +/// native kebab flag. +fn pin_argv_usage_errors() { + let refused: &[&[&str]] = &[ + &["--not-a-real-option"], + &["--datadir"], + &["--datadir-cold"], + &["--network"], + &["--network", "nope"], + &["--listen"], + &["--listen", "not-an-addr"], + &["--connect"], + &["--connect", "bad host"], + &["--milestone"], + &["--milestone", "x"], + &["--max-outbound"], + &["--max-outbound", "0"], + &["--max-outbound", "nope"], + &["--max-inbound"], + &["--max-inbound", "nope"], + &["--mempool-size-mb"], + &["--mempool-size-mb", "0"], + &["--mempool-size-mb", "x"], + &["--max-run-secs"], + &["--max-run-secs", "x"], + &["--log-level"], + &["--log-level", "loud"], + &["--electrum-listen", "bad"], + &["--api-log"], + &["--asmap"], + &["--conf"], + &["--conf="], + &["--sp-tweaks-dust"], + &["--sp-tweaks-dust", "nope"], + &["--min-relay-tx-fee", "nope"], + &["--min-relay-tx-fee", "-0.0001"], + ]; + for args in refused { + assert!(exit_is(node(args), 2), "{args:?} must be a usage error"); + } + for alias in [ + "--chain=regtest", + "--assumevalid-height=0", + "--maxconnections=5", + "--maxmempool=8", + "--whitelist=noban@127.0.0.1", + "--blocksonly", + "--minimumchainwork=0x65", + "--maxtipage=3600", + "--uacomment=x", + "--peertimeout=1", + "--prefillcompact=0", + "--limitclustercount=10", + "--limitclustersize=10", + "--minrelaytxfee=0.0001", + "--mempoolexpiry=1", + "--externalip=1.2.3.4", + "--seednode=127.0.0.1:1", + "--mocktime=1", + "--blockversion=1", + "--blockmintxfee=0.00000001", + "--bytespersigop=20", + "--blockreservedsigops=400", + "--alertnotify=echo", + "--startupnotify=echo", + "--testactivationheight=csv@102", + "--rpcworkqueue=1", + "--datadircold=/tmp/x", + "--electrumlisten=127.0.0.1:1", + "--esploralisten=127.0.0.1:1", + "--maxshcreates=1", + "--esplorablocktemplate=1", + "--apilog=/tmp/x", + "--maxrunsecs=1", + "--inhibitsuspend=1", + "--rpclisten=127.0.0.1:1", + "--rpc-user=u", + "--rpcuser=u", + "--rpcpassword=p", + "--checkblocks=6", + "--blocksdir=/tmp/x", + "--blocks-dir=/tmp/x", + "--whitelist-relay=0", + "--whitelist-forcerelay=1", + "--shindex", + "--sptweaks", + "--sptweaks-dust=1", + "--pruneseqsigwit", + "-shindex", + "-sptweaks", + "-datadir=/tmp/x", + ] { + assert!(exit_is(node(&[alias]), 2), "{alias} must be unknown"); + } +} + +/// A conf file that cannot be read or holds a line the node refuses exits 2. +/// Core's `rpcuser` / `rpcpassword` are refused, not ignored: the node +/// authenticates with `rpc.token`. +fn pin_conf_file_refusals(td: &TestDatadir) { + let missing = td.path().join("missing.conf"); + let d = td.path().join("conf-refused"); + assert!(exit_is( + node(&["--conf", missing.to_str().unwrap(), "--smoke"]), + 2 + )); + for (name, body) in [ + ("badlog", "network=regtest\nlog_level=notalevel\n"), + ("badline", "network=regtest\nnot_a_key_value\n"), + ("rpcuser", "network=regtest\nrpcuser=u\n"), + ("rpcpassword", "network=regtest\nrpcpassword=p\n"), + ("max-outbound-zero", "network=regtest\nmax_outbound=0\n"), + ] { + let conf = td.path().join(format!("{name}.conf")); + std::fs::write(&conf, body).unwrap(); + assert!( + exit_is( + node(&[ + "--conf", + conf.to_str().unwrap(), + "--datadir", + d.to_str().unwrap(), + "--smoke", + ]), + 2 + ), + "{name} conf must refuse" + ); + } + assert!(!d.join("store").exists(), "a refused conf opens no store"); +} + +/// Flags that parse but describe a node that cannot run exit 1 at validate. +fn pin_validate_refusals(td: &TestDatadir) { + for (name, args) in [ + ("chainwork-not-hex", &["--min-chain-work=test"][..]), + ("challenge-off-signet", &["--signet-challenge", "51"]), + ("tweaks-and-pruning", &["--sp-tweaks", "--prune-seqsigwit"]), + ] { + assert!(exit_is(smoke(td, name, args), 1), "{name} must refuse"); + } + let d = td.path().join("signet-time-no-challenge"); + assert!(exit_is( + node(&[ + "--datadir", + d.to_str().unwrap(), + "--network", + "signet", + "--signet-block-time", + "30", + "--smoke", + ]), + 1 + )); +} + +/// Operator starts that open a store: native flags, a conf file under CLI +/// overrides, a pruned `--datadir-cold` split, and a custom signet. +fn pin_operator_smokes(td: &TestDatadir) { + assert!(exit_success(smoke( + td, + "native-flags", + &[ + "--milestone", + "0", + "--max-inbound", + "0", + "--mempool-size-mb", + "8", + "--no-seeds=1", + "--no-discover", + "--seed-node", + "127.0.0.1:8333", + "--min-relay-tx-fee", + "0.00000001", + "--prefill-compact=0", + "--check-blocks=-1", + "--sh-index=1", + "--sp-tweaks", + "--sp-tweaks-dust=546", + "--test-activation-height=csv@102", + "--test-activation-height=dersig@50", + "--trusted", + "--limit-cluster-count=10", + "--min-chain-work=0x65", + ], + ))); + + // Bare network lines and comments parse. CLI --network and --datadir win + // over the conf: the store lands under the CLI datadir. + let conf = td.path().join("bare.conf"); + let conf_data = td.path().join("conf-datadir"); + std::fs::write( + &conf, + format!( + "signet\n# comment\n; also\n\nmax_inbound=0\nmin_relay_tx_fee=0\ndatadir={}\n", + conf_data.display() + ), + ) + .unwrap(); + assert!(exit_success(smoke( + td, + "cli-datadir", + &["--conf", conf.to_str().unwrap()] + ))); + assert!(td.path().join("cli-datadir").join("store").is_dir()); + assert!(!conf_data.exists(), "CLI --datadir wins over the conf"); + + let hot = td.path().join("hot"); + let cold = td.path().join("cold"); + assert!(exit_success(node(&[ + "--datadir", + hot.to_str().unwrap(), + "--datadir-cold", + cold.to_str().unwrap(), + "--prune-seqsigwit", + "--prune-seqsigwit-ram-threshold-bytes=4096", + "--network", + "regtest", + "--no-seeds", + "--log-level", + "error", + "--smoke", + ]))); + assert!(hot.join("store/txout.body").is_file()); + assert!(hot.join("store/seqsigwit.reloc").is_file()); + assert!(!hot.join("store/seqsigwit.body").exists()); + assert!(cold.join("store/seqsigwit.body").is_file()); + assert!(cold.join("store/seqsigwit.loc").is_file()); + + let signet = td.path().join("custom-signet"); + assert!(exit_success(node(&[ + "--datadir", + signet.to_str().unwrap(), + "--network", + "signet", + "--signet-challenge", + "51", + "--signet-block-time", + "60", + "--no-seeds", + "--log-level", + "error", + "--smoke", + ]))); +} + // ─── Lifecycle / CLI / surface smoke (collapsed) ──────────────────────────── #[allow(clippy::cognitive_complexity)] // one fixture, many CLI/surface arms @@ -162,96 +427,19 @@ fn node_cli_and_surface_smoke() { assert!(exit_success(node_cli_main(["rbitcoin-node", "--help"]))); assert!(exit_success(node_cli_main(["rbitcoin-node", "--version"]))); assert!(exit_success(node_cli_main(["rbitcoin-node", "-V"]))); - let _ = cli_cli_main(["rbitcoin-cli", "--help"]); - let _ = cli_cli_main(["rbitcoin-cli", "--version"]); - assert!(exit_success(cli_cli_main(["rbitcoin-cli", "help"]))); + // No node is listening: help and version answer without dialing RPC. + for arg in ["--help", "-h", "--version", "-V", "help"] { + assert!( + exit_success(cli_cli_main(["rbitcoin-cli", arg])), + "rbitcoin-cli {arg} must not dial" + ); + } assert!(!exit_success(cli_cli_main(["rbitcoin-cli"]))); assert!(!exit_success(cli_cli_main([ "rbitcoin-cli", "getblockchaininfo" ]))); - assert!(!exit_success(node_cli_main([ - "rbitcoin-node", - "--not-a-real-option" - ]))); - assert!(!exit_success(node_cli_main(["rbitcoin-node", "--datadir"]))); - assert!(!exit_success(node_cli_main([ - "rbitcoin-node", - "--network", - "nope" - ]))); - assert!(!exit_success(node_cli_main(["rbitcoin-node", "--listen"]))); - assert!(!exit_success(node_cli_main([ - "rbitcoin-node", - "--listen", - "not-an-addr" - ]))); - assert!(!exit_success(node_cli_main(["rbitcoin-node", "--connect"]))); - assert!(!exit_success(node_cli_main([ - "rbitcoin-node", - "--connect", - "bad host" - ]))); - assert!(!exit_success(node_cli_main([ - "rbitcoin-node", - "--milestone", - "x" - ]))); - assert!(!exit_success(node_cli_main([ - "rbitcoin-node", - "--max-outbound", - "0" - ]))); - assert!(!exit_success(node_cli_main([ - "rbitcoin-node", - "--max-outbound" - ]))); - assert!(!exit_success(node_cli_main([ - "rbitcoin-node", - "--max-outbound", - "nope" - ]))); - assert!(!exit_success(node_cli_main([ - "rbitcoin-node", - "--mempool-size-mb" - ]))); - assert!(!exit_success(node_cli_main([ - "rbitcoin-node", - "--mempool-size-mb", - "0" - ]))); - assert!(!exit_success(node_cli_main([ - "rbitcoin-node", - "--mempool-size-mb", - "x" - ]))); - assert!(!exit_success(node_cli_main([ - "rbitcoin-node", - "--max-run-secs" - ]))); - assert!(!exit_success(node_cli_main([ - "rbitcoin-node", - "--max-run-secs", - "x" - ]))); - assert!(!exit_success(node_cli_main([ - "rbitcoin-node", - "--log-level" - ]))); - assert!(!exit_success(node_cli_main([ - "rbitcoin-node", - "--log-level", - "loud" - ]))); - assert!(!exit_success(node_cli_main([ - "rbitcoin-node", - "--electrum-listen", - "bad" - ]))); - assert!(!exit_success(node_cli_main([ - "rbitcoin-node", - "--milestone" - ]))); + pin_argv_usage_errors(); // Electrum without --sh-index still smokes (channel-watch APIs; SH methods fail closed). let no_sh = td.path().join("electrum-no-shindex"); assert!(exit_success(node_cli_main([ @@ -340,6 +528,9 @@ fn node_cli_and_surface_smoke() { "--smoke", ]))); pin_conf_unknown_key_and_peertimeout(&td); + pin_conf_file_refusals(&td); + pin_validate_refusals(&td); + pin_operator_smokes(&td); assert!(!exit_success(cli_cli_main(["rbitcoin-cli", "a", "b"]))); for flag in [ "--rpcuser=u", diff --git a/docs/external_findings/042-milestone-anchor.md b/docs/external_findings/042-milestone-anchor.md index 17655dbdc..f6b6293d3 100644 --- a/docs/external_findings/042-milestone-anchor.md +++ b/docs/external_findings/042-milestone-anchor.md @@ -24,4 +24,4 @@ the IBD state lock. **Regression:** `rbitcoin-consensus` `milestone::tests::low_work_fork_does_not_skip_even_at_the_milestone_height`, `block::structure_rule_tests::p3_default_milestone_heights`, `block::structure_rule_tests::buried_rules_and_a_lying_header_path`, -`rbitcoin-node` `cli::tests::default_milestone_is_anchored_and_signet_is_full_scripts`. +`rbitcoin-node` `cli::tests::operator_conf_and_argv`. From 3311a45e924f80404b1ef77b23257dab4fda931b Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Mon, 28 Sep 2026 08:16:17 -0700 Subject: [PATCH 5/5] test: share the run_p2p spawn and stop helpers across node journeys node_listen_and_exit and tor_control_onion_lifecycle spawned run_p2p on a runtime worker and inlined the stop-and-join. They now use the same spawn_run_p2p and stop_run_p2p as enter_tip_mode_indexes. That runs run_p2p off the workers, as cli_main does, and one stop path reports why a node did not exit. Co-Authored-By: Claude Opus 5.5 --- crates/rbitcoin-test/tests/cross_surface.rs | 65 +++++++++------------ 1 file changed, 28 insertions(+), 37 deletions(-) diff --git a/crates/rbitcoin-test/tests/cross_surface.rs b/crates/rbitcoin-test/tests/cross_surface.rs index 8f803f214..053299ad0 100644 --- a/crates/rbitcoin-test/tests/cross_surface.rs +++ b/crates/rbitcoin-test/tests/cross_surface.rs @@ -1974,10 +1974,33 @@ async fn start_and_exit(cfg: NodeConfig) -> Result<(), rbitcoin_node::NodeError> .expect("run_p2p did not exit") } +/// `run_p2p` off the runtime workers, as `cli_main` blocks on it: an empty +/// datadir connects genesis through tip-accept, which refuses a worker. +fn spawn_run_p2p(cfg: NodeConfig) -> tokio::task::JoinHandle> { + tokio::task::spawn_blocking(move || { + let _block = rbitcoin_net::BlockingRegion::enter(); + tokio::runtime::Handle::current().block_on(run_p2p(cfg)) + }) +} + +async fn stop_run_p2p( + rpc_addr: SocketAddr, + node: tokio::task::JoinHandle>, +) { + let _ = jsonrpc(rpc_addr, "stop", json!([])).await; + match tokio::time::timeout(Duration::from_secs(15), node).await { + Ok(Ok(Ok(()))) => {} + Ok(Ok(Err(e))) => panic!("run_p2p error after stop: {e}"), + Ok(Err(e)) => panic!("run_p2p join: {e}"), + Err(_) => panic!("run_p2p did not exit after stop"), + } +} + /// One operator datadir restarted through the startup arms `run_p2p` owns /// while its one `--connect` peer is down: a junk peer book, a missing then a /// valid asmap, the wallet servers up until `stop`, an Electrum port someone -/// else holds, and a pruned datadir that refuses an unpruned start. +/// else holds, seeds with no `--connect`, and a pruned datadir that refuses +/// an unpruned start. #[tokio::test(flavor = "multi_thread")] async fn node_listen_and_exit() { let td = TestDatadir::new().unwrap(); @@ -2015,7 +2038,7 @@ async fn node_listen_and_exit() { cfg.listen.esplora = Some(rbitcoin_esplora::EsploraListen::Tcp(esplora_addr)); cfg.rpc.listen = Some(rpc_addr); cfg.max_run_secs = Some(60); - let node = tokio::spawn(run_p2p(cfg)); + let node = spawn_run_p2p(cfg); wait_listeners(&[electrum_addr, esplora_addr, rpc_addr]).await; let (st, height) = http_get(esplora_addr, "/blocks/tip/height").await; assert_eq!((st, height.as_str()), (200, "0"), "esplora on genesis"); @@ -2024,12 +2047,7 @@ async fn node_listen_and_exit() { assert_eq!(tip["result"]["height"], 0, "{tip}"); let count = jsonrpc(rpc_addr, "getblockcount", json!([])).await; assert_eq!(count["result"], 0, "{count}"); - let _ = jsonrpc(rpc_addr, "stop", json!([])).await; - let stopped = tokio::time::timeout(Duration::from_secs(15), node).await; - assert!( - matches!(stopped, Ok(Ok(Ok(())))), - "run_p2p did not stop cleanly" - ); + stop_run_p2p(rpc_addr, node).await; // Another process holds the Electrum port. The bind fails with a warning // and the node still starts and exits. @@ -2344,7 +2362,7 @@ async fn tor_control_onion_lifecycle() { cfg.tor.password = Some(TOR_PASSWORD.into()); cfg.rpc.listen = Some(rpc); cfg.max_run_secs = Some(60); - let node = tokio::spawn(run_p2p(cfg)); + let node = spawn_run_p2p(cfg); wait_listeners(&[electrum, esplora, rpc]).await; let info = jsonrpc(rpc, "getnetworkinfo", json!([])).await; let local: Vec<(String, u64)> = info["result"]["localaddresses"] @@ -2369,12 +2387,7 @@ async fn tor_control_onion_lifecycle() { } .host_str(); assert!(local.iter().any(|(a, _)| *a == i2p_p2p), "{local:?}"); - let _ = jsonrpc(rpc, "stop", json!([])).await; - let stopped = tokio::time::timeout(Duration::from_secs(15), node).await; - assert!( - matches!(stopped, Ok(Ok(Ok(())))), - "run_p2p did not stop cleanly" - ); + stop_run_p2p(rpc, node).await; let first = tor.take_log(); assert!(first.contains(&format!("AUTHENTICATE \"{TOR_PASSWORD}\""))); @@ -2442,28 +2455,6 @@ async fn tor_control_onion_lifecycle() { } } -/// `run_p2p` off the runtime workers, as `cli_main` blocks on it: an empty -/// datadir connects genesis through tip-accept, which refuses a worker. -fn spawn_run_p2p(cfg: NodeConfig) -> tokio::task::JoinHandle> { - tokio::task::spawn_blocking(move || { - let _block = rbitcoin_net::BlockingRegion::enter(); - tokio::runtime::Handle::current().block_on(run_p2p(cfg)) - }) -} - -async fn stop_run_p2p( - rpc_addr: SocketAddr, - node: tokio::task::JoinHandle>, -) { - let _ = jsonrpc(rpc_addr, "stop", json!([])).await; - match tokio::time::timeout(Duration::from_secs(15), node).await { - Ok(Ok(Ok(()))) => {} - Ok(Ok(Err(e))) => panic!("run_p2p error after stop: {e}"), - Ok(Err(e)) => panic!("run_p2p join: {e}"), - Err(_) => panic!("run_p2p did not exit after stop"), - } -} - async fn history_len(electrum_addr: SocketAddr, scripthash: &str) -> usize { let mut el = TcpStream::connect(electrum_addr).await.unwrap(); let hist = electrum_rpc(