Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(<address here>)`.
#
# 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize>,
Expand All @@ -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));
Expand All @@ -968,14 +970,15 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager {

downstream.downstream_data.super_safe_lock(|data| {
let mut messages: Vec<RouteMessageTo> = 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());
Expand Down Expand Up @@ -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 {
Expand All @@ -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() {
Expand Down Expand Up @@ -1149,14 +1157,19 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager {
}
}

Ok(messages)
Ok((messages, solo_block_found))
})
})?;

for messages in messages {
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(())
}

Expand All @@ -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<usize>,
Expand All @@ -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));
Expand All @@ -1203,10 +1218,11 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager {
};
downstream.downstream_data.super_safe_lock(|data| {
let mut messages: Vec<RouteMessageTo> = 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)) {
Expand All @@ -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());
Expand Down Expand Up @@ -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 {
Expand All @@ -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() {
Expand Down Expand Up @@ -1407,14 +1428,19 @@ impl HandleMiningMessagesFromClientAsync for ChannelManager {
}
}

Ok(messages)
Ok((messages, solo_block_found))
})
})?;

for messages in messages {
_ = 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(())
}

Expand Down
157 changes: 145 additions & 12 deletions miner-apps/jd-client/src/lib/channel_manager/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::{
collections::{BinaryHeap, HashMap, VecDeque},
net::SocketAddr,
path::PathBuf,
sync::{
atomic::{AtomicU32, AtomicUsize, Ordering},
Arc,
Expand All @@ -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,
Expand Down Expand Up @@ -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<Arc<XpubDerivator>>,
}

#[cfg_attr(not(test), hotpath::measure_all)]
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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);
}
}
}
}
Loading
Loading