diff --git a/TESTING.md b/TESTING.md index ebd210cd6..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. | @@ -344,6 +344,9 @@ 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` | +| `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). | @@ -361,7 +364,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-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 626b72058..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,317 +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()) - ); - - let h = operator_usage(); - assert!( - h.contains("--no-listen"), - "help must list kebab --no-listen" + cfg.api_log.as_deref(), + Some(std::path::Path::new("/tmp/rbitcoin-api.jsonl")) ); - assert!( - !h.contains("--nolisten"), - "help must not advertise concatenated --nolisten" + assert_eq!( + cfg.asmap.as_deref(), + Some(std::path::Path::new("/tmp/ip_asn.dat")) ); - } - - #[test] - fn prune_seqsigwit_is_kebab() { - let on = ready_config([ - "rbitcoin-node", - "--prune-seqsigwit", - "--prune-seqsigwit-ram-threshold-bytes=4096", - ]); - 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), + 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")) ); - } - #[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(); + // 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); } - 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 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 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] - 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); - } + 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 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() { @@ -990,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-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index 29c698401..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()) @@ -2002,31 +2000,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] @@ -2396,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(); @@ -2662,29 +2398,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 +2416,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,61 +2478,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; - 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; @@ -2902,57 +2499,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. @@ -2967,123 +2513,4 @@ mod tests { sd.request(); 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}; - 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 6519f0449..053299ad0 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; @@ -1948,3 +1950,617 @@ 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") +} + +/// `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, 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(); + 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 = 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}"); + 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. + 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}"); +} + +/// 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 = 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:?}"); + stop_run_p2p(rpc, node).await; + + 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:?}"); + } +} + +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; +} 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/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/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`. 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` |