From f2f497efa33d2ffc683033d72c3e78892a1d2fe4 Mon Sep 17 00:00:00 2001 From: Gary Krause Date: Thu, 29 Jan 2026 10:23:27 -0500 Subject: [PATCH 1/3] feat: coinbase address rotation for mining pools Implements automatic coinbase address rotation using HD wallet derivation. After each block is found, the pool/JDC derives a fresh address from an xpub descriptor, ensuring each coinbase output uses a unique address with an unexposed public key. Key features: - Wildcard descriptor support (e.g., wpkh(xpub.../0/*)) - Persistent index file survives restarts - Thread-safe atomic index management - Works with wpkh, tr, and sh(wpkh) descriptors Configuration: - coinbase_reward_script: descriptor with wildcard (e.g., wpkh(tpub.../0/*)) - coinbase_index_file: path to persist current derivation index - coinbase_start_index: initial index if no persistence file exists Validated on testnet4 with 2 blocks mined at indices 4 and 5. Files changed: - stratum-apps: XpubDerivator, wildcard detection in CoinbaseRewardScript - pool: ChannelManager rotation integration - jd-client: Solo mining rotation support --- .../downstream_message_handler.rs | 46 +- .../jd-client/src/lib/channel_manager/mod.rs | 157 ++++- miner-apps/jd-client/src/lib/config.rs | 45 +- miner-apps/jd-client/src/lib/error.rs | 3 + .../channel_manager/mining_message_handler.rs | 30 +- pool-apps/pool/src/lib/channel_manager/mod.rs | 130 ++++- pool-apps/pool/src/lib/config.rs | 40 +- pool-apps/pool/src/lib/error.rs | 3 + stratum-apps/Cargo.toml | 3 +- .../src/config_helpers/coinbase_output/mod.rs | 147 ++++- .../coinbase_output/serde_types.rs | 2 + stratum-apps/src/config_helpers/mod.rs | 4 + .../src/config_helpers/xpub_derivation.rs | 552 ++++++++++++++++++ 13 files changed, 1109 insertions(+), 53 deletions(-) create mode 100644 stratum-apps/src/config_helpers/xpub_derivation.rs diff --git a/miner-apps/jd-client/src/lib/channel_manager/downstream_message_handler.rs b/miner-apps/jd-client/src/lib/channel_manager/downstream_message_handler.rs index d108d0dda..60d98c608 100644 --- a/miner-apps/jd-client/src/lib/channel_manager/downstream_message_handler.rs +++ b/miner-apps/jd-client/src/lib/channel_manager/downstream_message_handler.rs @@ -936,6 +936,8 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { // - Translate the share into an upstream `SubmitSharesExtended`. // - Validate with the upstream channel. // - Forward valid shares (or block solutions) upstream. + // + // 3. If a block is found in solo mining mode, rotate the coinbase address. async fn handle_submit_shares_standard( &mut self, client_id: Option, @@ -956,7 +958,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { }) }; - let messages = self.channel_manager_data.super_safe_lock(|channel_manager_data| { + let (messages, solo_block_found) = self.channel_manager_data.super_safe_lock(|channel_manager_data| { let Some(downstream) = channel_manager_data.downstream.get_mut(&downstream_id) else { warn!("No downstream found for downstream_id={downstream_id}"); return Err(JDCError::disconnect(JDCErrorKind::DownstreamNotFound(downstream_id), downstream_id)); @@ -968,14 +970,15 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { downstream.downstream_data.super_safe_lock(|data| { let mut messages: Vec = vec![]; + let mut solo_block_found = false; let Some(standard_channel) = data.standard_channels.get_mut(&channel_id) else { error!("SubmitSharesError: channel_id: {channel_id}, sequence_number: {}, error_code: invalid-channel-id", msg.sequence_number); - return Ok(vec![(downstream_id, build_error("invalid-channel-id")).into()]); + return Ok((vec![(downstream_id, build_error("invalid-channel-id")).into()], false)); }; let Some(vardiff) = channel_manager_data.vardiff.get_mut(&(downstream_id, channel_id).into()) else { - return Ok(vec![(downstream_id, Mining::CloseChannel(create_close_channel_msg(channel_id, "invalid-channel-id"))).into()]); + return Ok((vec![(downstream_id, Mining::CloseChannel(create_close_channel_msg(channel_id, "invalid-channel-id"))).into()], false)); }; vardiff.increment_shares_since_last_update(); let res = standard_channel.validate_share(msg.clone()); @@ -1029,6 +1032,11 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { downstream.downstream_id, Mining::SubmitSharesSuccess(success), ).into()); + + // Track block found in solo mining mode (no upstream channel) + if channel_manager_data.upstream_channel.is_none() { + solo_block_found = true; + } } Err(err) => { let code = match err { @@ -1045,7 +1053,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { } if !is_downstream_share_valid { - return Ok(messages); + return Ok((messages, solo_block_found)); } if let Some(upstream_channel) = channel_manager_data.upstream_channel.as_mut() { @@ -1149,7 +1157,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { } } - Ok(messages) + Ok((messages, solo_block_found)) }) })?; @@ -1157,6 +1165,11 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { let _ = messages.forward(&self.channel_manager_channel).await; } + // Rotate coinbase address if block found in solo mining mode + if solo_block_found { + self.rotate_coinbase_address(); + } + Ok(()) } @@ -1171,6 +1184,8 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { // - Translate the share into an upstream `SubmitSharesExtended`. // - Validate with the upstream channel. // - Forward valid shares (or block solutions) upstream. + // + // 3. If a block is found in solo mining mode, rotate the coinbase address. async fn handle_submit_shares_extended( &mut self, client_id: Option, @@ -1192,7 +1207,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { }) }; - let messages = self.channel_manager_data.super_safe_lock(|channel_manager_data| { + let (messages, solo_block_found) = self.channel_manager_data.super_safe_lock(|channel_manager_data| { let Some(downstream) = channel_manager_data.downstream.get_mut(&downstream_id) else { warn!("No downstream found for downstream_id={downstream_id}"); return Err(JDCError::disconnect(JDCErrorKind::DownstreamNotFound(downstream_id), downstream_id)); @@ -1203,10 +1218,11 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { }; downstream.downstream_data.super_safe_lock(|data| { let mut messages: Vec = vec![]; + let mut solo_block_found = false; let Some(extended_channel) = data.extended_channels.get_mut(&channel_id) else { error!("SubmitSharesError: channel_id: {channel_id}, sequence_number: {}, error_code: invalid-channel-id", msg.sequence_number); - return Ok(vec![(downstream_id, build_error("invalid-channel-id")).into()]); + return Ok((vec![(downstream_id, build_error("invalid-channel-id")).into()], false)); }; // here we extract and set the user_identity from the TLV fields if the extension is negotiated let user_identity = if negotiated_extensions.as_ref().is_ok_and(|exts| exts.contains(&EXTENSION_TYPE_WORKER_HASHRATE_TRACKING)) { @@ -1226,7 +1242,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { } let Some(vardiff) = channel_manager_data.vardiff.get_mut(&(downstream_id, channel_id).into()) else { - return Ok(vec![(downstream_id, Mining::CloseChannel(create_close_channel_msg(channel_id, "invalid-channel-id"))).into()]); + return Ok((vec![(downstream_id, Mining::CloseChannel(create_close_channel_msg(channel_id, "invalid-channel-id"))).into()], false)); }; vardiff.increment_shares_since_last_update(); let res = extended_channel.validate_share(msg.clone()); @@ -1279,6 +1295,11 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { downstream.downstream_id, Mining::SubmitSharesSuccess(success), ).into()); + + // Track block found in solo mining mode (no upstream channel) + if channel_manager_data.upstream_channel.is_none() { + solo_block_found = true; + } } Err(err) => { let code = match err { @@ -1296,7 +1317,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { } if !is_downstream_share_valid{ - return Ok(messages); + return Ok((messages, solo_block_found)); } if let Some(upstream_channel) = channel_manager_data.upstream_channel.as_mut() { @@ -1407,7 +1428,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { } } - Ok(messages) + Ok((messages, solo_block_found)) }) })?; @@ -1415,6 +1436,11 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { _ = messages.forward(&self.channel_manager_channel).await; } + // Rotate coinbase address if block found in solo mining mode + if solo_block_found { + self.rotate_coinbase_address(); + } + Ok(()) } diff --git a/miner-apps/jd-client/src/lib/channel_manager/mod.rs b/miner-apps/jd-client/src/lib/channel_manager/mod.rs index f7f0d477d..3ebdadd5e 100644 --- a/miner-apps/jd-client/src/lib/channel_manager/mod.rs +++ b/miner-apps/jd-client/src/lib/channel_manager/mod.rs @@ -1,6 +1,7 @@ use std::{ collections::{BinaryHeap, HashMap, VecDeque}, net::SocketAddr, + path::PathBuf, sync::{ atomic::{AtomicU32, AtomicUsize, Ordering}, Arc, @@ -11,12 +12,16 @@ use async_channel::{Receiver, Sender}; use bitcoin_core_sv2::CancellationToken; use stratum_apps::{ coinbase_output_constraints::coinbase_output_constraints_message, + config_helpers::XpubDerivator, custom_mutex::Mutex, fallback_coordinator::FallbackCoordinator, key_utils::{Secp256k1PublicKey, Secp256k1SecretKey}, network_helpers::accept_noise_connection, stratum_core::{ - bitcoin::{consensus, Amount, Target, TxOut}, + bitcoin::{ + consensus::{self, Encodable}, + Amount, Target, TxOut, + }, channels_sv2::{ client::extended::ExtendedChannel, outputs::deserialize_outputs, @@ -267,6 +272,9 @@ pub struct ChannelManager { /// 3. Connected: An upstream channel is successfully established. /// 4. SoloMining: No upstream is available; the JDC operates in solo mining mode. case. pub upstream_state: AtomicUpstreamState, + /// Optional xpub derivator for coinbase rotation in solo mining mode. + /// When configured with a wildcard descriptor, derives new addresses on each block found. + xpub_derivator: Option>, } #[cfg_attr(not(test), hotpath::measure_all)] @@ -304,6 +312,84 @@ impl ChannelManager { let extranonce_prefix_factory_extended = make_extranonce_factory(); let extranonce_prefix_factory_standard = make_extranonce_factory(); + let channel_manager_channel = ChannelManagerChannel { + upstream_sender, + upstream_receiver, + jd_sender, + jd_receiver, + tp_sender, + tp_receiver, + downstream_sender, + downstream_receiver, + }; + + // Initialize XpubDerivator for coinbase rotation if configured with wildcard descriptor + // This must be done BEFORE creating channel_manager_data so we can use + // the derivator's current_script_pubkey() for the initial coinbase_outputs + let xpub_derivator = if config.coinbase_reward_script().has_wildcard() { + let descriptor_str = config + .coinbase_reward_script() + .wildcard_descriptor_str() + .expect("wildcard descriptor must be present when has_wildcard() is true"); + + let index_file = config.coinbase_index_file().map(PathBuf::from).ok_or_else(|| { + error!("coinbase_index_file is required when coinbase_reward_script has a wildcard"); + JDCError::shutdown(JDCErrorKind::InvalidConfiguration( + "coinbase_index_file is required when coinbase_reward_script has a wildcard".to_string() + )) + })?; + + let derivator = + XpubDerivator::new(descriptor_str, config.coinbase_start_index(), index_file) + .map_err(|e| { + error!("Failed to initialize XpubDerivator: {}", e); + JDCError::shutdown(JDCErrorKind::InvalidConfiguration(format!( + "failed to initialize coinbase rotation: {}", + e + ))) + })?; + + info!( + "Coinbase rotation enabled: starting at index {}", + derivator.current_index() + ); + + Some(Arc::new(derivator)) + } else { + None + }; + + // If we have an xpub derivator, use its current_script_pubkey() for the initial + // coinbase_outputs. This ensures we use the correct address from the persisted + // index (or start_index) rather than always using index 0. + let coinbase_outputs = if let Some(ref derivator) = xpub_derivator { + match derivator.current_script_pubkey() { + Ok(script) => { + let txout = TxOut { + value: Amount::from_sat(0), + script_pubkey: script, + }; + let mut encoded = vec![]; + if let Err(e) = vec![txout].consensus_encode(&mut encoded) { + error!("Failed to encode coinbase outputs from derivator: {}", e); + return Err(JDCError::shutdown(JDCErrorKind::InvalidConfiguration( + format!("failed to encode coinbase outputs: {}", e), + ))); + } + encoded + } + Err(e) => { + error!("Failed to derive initial coinbase script: {}", e); + return Err(JDCError::shutdown(JDCErrorKind::InvalidConfiguration( + format!("failed to derive initial coinbase script: {}", e), + ))); + } + } + } else { + // No derivator - use the passed-in coinbase_outputs (static address) + coinbase_outputs + }; + let channel_manager_data = Arc::new(Mutex::new(ChannelManagerData { downstream: HashMap::new(), extranonce_prefix_factory_extended, @@ -330,17 +416,6 @@ impl ChannelManager { cached_shares: HashMap::new(), })); - let channel_manager_channel = ChannelManagerChannel { - upstream_sender, - upstream_receiver, - jd_sender, - jd_receiver, - tp_sender, - tp_receiver, - downstream_sender, - downstream_receiver, - }; - let channel_manager = ChannelManager { channel_manager_data, channel_manager_channel, @@ -349,6 +424,7 @@ impl ChannelManager { miner_tag_string: config.jdc_signature().to_string(), user_identity: config.user_identity().to_string(), upstream_state: AtomicUpstreamState::new(UpstreamState::SoloMining), + xpub_derivator, }; Ok(channel_manager) @@ -1229,4 +1305,61 @@ impl ChannelManager { Ok(()) } + + /// Rotates the coinbase address to the next derived address. + /// + /// This method is called when a block is found in solo mining mode. + /// It derives the address at the block height (if provided) or the next + /// sequential index, then updates the `coinbase_outputs` in the channel + /// manager data. + /// + /// Only effective when: + /// 1. `xpub_derivator` is configured (wildcard descriptor) + /// 2. JDC is in solo mining mode + /// + /// The index is persisted to disk for restart recovery. + pub fn rotate_coinbase_address(&self) { + // Only rotate if we have an xpub derivator configured + let Some(derivator) = &self.xpub_derivator else { + return; + }; + + // Only rotate in solo mining mode + if self.upstream_state.get() != UpstreamState::SoloMining { + debug!("Skipping coinbase rotation: not in solo mining mode"); + return; + } + + match derivator.next_script_pubkey() { + Ok(script_pubkey) => { + let new_index = derivator.current_index(); + info!( + "Coinbase rotation: rotated to index {} (script: {})", + new_index, + script_pubkey.to_hex_string() + ); + + // Update coinbase_outputs in channel manager data + self.channel_manager_data + .super_safe_lock(|channel_manager_data| { + // Create new TxOut with the derived script + let new_output = TxOut { + value: Amount::from_sat(0), // Value will be set from template + script_pubkey, + }; + + // Serialize the new output + let mut output_bytes = Vec::new(); + new_output + .consensus_encode(&mut output_bytes) + .expect("TxOut encoding should never fail"); + + channel_manager_data.coinbase_outputs = output_bytes; + }); + } + Err(e) => { + error!("Failed to rotate coinbase address: {}", e); + } + } + } } diff --git a/miner-apps/jd-client/src/lib/config.rs b/miner-apps/jd-client/src/lib/config.rs index d754a83ce..2dda9c3e7 100644 --- a/miner-apps/jd-client/src/lib/config.rs +++ b/miner-apps/jd-client/src/lib/config.rs @@ -57,8 +57,30 @@ pub struct JobDeclaratorClientConfig { /// Optional monitoring server bind address #[serde(default)] monitoring_address: Option, + #[serde(default = "default_monitoring_cache_refresh_secs")] + monitoring_cache_refresh_secs: u64, + + /// Starting derivation index for coinbase rotation (default: 0). + /// + /// Only used when `coinbase_reward_script` contains a wildcard descriptor + /// (e.g., `wpkh(xpub.../0/*)`). Ignored for static addresses. #[serde(default)] - monitoring_cache_refresh_secs: Option, + coinbase_start_index: u32, + + /// Path to persist the current coinbase derivation index. + /// + /// Required when `coinbase_reward_script` contains a wildcard descriptor. + /// The index is persisted after each block found, allowing address derivation + /// to resume at the correct index after restarts. + /// + /// Parent directories will be created if they don't exist. + #[serde(default, deserialize_with = "opt_path_from_toml")] + coinbase_index_file: Option, +} + +fn default_monitoring_cache_refresh_secs() -> u64 { + 15 +} } impl JobDeclaratorClientConfig { @@ -101,7 +123,9 @@ impl JobDeclaratorClientConfig { supported_extensions, required_extensions, monitoring_address, - monitoring_cache_refresh_secs, + monitoring_cache_refresh_secs: monitoring_cache_refresh_secs.unwrap_or(15), + coinbase_start_index: 0, + coinbase_index_file: None, } } @@ -111,7 +135,7 @@ impl JobDeclaratorClientConfig { } /// Returns the monitoring cache refresh interval in seconds. - pub fn monitoring_cache_refresh_secs(&self) -> Option { + pub fn monitoring_cache_refresh_secs(&self) -> u64 { self.monitoring_cache_refresh_secs } @@ -198,6 +222,21 @@ impl JobDeclaratorClientConfig { pub fn required_extensions(&self) -> &[u16] { &self.required_extensions } + + /// Returns the coinbase reward script. + pub fn coinbase_reward_script(&self) -> &CoinbaseRewardScript { + &self.coinbase_reward_script + } + + /// Returns the starting derivation index for coinbase rotation. + pub fn coinbase_start_index(&self) -> u32 { + self.coinbase_start_index + } + + /// Returns the path to the coinbase derivation index file. + pub fn coinbase_index_file(&self) -> Option<&Path> { + self.coinbase_index_file.as_deref() + } } #[derive(Debug, Deserialize, Clone, Default)] diff --git a/miner-apps/jd-client/src/lib/error.rs b/miner-apps/jd-client/src/lib/error.rs index 0e57874ec..05d97f847 100644 --- a/miner-apps/jd-client/src/lib/error.rs +++ b/miner-apps/jd-client/src/lib/error.rs @@ -252,6 +252,8 @@ pub enum JDCErrorKind { InvalidKey, /// Upstream not found UpstreamNotFound, + /// Invalid configuration + InvalidConfiguration(String), } impl std::error::Error for JDCErrorKind {} @@ -394,6 +396,7 @@ impl fmt::Display for JDCErrorKind { CouldNotInitiateSystem => write!(f, "Could not initiate subsystem"), InvalidKey => write!(f, "Invalid key used during noise handshake"), UpstreamNotFound => write!(f, "Upstream not found"), + InvalidConfiguration(ref msg) => write!(f, "Invalid configuration: {msg}"), } } } diff --git a/pool-apps/pool/src/lib/channel_manager/mining_message_handler.rs b/pool-apps/pool/src/lib/channel_manager/mining_message_handler.rs index 643716fbc..c0ae9f03d 100644 --- a/pool-apps/pool/src/lib/channel_manager/mining_message_handler.rs +++ b/pool-apps/pool/src/lib/channel_manager/mining_message_handler.rs @@ -526,7 +526,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { let downstream_id = client_id.expect("client_id must be present for downstream_id extraction"); - let messages = self.channel_manager_data.super_safe_lock(|channel_manager_data| { + let (messages, block_found) = self.channel_manager_data.super_safe_lock(|channel_manager_data| { let channel_id = msg.channel_id; let Some(downstream) = channel_manager_data.downstream.get(&downstream_id) else { @@ -535,6 +535,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { downstream.downstream_data.super_safe_lock(|downstream_data| { let mut messages: Vec = Vec::new(); + let mut block_found = false; let Some(standard_channel) = downstream_data.standard_channels.get_mut(&channel_id) else { let submit_shares_error = SubmitSharesError { channel_id, @@ -545,11 +546,11 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { .expect("error code must be valid string"), }; error!("SubmitSharesError: downstream_id: {}, channel_id: {}, sequence_number: {}, error_code: invalid-channel-id ❌", downstream_id, channel_id, msg.sequence_number); - return Ok(vec![(downstream_id, Mining::SubmitSharesError(submit_shares_error)).into()]); + return Ok((vec![(downstream_id, Mining::SubmitSharesError(submit_shares_error)).into()], false)); }; let Some(vardiff) = channel_manager_data.vardiff.get_mut(&(downstream_id, channel_id).into()) else { - return Ok(vec![(downstream_id, Mining::CloseChannel(create_close_channel_msg(channel_id, "invalid-channel-id"))).into()]); + return Ok((vec![(downstream_id, Mining::CloseChannel(create_close_channel_msg(channel_id, "invalid-channel-id"))).into()], false)); }; let res = standard_channel.validate_share(msg.clone()); @@ -579,6 +580,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { } Ok(ShareValidationResult::BlockFound(share_hash, template_id, coinbase)) => { info!("SubmitSharesStandard: 💰 Block Found!!! 💰{share_hash}"); + block_found = true; // if we have a template id (i.e.: this was not a custom job) // we can propagate the solution to the TP if let Some(template_id) = template_id { @@ -667,7 +669,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { } } - Ok(messages) + Ok((messages, block_found)) }) })?; @@ -675,6 +677,11 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { message.forward(&self.channel_manager_channel).await; } + // Rotate coinbase address after block found + if block_found { + self.rotate_coinbase_address(); + } + Ok(()) } @@ -706,7 +713,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { None }; - let messages = self.channel_manager_data.super_safe_lock(|channel_manager_data| { + let (messages, block_found) = self.channel_manager_data.super_safe_lock(|channel_manager_data| { let channel_id = msg.channel_id; let Some(downstream) = channel_manager_data.downstream.get(&downstream_id) else { return Err(PoolError::disconnect(PoolErrorKind::DownstreamNotFound(downstream_id), downstream_id)); @@ -714,6 +721,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { downstream.downstream_data.super_safe_lock(|downstream_data| { let mut messages: Vec = Vec::new(); + let mut block_found = false; let Some(extended_channel) = downstream_data.extended_channels.get_mut(&channel_id) else { let error = SubmitSharesError { channel_id, @@ -724,7 +732,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { .expect("error code must be valid string"), }; error!("SubmitSharesError: downstream_id: {}, channel_id: {}, sequence_number: {}, error_code: invalid-channel-id ❌", downstream_id, channel_id, msg.sequence_number); - return Ok(vec![(downstream_id, Mining::SubmitSharesError(error)).into()]); + return Ok((vec![(downstream_id, Mining::SubmitSharesError(error)).into()], false)); }; if let Some(_user_identity) = user_identity { @@ -732,7 +740,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { } let Some(vardiff) = channel_manager_data.vardiff.get_mut(&(downstream_id, channel_id).into()) else { - return Ok(vec![(downstream_id, Mining::CloseChannel(create_close_channel_msg(channel_id, "invalid-channel-id"))).into()]); + return Ok((vec![(downstream_id, Mining::CloseChannel(create_close_channel_msg(channel_id, "invalid-channel-id"))).into()], false)); }; let res = extended_channel.validate_share(msg.clone()); @@ -760,6 +768,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { } Ok(ShareValidationResult::BlockFound(share_hash, template_id, coinbase)) => { info!("SubmitSharesExtended: 💰 Block Found!!! 💰{share_hash}"); + block_found = true; // if we have a template id (i.e.: this was not a custom job) // we can propagate the solution to the TP if let Some(template_id) = template_id { @@ -859,7 +868,7 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { } } - Ok(messages) + Ok((messages, block_found)) }) })?; @@ -867,6 +876,11 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager { message.forward(&self.channel_manager_channel).await; } + // Rotate coinbase address after block found + if block_found { + self.rotate_coinbase_address(); + } + Ok(()) } diff --git a/pool-apps/pool/src/lib/channel_manager/mod.rs b/pool-apps/pool/src/lib/channel_manager/mod.rs index 47c7e34f2..13b9ab213 100644 --- a/pool-apps/pool/src/lib/channel_manager/mod.rs +++ b/pool-apps/pool/src/lib/channel_manager/mod.rs @@ -12,12 +12,12 @@ use bitcoin_core_sv2::CancellationToken; use core::sync::atomic::Ordering; use stratum_apps::{ coinbase_output_constraints::coinbase_output_constraints_message, - config_helpers::CoinbaseRewardScript, + config_helpers::{CoinbaseRewardScript, XpubDerivator}, custom_mutex::Mutex, key_utils::{Secp256k1PublicKey, Secp256k1SecretKey}, network_helpers::accept_noise_connection, stratum_core::{ - bitcoin::{Amount, TxOut}, + bitcoin::{consensus::Encodable, Amount, TxOut}, channels_sv2::{ server::{ extended::ExtendedChannel, @@ -100,6 +100,9 @@ pub struct ChannelManager { supported_extensions: Vec, /// Protocol extensions that the pool requires (clients must support these). required_extensions: Vec, + /// Optional xpub derivator for coinbase rotation. + /// When set, the coinbase address rotates to a new derived address after each block is found. + xpub_derivator: Option>, } #[cfg_attr(not(test), hotpath::measure_all)] @@ -136,6 +139,76 @@ impl ChannelManager { let extranonce_prefix_factory_extended = make_extranonce_factory(); let extranonce_prefix_factory_standard = make_extranonce_factory(); + let channel_manager_channel = ChannelManagerChannel { + tp_sender, + tp_receiver, + downstream_sender, + downstream_receiver, + }; + + // Initialize xpub derivator if the coinbase reward script has a wildcard + // This must be done BEFORE creating channel_manager_data so we can use + // the derivator's current_script_pubkey() for the initial coinbase_outputs + let xpub_derivator = if config.coinbase_reward_script().has_wildcard() { + let descriptor_str = config + .coinbase_reward_script() + .wildcard_descriptor_str() + .expect("wildcard descriptor must exist when has_wildcard() is true"); + + let index_file = config.coinbase_index_file().ok_or_else(|| { + error!("coinbase_index_file is required when using a wildcard descriptor"); + PoolError::shutdown(PoolErrorKind::InvalidConfiguration) + })?; + + match XpubDerivator::new( + descriptor_str, + config.coinbase_start_index(), + index_file.to_path_buf(), + ) { + Ok(derivator) => { + info!( + "Coinbase rotation enabled. Starting at index {}, persisting to {:?}", + derivator.current_index(), + index_file + ); + Some(Arc::new(derivator)) + } + Err(e) => { + error!("Failed to initialize xpub derivator: {}", e); + return Err(PoolError::shutdown(PoolErrorKind::InvalidConfiguration)); + } + } + } else { + None + }; + + // If we have an xpub derivator, use its current_script_pubkey() for the initial + // coinbase_outputs. This ensures we use the correct address from the persisted + // index (or start_index) rather than always using index 0. + let coinbase_outputs = if let Some(ref derivator) = xpub_derivator { + match derivator.current_script_pubkey() { + Ok(script) => { + let txout = TxOut { + value: Amount::from_sat(0), + script_pubkey: script, + }; + let mut encoded = vec![]; + if let Err(e) = vec![txout].consensus_encode(&mut encoded) { + error!("Failed to encode coinbase outputs from derivator: {}", e); + return Err(PoolError::shutdown(PoolErrorKind::InvalidConfiguration)); + } + encoded + } + Err(e) => { + error!("Failed to derive initial coinbase script: {}", e); + return Err(PoolError::shutdown(PoolErrorKind::InvalidConfiguration)); + } + } + } else { + // No derivator - use the passed-in coinbase_outputs (static address) + coinbase_outputs + }; + let channel_manager_data = Arc::new(Mutex::new(ChannelManagerData { downstream: HashMap::new(), extranonce_prefix_factory_extended, @@ -147,13 +220,6 @@ impl ChannelManager { last_new_prev_hash: None, })); - let channel_manager_channel = ChannelManagerChannel { - tp_sender, - tp_receiver, - downstream_sender, - downstream_receiver, - }; - let channel_manager = ChannelManager { channel_manager_data, channel_manager_channel, @@ -163,6 +229,7 @@ impl ChannelManager { coinbase_reward_script: config.coinbase_reward_script().clone(), supported_extensions: config.supported_extensions().to_vec(), required_extensions: config.required_extensions().to_vec(), + xpub_derivator, }; Ok(channel_manager) @@ -644,6 +711,51 @@ impl ChannelManager { Ok(()) } + + /// Rotates the coinbase address to the next derived address. + /// + /// This should be called after a block is found. It: + /// 1. Derives the address at the block height (if provided) or the next sequential index + /// 2. Persists the index/height to disk + /// 3. Updates the internal coinbase_outputs for future templates + /// + /// If no xpub derivator is configured (static address), this is a no-op. + pub fn rotate_coinbase_address(&self) { + let Some(derivator) = &self.xpub_derivator else { + return; + }; + + match derivator.next_script_pubkey() { + Ok(new_script) => { + let new_index = derivator.current_index(); + info!( + "Rotated coinbase address to index {}. New script: {}", + new_index, + new_script.to_hex_string() + ); + + // Update the coinbase_outputs in ChannelManagerData + let new_txout = TxOut { + value: Amount::from_sat(0), + script_pubkey: new_script, + }; + + // Encode outputs using consensus encoding (same format as initialization) + let mut new_outputs = vec![]; + if let Err(e) = vec![new_txout].consensus_encode(&mut new_outputs) { + error!("Failed to encode new coinbase outputs: {}", e); + return; + } + + self.channel_manager_data.super_safe_lock(|data| { + data.coinbase_outputs = new_outputs; + }); + } + Err(e) => { + error!("Failed to rotate coinbase address: {}", e); + } + } + } } #[derive(Clone)] diff --git a/pool-apps/pool/src/lib/config.rs b/pool-apps/pool/src/lib/config.rs index 850b37f00..dc616fa79 100644 --- a/pool-apps/pool/src/lib/config.rs +++ b/pool-apps/pool/src/lib/config.rs @@ -43,8 +43,30 @@ pub struct PoolConfig { required_extensions: Vec, #[serde(default)] monitoring_address: Option, + #[serde(default = "default_monitoring_cache_refresh_secs")] + monitoring_cache_refresh_secs: u64, + + /// Starting derivation index for coinbase rotation (default: 0). + /// + /// Only used when `coinbase_reward_script` contains a wildcard descriptor + /// (e.g., `wpkh(xpub.../0/*)`). Ignored for static addresses. #[serde(default)] - monitoring_cache_refresh_secs: Option, + coinbase_start_index: u32, + + /// Path to persist the current coinbase derivation index. + /// + /// Required when `coinbase_reward_script` contains a wildcard descriptor. + /// The index is persisted after each block found, allowing address derivation + /// to resume at the correct index after restarts. + /// + /// Parent directories will be created if they don't exist. + #[serde(default, deserialize_with = "opt_path_from_toml")] + coinbase_index_file: Option, +} + +fn default_monitoring_cache_refresh_secs() -> u64 { + 15 +} } impl PoolConfig { @@ -82,7 +104,9 @@ impl PoolConfig { supported_extensions, required_extensions, monitoring_address, - monitoring_cache_refresh_secs, + monitoring_cache_refresh_secs: monitoring_cache_refresh_secs.unwrap_or(15), + coinbase_start_index: 0, + coinbase_index_file: None, } } @@ -175,9 +199,19 @@ impl PoolConfig { } /// Returns the monitoring cache refresh interval in seconds. - pub fn monitoring_cache_refresh_secs(&self) -> Option { + pub fn monitoring_cache_refresh_secs(&self) -> u64 { self.monitoring_cache_refresh_secs } + + /// Returns the starting derivation index for coinbase rotation. + pub fn coinbase_start_index(&self) -> u32 { + self.coinbase_start_index + } + + /// Returns the path to the coinbase derivation index file. + pub fn coinbase_index_file(&self) -> Option<&Path> { + self.coinbase_index_file.as_deref() + } } /// Pool's authority public and secret keys. diff --git a/pool-apps/pool/src/lib/error.rs b/pool-apps/pool/src/lib/error.rs index dd9f37d9c..aff6c7f7f 100644 --- a/pool-apps/pool/src/lib/error.rs +++ b/pool-apps/pool/src/lib/error.rs @@ -187,6 +187,8 @@ pub enum PoolErrorKind { CouldNotInitiateSystem, /// Configuration error Configuration(String), + /// Invalid configuration (validation failed at runtime) + InvalidConfiguration, /// Job not found JobNotFound, /// Invalid Key @@ -279,6 +281,7 @@ impl std::fmt::Display for PoolErrorKind { } CouldNotInitiateSystem => write!(f, "Could not initiate subsystem"), Configuration(e) => write!(f, "Configuration error: {e}"), + InvalidConfiguration => write!(f, "Invalid configuration"), JobNotFound => write!(f, "Job not found"), InvalidKey => write!(f, "Invalid key used during noise handshake") } diff --git a/stratum-apps/Cargo.toml b/stratum-apps/Cargo.toml index 5850c4b9e..35be94b93 100644 --- a/stratum-apps/Cargo.toml +++ b/stratum-apps/Cargo.toml @@ -27,7 +27,7 @@ tokio-util = { version = "0.7.10", default-features = false, features = ["codec" # Config helpers dependencies serde = { version = "1.0.89", features = ["derive", "alloc"], default-features = false } -miniscript = { version = "13.0.0", default-features = false } +miniscript = { version = "13.0.0", default-features = false, features = ["std"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing = { version = "0.1" } @@ -84,6 +84,7 @@ mining_device = ["config"] tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" hyper = "1.1.0" +tempfile = "3.10" [package.metadata.docs.rs] features = ["pool", "jd_client", "jd_server", "translator", "sv1", "rpc"] diff --git a/stratum-apps/src/config_helpers/coinbase_output/mod.rs b/stratum-apps/src/config_helpers/coinbase_output/mod.rs index 29de0dbee..1989f4cfb 100644 --- a/stratum-apps/src/config_helpers/coinbase_output/mod.rs +++ b/stratum-apps/src/config_helpers/coinbase_output/mod.rs @@ -3,6 +3,7 @@ mod serde_types; use miniscript::{ bitcoin::{address::NetworkUnchecked, Address, Network, ScriptBuf}, + descriptor::DescriptorPublicKey, DefiniteDescriptorKey, Descriptor, }; @@ -11,16 +12,54 @@ pub use errors::Error; /// Coinbase output transaction. /// /// Typically used for parsing coinbase outputs defined in SRI role configuration files. +/// +/// Supports two modes: +/// 1. **Static address**: A fixed address (e.g., `addr(bc1q...)`) - `script_pubkey` is set directly +/// 2. **Wildcard descriptor**: A derivable descriptor (e.g., `wpkh(xpub.../0/*)`) - stores the +/// descriptor string for later derivation via [`XpubDerivator`](crate::config_helpers::XpubDerivator) +/// +/// Use [`has_wildcard()`](Self::has_wildcard) to check which mode is active. #[derive(Debug, serde::Deserialize, Clone)] #[serde(try_from = "serde_types::SerdeCoinbaseOutput")] pub struct CoinbaseRewardScript { + /// The script pubkey for static addresses, or the index-0 derived script for wildcards. script_pubkey: ScriptBuf, + /// Whether this output is valid for mainnet. ok_for_mainnet: bool, + /// For wildcard descriptors, stores the original descriptor string for later derivation. + /// None for static addresses. + /// Note: We store the string instead of `Descriptor` because the latter + /// is not `Send + Sync` due to internal `RefCell` usage for taproot caching. + wildcard_descriptor_str: Option, } impl CoinbaseRewardScript { /// Creates a new [`CoinbaseRewardScript`] from a descriptor string. + /// + /// Supports both static descriptors (e.g., `addr(bc1q...)`) and wildcard descriptors + /// (e.g., `wpkh(xpub.../0/*)`). + /// + /// For wildcard descriptors, the initial `script_pubkey` is derived at index 0. + /// Use [`has_wildcard()`](Self::has_wildcard) and [`wildcard_descriptor_str()`](Self::wildcard_descriptor_str) + /// to access the underlying descriptor for runtime derivation. pub fn from_descriptor(s: &str) -> Result { + // First, try to parse as a wildcard descriptor (DescriptorPublicKey). + // This handles descriptors like wpkh(xpub.../0/*). + if let Ok(wildcard_desc) = s.parse::>() { + if wildcard_desc.has_wildcard() { + // Derive at index 0 to get the initial script_pubkey + let definite = wildcard_desc + .at_derivation_index(0) + .map_err(|e| Error::Miniscript(miniscript::Error::Unexpected(e.to_string())))?; + + return Ok(Self { + script_pubkey: definite.script_pubkey(), + ok_for_mainnet: true, + wildcard_descriptor_str: Some(s.to_string()), + }); + } + } + // Taproot descriptors cannot be parsed with `expression::Tree::from_str` and // need special handling. So we special-case them early and just pass to // rust-miniscript. In Miniscript 13 we will not need to do this. @@ -31,6 +70,7 @@ impl CoinbaseRewardScript { // Descriptors don't have a way to specify a network, so we assume // they are OK to be used on mainnet. ok_for_mainnet: true, + wildcard_descriptor_str: None, }); } @@ -45,6 +85,7 @@ impl CoinbaseRewardScript { Ok(Self { script_pubkey: addr.assume_checked_ref().script_pubkey(), ok_for_mainnet: addr.is_valid_for_network(Network::Bitcoin), + wildcard_descriptor_str: None, }) } "raw" => { @@ -59,6 +100,7 @@ impl CoinbaseRewardScript { script_pubkey: ScriptBuf::from_hex(&script_hex)?, // Users of hex scriptpubkeys are on their own. ok_for_mainnet: true, + wildcard_descriptor_str: None, }) } _ => { @@ -70,6 +112,7 @@ impl CoinbaseRewardScript { // Descriptors don't have a way to specify a network, so we assume // they are OK to be used on mainnet. ok_for_mainnet: true, + wildcard_descriptor_str: None, }) } } @@ -84,10 +127,32 @@ impl CoinbaseRewardScript { self.ok_for_mainnet } - /// The `scriptPubKey` associated with the coinbase output + /// The `scriptPubKey` associated with the coinbase output. + /// + /// For wildcard descriptors, this returns the script derived at index 0. + /// To get scripts at other indices, use [`wildcard_descriptor_str()`](Self::wildcard_descriptor_str) + /// with [`XpubDerivator`](crate::config_helpers::XpubDerivator). pub fn script_pubkey(&self) -> ScriptBuf { self.script_pubkey.clone() } + + /// Returns `true` if this is a wildcard descriptor that supports rotation. + /// + /// Wildcard descriptors contain `/*` in the derivation path (e.g., `wpkh(xpub.../0/*)`). + /// When rotation is enabled, a new address should be derived for each block found. + pub fn has_wildcard(&self) -> bool { + self.wildcard_descriptor_str.is_some() + } + + /// Returns the underlying wildcard descriptor string, if any. + /// + /// Use this with [`XpubDerivator`](crate::config_helpers::XpubDerivator) to derive + /// addresses at specific indices for coinbase rotation. + /// + /// Returns `None` for static addresses (non-wildcard descriptors). + pub fn wildcard_descriptor_str(&self) -> Option<&str> { + self.wildcard_descriptor_str.as_deref() + } } #[cfg(test)] @@ -326,12 +391,15 @@ mod tests { CoinbaseRewardScript::from_descriptor("pkh(xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8/1'/2/3)").unwrap_err().to_string(), "Miniscript: key with hardened derivation steps cannot be a DerivedDescriptorKey", ); - // no wildcards allowed (at least for now; gmax thinks it would be cool if we would - // instantiate it with the blockheight or something, but need to work out UX) - assert_eq!( - CoinbaseRewardScript::from_descriptor("pkh(xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8/*)").unwrap_err().to_string(), - "Miniscript: key with a wildcard cannot be a DerivedDescriptorKey", - ); + // wildcards ARE now allowed - they create a wildcard descriptor for rotation + let wildcard_desc = CoinbaseRewardScript::from_descriptor( + "pkh(xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8/*)" + ).unwrap(); + assert!(wildcard_desc.has_wildcard()); + assert!(wildcard_desc.wildcard_descriptor_str().is_some()); + // script_pubkey should be derived at index 0 + assert!(!wildcard_desc.script_pubkey().is_empty()); + // No multipath descriptors allowed; this is not a wallet with change assert_eq!( CoinbaseRewardScript::from_descriptor("pkh(xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8/<0;1>)").unwrap_err().to_string(), @@ -352,4 +420,69 @@ mod tests { "Miniscript: public keys must be 64, 66 or 130 characters in size", ); } + + #[test] + fn test_wildcard_wpkh_descriptor() { + // wpkh with wildcard - common format for BIP84 wallets + let desc = CoinbaseRewardScript::from_descriptor( + "wpkh(tpubD6NzVbkrYhZ4XgiXtGrdW5XDAPFCL9h7we1vwNCpn8tGbBcgfVYjXyhWo4E1xkh56hjod1RhGjxbaTLV3X4FyWuejifB9jusQ46QzG87VKp/0/*)" + ).unwrap(); + + assert!(desc.has_wildcard()); + assert!(desc.wildcard_descriptor_str().is_some()); + + // script_pubkey should be a valid p2wpkh script (starts with 0x0014) + let script = desc.script_pubkey(); + assert!(script.to_hex_string().starts_with("0014")); + } + + #[test] + fn test_wildcard_tr_descriptor() { + // Taproot with wildcard + let desc = CoinbaseRewardScript::from_descriptor( + "tr(tpubD6NzVbkrYhZ4XgiXtGrdW5XDAPFCL9h7we1vwNCpn8tGbBcgfVYjXyhWo4E1xkh56hjod1RhGjxbaTLV3X4FyWuejifB9jusQ46QzG87VKp/0/*)" + ).unwrap(); + + assert!(desc.has_wildcard()); + assert!(desc.wildcard_descriptor_str().is_some()); + + // script_pubkey should be a valid p2tr script (starts with 0x5120) + let script = desc.script_pubkey(); + assert!(script.to_hex_string().starts_with("5120")); + } + + #[test] + fn test_non_wildcard_has_no_descriptor() { + // Static address - no wildcard + let desc = CoinbaseRewardScript::from_descriptor( + "addr(tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx)", + ) + .unwrap(); + + assert!(!desc.has_wildcard()); + assert!(desc.wildcard_descriptor_str().is_none()); + } + + #[test] + fn test_xpub_without_wildcard_has_no_descriptor() { + // xpub with fixed path (no wildcard) + let desc = CoinbaseRewardScript::from_descriptor( + "wpkh(xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8/0/0)" + ).unwrap(); + + assert!(!desc.has_wildcard()); + assert!(desc.wildcard_descriptor_str().is_none()); + } + + #[test] + fn test_mainnet_xpub_wildcard() { + // Mainnet xpub with wildcard + let desc = CoinbaseRewardScript::from_descriptor( + "wpkh(xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8/0/*)" + ).unwrap(); + + assert!(desc.has_wildcard()); + // Mainnet xpubs are allowed + assert!(desc.ok_for_mainnet()); + } } diff --git a/stratum-apps/src/config_helpers/coinbase_output/serde_types.rs b/stratum-apps/src/config_helpers/coinbase_output/serde_types.rs index 21b004146..622125e49 100644 --- a/stratum-apps/src/config_helpers/coinbase_output/serde_types.rs +++ b/stratum-apps/src/config_helpers/coinbase_output/serde_types.rs @@ -99,6 +99,8 @@ impl TryFrom for super::CoinbaseRewardScript { script_pubkey, // legacy encoding gives no way to specify testnet or mainnet ok_for_mainnet: true, + // Legacy format doesn't support wildcards + wildcard_descriptor_str: None, }) } } diff --git a/stratum-apps/src/config_helpers/mod.rs b/stratum-apps/src/config_helpers/mod.rs index b2cc2398c..bef8841b8 100644 --- a/stratum-apps/src/config_helpers/mod.rs +++ b/stratum-apps/src/config_helpers/mod.rs @@ -3,6 +3,7 @@ //! This module provides utilities for: //! - Parsing configuration files (TOML, etc.) //! - Handling coinbase output specifications +//! - xpub-based coinbase rotation //! - Setting up logging and tracing //! //! Originally from the `config_helpers_sv2` crate. @@ -10,6 +11,9 @@ mod coinbase_output; pub use coinbase_output::{CoinbaseRewardScript, Error as CoinbaseOutputError}; +mod xpub_derivation; +pub use xpub_derivation::{XpubDerivationError, XpubDerivator}; + pub mod logging; mod toml; diff --git a/stratum-apps/src/config_helpers/xpub_derivation.rs b/stratum-apps/src/config_helpers/xpub_derivation.rs new file mode 100644 index 000000000..71b0570ea --- /dev/null +++ b/stratum-apps/src/config_helpers/xpub_derivation.rs @@ -0,0 +1,552 @@ +//! xpub-based coinbase address derivation with persistence. +//! +//! This module provides utilities for deriving sequential Bitcoin addresses from an +//! extended public key (xpub/tpub) descriptor. It's designed for coinbase rotation +//! in mining pools, where each block found uses a new address derived from the xpub. +//! +//! # Features +//! +//! - Parses wildcard descriptors like `wpkh(xpub.../0/*)` +//! - Derives addresses at sequential indices +//! - Persists the current index to disk (survives restarts) +//! - Thread-safe (uses atomic operations for index) +//! +//! # Example +//! +//! ```ignore +//! use stratum_apps::config_helpers::xpub_derivation::XpubDerivator; +//! use std::path::PathBuf; +//! +//! let descriptor_str = "wpkh(tpub.../0/*)"; +//! let derivator = XpubDerivator::new(descriptor_str, 0, PathBuf::from("/tmp/index.dat")).unwrap(); +//! +//! // Get current address (peek) +//! let current = derivator.current_script_pubkey().unwrap(); +//! +//! // Get next address and increment (rotate) +//! let next = derivator.next_script_pubkey().unwrap(); +//! ``` + +use miniscript::{bitcoin::ScriptBuf, descriptor::DescriptorPublicKey, Descriptor}; +use std::{ + fmt, fs, io, + path::{Path, PathBuf}, + sync::atomic::{AtomicU32, Ordering}, +}; + +/// Errors that can occur during xpub derivation. +#[derive(Debug)] +pub enum XpubDerivationError { + /// The descriptor does not have a wildcard. + NoWildcard, + /// Failed to parse the descriptor string. + ParseError(String), + /// Failed to derive at the specified index. + DerivationFailed(String), + /// Failed to persist the index to disk. + PersistenceError(io::Error), + /// Failed to create parent directories for index file. + CreateDirectoryError(io::Error), +} + +impl fmt::Display for XpubDerivationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + XpubDerivationError::NoWildcard => { + write!(f, "descriptor does not have a wildcard (e.g., /0/*)") + } + XpubDerivationError::ParseError(msg) => { + write!(f, "failed to parse descriptor: {}", msg) + } + XpubDerivationError::DerivationFailed(msg) => { + write!(f, "failed to derive at index: {}", msg) + } + XpubDerivationError::PersistenceError(e) => { + write!(f, "failed to persist index: {}", e) + } + XpubDerivationError::CreateDirectoryError(e) => { + write!(f, "failed to create index file directory: {}", e) + } + } + } +} + +impl std::error::Error for XpubDerivationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + XpubDerivationError::PersistenceError(e) => Some(e), + XpubDerivationError::CreateDirectoryError(e) => Some(e), + _ => None, + } + } +} + +/// Manages xpub-based address derivation with persistence. +/// +/// This struct holds a wildcard descriptor and maintains the current derivation +/// index. The index is persisted to disk so that address derivation can resume +/// after restarts without reusing addresses. +/// +/// # Thread Safety +/// +/// The derivation index uses `AtomicU32` for thread-safe access. Multiple threads +/// can safely call `next_script_pubkey()` concurrently, though the order of +/// indices assigned is not guaranteed. +/// +/// Note: The descriptor is stored as a `String` and re-parsed on each derivation +/// to ensure `Send + Sync` compatibility (miniscript's `Descriptor` +/// uses internal `RefCell` for taproot caching which is not thread-safe). +pub struct XpubDerivator { + /// The wildcard descriptor string (e.g., "wpkh(xpub.../0/*)") + descriptor_str: String, + + /// Current derivation index (atomic for thread safety) + current_index: AtomicU32, + + /// Path to persist the current index + index_file: PathBuf, +} + +impl XpubDerivator { + /// Creates a new `XpubDerivator` from a wildcard descriptor. + /// + /// If the index file exists, loads the persisted index. Otherwise, uses + /// `start_index` as the initial index. + /// + /// Creates parent directories for the index file if they don't exist. + /// + /// # Arguments + /// + /// * `descriptor_str` - A wildcard descriptor string (must have `*` in derivation path) + /// * `start_index` - Initial derivation index if no persisted index exists + /// * `index_file` - Path to store the current index + /// + /// # Errors + /// + /// Returns `XpubDerivationError::ParseError` if the descriptor string is invalid. + /// Returns `XpubDerivationError::NoWildcard` if the descriptor doesn't have a wildcard. + /// Returns `XpubDerivationError::CreateDirectoryError` if parent directories can't be created. + pub fn new( + descriptor_str: &str, + start_index: u32, + index_file: PathBuf, + ) -> Result { + // Parse the descriptor to validate it + let descriptor: Descriptor = descriptor_str + .parse() + .map_err(|e: miniscript::Error| XpubDerivationError::ParseError(e.to_string()))?; + + // Verify the descriptor has a wildcard + if !descriptor.has_wildcard() { + return Err(XpubDerivationError::NoWildcard); + } + + // Create parent directories if they don't exist + if let Some(parent) = index_file.parent() { + if !parent.exists() { + fs::create_dir_all(parent).map_err(XpubDerivationError::CreateDirectoryError)?; + } + } + + // Load persisted index or use start_index + let current_index = Self::load_index(&index_file, start_index); + + Ok(Self { + descriptor_str: descriptor_str.to_string(), + current_index: AtomicU32::new(current_index), + index_file, + }) + } + + /// Returns the current derivation index without incrementing. + pub fn current_index(&self) -> u32 { + self.current_index.load(Ordering::SeqCst) + } + + /// Derives the script pubkey at the current index without incrementing. + /// + /// This is useful for getting the current coinbase address without rotating. + /// + /// # Errors + /// + /// Returns an error if derivation fails (e.g., index out of range). + pub fn current_script_pubkey(&self) -> Result { + let index = self.current_index.load(Ordering::SeqCst); + self.derive_at_index(index) + } + + /// Increments the index and derives the script pubkey at the new index. + /// + /// This is the main method for coinbase rotation. Call this AFTER a block is + /// found to rotate to the next address. + /// + /// The new index is persisted to disk. If persistence fails, a warning is + /// logged but the operation still succeeds (the index is still incremented + /// in memory). + /// + /// # Errors + /// + /// Returns an error if derivation fails. + pub fn next_script_pubkey(&self) -> Result { + // Atomically increment and get the NEW index + // fetch_add returns the old value, so add 1 to get the new value + let new_index = self.current_index.fetch_add(1, Ordering::SeqCst) + 1; + + // Derive at the NEW index + let script = self.derive_at_index(new_index)?; + + // Persist the new index + // Don't fail if persistence fails - just log a warning + if let Err(e) = self.persist_index() { + tracing::warn!( + "Failed to persist coinbase rotation index to {:?}: {}", + self.index_file, + e + ); + } + + Ok(script) + } + + /// Derives the script pubkey at a specific index. + fn derive_at_index(&self, index: u32) -> Result { + // Re-parse descriptor each time for thread safety + // (miniscript's Descriptor is not Send + Sync due to RefCell) + let descriptor: Descriptor = self + .descriptor_str + .parse() + .map_err(|e: miniscript::Error| XpubDerivationError::ParseError(e.to_string()))?; + + let definite = descriptor + .at_derivation_index(index) + .map_err(|e| XpubDerivationError::DerivationFailed(e.to_string()))?; + + Ok(definite.script_pubkey()) + } + + /// Persists the current index to disk. + fn persist_index(&self) -> Result<(), XpubDerivationError> { + let index = self.current_index.load(Ordering::SeqCst); + fs::write(&self.index_file, index.to_string()) + .map_err(XpubDerivationError::PersistenceError) + } + + /// Loads the index from disk, or returns the default if the file doesn't exist + /// or can't be parsed. + fn load_index(path: &Path, default: u32) -> u32 { + match fs::read_to_string(path) { + Ok(contents) => contents.trim().parse().unwrap_or(default), + Err(_) => default, + } + } +} + +impl fmt::Debug for XpubDerivator { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("XpubDerivator") + .field("descriptor", &self.descriptor_str) + .field("current_index", &self.current_index.load(Ordering::SeqCst)) + .field("index_file", &self.index_file) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + // Test tpub from BIP84 test vectors + const TEST_TPUB: &str = "wpkh(tpubD6NzVbkrYhZ4XgiXtGrdW5XDAPFCL9h7we1vwNCpn8tGbBcgfVYjXyhWo4E1xkh56hjod1RhGjxbaTLV3X4FyWuejifB9jusQ46QzG87VKp/0/*)"; + + #[test] + fn test_new_with_wildcard() { + let dir = tempdir().unwrap(); + let index_file = dir.path().join("index.dat"); + + let derivator = XpubDerivator::new(TEST_TPUB, 0, index_file).unwrap(); + + assert_eq!(derivator.current_index(), 0); + } + + #[test] + fn test_new_without_wildcard_fails() { + let dir = tempdir().unwrap(); + let index_file = dir.path().join("index.dat"); + + // Descriptor without wildcard + let desc_str = "wpkh(tpubD6NzVbkrYhZ4XgiXtGrdW5XDAPFCL9h7we1vwNCpn8tGbBcgfVYjXyhWo4E1xkh56hjod1RhGjxbaTLV3X4FyWuejifB9jusQ46QzG87VKp/0/0)"; + + let result = XpubDerivator::new(desc_str, 0, index_file); + assert!(matches!(result, Err(XpubDerivationError::NoWildcard))); + } + + #[test] + fn test_new_with_invalid_descriptor_fails() { + let dir = tempdir().unwrap(); + let index_file = dir.path().join("index.dat"); + + let result = XpubDerivator::new("invalid_descriptor", 0, index_file); + assert!(matches!(result, Err(XpubDerivationError::ParseError(_)))); + } + + #[test] + fn test_current_script_pubkey_does_not_increment() { + let dir = tempdir().unwrap(); + let index_file = dir.path().join("index.dat"); + + let derivator = XpubDerivator::new(TEST_TPUB, 0, index_file).unwrap(); + + // Call current_script_pubkey multiple times + let script1 = derivator.current_script_pubkey().unwrap(); + let script2 = derivator.current_script_pubkey().unwrap(); + let script3 = derivator.current_script_pubkey().unwrap(); + + // All should be the same + assert_eq!(script1, script2); + assert_eq!(script2, script3); + + // Index should still be 0 + assert_eq!(derivator.current_index(), 0); + } + + #[test] + fn test_next_script_pubkey_increments() { + let dir = tempdir().unwrap(); + let index_file = dir.path().join("index.dat"); + + let derivator = XpubDerivator::new(TEST_TPUB, 0, index_file).unwrap(); + + // Get first address + let script0 = derivator.next_script_pubkey().unwrap(); + assert_eq!(derivator.current_index(), 1); + + // Get second address + let script1 = derivator.next_script_pubkey().unwrap(); + assert_eq!(derivator.current_index(), 2); + + // Get third address + let script2 = derivator.next_script_pubkey().unwrap(); + assert_eq!(derivator.current_index(), 3); + + // All should be different + assert_ne!(script0, script1); + assert_ne!(script1, script2); + assert_ne!(script0, script2); + } + + #[test] + fn test_index_persistence() { + let dir = tempdir().unwrap(); + let index_file = dir.path().join("subdir/index.dat"); + + // Create derivator and advance index + { + let derivator = XpubDerivator::new(TEST_TPUB, 0, index_file.clone()).unwrap(); + + // Advance to index 5 + for _ in 0..5 { + derivator.next_script_pubkey().unwrap(); + } + assert_eq!(derivator.current_index(), 5); + } + + // Create new derivator with same index file - should resume at 5 + { + let derivator = XpubDerivator::new(TEST_TPUB, 0, index_file).unwrap(); + + assert_eq!(derivator.current_index(), 5); + } + } + + #[test] + fn test_start_index() { + let dir = tempdir().unwrap(); + let index_file = dir.path().join("index.dat"); + + let derivator = XpubDerivator::new(TEST_TPUB, 100, index_file).unwrap(); + + assert_eq!(derivator.current_index(), 100); + + let script = derivator.next_script_pubkey().unwrap(); + assert_eq!(derivator.current_index(), 101); + + // Should be different from index 0 + let dir2 = tempdir().unwrap(); + let derivator2 = XpubDerivator::new(TEST_TPUB, 0, dir2.path().join("index.dat")).unwrap(); + let script0 = derivator2.next_script_pubkey().unwrap(); + + assert_ne!(script, script0); + } + + #[test] + fn test_creates_parent_directories() { + let dir = tempdir().unwrap(); + let index_file = dir.path().join("a/b/c/index.dat"); + + let derivator = XpubDerivator::new(TEST_TPUB, 0, index_file.clone()).unwrap(); + + // Should be able to persist + derivator.next_script_pubkey().unwrap(); + + // File should exist + assert!(index_file.exists()); + } + + #[test] + fn test_mainnet_xpub() { + let dir = tempdir().unwrap(); + let index_file = dir.path().join("index.dat"); + + // Mainnet xpub + let desc_str = "wpkh(xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8/0/*)"; + + let derivator = XpubDerivator::new(desc_str, 0, index_file).unwrap(); + + // Should work fine + let script = derivator.next_script_pubkey().unwrap(); + assert!(!script.is_empty()); + } + + #[test] + fn test_taproot_descriptor() { + let dir = tempdir().unwrap(); + let index_file = dir.path().join("index.dat"); + + // Taproot descriptor with wildcard + let desc_str = "tr(tpubD6NzVbkrYhZ4XgiXtGrdW5XDAPFCL9h7we1vwNCpn8tGbBcgfVYjXyhWo4E1xkh56hjod1RhGjxbaTLV3X4FyWuejifB9jusQ46QzG87VKp/0/*)"; + + let derivator = XpubDerivator::new(desc_str, 0, index_file).unwrap(); + + let script0 = derivator.next_script_pubkey().unwrap(); + let script1 = derivator.next_script_pubkey().unwrap(); + + assert_ne!(script0, script1); + // Taproot scripts start with 0x5120 + assert!(script0.to_hex_string().starts_with("5120")); + } + + /// Validates derivation against known test vectors. + /// + /// This test uses a specific tpub and validates that the derived scripts + /// match the expected values computed from the known public keys. + /// + /// Descriptor: wpkh(tpubDDHYkDsJ8XB1LLjMNrk5gXsmze87LRkWoNqprdXPud9Yx3ZfsjZZJEqscUgSRLJ1EG77KSKygC9uNAeDtgHsLtvH93MnPF2M9Vq5WvGvcLw/0/*) + /// + /// Expected derived scripts at each index (P2WPKH format: 0014<20-byte-hash>): + /// Index 0: 0014798fb52bc77ba8e028dfad1b522505223c7e7ca0 + /// Index 1: 00143acc8d6d349a24a198fb9eec0e27b822c589d407 + /// Index 2: 0014dd4da77967b0a8c59ee3026af582de496abad124 + /// Index 3: 001401b85a64c3c8d8dcf46f49230d938ec1245fcd8e + /// Index 4: 0014a72ae2dddcc84c99a0abe43f4fbef1a46d153b8e + #[test] + fn test_known_derivation_vectors() { + const KNOWN_TPUB: &str = "wpkh(tpubDDHYkDsJ8XB1LLjMNrk5gXsmze87LRkWoNqprdXPud9Yx3ZfsjZZJEqscUgSRLJ1EG77KSKygC9uNAeDtgHsLtvH93MnPF2M9Vq5WvGvcLw/0/*)"; + + // Expected scripts at each index (P2WPKH format: 0014<20-byte-hash>) + let expected_scripts = [ + "0014798fb52bc77ba8e028dfad1b522505223c7e7ca0", // Index 0 + "00143acc8d6d349a24a198fb9eec0e27b822c589d407", // Index 1 + "0014dd4da77967b0a8c59ee3026af582de496abad124", // Index 2 + "001401b85a64c3c8d8dcf46f49230d938ec1245fcd8e", // Index 3 + "0014a72ae2dddcc84c99a0abe43f4fbef1a46d153b8e", // Index 4 + ]; + + let dir = tempdir().unwrap(); + let index_file = dir.path().join("index.dat"); + + let derivator = XpubDerivator::new(KNOWN_TPUB, 0, index_file).unwrap(); + + // Verify index 0 via current_script_pubkey + let script0 = derivator.current_script_pubkey().unwrap(); + assert_eq!( + script0.to_hex_string(), + expected_scripts[0], + "Script mismatch at index 0" + ); + + // Verify indices 1-4 via next_script_pubkey (which increments then derives) + for (i, expected_script) in expected_scripts.iter().enumerate().skip(1) { + let script = derivator.next_script_pubkey().unwrap(); + assert_eq!( + script.to_hex_string(), + *expected_script, + "Script mismatch at index {}: expected {}, got {}", + i, + expected_script, + script.to_hex_string() + ); + } + } + + /// Test the complete rotation flow simulating pool behavior. + /// + /// This simulates: + /// 1. Start at index 2 (from persisted file) + /// 2. Verify current_script_pubkey() returns index 2's script + /// 3. After block found, next_script_pubkey() returns index 3's script + /// 4. After another block, next_script_pubkey() returns index 4's script + /// 5. Restart and verify resumption at index 4 + #[test] + fn test_rotation_flow_with_known_vectors() { + const KNOWN_TPUB: &str = "wpkh(tpubDDHYkDsJ8XB1LLjMNrk5gXsmze87LRkWoNqprdXPud9Yx3ZfsjZZJEqscUgSRLJ1EG77KSKygC9uNAeDtgHsLtvH93MnPF2M9Vq5WvGvcLw/0/*)"; + + let expected_scripts = [ + "0014798fb52bc77ba8e028dfad1b522505223c7e7ca0", // Index 0 + "00143acc8d6d349a24a198fb9eec0e27b822c589d407", // Index 1 + "0014dd4da77967b0a8c59ee3026af582de496abad124", // Index 2 + "001401b85a64c3c8d8dcf46f49230d938ec1245fcd8e", // Index 3 + "0014a72ae2dddcc84c99a0abe43f4fbef1a46d153b8e", // Index 4 + ]; + + let dir = tempdir().unwrap(); + let index_file = dir.path().join("index.dat"); + + // Simulate: index file contains "2" (pool was at index 2) + fs::write(&index_file, "2").unwrap(); + + let derivator = XpubDerivator::new(KNOWN_TPUB, 0, index_file.clone()).unwrap(); + + // Step 1: Should load index 2 from file + assert_eq!(derivator.current_index(), 2); + + // Step 2: current_script_pubkey() should return index 2's script + let initial_script = derivator.current_script_pubkey().unwrap(); + assert_eq!( + initial_script.to_hex_string(), + expected_scripts[2], + "Initial script should be at index 2" + ); + + // Step 3: First block found - rotate to index 3 + let script_after_first_block = derivator.next_script_pubkey().unwrap(); + assert_eq!(derivator.current_index(), 3); + assert_eq!( + script_after_first_block.to_hex_string(), + expected_scripts[3], + "After first rotation, script should be at index 3" + ); + + // Step 4: Second block found - rotate to index 4 + let script_after_second_block = derivator.next_script_pubkey().unwrap(); + assert_eq!(derivator.current_index(), 4); + assert_eq!( + script_after_second_block.to_hex_string(), + expected_scripts[4], + "After second rotation, script should be at index 4" + ); + + // Verify file was updated + let contents = fs::read_to_string(&index_file).unwrap(); + assert_eq!(contents, "4"); + + // Step 5: Simulate restart - create new derivator from same file + let derivator2 = XpubDerivator::new(KNOWN_TPUB, 0, index_file).unwrap(); + assert_eq!(derivator2.current_index(), 4); + assert_eq!( + derivator2.current_script_pubkey().unwrap().to_hex_string(), + expected_scripts[4], + "After restart, should resume at index 4" + ); + } +} From d5446a870f8c5d973c11db3878392415d83b3d69 Mon Sep 17 00:00:00 2001 From: Gary Krause Date: Thu, 29 Jan 2026 10:24:09 -0500 Subject: [PATCH 2/3] docs: add coinbase rotation documentation to config examples Document the new coinbase rotation configuration options in testnet4 config examples for both pool and JD-client. --- .../testnet4/jdc-config-local-infra-example.toml | 10 ++++++++++ .../testnet4/pool-config-local-sv2-tp-example.toml | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/miner-apps/jd-client/config-examples/testnet4/jdc-config-local-infra-example.toml b/miner-apps/jd-client/config-examples/testnet4/jdc-config-local-infra-example.toml index 26d32cf82..600d668e3 100644 --- a/miner-apps/jd-client/config-examples/testnet4/jdc-config-local-infra-example.toml +++ b/miner-apps/jd-client/config-examples/testnet4/jdc-config-local-infra-example.toml @@ -35,8 +35,18 @@ jdc_signature = "Sv2MinerSignature" # https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki#appendix-b-index-of-script-expressions # Although the `musig` descriptor is not yet supported and the legacy `combo` descriptor never # will be. If you have an address, embed it in a descriptor like `addr(
)`. +# +# For automatic address rotation (quantum-resistant payout hygiene), use a wildcard descriptor: +# coinbase_reward_script = "wpkh(tpub.../0/*)" +# This derives a fresh address for each block found. Requires coinbase_index_file. coinbase_reward_script = "addr(tb1qpusf5256yxv50qt0pm0tue8k952fsu5lzsphft)" +# Coinbase rotation settings (only used when coinbase_reward_script has a wildcard) +# Path to persist the current derivation index (required for rotation) +# coinbase_index_file = "/var/lib/jdc/coinbase_index.dat" +# Starting index if no persistence file exists (default: 0) +# coinbase_start_index = 0 + # Enable this option to set a predefined log file path. # When enabled, logs will always be written to this file. # The CLI option --log-file (or -f) will override this setting if provided. diff --git a/pool-apps/pool/config-examples/testnet4/pool-config-local-sv2-tp-example.toml b/pool-apps/pool/config-examples/testnet4/pool-config-local-sv2-tp-example.toml index 9ff3b80f1..cbdbf0db2 100644 --- a/pool-apps/pool/config-examples/testnet4/pool-config-local-sv2-tp-example.toml +++ b/pool-apps/pool/config-examples/testnet4/pool-config-local-sv2-tp-example.toml @@ -8,8 +8,18 @@ listen_address = "0.0.0.0:43333" # https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki#appendix-b-index-of-script-expressions # Although the `musig` descriptor is not yet supported and the legacy `combo` descriptor never # will be. If you have an address, embed it in a descriptor like `addr(
)`. +# +# For automatic address rotation (quantum-resistant payout hygiene), use a wildcard descriptor: +# coinbase_reward_script = "wpkh(tpub.../0/*)" +# This derives a fresh address for each block found. Requires coinbase_index_file. coinbase_reward_script = "addr(tb1qa0sm0hxzj0x25rh8gw5xlzwlsfvvyz8u96w3p8)" +# Coinbase rotation settings (only used when coinbase_reward_script has a wildcard) +# Path to persist the current derivation index (required for rotation) +# coinbase_index_file = "/var/lib/pool/coinbase_index.dat" +# Starting index if no persistence file exists (default: 0) +# coinbase_start_index = 0 + # Server Id (number to guarantee unique search space allocation across different Pool servers) server_id = 1 From b35b5d97839e4cfbbd7ebfcf0f9b2ed93944a13f Mon Sep 17 00:00:00 2001 From: Gary Krause Date: Mon, 2 Feb 2026 12:13:38 -0500 Subject: [PATCH 3/3] fix: properly detect testnet keys in wildcard descriptors - Add detection for testnet extended key prefixes (tpub, tprv, upub, uprv, vpub, vprv) - Set ok_for_mainnet=false for descriptors using testnet keys - Log at info level whether descriptor uses mainnet or testnet keys - Add tests for testnet key detection and ok_for_mainnet validation --- .../src/config_helpers/coinbase_output/mod.rs | 65 ++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/stratum-apps/src/config_helpers/coinbase_output/mod.rs b/stratum-apps/src/config_helpers/coinbase_output/mod.rs index 1989f4cfb..ae0a4be38 100644 --- a/stratum-apps/src/config_helpers/coinbase_output/mod.rs +++ b/stratum-apps/src/config_helpers/coinbase_output/mod.rs @@ -6,9 +6,25 @@ use miniscript::{ descriptor::DescriptorPublicKey, DefiniteDescriptorKey, Descriptor, }; +use tracing::info; pub use errors::Error; +/// Testnet extended key prefixes (BIP32/BIP49/BIP84). +/// These indicate the key is for testnet/regtest, not mainnet. +const TESTNET_KEY_PREFIXES: &[&str] = &[ + "tpub", "tprv", // BIP32 testnet + "upub", "uprv", // BIP49 testnet (P2WPKH-P2SH) + "vpub", "vprv", // BIP84 testnet (P2WPKH native) +]; + +/// Returns true if the descriptor string contains any testnet extended key prefixes. +fn descriptor_uses_testnet_keys(descriptor: &str) -> bool { + TESTNET_KEY_PREFIXES + .iter() + .any(|prefix| descriptor.contains(prefix)) +} + /// Coinbase output transaction. /// /// Typically used for parsing coinbase outputs defined in SRI role configuration files. @@ -52,9 +68,23 @@ impl CoinbaseRewardScript { .at_derivation_index(0) .map_err(|e| Error::Miniscript(miniscript::Error::Unexpected(e.to_string())))?; + // Detect if this is a testnet key based on the key prefix + let uses_testnet_keys = descriptor_uses_testnet_keys(s); + let ok_for_mainnet = !uses_testnet_keys; + + if uses_testnet_keys { + info!( + "Coinbase descriptor uses testnet keys (tpub/upub/vpub) - not valid for mainnet" + ); + } else { + info!( + "Coinbase descriptor uses mainnet keys (xpub/ypub/zpub) - valid for mainnet" + ); + } + return Ok(Self { script_pubkey: definite.script_pubkey(), - ok_for_mainnet: true, + ok_for_mainnet, wildcard_descriptor_str: Some(s.to_string()), }); } @@ -482,7 +512,38 @@ mod tests { ).unwrap(); assert!(desc.has_wildcard()); - // Mainnet xpubs are allowed + // Mainnet xpubs are valid for mainnet assert!(desc.ok_for_mainnet()); } + + #[test] + fn test_testnet_tpub_wildcard_not_ok_for_mainnet() { + // Testnet tpub with wildcard - should NOT be ok for mainnet + let desc = CoinbaseRewardScript::from_descriptor( + "wpkh(tpubD6NzVbkrYhZ4XgiXtGrdW5XDAPFCL9h7we1vwNCpn8tGbBcgfVYjXyhWo4E1xkh56hjod1RhGjxbaTLV3X4FyWuejifB9jusQ46QzG87VKp/0/*)" + ).unwrap(); + + assert!(desc.has_wildcard()); + // Testnet tpubs are NOT valid for mainnet + assert!(!desc.ok_for_mainnet()); + } + + #[test] + fn test_testnet_key_detection() { + // Test the testnet key detection helper + assert!(super::descriptor_uses_testnet_keys("wpkh(tpub.../0/*)")); + assert!(super::descriptor_uses_testnet_keys("wpkh(tprv.../0/*)")); + assert!(super::descriptor_uses_testnet_keys("wpkh(upub.../0/*)")); + assert!(super::descriptor_uses_testnet_keys("wpkh(uprv.../0/*)")); + assert!(super::descriptor_uses_testnet_keys("wpkh(vpub.../0/*)")); + assert!(super::descriptor_uses_testnet_keys("wpkh(vprv.../0/*)")); + + // Mainnet keys should return false + assert!(!super::descriptor_uses_testnet_keys("wpkh(xpub.../0/*)")); + assert!(!super::descriptor_uses_testnet_keys("wpkh(xprv.../0/*)")); + assert!(!super::descriptor_uses_testnet_keys("wpkh(ypub.../0/*)")); + assert!(!super::descriptor_uses_testnet_keys("wpkh(yprv.../0/*)")); + assert!(!super::descriptor_uses_testnet_keys("wpkh(zpub.../0/*)")); + assert!(!super::descriptor_uses_testnet_keys("wpkh(zprv.../0/*)")); + } }