diff --git a/OPERATOR.md b/OPERATOR.md index 3f04a87..cf4eaa0 100644 --- a/OPERATOR.md +++ b/OPERATOR.md @@ -41,6 +41,8 @@ Routine knobs are **CLI / conf**, not required env vars. Clean smoke: |------|----------------|---------| | `--datadir PATH` | same | `./datadir` | | `--network NET` | `--chain` | `mainnet` | +| `--signetchallenge HEX` | `--signet-challenge` | default global Signet challenge | +| `--signetblocktime SECONDS` | `--signet-block-time` | 600; requires a custom challenge | | `--listen ADDR` | | bind later default port | | `--connect ADDR` | (repeatable) | seeds | | `--milestone HEIGHT` | `--assumevalid-height` | network default (mainnet 840000) | @@ -304,6 +306,29 @@ mkdir -p ./datadir-signet --log-level info ``` +### Custom Signet + +A custom Signet derives its P2P message magic from the challenge. Default +Signet seeds are not used, so provide at least one peer with `--connect`. +Use a dedicated datadir for each challenge. + +```bash +mkdir -p ./datadir-custom-signet +./target/release/rbitcoin-node \ + --datadir ./datadir-custom-signet \ + --network signet \ + --signetchallenge 51 \ + --signetblocktime 60 \ + --connect 192.0.2.1:38333 \ + --listen 0.0.0.0:38333 \ + --milestone 0 \ + --log-level info +``` + +The equivalent conf-file keys are `signetchallenge` and `signetblocktime`. +Replace the illustrative `OP_TRUE` challenge and documentation-only peer with +the parameters supplied by the custom Signet operator. + ### Resume / clean stop Same `--datadir` resumes tip from the relational archive. diff --git a/README.md b/README.md index d99a509..1f24aab 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,9 @@ install -m 755 result/bin/rbitcoin-node result/bin/rbitcoin-cli target/release/ --listen 127.0.0.1:38333 --milestone 200000 --max-run-secs 120 ``` +Custom Signets are supported with `--signetchallenge` and +`--signetblocktime`; see the [custom Signet example](./OPERATOR.md#custom-signet). + ## Build ### Portable static release (recommended) diff --git a/crates/rbitcoin-consensus/src/lib.rs b/crates/rbitcoin-consensus/src/lib.rs index 9243d41..b1d60c9 100644 --- a/crates/rbitcoin-consensus/src/lib.rs +++ b/crates/rbitcoin-consensus/src/lib.rs @@ -104,7 +104,7 @@ pub use header::{expected_next_bits, median_time_past, validate_header}; pub use milestone::Milestone; pub use params::{default_milestone_height, genesis_block, ChainParams, Checkpoint}; pub use policy::{check_tx_standard, is_push_only, is_standard_script_pubkey, PolicyResult}; -pub use signet::{default_signet_challenge, validate_signet_block_solution}; +pub use signet::{default_signet_challenge, signet_magic, validate_signet_block_solution}; pub fn crate_name() -> &'static str { "rbitcoin-consensus" diff --git a/crates/rbitcoin-consensus/src/params.rs b/crates/rbitcoin-consensus/src/params.rs index bbca9ee..02bb622 100644 --- a/crates/rbitcoin-consensus/src/params.rs +++ b/crates/rbitcoin-consensus/src/params.rs @@ -71,15 +71,29 @@ impl ChainParams { } pub fn signet() -> Self { + Self::custom_signet(crate::signet::default_signet_challenge(), 10 * 60) + .expect("default signet block time is nonzero") + } + + /// Build custom BIP325 signet parameters. + /// + /// `block_time` changes PoW target spacing while retaining Signet's two-week + /// target timespan, matching Bitcoin Core's `signetblocktime` behavior. + pub fn custom_signet(challenge: ScriptBuf, block_time: u64) -> Result { + if block_time == 0 { + return Err("signet block time must be greater than zero"); + } let genesis = constants::genesis_block(Network::Signet); - Self { + let mut btc = BtcParams::new(Network::Signet); + btc.pow_target_spacing = block_time; + Ok(Self { network: Network::Signet, genesis_hash: genesis.block_hash(), pow_limit: Target::MAX_ATTAINABLE_SIGNET, checkpoints: vec![], - btc: BtcParams::new(Network::Signet), - signet_challenge: Some(crate::signet::default_signet_challenge()), - } + btc, + signet_challenge: Some(challenge), + }) } pub fn checkpoint_at(&self, height: Height) -> Option { @@ -313,6 +327,18 @@ mod tests { assert!(p.signet_challenge.is_some()); } + #[test] + fn custom_signet_uses_challenge_and_block_time() { + let challenge = ScriptBuf::from_bytes(vec![0x51]); + let p = ChainParams::custom_signet(challenge.clone(), 60).unwrap(); + + assert_eq!(p.signet_challenge.as_ref(), Some(&challenge)); + assert_eq!(p.btc.pow_target_spacing, 60); + assert_eq!(p.btc.pow_target_timespan, 14 * 24 * 60 * 60); + assert_eq!(p.difficulty_adjustment_interval(), 20_160); + assert!(ChainParams::custom_signet(challenge, 0).is_err()); + } + /// Mainnet buried heights (Core + Inquisition). #[test] fn mainnet_buried_deployments_match_core() { diff --git a/crates/rbitcoin-consensus/src/signet.rs b/crates/rbitcoin-consensus/src/signet.rs index 441fc8c..9a006ac 100644 --- a/crates/rbitcoin-consensus/src/signet.rs +++ b/crates/rbitcoin-consensus/src/signet.rs @@ -6,7 +6,7 @@ //! expensive for the IBD prep path). use bitcoin::absolute::LockTime; -use bitcoin::consensus::Encodable; +use bitcoin::consensus::{serialize, Encodable}; use bitcoin::hashes::{sha256d, Hash}; use bitcoin::script::{Script, ScriptBuf}; use bitcoin::{Amount, Block, OutPoint, Sequence, Transaction, TxIn, TxOut, Witness}; @@ -26,6 +26,17 @@ pub fn default_signet_challenge() -> ScriptBuf { )) } +/// Derive the four P2P message-start bytes for a BIP325 challenge. +/// +/// Bitcoin Core hashes the consensus-serialized challenge byte vector, including +/// its CompactSize length prefix, and uses the first four digest bytes. +pub fn signet_magic(challenge: &Script) -> [u8; 4] { + let encoded = serialize(&challenge.as_bytes().to_vec()); + sha256d::Hash::hash(&encoded).to_byte_array()[..4] + .try_into() + .expect("four-byte digest prefix") +} + fn hex_decode(s: &str) -> Vec { (0..s.len()) .step_by(2) @@ -436,6 +447,19 @@ mod tests { .expect("BIP325 solution for real signet height 1"); } + #[test] + fn custom_challenge_derives_expected_wire_magic() { + let challenge = ScriptBuf::from_bytes(vec![0x51]); + assert_eq!( + signet_magic(challenge.as_script()), + [0x54, 0xd2, 0x6f, 0xbd] + ); + assert_eq!( + signet_magic(default_signet_challenge().as_script()), + [0x0a, 0x03, 0xcf, 0x40] + ); + } + #[test] fn signet_block_1_rejects_mutated_solution() { let raw = include_bytes!("../tests/fixtures/signet_block_1.bin"); diff --git a/crates/rbitcoin-net/src/lib.rs b/crates/rbitcoin-net/src/lib.rs index 668f1e6..cfa9abd 100644 --- a/crates/rbitcoin-net/src/lib.rs +++ b/crates/rbitcoin-net/src/lib.rs @@ -30,7 +30,7 @@ pub use seeds::{ default_port, dns_seeds, fixed_seed_hosts, resolve_all_seeds, resolve_dns_seeds, resolve_fixed_seeds, AddrMan, PeerEntry, PeerFlags, }; -pub use service::{magic_for, NetConfig, P2PHandle, P2PNode}; +pub use service::{magic_for, magic_for_params, NetConfig, P2PHandle, P2PNode}; pub use tx_relay::{ decode_len_prefixed_package, ElectrumMempoolItem, MempoolHub, QueryUtxoProvider, }; diff --git a/crates/rbitcoin-net/src/service.rs b/crates/rbitcoin-net/src/service.rs index 3531f00..e8c9074 100644 --- a/crates/rbitcoin-net/src/service.rs +++ b/crates/rbitcoin-net/src/service.rs @@ -9,7 +9,7 @@ use crate::peer_dos::{inbound_semaphore, max_inbound_from_env}; use bitcoin::p2p::Magic; use bitcoin::Block; use bitcoin::BlockHash; -use rbitcoin_consensus::{ChainParams, Milestone}; +use rbitcoin_consensus::{signet_magic, ChainParams, Milestone}; use rbitcoin_primitives::Network as RNetwork; use rbitcoin_query::Query; use std::net::SocketAddr; @@ -65,7 +65,7 @@ impl P2PNode { params: ChainParams, milestone: Milestone, ) -> Result { - let magic = Magic::from(params.network); + let magic = magic_for_params(¶ms); let hub = Arc::new(ChainHub::new(query, params, milestone)); hub.ensure_genesis()?; let cache = hub.cache.clone(); @@ -279,6 +279,14 @@ pub fn magic_for(network: RNetwork) -> Magic { }) } +/// Resolve P2P message magic, including BIP325 custom-Signet derivation. +pub fn magic_for_params(params: &ChainParams) -> Magic { + match params.signet_challenge.as_ref() { + Some(challenge) => Magic::from_bytes(signet_magic(challenge.as_script())), + None => Magic::from(params.network), + } +} + #[cfg(test)] mod tests { use super::*; @@ -303,4 +311,16 @@ mod tests { assert!(cfg.listen.is_none()); assert_eq!(cfg.user_agent, "/rbitcoin:0.1.0/"); } + + #[test] + fn custom_signet_uses_challenge_derived_magic() { + use bitcoin::ScriptBuf; + + let challenge = ScriptBuf::from_bytes(vec![0x51]); + let params = ChainParams::custom_signet(challenge, 60).unwrap(); + assert_eq!( + magic_for_params(¶ms), + Magic::from_bytes([0x54, 0xd2, 0x6f, 0xbd]) + ); + } } diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index 136aeac..3539509 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -21,6 +21,8 @@ where let mut datadir_set = false; let mut network = Network::Mainnet; let mut network_set = false; + let mut signet_challenge = None; + let mut signet_block_time = None; let mut smoke = false; let mut listen: Option = None; let mut electrum_listen: Option = None; @@ -55,6 +57,7 @@ where [--mempool-size-mb|--maxmempool N] [--archive-queue-mb N] \\\n\ [--max-run-secs N] [--log-level LEVEL] [--no-seeds] [--smoke] [--inhibit-suspend]\n\n\ Networks: mainnet|testnet|signet|regtest\n\ +Custom Signet: --signetchallenge HEX [--signetblocktime SECONDS].\n\ Log level: error|warn|info|debug|trace|off (CLI > conf log_level > RBITCOIN_LOG / RUST_LOG).\n\ Milestone / assumevalid-height: skip script/sig checks at/below HEIGHT.\n\ Defaults: mainnet 840000, signet 2000000, testnet 2500000, regtest 0. Use 0 for full scripts.\n\ @@ -122,6 +125,40 @@ IBD: up to 1024 concurrent getdata, max 16 in transit per peer.", } i += 1; } + "--signetchallenge" | "--signet-challenge" => { + i += 1; + if i >= args.len() { + eprintln!("error: --signetchallenge requires hexadecimal script bytes"); + return ExitCode::from(2); + } + match crate::config::parse_signet_challenge(&args[i].to_string_lossy()) { + Ok(challenge) => signet_challenge = Some(challenge), + Err(e) => { + eprintln!("error: bad --signetchallenge: {e}"); + return ExitCode::from(2); + } + } + i += 1; + } + "--signetblocktime" | "--signet-block-time" => { + i += 1; + if i >= args.len() { + eprintln!("error: --signetblocktime requires seconds"); + return ExitCode::from(2); + } + match args[i].to_string_lossy().parse::() { + Ok(n) if n > 0 => signet_block_time = Some(n), + Ok(_) => { + eprintln!("error: --signetblocktime must be greater than zero"); + return ExitCode::from(2); + } + Err(e) => { + eprintln!("error: bad --signetblocktime: {e}"); + return ExitCode::from(2); + } + } + i += 1; + } "--listen" => { i += 1; if i >= args.len() { @@ -358,6 +395,12 @@ IBD: up to 1024 concurrent getdata, max 16 in transit per peer.", if network_set { config.network = network; } + if let Some(challenge) = signet_challenge { + config.signet_challenge = Some(challenge); + } + if signet_block_time.is_some() { + config.signet_block_time = signet_block_time; + } if let Some(a) = listen { config.p2p_listen = Some(a); } @@ -609,6 +652,30 @@ mod tests { 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(), + "--signetchallenge", + "51", + "--signetblocktime", + "60", + "--no-seeds", + "--log-level", + "error", + "--milestone", + "0", + ]); + assert_exit(code, ExitCode::SUCCESS); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn help_lists_coreish_flags_not_only_env() { let _g = OPERATOR_ENV_TEST_LOCK diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index 3c0eb22..a56b91d 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -1,5 +1,7 @@ use crate::error::NodeError; -use rbitcoin_consensus::Milestone; +use bitcoin::hex::FromHex; +use bitcoin::ScriptBuf; +use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_primitives::Network; use std::net::SocketAddr; use std::path::{Path, PathBuf}; @@ -23,6 +25,10 @@ pub const DEFAULT_MAX_INBOUND: u32 = 125; pub struct NodeConfig { pub datadir: PathBuf, pub network: Network, + /// Custom BIP325 challenge. `None` selects the default global Signet. + pub signet_challenge: Option, + /// Custom Signet PoW target spacing in seconds. + pub signet_block_time: Option, pub archive_durability: bool, pub wire_depth_blocks: u32, /// Bind address for P2P listen (`None` = do not listen / default bind later). @@ -66,6 +72,8 @@ impl Default for NodeConfig { Self { datadir: PathBuf::from("./datadir"), network: Network::Mainnet, + signet_challenge: None, + signet_block_time: None, archive_durability: true, wire_depth_blocks: 100, p2p_listen: None, @@ -123,6 +131,17 @@ impl NodeConfig { } } + /// Compose immutable consensus parameters from operator configuration. + pub fn chain_params(&self) -> Result { + match self.signet_challenge.clone() { + Some(challenge) => { + ChainParams::custom_signet(challenge, self.signet_block_time.unwrap_or(10 * 60)) + .map_err(|e| NodeError::Config(e.into())) + } + None => Ok(ChainParams::for_network(self.network)), + } + } + pub fn validate(&self) -> Result<(), NodeError> { if self.datadir.as_os_str().is_empty() { return Err(NodeError::Config("datadir must not be empty".into())); @@ -133,6 +152,23 @@ impl NodeConfig { if self.max_inbound == 0 { return Err(NodeError::Config("max_inbound must be >= 1".into())); } + if (self.signet_challenge.is_some() || self.signet_block_time.is_some()) + && self.network != Network::Signet + { + return Err(NodeError::Config( + "signetchallenge and signetblocktime require network=signet".into(), + )); + } + if self.signet_block_time.is_some() && self.signet_challenge.is_none() { + return Err(NodeError::Config( + "signetblocktime requires signetchallenge".into(), + )); + } + if self.signet_block_time == Some(0) { + return Err(NodeError::Config( + "signetblocktime must be greater than zero".into(), + )); + } let _ = (self.wire_depth_blocks, self.archive_durability); Ok(()) } @@ -182,7 +218,8 @@ impl NodeConfig { /// Supported keys: `datadir`, `network` / `chain`, `listen`, `connect` (repeatable), /// `milestone` / `assumevalid_height`, `maxoutbound` / `max_outbound`, /// `maxinbound` / `max_inbound` / `maxconnections`, `mempool_size_mb` / `maxmempool`, - /// `archive_queue_mb`, `log_level`, `electrum_listen`, `noseeds` / `no_seeds`. + /// `archive_queue_mb`, `log_level`, `electrum_listen`, `noseeds` / `no_seeds`, + /// `signetchallenge`, and `signetblocktime`. pub fn merge_conf_file(&mut self, path: &Path) -> Result<(), NodeError> { let text = std::fs::read_to_string(path).map_err(|source| { NodeError::Config(format!("read conf {}: {source}", path.display())) @@ -223,6 +260,18 @@ impl NodeConfig { self.network = Network::parse(val) .map_err(|e| NodeError::Config(format!("conf network: {e}")))?; } + "signetchallenge" | "signet_challenge" => { + self.signet_challenge = + Some(parse_signet_challenge(val).map_err(|e| { + NodeError::Config(format!("conf signetchallenge: {e}")) + })?); + } + "signetblocktime" | "signet_block_time" => { + self.signet_block_time = + Some(val.parse().map_err(|e| { + NodeError::Config(format!("conf signetblocktime: {e}")) + })?); + } "listen" => { self.p2p_listen = Some( val.parse() @@ -299,6 +348,12 @@ impl NodeConfig { } } +pub(crate) fn parse_signet_challenge(value: &str) -> Result { + Vec::::from_hex(value) + .map(ScriptBuf::from_bytes) + .map_err(|e| format!("must be hexadecimal: {e}")) +} + fn is_conf_true(val: &str) -> bool { matches!( val.to_ascii_lowercase().as_str(), @@ -446,6 +501,45 @@ 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\ + signetchallenge=51\n\ + signetblocktime=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(); diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index e4753ec..dd45b3b 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -1,7 +1,6 @@ use crate::config::NodeConfig; use crate::error::NodeError; use bitcoin::consensus::Encodable; -use rbitcoin_consensus::ChainParams; use rbitcoin_electrum::{run_electrum, ElectrumConfig, TipNotify}; use rbitcoin_log::{info, warn}; use rbitcoin_net::{default_port, AddrMan, IbdConfig, MempoolHub, P2PNode, TipEvent}; @@ -149,7 +148,7 @@ pub fn run_node(config: NodeConfig) -> Result { /// and aborts peer tasks. pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { let handle = run_node(config.clone())?; - let params = ChainParams::for_network(config.network); + let params = config.chain_params()?; let milestone = config.milestone(); if milestone.height > 0 { info!( @@ -249,7 +248,7 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { for c in &config.connect { addrman.add(*c); } - if config.use_seeds && config.connect.is_empty() { + if should_resolve_default_seeds(&config) { info!( "ibd: resolving DNS/fixed seeds for {}…", config.network.as_str() @@ -261,6 +260,8 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { addrman.len().saturating_sub(n_before), addrman.len() ); + } else if config.signet_challenge.is_some() && config.connect.is_empty() && addrman.is_empty() { + warn!("custom signet has no peers; use --connect ADDR or reuse a datadir with known peers"); } // Shared with IBD so learned addrs/flags flush back on IBD exit. let shared_peers = std::sync::Arc::new(std::sync::Mutex::new(addrman.clone())); @@ -695,6 +696,10 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { Ok(()) } +fn should_resolve_default_seeds(config: &NodeConfig) -> bool { + config.use_seeds && config.connect.is_empty() && config.signet_challenge.is_none() +} + /// Enter steady-state tip mode after true catch-up. /// /// **Preconditions (enforced by IBD, not repaired here):** Direct catch-up already @@ -784,6 +789,14 @@ mod tests { use super::*; use std::time::{SystemTime, UNIX_EPOCH}; + #[test] + fn custom_signet_does_not_use_default_signet_seeds() { + let mut cfg = NodeConfig::default().with_network(rbitcoin_primitives::Network::Signet); + assert!(should_resolve_default_seeds(&cfg)); + cfg.signet_challenge = Some(bitcoin::ScriptBuf::from_bytes(vec![0x51])); + assert!(!should_resolve_default_seeds(&cfg)); + } + #[test] fn enter_tip_mode_reenables_indexes() { use rbitcoin_query::IndexMode;