diff --git a/.gitignore b/.gitignore index f0942762..fba4328c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,8 @@ heap-profile-* core.* /tmp/ /.bitcoin-rs/ -/.env +/data/ +.env # Re-include project plan tracked in-repo (global ~/.gitignore drops PLAN.md). !PLAN.md diff --git a/CONCEPTS.md b/CONCEPTS.md index eaba205d..1d11f4f4 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -37,7 +37,10 @@ The mainnet consensus checkpoint (height 938343, block `00000000000000000000cceb The standard node operational configuration tuned for mainnet sync: `fjall` storage backend, multi-peer block download active (outbound peer target 8, pending block budget 128, 16 in-flight requests per peer), hash-pinned assume-valid active on mainnet (height 938343), 450 MiB database cache (`dbcache`, matching Bitcoin Core parity), with secondary indexes (`txindex`, `blockfilterindex`), pruning, and `utreexo` stateless validation disabled by default. ### Container deployment posture -The checked-in Docker Compose specialization of the optimized default posture. The image compiles only the production `fjall` storage and `bitcoinkernel` verifier features and runs as an unprivileged user. Compose publishes P2P on the configured host port, keeps JSON-RPC on the host loopback interface, requires an explicit non-empty RPC password, and gives every selected Bitcoin network its own named data volume so incompatible checkpoints are never reused across a network switch. Shutdown allows up to 5 minutes because the bounded subsystem drain is followed by an unbounded, synchronous full-UTXO clean checkpoint; this is an operational SIGKILL guard, not a checkpoint-duration guarantee. +The checked-in Docker Compose specialization of the optimized default posture. The image compiles only the production `fjall` storage and `bitcoinkernel` verifier features and runs as an unprivileged user. The BIP300/301 integration Compose publishes P2P on the configured host port, keeps JSON-RPC on the host loopback interface, leaves `txindex` and the optional Electrum service disabled, supplies local-development RPC credential fallbacks that deployments should override, and namespaces node and enforcer data by `BITCOIN_RS_NETWORK` so incompatible P2P networks never reuse runtime state. Shutdown allows up to 5 minutes because the bounded subsystem drain is followed by an unbounded, synchronous full-UTXO clean checkpoint; this is an operational SIGKILL guard, not a checkpoint-duration guarantee. + +### Node network selection +The user-facing `BITCOIN_RS_NETWORK`/`--network` selection that atomically supplies consensus rules and P2P bootstrap identity while preserving later, low-level overrides. Standard Bitcoin names use their matching consensus `Network`, message start, and DNS bootstrap. `drynet4` uses mainnet consensus history with message start `eca5d404`, disables Bitcoin DNS seeds, and connects to `drynet4.drivechain.dev:8533`. Compose passes the same selection to bitcoin-rs and the BIP300/301 enforcer and uses it to namespace their data directories. The internal consensus `Network` remains `mainnet` for drynet4. ### Sync regimes (download-bound vs processing-bound) The two distinct cost regimes any sync measurement must name before its numbers mean anything. **Download-bound:** wall-clock is decided by the network path (peer scheduling, per-peer bandwidth, staller handling) — the regime of live IBD. **Processing-bound:** blocks are already local and wall-clock is decided by validation plus storage commit — the regime of reindex and offline replay. A node can rank differently in the two regimes, so a faster-than-X claim is meaningless without stating which regime was measured and with what validation posture. Within a regime the comparison is only as good as its least-matched input — see *Matched-harness comparison*. @@ -177,6 +180,20 @@ disconnects are emitted tip-first before connects on the replacement branch. This implementation deliberately omits mempool `A`/`R` events until the mempool has per-transaction sequence assignment and explicit removal reasons. +### Chain control + +Consensus-affecting RPCs do not mutate the RPC context's block-tree handle +directly. They delegate through the node-owned `ChainControl` boundary so the +same apply-admission and chain-transition locks protect RPC-triggered and +sync-triggered reorganizations. `invalidateblock` marks the named subtree +invalid, republishes the best remaining header tip, and moves applied +chainstate to it through the normal disconnect path. Before changing header +status it previews the replacement tip and loads every body required by the +complete disconnect/connect plan. The same chain-transition witness remains +held from that preflight through header invalidation and branch switching, so +another apply or reorg cannot enter between them; successful disconnects emit +the same `pubsequence` `D` events as an organic reorg. + ### Dispatch-bound parallelism A stage that is parallel in shape but serial in effect because each dispatch is diff --git a/README.md b/README.md index ad57cdd3..220af1d1 100644 --- a/README.md +++ b/README.md @@ -36,21 +36,26 @@ That starts a mainnet node storing state in `.bitcoin-rs` and serving JSON-RPC on `127.0.0.1:8332`. See [docs/getting-started.md](docs/getting-started.md) for backend selection, RPC authentication, and checking sync progress. -### Docker Compose +### Enforcer integration -The included Compose configuration builds the production `fjall` + -`bitcoinkernel` profile, keeps each network's chain state in a separate named -volume, exposes P2P on port 8333, and binds RPC to the Docker host's loopback -interface only. +The Compose example under `tools/bip300301-enforcer` is specifically for +running bitcoin-rs together with the BIP300/301 enforcer; it is not the general +bitcoin-rs deployment path. It builds the production `fjall` + `bitcoinkernel` +node profile, starts both services, stores their data under `data/`, exposes +P2P, and binds RPC to the Docker host's loopback interface only. One +`BITCOIN_RS_NETWORK` selects the matching network for both services. The +`drynet4` selection derives its mainnet consensus rules, custom P2P magic, +fixed peer, and disabled DNS seeding inside bitcoin-rs. -Set explicit RPC credentials in `.env`, then start the node: +Set explicit RPC credentials in `.env`, then start bitcoin-rs and the enforcer: ```sh +cd tools/bip300301-enforcer cp .env.example .env # Edit .env and set BITCOIN_RS_RPC_PASSWORD before starting the node. docker compose up --build -d -docker compose logs -f node +docker compose logs -f ``` Check sync progress inside the container. This uses the credentials already @@ -64,10 +69,10 @@ docker compose exec node sh -c \ http://127.0.0.1:8332/' ``` -Stop the process without deleting its chain data with `docker compose down`. +Stop the process without deleting its chain data with +`docker compose down`. Compose allows up to 5 minutes for the full clean checkpoint before forcing -termination. Deleting the selected network's named volume requires the explicit -`docker compose down -v` form. +termination. Chain data remains under `data/` until it is explicitly removed. ## Measured performance diff --git a/crash-1efd99623c772a1dd3aca1178fdafc93c933dec0 b/crash-1efd99623c772a1dd3aca1178fdafc93c933dec0 deleted file mode 100644 index f8a53aa3..00000000 --- a/crash-1efd99623c772a1dd3aca1178fdafc93c933dec0 +++ /dev/null @@ -1 +0,0 @@ -zYEq5n��˖ivߦ��/�X���Y��'��:Ӌ��\�V \ No newline at end of file diff --git a/crates/chain/src/tree.rs b/crates/chain/src/tree.rs index bd8b60ac..7bb2d5fa 100644 --- a/crates/chain/src/tree.rs +++ b/crates/chain/src/tree.rs @@ -627,6 +627,52 @@ impl BlockTree { /// chain-transition witness. Equal-work valid tips retain insertion order, matching /// normal tip publication. pub fn invalidate_subtree(&mut self, root: NodeId) -> Result, ChainError> { + let (invalid, best) = self.invalidation_plan(root)?; + + // Demote the previous active tip to Stale if it is not the new best and is not + // about to be marked invalid. + if let Some(old_tip) = self.tip_id() { + if let Some(best) = best { + if best != old_tip { + let old_index = old_tip + .index() + .ok_or(ChainError::UnknownNode { id: old_tip })?; + if !invalid[old_index] { + self.node_mut_without_index_invalidation(old_tip)?.status = + NodeStatus::Stale; + } + } + } + } + + // Wipe the published tip and active index before republishing. + self.tip.store(None); + self.active_by_height.clear_tainted(); + + // Mark the subtree invalid and collect the hashes in deterministic slab order. + let mut hashes = Vec::with_capacity(invalid.iter().filter(|&&b| b).count()); + for (index, node) in &mut self.nodes { + if invalid[index] { + node.status = NodeStatus::Invalid; + hashes.push(node.hash); + } + } + + // Republish the best valid tip (if any), which also rebuilds the active index. + if let Some(best) = best { + self.publish_tip_if_best(best)?; + } + + Ok(hashes) + } + + /// Returns the tip that would become active after invalidating `root` and + /// its descendants, without changing the tree. + pub fn tip_after_invalidation(&self, root: NodeId) -> Result, ChainError> { + self.invalidation_plan(root).map(|(_, best)| best) + } + + fn invalidation_plan(&self, root: NodeId) -> Result<(Vec, Option), ChainError> { let root_index = root.index().ok_or(ChainError::UnknownNode { id: root })?; self.node(root)?; @@ -680,41 +726,7 @@ impl BlockTree { }) .transpose()?; - // Demote the previous active tip to Stale if it is not the new best and is not - // about to be marked invalid. - if let Some(old_tip) = self.tip_id() { - if let Some(best) = best { - if best != old_tip { - let old_index = old_tip - .index() - .ok_or(ChainError::UnknownNode { id: old_tip })?; - if !invalid[old_index] { - self.node_mut_without_index_invalidation(old_tip)?.status = - NodeStatus::Stale; - } - } - } - } - - // Wipe the published tip and active index before republishing. - self.tip.store(None); - self.active_by_height.clear_tainted(); - - // Mark the subtree invalid and collect the hashes in deterministic slab order. - let mut hashes = Vec::with_capacity(invalid.iter().filter(|&&b| b).count()); - for (index, node) in &mut self.nodes { - if invalid[index] { - node.status = NodeStatus::Invalid; - hashes.push(node.hash); - } - } - - // Republish the best valid tip (if any), which also rebuilds the active index. - if let Some(best) = best { - self.publish_tip_if_best(best)?; - } - - Ok(hashes) + Ok((invalid, best)) } /// Returns all ancestors from `start` down to the root, including `start`. pub fn ancestor_chain(&self, start: NodeId) -> Result, ChainError> { @@ -1907,6 +1919,11 @@ mod tests { assert_eq!(tree.tip_id(), Some(side_ids[2])); assert_eq!(tree.active_by_height.get(1), Some(side_ids[0])); + // Previewing the invalidation selects a2 without mutating status or tip. + assert_eq!(tree.tip_after_invalidation(side_ids[0])?, Some(a2_id)); + assert_eq!(tree.tip_id(), Some(side_ids[2])); + assert_eq!(tree.node(side_ids[0])?.status, NodeStatus::HeaderValid); + // Invalidate the side root (b1). This must mark b1..b3 invalid and reselect a2. let invalid_hashes = tree.invalidate_subtree(side_ids[0])?; assert_eq!(invalid_hashes.len(), 3); diff --git a/crates/node/src/apply.rs b/crates/node/src/apply.rs index 45e073fc..7c791154 100644 --- a/crates/node/src/apply.rs +++ b/crates/node/src/apply.rs @@ -8923,6 +8923,256 @@ mod consensus_rule_tests { Ok(()) } + #[test] + fn invalidate_block_disconnects_active_tip_and_emits_sequence_event() + -> Result<(), Box> { + let utxo = Arc::new(UtxoSet::new()); + let publisher = Arc::new(RecordingSequencePublisher::default()); + let publisher_handle: Arc = publisher.clone(); + let mut handles = apply_handles_without_tx_index(Network::Regtest, Arc::clone(&utxo)) + .with_zmq_publisher(publisher_handle); + let bodies = Arc::new(MapBodyStore::default()); + let body_handle: Arc = bodies.clone(); + handles.block_body_store = Some(body_handle); + + let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); + let genesis_hash = Hash256::from_le_bytes(genesis.block_hash().as_byte_array()); + let genesis_tip = applied_header_tip(&handles, genesis_hash, &genesis, 0)?; + handles.applied_tip.store(Some(Arc::new(genesis_tip))); + + let one = mined_block_with_prev_hash_and_transactions( + genesis.block_hash(), + vec![coinbase_transaction(1)], + )?; + let one_raw = bytes::Bytes::from(bitcoin::consensus::encode::serialize(&one)); + let one_tip = apply_block_with_serialized(&handles, &one, one_raw.clone())?; + bodies + .bodies + .write() + .insert((one_tip.height, one_tip.hash), one_raw.to_vec()); + + let two = mined_block_with_prev_hash_and_transactions( + one.block_hash(), + vec![coinbase_transaction(2)], + )?; + let two_raw = bytes::Bytes::from(bitcoin::consensus::encode::serialize(&two)); + let two_tip = apply_block_with_serialized(&handles, &two, two_raw.clone())?; + bodies + .bodies + .write() + .insert((two_tip.height, two_tip.hash), two_raw.to_vec()); + publisher.events.lock().clear(); + *publisher.next_sequence.lock() = 0; + + crate::reorg::invalidate_block(&handles, two_tip.hash)?; + + assert_eq!( + handles.applied_tip.load_full().map(|tip| tip.hash), + Some(one_tip.hash) + ); + let tree = handles.block_tree.read(); + let invalid_id = tree.lookup(two_tip.hash).ok_or("missing invalidated tip")?; + assert_eq!(tree.node(invalid_id)?.status, NodeStatus::Invalid); + drop(tree); + assert_eq!( + publisher.events.lock().as_slice(), + &[(two_tip.hash, b'D', 0)] + ); + Ok(()) + } + + #[test] + fn invalidate_block_missing_disconnect_body_mutates_nothing() + -> Result<(), Box> { + let utxo = Arc::new(UtxoSet::new()); + let mut handles = apply_handles_without_tx_index(Network::Regtest, Arc::clone(&utxo)); + let bodies = Arc::new(MapBodyStore::default()); + let body_handle: Arc = bodies.clone(); + handles.block_body_store = Some(body_handle); + + let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); + let genesis_hash = Hash256::from_le_bytes(genesis.block_hash().as_byte_array()); + let genesis_tip = applied_header_tip(&handles, genesis_hash, &genesis, 0)?; + handles.applied_tip.store(Some(Arc::new(genesis_tip))); + + let block = mined_block_with_prev_hash_and_transactions( + genesis.block_hash(), + vec![coinbase_transaction(1)], + )?; + let raw = bytes::Bytes::from(bitcoin::consensus::encode::serialize(&block)); + let applied = apply_block_with_serialized(&handles, &block, raw)?; + bodies + .bodies + .write() + .remove(&(applied.height, applied.hash)); + handles.blocks.write().clear(); + + let header_tip_before = handles.chain_tip.load_full(); + let applied_tip_before = handles.applied_tip.load_full(); + let utxo_len_before = utxo.len(); + let outcome = crate::reorg::invalidate_block(&handles, applied.hash); + + assert!( + matches!(outcome, Err(crate::reorg::ReorgError::MissingBody { .. })), + "missing disconnect data must abort invalidation, got {outcome:?}" + ); + assert_eq!( + handles.block_tree.read().node(applied.tip_id)?.status, + NodeStatus::Active, + "preflight failure must leave the requested header valid and active" + ); + assert_eq!(handles.chain_tip.load_full(), header_tip_before); + assert_eq!(handles.applied_tip.load_full(), applied_tip_before); + assert_eq!(utxo.len(), utxo_len_before); + Ok(()) + } + + #[test] + fn invalidate_block_rejects_unknown_and_genesis_without_mutation() + -> Result<(), Box> { + let handles = apply_handles_without_tx_index(Network::Regtest, Arc::new(UtxoSet::new())); + let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); + let genesis_hash = Hash256::from_le_bytes(genesis.block_hash().as_byte_array()); + let genesis_tip = applied_header_tip(&handles, genesis_hash, &genesis, 0)?; + handles + .applied_tip + .store(Some(Arc::new(genesis_tip.clone()))); + let header_tip_before = handles.chain_tip.load_full(); + + let unknown = Hash256::from_le_bytes(&[0x5a; 32]); + assert!(matches!( + crate::reorg::invalidate_block(&handles, unknown), + Err(crate::reorg::ReorgError::UnknownBlock(hash)) if hash == unknown + )); + assert!(matches!( + crate::reorg::invalidate_block(&handles, genesis_hash), + Err(crate::reorg::ReorgError::CannotInvalidateGenesis) + )); + assert_eq!(handles.chain_tip.load_full(), header_tip_before); + assert_eq!( + handles.applied_tip.load_full().as_deref(), + Some(&genesis_tip) + ); + assert_eq!( + handles.block_tree.read().node(genesis_tip.tip_id)?.status, + NodeStatus::Active + ); + Ok(()) + } + + struct BlockingBodyStore { + body: Vec, + entered: std::sync::Barrier, + release: std::sync::Barrier, + block_once: AtomicBool, + } + + impl crate::apply::PruneBodyStore for BlockingBodyStore { + fn load_block_body( + &self, + _height: u32, + _hash: Hash256, + ) -> Result>, StorageError> { + if self.block_once.swap(false, Ordering::AcqRel) { + self.entered.wait(); + self.release.wait(); + } + Ok(Some(self.body.clone())) + } + + fn persist_block_body( + &self, + _height: u32, + _hash: Hash256, + _body: &[u8], + ) -> Result<(), StorageError> { + Ok(()) + } + + fn sync(&self) -> Result<(), StorageError> { + Ok(()) + } + } + + #[test] + fn invalidate_block_holds_chain_transition_through_preflight_and_disconnect() + -> Result<(), Box> { + let utxo = Arc::new(UtxoSet::new()); + let mut handles = apply_handles_without_tx_index(Network::Regtest, Arc::clone(&utxo)); + let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); + let genesis_hash = Hash256::from_le_bytes(genesis.block_hash().as_byte_array()); + let genesis_tip = applied_header_tip(&handles, genesis_hash, &genesis, 0)?; + handles.applied_tip.store(Some(Arc::new(genesis_tip))); + + let block = mined_block_with_prev_hash_and_transactions( + genesis.block_hash(), + vec![coinbase_transaction(1)], + )?; + let raw = bytes::Bytes::from(bitcoin::consensus::encode::serialize(&block)); + let applied = apply_block_with_serialized(&handles, &block, raw.clone())?; + handles.blocks.write().clear(); + + let store = Arc::new(BlockingBodyStore { + body: raw.to_vec(), + entered: std::sync::Barrier::new(2), + release: std::sync::Barrier::new(2), + block_once: AtomicBool::new(true), + }); + let body_handle: Arc = store.clone(); + handles.block_body_store = Some(body_handle); + + let worker_handles = handles.clone(); + let contender_handles = handles.clone(); + let (started_tx, started_rx) = std::sync::mpsc::sync_channel(1); + let (acquired_tx, acquired_rx) = std::sync::mpsc::sync_channel(1); + std::thread::scope(|scope| -> Result<(), Box> { + let invalidator = + scope.spawn(move || crate::reorg::invalidate_block(&worker_handles, applied.hash)); + store.entered.wait(); + + let contender = scope.spawn(move || { + let _ = started_tx.send(()); + let transition = contender_handles.begin_chain_transition(); + let acquired = transition.is_ok(); + let _ = acquired_tx.send(acquired); + drop(transition); + if acquired { + Ok(()) + } else { + Err(ApplyError::Shutdown) + } + }); + started_rx.recv_timeout(std::time::Duration::from_secs(5))?; + assert!( + matches!( + acquired_rx.recv_timeout(std::time::Duration::from_millis(100)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + ), + "a competing transition entered while invalidation was preloading" + ); + + store.release.wait(); + invalidator + .join() + .map_err(|_| std::io::Error::other("invalidation worker panicked"))??; + assert!(acquired_rx.recv_timeout(std::time::Duration::from_secs(5))?); + contender + .join() + .map_err(|_| std::io::Error::other("transition contender panicked"))??; + Ok(()) + })?; + + assert_eq!( + handles.applied_tip.load_full().map(|tip| tip.hash), + Some(genesis_hash) + ); + assert_eq!( + handles.block_tree.read().node(applied.tip_id)?.status, + NodeStatus::Invalid + ); + Ok(()) + } + #[derive(Debug)] struct AppliedTipVisiblePublisher { applied_tip: Arc>, diff --git a/crates/node/src/config.rs b/crates/node/src/config.rs index 552add28..d45fc029 100644 --- a/crates/node/src/config.rs +++ b/crates/node/src/config.rs @@ -16,6 +16,42 @@ const DEFAULT_RPC_USER: &str = "bitcoin-rs"; const DEFAULT_RPC_PASSWORD: &str = "bitcoin-rs"; const DEFAULT_DBCACHE_MB: u64 = 450; const DEFAULT_ZMQ_HWM: u32 = 1_000; +const DRYNET4_CONNECT: &str = "drynet4.drivechain.dev:8533"; +const DRYNET4_P2P_MAGIC: [u8; 4] = [0xec, 0xa5, 0xd4, 0x04]; + +/// A complete built-in node network selection. +/// +/// Unlike [`Network`], which selects consensus rules, this also selects P2P +/// bootstrap behavior. Low-level settings in the same or a later configuration +/// layer may explicitly override the network-derived defaults. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum NetworkSelection { + /// Bitcoin mainnet. + Mainnet, + /// Legacy Bitcoin testnet. + Testnet3, + /// Bitcoin testnet4. + Testnet4, + /// Bitcoin signet. + Signet, + /// Local regression-test network. + Regtest, + /// ecash drynet4: mainnet consensus history on a distinct P2P network. + Drynet4, +} + +impl NetworkSelection { + const fn consensus_network(self) -> Network { + match self { + Self::Mainnet | Self::Drynet4 => Network::Mainnet, + Self::Testnet3 => Network::Testnet3, + Self::Testnet4 => Network::Testnet4, + Self::Signet => Network::Signet, + Self::Regtest => Network::Regtest, + } + } +} /// RPC authentication configuration before it is converted into the RPC crate's runtime policy. #[derive(Clone, Debug, Eq, PartialEq, Deserialize)] @@ -80,7 +116,8 @@ pub struct ZmqPublication { pub hwm: u32, } -/// Fully resolved node configuration. +/// Fully merged node configuration. Fixed-peer hostnames are intentionally +/// resolved later by the P2P bootstrap worker. #[derive(Clone, Deserialize)] #[serde(default)] #[allow(clippy::struct_excessive_bools)] @@ -88,6 +125,8 @@ pub struct Config { /// Bitcoin network selected for consensus and default ports. #[serde(deserialize_with = "deserialize_network")] pub network: Network, + /// Optional P2P message-start override for fork networks sharing this chain's genesis. + pub p2p_magic: Option<[u8; 4]>, /// Node data directory. pub data_dir: PathBuf, /// Storage backend name: `rocksdb`, `fjall`, `redb`, or `mdbx`. @@ -106,9 +145,11 @@ pub struct Config { pub p2p_listen: Vec, /// Whether DNS seeds are used for peer bootstrap. pub dns_seeds_enabled: bool, - /// Fixed outbound peers to connect to. When non-empty, DNS seed bootstrap is - /// disabled and the node dials only these addresses (Bitcoin Core `-connect`). - pub connect: Vec, + /// Fixed outbound peer endpoints to connect to. Hostnames remain unresolved + /// until the P2P dial path so transient DNS failures do not prevent startup. + /// When non-empty, DNS seed bootstrap is disabled and the node dials only + /// these endpoints (Bitcoin Core `-connect`). + pub connect: Vec, /// Pruning target in MiB. Zero disables pruning. pub prune_target_mb: u64, /// Whether utreexo mode is enabled. @@ -174,6 +215,7 @@ impl fmt::Debug for Config { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Config") .field("network", &self.network) + .field("p2p_magic", &self.p2p_magic) .field("data_dir", &self.data_dir) .field("storage_backend", &self.storage_backend) .field("rpc_bind", &self.rpc_bind) @@ -237,6 +279,7 @@ impl Config { pub fn default_for_network(network: Network) -> Self { Self { network, + p2p_magic: None, data_dir: PathBuf::from(".bitcoin-rs"), storage_backend: DEFAULT_STORAGE_BACKEND.to_owned(), rpc_bind: SocketAddr::from(([127, 0, 0, 1], network.default_rpc_port())), @@ -327,6 +370,20 @@ impl Config { /// Validates backend names and simple cross-field constraints. pub fn validate(&self) -> Result<()> { + if self.p2p_magic.is_some() { + ensure!( + self.network == Network::Mainnet, + "P2P magic overrides currently require --network mainnet" + ); + ensure!( + !self.connect.is_empty(), + "P2P magic overrides require at least one --connect peer" + ); + ensure!( + !self.dns_seeds_enabled, + "P2P magic overrides require --dns-seeds-enabled=false" + ); + } match self.storage_backend.as_str() { "rocksdb" | "fjall" | "redb" | "mdbx" => {} other => bail!("unsupported storage backend {other}"), @@ -378,6 +435,12 @@ impl Config { Ok(()) } + /// Returns the effective P2P message-start bytes. + #[must_use] + pub fn p2p_magic(&self) -> [u8; 4] { + self.p2p_magic.unwrap_or_else(|| self.network.magic()) + } + /// Returns active ZMQ publications in Core notification order. #[must_use] pub fn zmq_publications(&self) -> Vec { @@ -449,7 +512,10 @@ impl Config { #[allow(clippy::too_many_lines)] fn apply_layer(&mut self, layer: &ConfigLayer) { if let Some(network) = layer.network { - self.network = network; + self.apply_network_selection(network); + } + if let Some(p2p_magic) = layer.p2p_magic { + self.p2p_magic = Some(p2p_magic); } if let Some(data_dir) = &layer.data_dir { self.data_dir.clone_from(data_dir); @@ -559,6 +625,22 @@ impl Config { } } + fn apply_network_selection(&mut self, selection: NetworkSelection) { + let network = selection.consensus_network(); + self.network = network; + self.p2p_magic = None; + self.rpc_bind = SocketAddr::from(([127, 0, 0, 1], network.default_rpc_port())); + self.p2p_listen = vec![SocketAddr::from(([0, 0, 0, 0], network.default_p2p_port()))]; + self.dns_seeds_enabled = true; + self.connect.clear(); + + if selection == NetworkSelection::Drynet4 { + self.p2p_magic = Some(DRYNET4_P2P_MAGIC); + self.dns_seeds_enabled = false; + self.connect = vec![DRYNET4_CONNECT.to_owned()]; + } + } + fn apply_g14_utxo_commit_layer(&mut self, layer: &ConfigLayer) { if let Some(path) = &layer.g14_utxo_commit_samples { self.g14_utxo_commit_samples = Some(path.clone()); @@ -586,9 +668,13 @@ pub(crate) struct ConfigLayer { pub(crate) config: Option, #[arg(long = "bitcoin-conf")] pub(crate) bitcoin_conf: Option, - #[arg(long, value_parser = parse_network)] - #[serde(deserialize_with = "deserialize_optional_network")] - pub(crate) network: Option, + /// Select the Bitcoin or fork network, including its P2P bootstrap profile. + #[arg(long, value_parser = parse_network_selection)] + pub(crate) network: Option, + /// Override the four P2P message-start bytes for a fork network. + #[arg(long = "p2p-magic", value_parser = parse_p2p_magic)] + #[serde(deserialize_with = "deserialize_optional_p2p_magic")] + pub(crate) p2p_magic: Option<[u8; 4]>, #[arg(long = "data-dir")] pub(crate) data_dir: Option, #[arg(long = "storage-backend")] @@ -616,8 +702,12 @@ pub(crate) struct ConfigLayer { pub(crate) p2p_listen: Option>, #[arg(long = "dns-seeds-enabled")] pub(crate) dns_seeds_enabled: Option, - #[arg(long = "connect", value_delimiter = ',')] - pub(crate) connect: Option>, + #[arg( + long = "connect", + value_delimiter = ',', + value_parser = parse_connect_endpoint + )] + pub(crate) connect: Option>, #[arg(long = "prune-target-mb")] pub(crate) prune_target_mb: Option, #[arg(long = "utreexo-mode")] @@ -688,7 +778,8 @@ impl ConfigLayer { let key = key.as_ref(); let value = value.as_ref(); match key { - "BITCOIN_RS_NETWORK" => layer.network = Some(parse_network(value)?), + "BITCOIN_RS_NETWORK" => layer.network = Some(parse_network_selection(value)?), + "BITCOIN_RS_P2P_MAGIC" => layer.p2p_magic = Some(parse_p2p_magic(value)?), "BITCOIN_RS_DATA_DIR" => layer.data_dir = Some(PathBuf::from(value)), "BITCOIN_RS_STORAGE_BACKEND" => layer.storage_backend = Some(value.to_owned()), "BITCOIN_RS_RPC_BIND" => layer.rpc_bind = Some(value.parse()?), @@ -696,6 +787,9 @@ impl ConfigLayer { "BITCOIN_RS_RPC_USER" => layer.rpc_user = Some(value.to_owned()), "BITCOIN_RS_RPC_PASSWORD" => layer.rpc_password = Some(value.to_owned()), "BITCOIN_RS_RPC_COOKIE" => layer.rpc_cookie = Some(PathBuf::from(value)), + "BITCOIN_RS_ELECTRUM_BIND" if value.trim().is_empty() => { + layer.clear_electrum_bind = true; + } "BITCOIN_RS_ELECTRUM_BIND" => layer.electrum_bind = Some(value.parse()?), "BITCOIN_RS_ELECTRUM_TLS_CERT" => { layer.electrum_tls_cert = Some(PathBuf::from(value)); @@ -704,7 +798,7 @@ impl ConfigLayer { "BITCOIN_RS_DNS_SEEDS_ENABLED" => { layer.dns_seeds_enabled = Some(parse_bool(value)?); } - "BITCOIN_RS_CONNECT" => layer.connect = Some(parse_socket_list(value)?), + "BITCOIN_RS_CONNECT" => layer.connect = Some(parse_connect_list(value)?), "BITCOIN_RS_PRUNE_TARGET_MB" => layer.prune_target_mb = Some(value.parse()?), "BITCOIN_RS_UTREEXO_MODE" => layer.utreexo_mode = Some(parse_bool(value)?), "BITCOIN_RS_TXINDEX" => layer.txindex = Some(parse_bool(value)?), @@ -794,12 +888,16 @@ fn load_toml_layer(path: &Path) -> Result { } fn effective_network(toml: Option<&ConfigLayer>, env: &ConfigLayer, cli: &ConfigLayer) -> Network { - cli.network - .or(env.network) - .or_else(|| toml.and_then(|layer| layer.network)) + layer_network(cli) + .or_else(|| layer_network(env)) + .or_else(|| toml.and_then(layer_network)) .unwrap_or(Network::Mainnet) } +fn layer_network(layer: &ConfigLayer) -> Option { + layer.network.map(NetworkSelection::consensus_network) +} + fn parse_socket_list(value: &str) -> Result> { value .split(',') @@ -808,6 +906,30 @@ fn parse_socket_list(value: &str) -> Result> { .collect() } +fn parse_connect_endpoint(value: &str) -> std::result::Result { + let value = value.trim(); + if value.parse::().is_ok() { + return Ok(value.to_owned()); + } + let Some((host, port)) = value.rsplit_once(':') else { + return Err(format!("connect peer `{value}` must include a port")); + }; + if host.is_empty() { + return Err(format!("connect peer `{value}` has an empty hostname")); + } + port.parse::() + .map_err(|error| format!("connect peer `{value}` has an invalid port: {error}"))?; + Ok(value.to_owned()) +} + +fn parse_connect_list(value: &str) -> Result> { + value + .split(',') + .filter(|part| !part.trim().is_empty()) + .map(|part| parse_connect_endpoint(part.trim()).map_err(anyhow::Error::msg)) + .collect() +} + fn parse_string_list(value: &str) -> Vec { value .split(',') @@ -839,6 +961,20 @@ fn parse_bool(value: &str) -> Result { } } +fn parse_p2p_magic(value: &str) -> Result<[u8; 4]> { + let value = value.trim(); + ensure!( + value.len() == 8 && value.bytes().all(|byte| byte.is_ascii_hexdigit()), + "p2p magic must be exactly eight hexadecimal characters" + ); + let mut magic = [0_u8; 4]; + for (index, slot) in magic.iter_mut().enumerate() { + let start = index * 2; + *slot = u8::from_str_radix(&value[start..start + 2], 16)?; + } + Ok(magic) +} + fn parse_network(value: &str) -> anyhow::Result { match value.trim().to_ascii_lowercase().as_str() { "main" | "mainnet" | "bitcoin" => Ok(Network::Mainnet), @@ -850,6 +986,18 @@ fn parse_network(value: &str) -> anyhow::Result { } } +fn parse_network_selection(value: &str) -> anyhow::Result { + match value.trim().to_ascii_lowercase().as_str() { + "main" | "mainnet" | "bitcoin" => Ok(NetworkSelection::Mainnet), + "test" | "testnet" | "testnet3" => Ok(NetworkSelection::Testnet3), + "testnet4" => Ok(NetworkSelection::Testnet4), + "signet" => Ok(NetworkSelection::Signet), + "regtest" => Ok(NetworkSelection::Regtest), + "drynet4" => Ok(NetworkSelection::Drynet4), + other => bail!("unknown network {other}"), + } +} + fn deserialize_network<'de, D>(deserializer: D) -> core::result::Result where D: serde::Deserializer<'de>, @@ -858,15 +1006,15 @@ where parse_network(&raw).map_err(serde::de::Error::custom) } -fn deserialize_optional_network<'de, D>( +fn deserialize_optional_p2p_magic<'de, D>( deserializer: D, -) -> core::result::Result, D::Error> +) -> core::result::Result, D::Error> where D: serde::Deserializer<'de>, { let raw = Option::::deserialize(deserializer)?; raw.as_deref() - .map(parse_network) + .map(parse_p2p_magic) .transpose() .map_err(serde::de::Error::custom) } diff --git a/crates/node/src/reorg.rs b/crates/node/src/reorg.rs index 40a82d25..943712c8 100644 --- a/crates/node/src/reorg.rs +++ b/crates/node/src/reorg.rs @@ -18,12 +18,79 @@ use bitcoin_rs_storage::StorageError; use crate::apply::ApplyHandles; use crate::{ApplyError, DisconnectError}; +/// Invalidates `hash` and its descendants, then moves applied chainstate to the +/// best remaining valid tip. +pub fn invalidate_block( + handles: &ApplyHandles, + hash: Hash256, +) -> core::result::Result<(), ReorgError> { + let transition = handles + .begin_chain_transition() + .map_err(|source| ReorgError::Unavailable(Box::new(source)))?; + + loop { + let (root, target) = { + let tree = handles.block_tree.read(); + let root = tree.lookup(hash).ok_or(ReorgError::UnknownBlock(hash))?; + if tree.node(root).map_err(ReorgError::Plan)?.height == 0 { + return Err(ReorgError::CannotInvalidateGenesis); + } + let target = tree + .tip_after_invalidation(root) + .map_err(ReorgError::Plan)? + .ok_or(ReorgError::NoValidTip)?; + (root, target) + }; + + let plan = current_reorg_plan(handles, target)?; + let (disconnect, connect) = if let Some(plan) = plan.as_ref() { + let mut no_staged_body = |_| None; + ( + load_branch_bodies(handles, &plan.disconnect, &mut no_staged_body)?, + load_branch_bodies(handles, &plan.connect, &mut no_staged_body)?, + ) + } else { + (Vec::new(), Vec::new()) + }; + + let published_target = { + let mut tree = handles.block_tree.write(); + let current_root = tree.lookup(hash).ok_or(ReorgError::UnknownBlock(hash))?; + let current_target = tree + .tip_after_invalidation(current_root) + .map_err(ReorgError::Plan)? + .ok_or(ReorgError::NoValidTip)?; + if current_root != root || current_target != target { + continue; + } + tree.invalidate_subtree(root).map_err(ReorgError::Plan)?; + let tip = tree.tip().ok_or(ReorgError::NoValidTip)?; + handles.chain_tip.store(Some(tip.clone())); + handles.assume_valid_gate.evaluate(&tree); + tip.tip_id + }; + debug_assert_eq!(published_target, target); + + let (_, outcome) = execute_loaded_plan(handles, &disconnect, &connect, &transition); + return outcome; + } +} + /// Why a branch switch stopped, and what the chain looks like now. /// /// Four outcomes rather than one error type, because the caller must act /// differently for each and the difference is exactly how much damage there is. #[derive(Debug, thiserror::Error)] pub enum ReorgError { + /// The requested block hash is unknown. + #[error("unknown block {0}")] + UnknownBlock(Hash256), + /// The genesis block cannot be invalidated. + #[error("cannot invalidate the genesis block")] + CannotInvalidateGenesis, + /// Invalidation unexpectedly left no valid chain tip. + #[error("invalidation left no valid chain tip")] + NoValidTip, /// Planning failed: the two tips share no ancestor, or a node is unknown. /// /// Nothing was touched. @@ -197,65 +264,75 @@ where continue; } - for body in &disconnect { - match crate::apply::disconnect_block_admitted(handles, &body.block, &transition) { - Ok(_) => {} - Err( - error @ (DisconnectError::Fatal { .. } | DisconnectError::MarkerStuck { .. }), - ) => { - handles.admission.close_permanently(); - return Err(ReorgError::Fatal(Box::new(error))); - } - Err(error) => { - return Err(ReorgError::Refused { + let (connected, outcome) = execute_loaded_plan(handles, &disconnect, &connect, &transition); + drop(transition); + for body in &connect[..connected] { + connected_body(body.hash); + } + outcome?; + if let Some((hash, height)) = missing_connect { + return Err(ReorgError::MissingBody { hash, height }); + } + return Ok(()); + } +} + +fn execute_loaded_plan( + handles: &ApplyHandles, + disconnect: &[LoadedBranchBody], + connect: &[LoadedBranchBody], + transition: &crate::apply::ChainTransition<'_>, +) -> (usize, core::result::Result<(), ReorgError>) { + for body in disconnect { + match crate::apply::disconnect_block_admitted(handles, &body.block, transition) { + Ok(_) => {} + Err(error @ (DisconnectError::Fatal { .. } | DisconnectError::MarkerStuck { .. })) => { + handles.admission.close_permanently(); + return (0, Err(ReorgError::Fatal(Box::new(error)))); + } + Err(error) => { + return ( + 0, + Err(ReorgError::Refused { stopped_at: body.height, source: Box::new(error), - }); - } + }), + ); } } + } - let mut connected = 0_usize; - let mut failure = None; - for body in &connect { - match crate::apply::apply_block_with_serialized_admitted( - handles, - &body.block, - body.serialized.clone(), - &transition, - ) { - Ok(_) => connected += 1, - Err(source) => { - let invalidated = if is_permanent_invalid(&source) { - let mut tree = handles.block_tree.write(); - tree.lookup(body.hash) - .and_then(|node_id| tree.invalidate_subtree(node_id).ok()) - .unwrap_or_default() - } else { - Vec::new() - }; - failure = Some(ReorgError::ConnectFailed { + let mut connected = 0_usize; + for body in connect { + match crate::apply::apply_block_with_serialized_admitted( + handles, + &body.block, + body.serialized.clone(), + transition, + ) { + Ok(_) => connected += 1, + Err(source) => { + let invalidated = if is_permanent_invalid(&source) { + let mut tree = handles.block_tree.write(); + tree.lookup(body.hash) + .and_then(|node_id| tree.invalidate_subtree(node_id).ok()) + .unwrap_or_default() + } else { + Vec::new() + }; + return ( + connected, + Err(ReorgError::ConnectFailed { hash: body.hash, stopped_at: body.height.saturating_sub(1), source: Box::new(source), invalidated, - }); - break; - } + }), + ); } } - drop(transition); - for body in &connect[..connected] { - connected_body(body.hash); - } - if let Some(error) = failure { - return Err(error); - } - if let Some((hash, height)) = missing_connect { - return Err(ReorgError::MissingBody { hash, height }); - } - return Ok(()); } + (connected, Ok(())) } fn current_reorg_plan( diff --git a/crates/node/src/run.rs b/crates/node/src/run.rs index 1fc2a327..5b9b6114 100644 --- a/crates/node/src/run.rs +++ b/crates/node/src/run.rs @@ -1,7 +1,7 @@ //! Top-level orchestration: wire subsystems, spin the event loop, drain. use crate as bitcoin_rs_node; -use std::net::SocketAddr; +use std::net::{SocketAddr, ToSocketAddrs}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; @@ -66,6 +66,28 @@ type P2pChainQuery = Arc; type OutboundConnectionHandle = std::thread::JoinHandle>; +#[derive(Clone)] +struct RpcChainControl { + handles: crate::apply::ApplyHandles, +} + +impl bitcoin_rs_rpc::ChainControl for RpcChainControl { + fn invalidate_block( + &self, + hash: bitcoin_rs_primitives::Hash256, + ) -> core::result::Result<(), bitcoin_rs_rpc::ChainControlError> { + crate::reorg::invalidate_block(&self.handles, hash).map_err(|error| match error { + crate::reorg::ReorgError::UnknownBlock(_) => { + bitcoin_rs_rpc::ChainControlError::UnknownBlock + } + crate::reorg::ReorgError::CannotInvalidateGenesis => { + bitcoin_rs_rpc::ChainControlError::Genesis + } + other => bitcoin_rs_rpc::ChainControlError::Failed(other.to_string()), + }) + } +} + /// Bounds rapid DNS retries while the initial outbound pool is still empty. #[derive(Default)] struct DnsBootstrapRefill { @@ -174,7 +196,7 @@ fn spawn_p2p_listeners( ) -> anyhow::Result>>> { let mut handles = Vec::with_capacity(config.p2p_listen.len()); - let magic = bitcoin::p2p::Magic::from_bytes(config.network.magic()); + let magic = bitcoin::p2p::Magic::from_bytes(config.p2p_magic()); for addr in &config.p2p_listen { let listener_addr = *addr; let listener_shutdown = std::sync::Arc::clone(shutdown); @@ -260,7 +282,7 @@ fn spawn_p2p_outbound_drain( >, ) -> anyhow::Result> { let outbound_rx = state.p2p_outbound_receiver(); - let magic = bitcoin::p2p::Magic::from_bytes(state.config().network.magic()); + let magic = bitcoin::p2p::Magic::from_bytes(state.config().p2p_magic()); let outbound_registry = state.peers(); let outbound_peer_outbound = state.peer_outbound(); let outbound_banned = state.banned_subnets(); @@ -524,15 +546,24 @@ fn spawn_fixed_peer_bootstrap( .name("bitcoin-rs-fixed-peer-bootstrap".to_owned()) .spawn(move || { while !bootstrap_shutdown.load(std::sync::atomic::Ordering::Relaxed) { - for addr in &connect { - if peer_outbound.read().contains_key(addr) - || peers.read().iter().any(|peer| peer.addr == *addr) - { - continue; - } - if outbound_tx.try_send(*addr).is_err() { - // Queue full or closed; retry on the next tick. - break; + 'endpoints: for endpoint in &connect { + let addresses = match endpoint.as_str().to_socket_addrs() { + Ok(addresses) => addresses, + Err(error) => { + tracing::warn!(endpoint, %error, "fixed peer resolution failed"); + continue; + } + }; + for addr in addresses { + if peer_outbound.read().contains_key(&addr) + || peers.read().iter().any(|peer| peer.addr == addr) + { + continue; + } + if outbound_tx.try_send(addr).is_err() { + // Queue full or closed; retry on the next tick. + break 'endpoints; + } } } if wait_for_shutdown(&bootstrap_shutdown, Duration::from_secs(2)) { @@ -618,6 +649,9 @@ pub fn run(mut config: Config) -> Result<()> { if let Some(prune_service) = state.prune_service() { rpc_context = rpc_context.with_prune_service(prune_service); } + rpc_context = rpc_context.with_chain_control(Arc::new(RpcChainControl { + handles: state.apply_handles(), + })); rpc_context = rpc_context.with_zmq_notifications(state.active_zmq_notifications()); let rpc_handler = Arc::new(bitcoin_rs_rpc::Handler::new(Arc::new(rpc_context))); let rpc_server = bitcoin_rs_rpc::RpcServer::bind( @@ -884,7 +918,7 @@ mod tests { // below is exercised — with an empty `connect`, `bootstrap_worker` // would be `None` and the cleanup-ordering assertion could not catch a // regression that moves `?` back onto `write_clean_checkpoint`. - config.connect = vec![SocketAddr::from(([127, 0, 0, 1], 1))]; + config.connect = vec!["127.0.0.1:1".to_owned()]; let state = crate::state::NodeState::open(config.clone())?; state.apply_block(&bitcoin::blockdata::constants::genesis_block( diff --git a/crates/node/tests/config_layered.rs b/crates/node/tests/config_layered.rs index 496bc26d..c6093a7f 100644 --- a/crates/node/tests/config_layered.rs +++ b/crates/node/tests/config_layered.rs @@ -96,6 +96,97 @@ fn cli_can_override_socket_and_vector_fields() -> Result<()> { Ok(()) } +#[test] +fn p2p_magic_override_preserves_consensus_network() -> Result<()> { + let peer = "127.0.0.1:8333".to_owned(); + let config = Config::from_layered_sources( + None, + None, + core::iter::empty::(), + [ + "bitcoin-rs-node", + "--p2p-magic", + "eca5d434", + "--dns-seeds-enabled", + "false", + "--connect", + "127.0.0.1:8333", + ], + )?; + + assert_eq!(config.network, Network::Mainnet); + assert_eq!(config.p2p_magic(), [0xec, 0xa5, 0xd4, 0x34]); + assert_eq!(config.connect, vec![peer]); + assert!(!config.dns_seeds_enabled); + Ok(()) +} + +#[test] +fn drynet4_network_applies_atomic_p2p_profile() -> Result<()> { + let config = Config::from_layered_sources( + None, + None, + [("BITCOIN_RS_NETWORK", "drynet4")], + ["bitcoin-rs-node"], + )?; + + assert_eq!(config.network, Network::Mainnet); + assert_eq!(config.p2p_magic(), [0xec, 0xa5, 0xd4, 0x04]); + assert_eq!(config.connect, vec!["drynet4.drivechain.dev:8533"]); + assert!(!config.dns_seeds_enabled); + Ok(()) +} + +#[test] +fn explicit_fields_override_network_defaults_within_the_same_layer() -> Result<()> { + let config = Config::from_layered_sources( + None, + None, + [ + ("BITCOIN_RS_NETWORK", "drynet4"), + ("BITCOIN_RS_CONNECT", "127.0.0.1:8333"), + ], + ["bitcoin-rs-node", "--p2p-magic", "01020304"], + )?; + + assert_eq!(config.p2p_magic(), [1, 2, 3, 4]); + assert_eq!(config.connect, vec!["127.0.0.1:8333"]); + Ok(()) +} + +#[test] +fn standard_network_uses_builtin_defaults() -> Result<()> { + let config = Config::from_layered_sources( + None, + None, + [("BITCOIN_RS_NETWORK", "testnet4")], + ["bitcoin-rs-node"], + )?; + + assert_eq!(config.network, Network::Testnet4); + assert_eq!(config.p2p_magic(), Network::Testnet4.magic()); + assert!(config.connect.is_empty()); + assert!(config.dns_seeds_enabled); + Ok(()) +} + +#[test] +fn p2p_magic_override_requires_an_explicit_peer() { + let result = Config::from_layered_sources( + None, + None, + core::iter::empty::(), + [ + "bitcoin-rs-node", + "--p2p-magic", + "eca5d434", + "--dns-seeds-enabled", + "false", + ], + ); + assert!(result.is_err_and(|error| error.to_string().contains("at least one --connect peer"))); +} + #[test] fn electrum_bind_requires_txindex() -> Result<()> { let mut config = Config::default_for_network(Network::Regtest); @@ -109,6 +200,23 @@ fn electrum_bind_requires_txindex() -> Result<()> { Ok(()) } +#[test] +fn empty_electrum_bind_disables_the_optional_service() -> Result<()> { + let config = Config::from_layered_sources( + None, + None, + [ + ("BITCOIN_RS_TXINDEX", "false"), + ("BITCOIN_RS_ELECTRUM_BIND", ""), + ], + ["bitcoin-rs-node"], + )?; + + assert!(!config.txindex); + assert_eq!(config.electrum_bind, None); + Ok(()) +} + #[test] fn zmq_layers_parse_precedence_and_publication_order() -> Result<()> { let temp = tempfile::tempdir()?; @@ -376,13 +484,7 @@ fn connect_layers_parse_cli_and_env_peer_lists() -> Result<()> { "127.0.0.1:8333,10.0.0.2:8333", ], )?; - assert_eq!( - cli_config.connect, - vec![ - "127.0.0.1:8333".parse::()?, - "10.0.0.2:8333".parse::()?, - ] - ); + assert_eq!(cli_config.connect, vec!["127.0.0.1:8333", "10.0.0.2:8333"]); let env_config = Config::from_layered_sources( None, @@ -390,10 +492,15 @@ fn connect_layers_parse_cli_and_env_peer_lists() -> Result<()> { [("BITCOIN_RS_CONNECT", "192.0.2.5:8333")], ["bitcoin-rs-node"], )?; - assert_eq!( - env_config.connect, - vec!["192.0.2.5:8333".parse::()?] - ); + assert_eq!(env_config.connect, vec!["192.0.2.5:8333"]); + + let hostname_config = Config::from_layered_sources( + None, + None, + [("BITCOIN_RS_CONNECT", "localhost:18444")], + ["bitcoin-rs-node"], + )?; + assert_eq!(hostname_config.connect, vec!["localhost:18444"]); let default_config = Config::from_layered_sources( None, diff --git a/crates/rpc/src/context.rs b/crates/rpc/src/context.rs index 1887a443..4c1efca1 100644 --- a/crates/rpc/src/context.rs +++ b/crates/rpc/src/context.rs @@ -219,6 +219,29 @@ pub trait PruneService: Send + Sync { /// Reports whether pruning is enabled and the highest completed prune height. fn status(&self) -> PruneStatus; } + +/// Node-owned control plane for consensus-affecting chain RPCs. +pub trait ChainControl: Send + Sync { + /// Invalidates a block and descendants and selects the best remaining chain. + fn invalidate_block( + &self, + hash: bitcoin_rs_primitives::Hash256, + ) -> Result<(), ChainControlError>; +} + +/// Failure from a node-owned chain mutation. +#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)] +pub enum ChainControlError { + /// The requested block is unknown. + #[error("unknown block")] + UnknownBlock, + /// Genesis cannot be invalidated. + #[error("cannot invalidate the genesis block")] + Genesis, + /// The mutation failed after its request was accepted. + #[error("{0}")] + Failed(String), +} #[derive(Debug, Default)] struct NoopFilterIndex; @@ -272,6 +295,8 @@ pub struct Context { pub filter_index: Arc>, /// Optional storage pruning mutator. pub prune_service: Option>, + /// Optional node-owned chain mutation service. + pub chain_control: Option>, /// Optional shared confirmed-block indexer used to resolve prevout values for fee statistics. /// `None` for embedded/test callers without txindex. pub indexer: Option>>>, @@ -351,6 +376,7 @@ impl Context { filter_index: noop_filter_index(), indexer: None, prune_service: None, + chain_control: None, network: Arc::new(RwLock::new(NetworkState::default())), chain_network: Network::Mainnet, peers: Arc::new(RwLock::new(Vec::new())), @@ -417,6 +443,7 @@ impl Context { banned, added_nodes, prune_service: None, + chain_control: None, zmq_notifications: Arc::from(Vec::::new()), mining_sender, } @@ -436,6 +463,13 @@ impl Context { self } + /// Attaches the node-owned chain mutation service. + #[must_use] + pub fn with_chain_control(mut self, chain_control: Arc) -> Self { + self.chain_control = Some(chain_control); + self + } + /// Attaches active ZMQ notification metadata reported by `getzmqnotifications`. #[must_use] pub fn with_zmq_notifications(mut self, notifications: Vec) -> Self { diff --git a/crates/rpc/src/error.rs b/crates/rpc/src/error.rs index 2fa99ecf..c710c556 100644 --- a/crates/rpc/src/error.rs +++ b/crates/rpc/src/error.rs @@ -1,7 +1,6 @@ use core::fmt; use std::io; -use sonic_rs::{Value, json}; use thiserror::Error; /// JSON-RPC 2.0 and Bitcoin Core-compatible RPC errors. @@ -70,17 +69,6 @@ impl RpcError { Self::MethodDisabled(_) | Self::Internal(_) => Self::INTERNAL_ERROR, } } - - /// Converts this error into a JSON-RPC response object for `id`. - #[must_use] - pub fn response(&self, id: &Value) -> Value { - json!({ - "jsonrpc": "2.0", - "result": null, - "error": {"code": self.code(), "message": self.to_string()}, - "id": id - }) - } } impl From for RpcError { diff --git a/crates/rpc/src/handlers.rs b/crates/rpc/src/handlers.rs index 49d8d185..a32d5b32 100644 --- a/crates/rpc/src/handlers.rs +++ b/crates/rpc/src/handlers.rs @@ -53,6 +53,7 @@ impl Handler { "getblockfilter" => chain::getblockfilter(&self.ctx, params), "getindexinfo" => chain::getindexinfo(&self.ctx, params), "pruneblockchain" => chain::pruneblockchain(&self.ctx, params), + "invalidateblock" => chain::invalidateblock(&self.ctx, params), "getrawtransaction" => tx::getrawtransaction(&self.ctx, params), "gettxout" => tx::gettxout(&self.ctx, params), "gettxoutproof" => tx::gettxoutproof(&self.ctx, params), diff --git a/crates/rpc/src/handlers/chain.rs b/crates/rpc/src/handlers/chain.rs index d7836206..abf819c0 100644 --- a/crates/rpc/src/handlers/chain.rs +++ b/crates/rpc/src/handlers/chain.rs @@ -9,7 +9,7 @@ use bitcoin_rs_primitives::Hash256; use bitcoin_rs_pruning::policy::CORE_REORG_SAFETY_MARGIN; use sonic_rs::{JsonContainerTrait as _, JsonValueTrait, Value, json}; -use crate::context::{BlockRecord, Context}; +use crate::context::{BlockRecord, ChainControlError, Context}; use crate::error::RpcError; use crate::handlers::{ensure_no_params, optional_bool, params_array, required_str, required_u64}; @@ -587,6 +587,22 @@ pub(crate) fn pruneblockchain(ctx: &Arc, params: &Value) -> Result, params: &Value) -> Result { + let hash = parse_hash(required_str(params, 0, "block hash is required")?)?; + let control = ctx + .chain_control + .as_ref() + .ok_or(RpcError::MethodDisabled("invalidateblock is unavailable"))?; + match control.invalidate_block(hash) { + Ok(()) => Ok(json!(null)), + Err(ChainControlError::UnknownBlock) => Err(RpcError::NotFound("block not found")), + Err(ChainControlError::Genesis) => Err(RpcError::InvalidParams( + "cannot invalidate the genesis block", + )), + Err(ChainControlError::Failed(message)) => Err(RpcError::Internal(message)), + } +} + pub(crate) fn verifychain(ctx: &Arc, params: &Value) -> Result { use bitcoin::consensus::encode::deserialize; diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index aab1da3d..437700dd 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -26,8 +26,8 @@ pub mod server; pub use auth::Auth; pub use context::{ - BlockBodyMetadata, BlockBodySource, BlockRecord, Context, NetworkState, PruneResult, - PruneService, PruneServiceError, PruneStatus, ZmqNotification, + BlockBodyMetadata, BlockBodySource, BlockRecord, ChainControl, ChainControlError, Context, + NetworkState, PruneResult, PruneService, PruneServiceError, PruneStatus, ZmqNotification, }; pub use error::RpcError; pub use handlers::Handler; diff --git a/crates/rpc/src/server.rs b/crates/rpc/src/server.rs index f44e3674..c4566a87 100644 --- a/crates/rpc/src/server.rs +++ b/crates/rpc/src/server.rs @@ -5,7 +5,7 @@ use std::thread; use std::time::Duration; use parking_lot::Mutex; -use sonic_rs::{JsonValueTrait as _, Value, json}; +use sonic_rs::{JsonContainerTrait as _, JsonValueTrait as _, Value, json}; use tracing::{debug, warn}; use crate::auth::Auth; @@ -144,8 +144,9 @@ fn serve_connection( Ok(Some(request)) => request, Ok(None) => return Ok(()), Err(error) => { + let rpc_error = RpcError::InvalidRequest("malformed http request"); let response = - RpcError::InvalidRequest("malformed http request").response(&Value::new_null()); + JsonRpcVersion::Legacy.error_response(&rpc_error, &Value::new_null()); write_json(reader.get_mut(), 400, "Bad Request", &response, false)?; return Err(error); } @@ -178,7 +179,17 @@ fn serve_connection( } let response = handle_json(handler, &request.body); - write_json(reader.get_mut(), 200, "OK", &response, keep_alive)?; + if let Some(body) = response.body.as_ref() { + write_json( + reader.get_mut(), + response.status, + response.reason, + body, + keep_alive, + )?; + } else { + write_status(reader.get_mut(), 204, "No Content", b"", keep_alive)?; + } if !keep_alive { return Ok(()); } @@ -289,24 +300,180 @@ fn read_request(reader: &mut BufReader) -> io::Result Value { +struct JsonResponse { + status: u16, + reason: &'static str, + body: Option, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum JsonRpcVersion { + Legacy, + V2, +} + +impl JsonRpcVersion { + fn from_request(request: &Value) -> Self { + if request.get("jsonrpc").and_then(Value::as_str) == Some("2.0") { + Self::V2 + } else { + Self::Legacy + } + } + + fn success_response(self, result: &Value, id: &Value) -> Value { + match self { + Self::Legacy => json!({"result": result, "error": null, "id": id}), + Self::V2 => json!({"jsonrpc": "2.0", "result": result, "id": id}), + } + } + + fn error_response(self, error: &RpcError, id: &Value) -> Value { + match self { + Self::Legacy => json!({ + "result": null, + "error": {"code": error.code(), "message": error.to_string()}, + "id": id + }), + Self::V2 => json!({ + "jsonrpc": "2.0", + "error": {"code": error.code(), "message": error.to_string()}, + "id": id + }), + } + } + + const fn error_status(self) -> u16 { + match self { + Self::Legacy => 500, + Self::V2 => 200, + } + } +} + +enum CallResponse { + Reply { + body: Value, + version: JsonRpcVersion, + is_error: bool, + }, + Notification, +} + +impl CallResponse { + fn reply(body: Value, version: JsonRpcVersion, is_error: bool) -> Self { + Self::Reply { + body, + version, + is_error, + } + } + + const fn http_status(&self) -> u16 { + match self { + Self::Reply { + version, + is_error: true, + .. + } => version.error_status(), + Self::Reply { .. } | Self::Notification => 200, + } + } +} + +fn handle_json(handler: &Handler, body: &[u8]) -> JsonResponse { let body = match core::str::from_utf8(body) { Ok(body) => body, - Err(error) => return RpcError::from(error).response(&Value::new_null()), + Err(error) => { + return legacy_error_response(&RpcError::from(error), &Value::new_null()); + } }; let request = match sonic_rs::from_str::(body) { Ok(request) => request, - Err(error) => return RpcError::from(error).response(&Value::new_null()), + Err(error) => { + return legacy_error_response(&RpcError::from(error), &Value::new_null()); + } }; + + if let Some(requests) = request.as_array() { + if requests.is_empty() { + return legacy_error_response( + &RpcError::InvalidRequest("batch must not be empty"), + &Value::new_null(), + ); + } + let mut responses = Vec::with_capacity(requests.len()); + let mut status = 200; + for request in requests { + let response = handle_single_json(handler, request); + status = status.max(response.http_status()); + if let CallResponse::Reply { body, .. } = response { + responses.push(body); + } + } + if responses.is_empty() { + return no_content_response(); + } + return JsonResponse { + status, + reason: reason_for_status(status), + body: Some(json!(responses)), + }; + } + + let response = handle_single_json(handler, &request); + let status = response.http_status(); + match response { + CallResponse::Reply { body, .. } => JsonResponse { + status, + reason: reason_for_status(status), + body: Some(body), + }, + CallResponse::Notification => no_content_response(), + } +} + +fn handle_single_json(handler: &Handler, request: &Value) -> CallResponse { let id = request.get("id").cloned().unwrap_or_else(Value::new_null); + let version = JsonRpcVersion::from_request(request); let Some(method) = request.get("method").and_then(Value::as_str) else { - return RpcError::InvalidRequest("method is required").response(&id); + let error = RpcError::InvalidRequest("method is required"); + return CallResponse::reply(version.error_response(&error, &id), version, true); }; let null_params = Value::new_null(); let params = request.get("params").unwrap_or(&null_params); - match handler.dispatch(method, params) { - Ok(result) => json!({"jsonrpc": "2.0", "result": result, "error": null, "id": id}), - Err(error) => error.response(&id), + let result = handler.dispatch(method, params); + if version == JsonRpcVersion::V2 && request.get("id").is_none() { + return CallResponse::Notification; + } + match result { + Ok(result) => CallResponse::reply(version.success_response(&result, &id), version, false), + Err(error) => CallResponse::reply(version.error_response(&error, &id), version, true), + } +} + +fn legacy_error_response(error: &RpcError, id: &Value) -> JsonResponse { + let version = JsonRpcVersion::Legacy; + JsonResponse { + status: version.error_status(), + reason: reason_for_status(version.error_status()), + body: Some(version.error_response(error, id)), + } +} + +const fn reason_for_status(status: u16) -> &'static str { + match status { + 200 => "OK", + 204 => "No Content", + _ => "Internal Server Error", + } +} + +const fn no_content_response() -> JsonResponse { + JsonResponse { + status: 204, + reason: "No Content", + body: None, } } @@ -403,4 +570,80 @@ mod tests { ("/rest/chaininfo.json", "") ); } + + #[test] + fn json_rpc_2_success_omits_null_error_for_jsonrpsee_clients() { + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json( + &handler, + br#"{"jsonrpc":"2.0","id":1,"method":"getblockchaininfo","params":[]}"#, + ); + let response = response.body.expect("JSON-RPC response body"); + + assert_eq!(response.get("jsonrpc").and_then(Value::as_str), Some("2.0")); + assert!(response.get("result").is_some()); + assert!(response.get("error").is_none()); + } + + #[test] + fn bitcoin_core_1_success_keeps_null_error() { + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json( + &handler, + br#"{"jsonrpc":"1.0","id":1,"method":"getblockchaininfo","params":[]}"#, + ); + let response = response.body.expect("JSON-RPC response body"); + + assert!(response.get("jsonrpc").is_none()); + assert!(response.get("result").is_some()); + assert!(response.get("error").is_some_and(Value::is_null)); + } + + #[test] + fn json_rpc_2_error_omits_result_and_uses_http_200() { + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json( + &handler, + br#"{"jsonrpc":"2.0","id":7,"method":"missing","params":[]}"#, + ); + let status = response.status; + let body = response.body.expect("JSON-RPC response body"); + + assert_eq!(status, 200); + assert!(body.get("result").is_none()); + assert!(body.get("error").is_some_and(Value::is_object)); + assert_eq!(body.get("id").and_then(Value::as_i64), Some(7)); + } + + #[test] + fn json_rpc_2_notification_has_no_response_body() { + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json( + &handler, + br#"{"jsonrpc":"2.0","method":"getblockcount","params":[]}"#, + ); + + assert!(response.body.is_none()); + } + + #[test] + fn json_rpc_batch_excludes_notifications() { + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json( + &handler, + br#"[ + {"jsonrpc":"2.0","id":1,"method":"getblockcount","params":[]}, + {"jsonrpc":"2.0","method":"getblockcount","params":[]}, + {"jsonrpc":"2.0","id":2,"method":"missing","params":[]} + ]"#, + ); + let status = response.status; + let body = response.body.expect("JSON-RPC batch response body"); + let rows = body.as_array().expect("batch response array"); + + assert_eq!(status, 200); + assert_eq!(rows.len(), 2); + assert!(rows[0].get("result").is_some()); + assert!(rows[1].get("error").is_some()); + } } diff --git a/crates/rpc/tests/auth.rs b/crates/rpc/tests/auth.rs index 118aa290..a4d1b2a5 100644 --- a/crates/rpc/tests/auth.rs +++ b/crates/rpc/tests/auth.rs @@ -9,8 +9,8 @@ use std::time::Duration; use bitcoin_rs_rpc::auth::constant_time_eq; use bitcoin_rs_rpc::{Auth, Context, Handler, RpcServer}; -use sonic_rs::JsonValueTrait; use sonic_rs::json; +use sonic_rs::{JsonContainerTrait, JsonValueTrait}; #[test] fn basic_auth_accepts_and_rejects_requests() -> Result<(), Box> { @@ -26,6 +26,104 @@ fn basic_auth_accepts_and_rejects_requests() -> Result<(), Box Result<(), Box> { + let address = spawn(Auth::basic("alice", "secret"))?; + let body = r#"{"jsonrpc":"2.0","method":"missing","params":[],"id":7}"#; + let response = request(address, "YWxpY2U6c2VjcmV0", body)?; + let payload = response.split_once("\r\n\r\n").ok_or("missing body")?.1; + let value: sonic_rs::Value = sonic_rs::from_str(payload)?; + + assert!(response.starts_with("HTTP/1.1 200 OK")); + assert_eq!(value.get("jsonrpc").and_then(|v| v.as_str()), Some("2.0")); + assert!(value.get("error").is_some()); + assert!(value.get("result").is_none()); + Ok(()) +} + +#[test] +fn legacy_error_has_core_envelope_and_http_error() -> Result<(), Box> { + let address = spawn(Auth::basic("alice", "secret"))?; + let body = r#"{"method":"missing","params":[],"id":7}"#; + let response = request(address, "YWxpY2U6c2VjcmV0", body)?; + let payload = response.split_once("\r\n\r\n").ok_or("missing body")?.1; + let value: sonic_rs::Value = sonic_rs::from_str(payload)?; + + assert!(response.starts_with("HTTP/1.1 500 Internal Server Error")); + assert!(value.get("jsonrpc").is_none()); + assert!(value.get("error").is_some()); + assert!(value.get("result").is_some_and(|v| v.is_null())); + Ok(()) +} + +#[test] +fn json_rpc_2_notification_returns_http_204() -> Result<(), Box> { + let address = spawn(Auth::basic("alice", "secret"))?; + let body = r#"{"jsonrpc":"2.0","method":"getblockcount","params":[]}"#; + let response = request(address, "YWxpY2U6c2VjcmV0", body)?; + + assert!(response.starts_with("HTTP/1.1 204 No Content")); + assert!(response.ends_with("\r\n\r\n")); + Ok(()) +} + +#[test] +fn json_rpc_2_batch_returns_an_array_and_excludes_notifications() +-> Result<(), Box> { + let address = spawn(Auth::basic("alice", "secret"))?; + let body = r#"[ + {"jsonrpc":"2.0","method":"getblockcount","params":[],"id":1}, + {"jsonrpc":"2.0","method":"getblockcount","params":[]}, + {"jsonrpc":"2.0","method":"missing","params":[],"id":2} + ]"#; + let response = request(address, "YWxpY2U6c2VjcmV0", body)?; + let payload = response.split_once("\r\n\r\n").ok_or("missing body")?.1; + let value: sonic_rs::Value = sonic_rs::from_str(payload)?; + let rows = value.as_array().ok_or("batch response must be an array")?; + + assert!(response.starts_with("HTTP/1.1 200 OK")); + assert_eq!(rows.len(), 2); + assert!(rows[0].get("result").is_some()); + assert!(rows[1].get("error").is_some()); + Ok(()) +} + +#[test] +fn notification_only_batch_returns_http_204() -> Result<(), Box> { + let address = spawn(Auth::basic("alice", "secret"))?; + let body = r#"[ + {"jsonrpc":"2.0","method":"getblockcount","params":[]}, + {"jsonrpc":"2.0","method":"getblockchaininfo","params":[]} + ]"#; + let response = request(address, "YWxpY2U6c2VjcmV0", body)?; + + assert!(response.starts_with("HTTP/1.1 204 No Content")); + assert!(response.ends_with("\r\n\r\n")); + Ok(()) +} + +#[test] +fn malformed_json_uses_core_legacy_parse_error_envelope() -> Result<(), Box> +{ + let address = spawn(Auth::basic("alice", "secret"))?; + let response = request(address, "YWxpY2U6c2VjcmV0", "{")?; + let payload = response.split_once("\r\n\r\n").ok_or("missing body")?.1; + let value: sonic_rs::Value = sonic_rs::from_str(payload)?; + + assert!(response.starts_with("HTTP/1.1 500 Internal Server Error")); + assert!(value.get("jsonrpc").is_none()); + assert!(value.get("result").is_some_and(|v| v.is_null())); + assert_eq!( + value + .get("error") + .and_then(|error| error.get("code")) + .and_then(|code| code.as_i64()), + Some(-32_700) + ); + assert!(value.get("id").is_some_and(|id| id.is_null())); + Ok(()) +} + #[test] fn rest_enabled_does_not_require_authentication() -> Result<(), Box> { let address = spawn_with_rest(Auth::basic("alice", "secret"), true)?; diff --git a/crates/rpc/tests/handler_smoke.rs b/crates/rpc/tests/handler_smoke.rs index bf1151f7..adf75fe0 100644 --- a/crates/rpc/tests/handler_smoke.rs +++ b/crates/rpc/tests/handler_smoke.rs @@ -4,6 +4,7 @@ extern crate alloc; use alloc::sync::Arc; use hashbrown::HashMap; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::atomic::{AtomicBool, Ordering}; use bitcoin::consensus::encode::serialize_hex; use bitcoin::hashes::Hash as _; @@ -14,7 +15,7 @@ use bitcoin_rs_index::{BlockSource, IndexError, IndexRowCounts, IndexerLike}; use bitcoin_rs_mempool::MempoolEntry; use bitcoin_rs_p2p::PeerInfo; use bitcoin_rs_primitives::Hash256; -use bitcoin_rs_rpc::{BlockRecord, Context, Handler, RpcError}; +use bitcoin_rs_rpc::{BlockRecord, ChainControl, ChainControlError, Context, Handler, RpcError}; use bitcoin_rs_utxo::{BlockChanges, UtxoAdd}; use parking_lot::{Mutex, RwLock}; use sonic_rs::{JsonContainerTrait as _, JsonValueTrait as _, json}; @@ -129,6 +130,53 @@ fn getblockhash_zero_returns_mainnet_genesis_on_fresh_context() Ok(()) } +#[derive(Debug)] +struct RecordingChainControl { + called: Arc, + result: Result<(), ChainControlError>, +} + +impl ChainControl for RecordingChainControl { + fn invalidate_block(&self, _hash: Hash256) -> Result<(), ChainControlError> { + self.called.store(true, Ordering::Release); + self.result.clone() + } +} + +#[test] +fn invalidateblock_delegates_to_node_control_and_returns_null() -> Result<(), RpcError> { + let called = Arc::new(AtomicBool::new(false)); + let ctx = Context::new().with_chain_control(Arc::new(RecordingChainControl { + called: Arc::clone(&called), + result: Ok(()), + })); + let handler = Handler::new(Arc::new(ctx)); + let hash = Hash256::from_le_bytes(&[7_u8; 32]).to_string_be(); + + assert!( + handler + .dispatch("invalidateblock", &json!([hash]))? + .is_null() + ); + assert!(called.load(Ordering::Acquire)); + Ok(()) +} + +#[test] +fn invalidateblock_maps_unknown_block_to_core_not_found() { + let ctx = Context::new().with_chain_control(Arc::new(RecordingChainControl { + called: Arc::new(AtomicBool::new(false)), + result: Err(ChainControlError::UnknownBlock), + })); + let handler = Handler::new(Arc::new(ctx)); + let hash = Hash256::from_le_bytes(&[8_u8; 32]).to_string_be(); + + let error = handler + .dispatch("invalidateblock", &json!([hash])) + .expect_err("unknown block must fail"); + assert_eq!(error.code(), RpcError::CORE_NOT_FOUND); +} + #[test] fn getblockchaininfo_surfaces_published_chainwork_hex() -> Result<(), Box> { let ctx = Arc::new(Context::new()); diff --git a/docker-compose.yaml b/docker-compose.yaml deleted file mode 100644 index c92d88cb..00000000 --- a/docker-compose.yaml +++ /dev/null @@ -1,42 +0,0 @@ -name: bitcoin-rs - -services: - node: - build: - context: . - dockerfile: Dockerfile - image: bitcoin-rs:local - restart: unless-stopped - init: true - stop_grace_period: 5m - environment: - BITCOIN_RS_NETWORK: "${BITCOIN_RS_NETWORK:-mainnet}" - BITCOIN_RS_STORAGE_BACKEND: fjall - BITCOIN_RS_RPC_USER: "${BITCOIN_RS_RPC_USER:-bitcoin-rs}" - BITCOIN_RS_RPC_PASSWORD: "${BITCOIN_RS_RPC_PASSWORD:-password}" - BITCOIN_RS_LOG_LEVEL: "${BITCOIN_RS_LOG_LEVEL:-info}" - volumes: - - bitcoin-rs-data:/data - ports: - # Keep RPC private to the Docker host. Change this deliberately if a - # trusted remote client must connect. - - "127.0.0.1:${BITCOIN_RS_RPC_PORT:-8332}:8332" - - "${BITCOIN_RS_P2P_PORT:-8333}:8333" - healthcheck: - test: - - CMD-SHELL - - >- - curl --fail --silent --show-error - --user "$${BITCOIN_RS_RPC_USER}:$${BITCOIN_RS_RPC_PASSWORD}" - --header 'content-type: application/json' - --data '{"jsonrpc":"1.0","id":"health","method":"getblockchaininfo","params":[]}' - http://127.0.0.1:8332/ >/dev/null - interval: 30s - timeout: 5s - retries: 5 - start_period: 30s - -volumes: - bitcoin-rs-data: - # Prevent a network switch from opening an incompatible checkpoint. - name: "bitcoin-rs-${BITCOIN_RS_NETWORK:-mainnet}-data" diff --git a/docs/rest-interface.md b/docs/rest-interface.md index 01c25746..2d68661b 100644 --- a/docs/rest-interface.md +++ b/docs/rest-interface.md @@ -54,3 +54,8 @@ returns HTTP 400 with `Invalid hash: `. This distinction is load-bearing for the enforcer: it treats a 404 on `/rest/*` as evidence that REST is not enabled, so an unknown or non-active block hash must not produce a misleading 404. + +The checked-in default Compose stack supplies the REST, `pubsequence`, +version-check bypass, and drynet4 network settings required to run the +unmodified enforcer in block-only mode. It deliberately omits `--enable-mempool` +until `pubsequence` also provides transaction `A`/`R` events. diff --git a/docs/solutions/architecture-patterns/network-selection-keeps-p2p-identity-atomic.md b/docs/solutions/architecture-patterns/network-selection-keeps-p2p-identity-atomic.md new file mode 100644 index 00000000..5eb0ffee --- /dev/null +++ b/docs/solutions/architecture-patterns/network-selection-keeps-p2p-identity-atomic.md @@ -0,0 +1,46 @@ +# Network selection keeps P2P identity atomic + +## Problem + +A fork network can reuse Bitcoin consensus history without joining Bitcoin's +P2P network. Describing that deployment as independent `network`, `p2p_magic`, +`connect`, and `dns_seeds_enabled` settings makes partially applied profiles +possible. In particular, mainnet DNS seeds combined with a fork message start +cannot bootstrap successfully, while omitting the custom message start joins +the wrong P2P network. + +## Decision + +The internal `Network` remains the consensus-rule selector. The user-facing +`BITCOIN_RS_NETWORK`/`--network` selection applies consensus and P2P bootstrap +defaults as one unit and additionally accepts `drynet4`. Configuration keeps +its normal precedence: + +1. built-in defaults; +2. Bitcoin-compatible configuration; +3. TOML; +4. environment variables; +5. CLI arguments. + +Within a layer, the network selection is applied first and explicit low-level +fields then override it. This makes the safe profile the short path without removing the +escape hatches needed for private peers and experiments. + +The `drynet4` selection uses mainnet consensus, P2P magic `eca5d404`, the fixed +peer `drynet4.drivechain.dev:8533`, and disabled DNS seeding. Standard network +names select their matching consensus network, built-in message start, and DNS +bootstrap. Compose uses the same `BITCOIN_RS_NETWORK` for bitcoin-rs and the +BIP300/301 enforcer and includes it in both host data paths. + +Fixed-peer hostnames remain unresolved in configuration and are resolved by +the P2P bootstrap worker on each retry. A transient resolver failure therefore +does not reject otherwise valid configuration or prevent the node from +starting, and all addresses returned for an endpoint remain eligible to dial. + +## Guardrails + +- A raw P2P magic override still requires mainnet consensus, a fixed peer, and + disabled DNS seeds. +- Explicit fields in the same or a later layer override network-derived values. +- Switching networks switches data directories; it does not reuse another P2P + network's runtime state. diff --git a/docs/solutions/architecture-patterns/node-reorg-execution-design.md b/docs/solutions/architecture-patterns/node-reorg-execution-design.md index 7ac6a22b..5b98a26a 100644 --- a/docs/solutions/architecture-patterns/node-reorg-execution-design.md +++ b/docs/solutions/architecture-patterns/node-reorg-execution-design.md @@ -37,6 +37,12 @@ Done: checkpoint makes the queued record durable. * `disconnect_block`, which restores the UTXO set, the transaction index, and `applied_tip`. Its ordering claims are mutation-verified. +* Node-owned `invalidateblock` control, which invalidates the named header + subtree and uses the normal branch-switch/disconnect path rather than + mutating chainstate in the RPC crate. It previews the replacement tip and + preloads the complete disconnect/connect plan before changing header status; + one chain-transition witness then spans header invalidation and all + chainstate mutation. * `switch_to_branch` (`crates/node/src/reorg.rs`), called by sync when the best-work header branch outweighs the applied branch. It loads all disconnect bodies and the contiguous target prefix that fits in bounded @@ -149,6 +155,7 @@ Done: | Branch switching | `switch_to_branch` recomputes the complete ordered `plan_reorg` result under the transition guard and mutates only when it equals the optimistic plan. A shorter branch is eligible when its accumulated work is greater. A permanent connect failure invalidates its subtree and selects the best valid tip. | | Body acquisition | Each attempt loads all disconnect bodies and the contiguous connect prefix available from bounded staging, durable storage, or the applied body cache. The first missing connect body prevents mutation. A later missing body follows a coherent committed prefix. Each committed connect retires its exact staging and download-window entry; invalid subtree ownership is purged. | | Fatal lifecycle | `Fatal` and `MarkerStuck` close apply admission while the transition lock is held; sync sets the shared process shutdown token | +| RPC invalidation | `invalidateblock` delegates through `ChainControl`; unknown blocks map to Core not-found, genesis is refused, required bodies are preflighted before header mutation, one transition witness spans invalidation and branch switching, and a successful active-tip rollback emits `pubsequence D` | Open: diff --git a/.env.example b/tools/bip300301-enforcer/.env.example similarity index 51% rename from .env.example rename to tools/bip300301-enforcer/.env.example index 94f25651..c6aceba1 100644 --- a/.env.example +++ b/tools/bip300301-enforcer/.env.example @@ -1,5 +1,5 @@ -# Node network: mainnet, testnet3, testnet4, signet, or regtest. -BITCOIN_RS_NETWORK=mainnet +# Selects the matching bitcoin-rs and BIP300/301 enforcer network. +BITCOIN_RS_NETWORK=drynet4 # JSON-RPC is published to 127.0.0.1 on the Docker host. BITCOIN_RS_RPC_PORT=8332 @@ -9,5 +9,12 @@ BITCOIN_RS_RPC_PASSWORD= # P2P is published on all Docker host interfaces so other nodes can connect. BITCOIN_RS_P2P_PORT=8333 +# Optional indexes and wallet protocol services are disabled by default. +BITCOIN_RS_TXINDEX=false +BITCOIN_RS_ELECTRUM_BIND= + # Runtime tracing filter. BITCOIN_RS_LOG_LEVEL=info + +ENFORCER_GRPC_PORT=50051 +ENFORCER_RPC_PORT=8122 diff --git a/tools/bip300301-enforcer/Dockerfile.enforcer b/tools/bip300301-enforcer/Dockerfile.enforcer new file mode 100644 index 00000000..9979efd4 --- /dev/null +++ b/tools/bip300301-enforcer/Dockerfile.enforcer @@ -0,0 +1,39 @@ +# syntax=docker/dockerfile:1.7 + +ARG RUST_VERSION=1.95 +FROM rust:${RUST_VERSION}-bookworm AS builder + +ARG ENFORCER_REPOSITORY=https://github.com/LayerTwo-Labs/bip300301_enforcer.git +ARG ENFORCER_REVISION=master + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + clang \ + git \ + libclang-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace +RUN git clone --filter=blob:none "${ENFORCER_REPOSITORY}" . \ + && git checkout --detach "${ENFORCER_REVISION}" +RUN cargo build --locked --release -p bip300301_enforcer + +FROM debian:bookworm-slim AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid 10002 enforcer \ + && useradd --uid 10002 --gid enforcer --no-create-home enforcer \ + && install -d -o enforcer -g enforcer /data + +COPY --from=builder /workspace/target/release/bip300301_enforcer /usr/local/bin/ + +USER enforcer +VOLUME ["/data"] +EXPOSE 50051 8122 + +ENTRYPOINT ["bip300301_enforcer"] diff --git a/tools/bip300301-enforcer/docker-compose.yaml b/tools/bip300301-enforcer/docker-compose.yaml new file mode 100644 index 00000000..415b2d08 --- /dev/null +++ b/tools/bip300301-enforcer/docker-compose.yaml @@ -0,0 +1,82 @@ +name: bitcoin-rs + +services: + node: + build: + context: ../.. + dockerfile: Dockerfile + image: bitcoin-rs:local + restart: unless-stopped + init: true + stop_grace_period: 5m + environment: + BITCOIN_RS_NETWORK: "${BITCOIN_RS_NETWORK:-drynet4}" + BITCOIN_RS_STORAGE_BACKEND: fjall + BITCOIN_RS_RPC_USER: "${BITCOIN_RS_RPC_USER:-bitcoin-rs}" + BITCOIN_RS_RPC_PASSWORD: "${BITCOIN_RS_RPC_PASSWORD:-password}" + BITCOIN_RS_LOG_LEVEL: "${BITCOIN_RS_LOG_LEVEL:-info}" + BITCOIN_RS_REST: "true" + BITCOIN_RS_TXINDEX: "${BITCOIN_RS_TXINDEX:-false}" + BITCOIN_RS_ELECTRUM_BIND: "${BITCOIN_RS_ELECTRUM_BIND:-}" + BITCOIN_RS_ZMQPUBSEQUENCE: tcp://0.0.0.0:29000 + volumes: + - ../../data/bitcoin-rs/${BITCOIN_RS_NETWORK:-drynet4}:/data + ports: + # Keep RPC private to the Docker host. Change this deliberately if a + # trusted remote client must connect. + - "127.0.0.1:${BITCOIN_RS_RPC_PORT:-18443}:8332" + - "${BITCOIN_RS_P2P_PORT:-18444}:8333" + healthcheck: + test: + - CMD-SHELL + - >- + curl --fail --silent --show-error + --user "$${BITCOIN_RS_RPC_USER}:$${BITCOIN_RS_RPC_PASSWORD}" + --header 'content-type: application/json' + --data '{"jsonrpc":"1.0","id":"health","method":"getblockchaininfo","params":[]}' + http://127.0.0.1:8332/ >/dev/null + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + + enforcer: + build: + context: ../.. + dockerfile: tools/bip300301-enforcer/Dockerfile.enforcer + args: + ENFORCER_REVISION: "${ENFORCER_REVISION:-3d3b3d19ad90c33836f0a62e5e596875f0b49dc1}" + image: bip300301-enforcer:local + restart: unless-stopped + init: true + stop_grace_period: 30s + depends_on: + node: + condition: service_healthy + command: + - --data-dir=/data + - --network-preset=${BITCOIN_RS_NETWORK:-drynet4} + - --node-rpc-addr=node:8332 + - --node-rpc-user=${BITCOIN_RS_RPC_USER:-bitcoin-rs} + - --node-rpc-pass=${BITCOIN_RS_RPC_PASSWORD:-password} + - --node-zmq-addr-sequence=tcp://node:29000 + - --bitcoin-core-skip-version-check + - --serve-grpc-addr=0.0.0.0:50051 + - --serve-rpc-addr=0.0.0.0:8122 + volumes: + - ../../data/enforcer/${BITCOIN_RS_NETWORK:-drynet4}:/data + ports: + - "127.0.0.1:${ENFORCER_GRPC_PORT:-50051}:50051" + - "127.0.0.1:${ENFORCER_RPC_PORT:-8122}:8122" + healthcheck: + test: + - CMD-SHELL + - >- + curl --fail --silent --show-error + -X POST -H 'Content-Type: application/json' + http://127.0.0.1:50051/cusf.mainchain.v1.ValidatorService/GetChainInfo + >/dev/null + interval: 30s + timeout: 5s + retries: 10 + start_period: 60s