use crate::{
chain_tip::ChainTip,
extranonce_manager::{AllocatedExtranoncePrefix, ExtranoncePrefix},
merkle_root::merkle_root_from_path,
server::{
error::ExtendedChannelError,
jobs::{
extended::ExtendedJob,
factory::JobFactory,
job_store::{JobStore, MAX_PAST_JOBS},
JobOrigin,
},
share_accounting::{ShareAccounting, ShareValidationError, ShareValidationResult},
},
target::{bytes_to_hex, hash_rate_to_target, u256_to_block_hash},
MAX_EXTRANONCE_LEN, MAX_FUTURE_BLOCK_TIME, VERSION_ROLLING_MASK,
};
use bitcoin::{
blockdata::block::{Header, Version},
hashes::sha256d::Hash,
transaction::TxOut,
CompactTarget, Target,
};
use mining_sv2::{
SetCustomMiningJobOwned, SubmitSharesExtendedOwned,
ERROR_CODE_OPEN_MINING_CHANNEL_INVALID_NOMINAL_HASHRATE,
ERROR_CODE_OPEN_MINING_CHANNEL_MAX_TARGET_OUT_OF_RANGE,
ERROR_CODE_SUBMIT_SHARES_BAD_EXTRANONCE_SIZE, ERROR_CODE_SUBMIT_SHARES_DIFFICULTY_TOO_LOW,
ERROR_CODE_SUBMIT_SHARES_DUPLICATE_SHARE, ERROR_CODE_SUBMIT_SHARES_INVALID_JOB_ID,
ERROR_CODE_SUBMIT_SHARES_INVALID_NON_ROLLABLE_VERSION_BIT,
ERROR_CODE_SUBMIT_SHARES_INVALID_SHARE, ERROR_CODE_SUBMIT_SHARES_STALE_SHARE,
ERROR_CODE_UPDATE_CHANNEL_INVALID_NOMINAL_HASHRATE,
ERROR_CODE_UPDATE_CHANNEL_MAX_TARGET_OUT_OF_RANGE, ERROR_CODE_VERSION_ROLLING_NOT_ALLOWED,
};
use std::collections::HashMap;
use template_distribution_sv2::{NewTemplateOwned, SetNewPrevHashOwned as SetNewPrevHashTdp};
use tracing::debug;
#[derive(Debug)]
pub struct ExtendedChannel {
channel_id: u32,
user_identity: String,
extranonce_prefix: ExtranoncePrefix,
rollable_extranonce_size: u16,
version_rolling_allowed: bool,
requested_max_target: Target,
target: Target,
job_id_to_target: HashMap<u32, Target>,
nominal_hashrate: f32,
stable_hashrate: bool,
job_store: JobStore<ExtendedJob>,
job_factory: JobFactory,
share_accounting: ShareAccounting,
expected_share_per_minute: f32,
chain_tip: Option<ChainTip>,
}
impl ExtendedChannel {
#[allow(clippy::too_many_arguments)]
pub fn new_for_pool(
channel_id: u32,
user_identity: String,
extranonce_prefix: AllocatedExtranoncePrefix,
max_target: Target,
nominal_hashrate: f32,
version_rolling_allowed: bool,
rollable_extranonce_size: u16,
share_batch_size: usize,
expected_share_per_minute: f32,
pool_tag_string: String,
max_past_jobs: Option<usize>,
) -> Result<Self, ExtendedChannelError> {
Self::new(
channel_id,
user_identity,
extranonce_prefix.into(),
max_target,
nominal_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
Some(pool_tag_string),
None,
max_past_jobs,
)
}
#[allow(clippy::too_many_arguments)]
pub fn new_for_job_declaration_client(
channel_id: u32,
user_identity: String,
extranonce_prefix: AllocatedExtranoncePrefix,
max_target: Target,
nominal_hashrate: f32,
version_rolling_allowed: bool,
rollable_extranonce_size: u16,
share_batch_size: usize,
expected_share_per_minute: f32,
pool_tag_string: Option<String>,
miner_tag_string: String,
max_past_jobs: Option<usize>,
) -> Result<Self, ExtendedChannelError> {
Self::new(
channel_id,
user_identity,
extranonce_prefix.into(),
max_target,
nominal_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
pool_tag_string,
Some(miner_tag_string),
max_past_jobs,
)
}
#[allow(clippy::too_many_arguments)]
fn new(
channel_id: u32,
user_identity: String,
extranonce_prefix: ExtranoncePrefix,
max_target: Target,
nominal_hashrate: f32,
version_rolling_allowed: bool,
rollable_extranonce_size: u16,
share_batch_size: usize,
expected_share_per_minute: f32,
pool_tag: Option<String>,
miner_tag: Option<String>,
max_past_jobs: Option<usize>,
) -> Result<Self, ExtendedChannelError> {
if max_target == Target::ZERO {
return Err(ExtendedChannelError::OpenChannelInvalidMaxTarget(
ERROR_CODE_OPEN_MINING_CHANNEL_MAX_TARGET_OUT_OF_RANGE,
));
}
let target =
match hash_rate_to_target(nominal_hashrate.into(), expected_share_per_minute.into()) {
Ok(target) => target,
Err(_) => {
return Err(ExtendedChannelError::OpenChannelInvalidNominalHashrate(
ERROR_CODE_OPEN_MINING_CHANNEL_INVALID_NOMINAL_HASHRATE,
));
}
};
let target = target.min(max_target);
if extranonce_prefix.len() > MAX_EXTRANONCE_LEN as usize {
return Err(ExtendedChannelError::ExtranoncePrefixTooLarge);
}
let job_factory = JobFactory::new(version_rolling_allowed, pool_tag, miner_tag);
if !job_factory
.fits_script_sig_budget(extranonce_prefix.len() + rollable_extranonce_size as usize)
{
return Err(ExtendedChannelError::ScriptSigSizeTooLarge);
}
let max_past_jobs = match max_past_jobs {
Some(cap) if cap > 0 => cap,
_ => MAX_PAST_JOBS,
};
Ok(Self {
channel_id,
user_identity,
extranonce_prefix,
rollable_extranonce_size,
version_rolling_allowed,
requested_max_target: max_target,
target,
job_id_to_target: HashMap::new(),
nominal_hashrate,
stable_hashrate: false,
job_store: JobStore::new(max_past_jobs),
job_factory,
share_accounting: ShareAccounting::new(
share_batch_size,
crate::seen_shares_budget(expected_share_per_minute as f64),
),
expected_share_per_minute,
chain_tip: None,
})
}
pub fn get_channel_id(&self) -> u32 {
self.channel_id
}
pub fn get_user_identity(&self) -> &str {
&self.user_identity
}
pub fn get_extranonce_prefix(&self) -> &[u8] {
self.extranonce_prefix.as_bytes()
}
pub fn get_chain_tip(&self) -> Option<&ChainTip> {
self.chain_tip.as_ref()
}
pub fn get_shares_per_minute(&self) -> f32 {
self.expected_share_per_minute
}
#[cfg(test)]
fn set_chain_tip(&mut self, chain_tip: ChainTip) {
self.chain_tip = Some(chain_tip);
}
pub fn set_extranonce_prefix(
&mut self,
extranonce_prefix: AllocatedExtranoncePrefix,
) -> Result<(), ExtendedChannelError> {
let full_extranonce_size = extranonce_prefix.len() + self.rollable_extranonce_size as usize;
if full_extranonce_size > MAX_EXTRANONCE_LEN as usize {
return Err(ExtendedChannelError::ExtranoncePrefixTooLarge);
}
if !self
.job_factory
.fits_script_sig_budget(full_extranonce_size)
{
return Err(ExtendedChannelError::ScriptSigSizeTooLarge);
}
let retired_extranonce_prefix =
std::mem::replace(&mut self.extranonce_prefix, extranonce_prefix.into());
self.job_store
.retire_extranonce_prefix(retired_extranonce_prefix);
Ok(())
}
pub fn set_upstream_extranonce_prefix(
&mut self,
upstream_prefix: &[u8],
) -> Result<(), ExtendedChannelError> {
let full_extranonce_size = upstream_prefix.len()
+ self.extranonce_prefix.preserved_len()
+ self.rollable_extranonce_size as usize;
if full_extranonce_size > MAX_EXTRANONCE_LEN as usize {
return Err(ExtendedChannelError::ExtranoncePrefixTooLarge);
}
if !self
.job_factory
.fits_script_sig_budget(full_extranonce_size)
{
return Err(ExtendedChannelError::ScriptSigSizeTooLarge);
}
let snapshot = self
.extranonce_prefix
.snapshot_for_upstream_update(upstream_prefix);
self.extranonce_prefix
.set_upstream_prefix(upstream_prefix)
.map_err(|_| ExtendedChannelError::ExtranoncePrefixTooLarge)?;
if let Some(snapshot) = snapshot {
self.job_store.retire_extranonce_prefix(snapshot);
}
Ok(())
}
pub fn get_rollable_extranonce_size(&self) -> u16 {
self.rollable_extranonce_size
}
pub fn get_full_extranonce_size(&self) -> usize {
self.extranonce_prefix.len() + self.rollable_extranonce_size as usize
}
pub fn get_requested_max_target(&self) -> &Target {
&self.requested_max_target
}
pub fn get_target(&self) -> &Target {
&self.target
}
pub fn set_target(&mut self, target: Target) -> Result<(), ExtendedChannelError> {
if target == Target::ZERO {
return Err(ExtendedChannelError::InvalidTarget);
}
self.target = target;
Ok(())
}
pub fn get_future_job_id_from_template_id(&self, template_id: u64) -> Option<u32> {
self.job_store
.get_future_job_id_from_template_id(template_id)
}
pub fn get_nominal_hashrate(&self) -> f32 {
self.nominal_hashrate
}
pub fn set_stable_hashrate(&mut self, stable_hashrate: bool) {
self.stable_hashrate = stable_hashrate;
}
pub fn get_stable_hashrate(&self) -> bool {
self.stable_hashrate
}
pub fn set_nominal_hashrate(&mut self, hashrate: f32) {
self.nominal_hashrate = hashrate;
}
pub fn update_channel(
&mut self,
new_nominal_hashrate: f32,
requested_max_target: Option<Target>,
) -> Result<(), ExtendedChannelError> {
let target = match hash_rate_to_target(
new_nominal_hashrate.into(),
self.expected_share_per_minute.into(),
) {
Ok(target) => target,
Err(_) => {
return Err(ExtendedChannelError::UpdateChannelInvalidNominalHashrate(
ERROR_CODE_UPDATE_CHANNEL_INVALID_NOMINAL_HASHRATE,
));
}
};
let requested_max_target = match requested_max_target {
Some(ref requested_max_target) => requested_max_target,
None => &self.requested_max_target,
};
if *requested_max_target == Target::ZERO {
return Err(ExtendedChannelError::UpdateChannelInvalidMaxTarget(
ERROR_CODE_UPDATE_CHANNEL_MAX_TARGET_OUT_OF_RANGE,
));
}
let target_bytes = target.to_be_bytes();
let max_target = requested_max_target;
let max_target_bytes = max_target.to_be_bytes();
let old_target = self.target;
let old_target_bytes = old_target.to_be_bytes();
debug!(
"updating channel target \nold target:\t{}\nnew target:\t{}\nmax_target:\t{}",
bytes_to_hex(&old_target_bytes),
bytes_to_hex(&target_bytes),
bytes_to_hex(&max_target_bytes)
);
let new_target = target.min(*requested_max_target);
self.nominal_hashrate = new_nominal_hashrate;
self.target = new_target;
self.requested_max_target = *requested_max_target;
Ok(())
}
pub fn get_active_job(&self) -> Option<&ExtendedJob> {
self.job_store.get_active_job()
}
pub fn get_future_job(&self, job_id: u32) -> Option<&ExtendedJob> {
self.job_store.get_future_job(job_id)
}
pub fn get_past_job(&self, job_id: u32) -> Option<&ExtendedJob> {
self.job_store.get_past_job(job_id)
}
pub fn get_share_accounting(&self) -> &ShareAccounting {
&self.share_accounting
}
pub fn on_new_template(
&mut self,
template: NewTemplateOwned,
coinbase_reward_outputs: Vec<TxOut>,
) -> Result<(), ExtendedChannelError> {
match template.future_template {
true => {
let new_job = self
.job_factory
.new_extended_job(
self.channel_id,
None,
self.extranonce_prefix.as_bytes().to_vec(),
template.clone(),
coinbase_reward_outputs,
self.get_full_extranonce_size(),
)
.map_err(ExtendedChannelError::JobFactoryError)?;
self.job_store.add_future_job(template.template_id, new_job);
}
false => {
match self.chain_tip.clone() {
None => return Err(ExtendedChannelError::ChainTipNotSet),
Some(chain_tip) => {
let new_job = self
.job_factory
.new_extended_job(
self.channel_id,
Some(chain_tip),
self.extranonce_prefix.as_bytes().to_vec(),
template.clone(),
coinbase_reward_outputs,
self.get_full_extranonce_size(),
)
.map_err(ExtendedChannelError::JobFactoryError)?;
let job_id = new_job.get_job_id();
if let Some(evicted_job_id) = self.job_store.add_active_job(new_job) {
self.job_id_to_target.remove(&evicted_job_id);
}
self.job_id_to_target.insert(job_id, self.target);
}
}
}
}
Ok(())
}
pub fn on_group_channel_job(
&mut self,
mut extended_job: ExtendedJob,
) -> Result<(), ExtendedChannelError> {
extended_job.set_extranonce_prefix(self.extranonce_prefix.as_bytes().to_vec());
let template_id = match extended_job.get_origin() {
JobOrigin::NewTemplate(template) => template.template_id,
JobOrigin::SetCustomMiningJob(_) => {
return Err(ExtendedChannelError::InvalidJobOrigin);
}
};
if extended_job.version_rolling_allowed() && !self.version_rolling_allowed {
return Err(ExtendedChannelError::GroupJobVersionRollingNotAllowed);
}
match extended_job.is_future() {
true => {
self.job_store.add_future_job(template_id, extended_job);
}
false => {
if let Some(min_ntime) = extended_job.get_min_ntime() {
if self
.chain_tip
.as_ref()
.is_some_and(|chain_tip| min_ntime < chain_tip.min_ntime())
{
return Err(ExtendedChannelError::JobMinNtimeBelowChainTip);
}
}
let job_id = extended_job.get_job_id();
if let Some(evicted_job_id) = self.job_store.add_active_job(extended_job) {
self.job_id_to_target.remove(&evicted_job_id);
}
self.job_id_to_target.insert(job_id, self.target);
}
}
Ok(())
}
pub fn on_set_new_prev_hash(
&mut self,
set_new_prev_hash: SetNewPrevHashTdp,
) -> Result<(), ExtendedChannelError> {
match self.job_store.has_future_jobs() {
false => {
self.job_store.deactivate_job();
self.job_store.mark_past_jobs_as_stale();
self.job_id_to_target.clear();
}
true => {
if !self.job_store.activate_future_job(
set_new_prev_hash.template_id,
set_new_prev_hash.header_timestamp,
) {
return Err(ExtendedChannelError::TemplateIdNotFound);
}
self.job_id_to_target.clear();
let job_id = self
.job_store
.get_active_job()
.expect("active job must exist")
.get_job_id();
self.job_id_to_target.insert(job_id, self.target);
}
}
if self
.chain_tip
.as_ref()
.is_some_and(|chain_tip| chain_tip.prev_hash() != set_new_prev_hash.prev_hash)
{
self.share_accounting.flush_seen_shares();
}
self.chain_tip = Some(set_new_prev_hash.into());
Ok(())
}
pub fn on_set_custom_mining_job(
&mut self,
set_custom_mining_job: SetCustomMiningJobOwned,
) -> Result<u32, ExtendedChannelError> {
let new_job = self
.job_factory
.new_extended_job_from_custom_job(
set_custom_mining_job.clone(),
self.extranonce_prefix.as_bytes().to_vec(),
self.get_full_extranonce_size(),
)
.map_err(ExtendedChannelError::JobFactoryError)?;
let set_custom_mining_job_static = set_custom_mining_job;
let prev_hash = set_custom_mining_job_static.prev_hash;
let nbits = set_custom_mining_job_static.nbits;
let min_ntime = set_custom_mining_job_static.min_ntime;
let new_chain_tip = ChainTip::new(prev_hash, nbits, min_ntime);
let is_new_prev_hash = self
.chain_tip
.as_ref()
.is_some_and(|chain_tip| chain_tip.prev_hash() != new_chain_tip.prev_hash());
let is_new_chain_tip = is_new_prev_hash
|| self.chain_tip.as_ref().is_some_and(|chain_tip| {
chain_tip.nbits() != new_chain_tip.nbits()
|| chain_tip.min_ntime() != new_chain_tip.min_ntime()
});
let job_id = new_job.get_job_id();
if is_new_chain_tip {
self.job_store.deactivate_job();
self.job_store.mark_past_jobs_as_stale();
self.job_id_to_target.clear();
}
if is_new_prev_hash {
self.share_accounting.flush_seen_shares();
}
if let Some(evicted_job_id) = self.job_store.add_active_job(new_job) {
self.job_id_to_target.remove(&evicted_job_id);
}
self.chain_tip = Some(new_chain_tip);
self.job_id_to_target.insert(job_id, self.target);
Ok(job_id)
}
pub fn validate_share(
&mut self,
share: SubmitSharesExtendedOwned,
) -> Result<ShareValidationResult, ShareValidationError> {
if self.share_accounting.is_seen_shares_budget_exhausted() {
return Err(ShareValidationError::SeenSharesBudgetExhausted);
}
let job_id = share.job_id;
let is_active_job = self
.job_store
.get_active_job()
.is_some_and(|job| job.get_job_id() == job_id);
let is_past_job = self.job_store.get_past_job(job_id).is_some();
let is_stale_job = self.job_store.get_stale_job(job_id).is_some();
if is_stale_job {
self.share_accounting
.increment_rejected_shares(ERROR_CODE_SUBMIT_SHARES_STALE_SHARE);
return Err(ShareValidationError::Stale(
ERROR_CODE_SUBMIT_SHARES_STALE_SHARE,
));
}
if !is_active_job && !is_past_job && !is_stale_job {
self.share_accounting
.increment_rejected_shares(ERROR_CODE_SUBMIT_SHARES_INVALID_JOB_ID);
return Err(ShareValidationError::InvalidJobId(
ERROR_CODE_SUBMIT_SHARES_INVALID_JOB_ID,
));
};
let job = if is_active_job {
self.job_store
.get_active_job()
.expect("active job must exist")
} else if is_past_job {
self.job_store
.get_past_job(job_id)
.expect("past job must exist")
} else {
self.job_store
.get_stale_job(job_id)
.expect("stale job must exist")
};
let Some(job_target) = self.job_id_to_target.get(&job_id) else {
self.share_accounting
.increment_rejected_shares(ERROR_CODE_SUBMIT_SHARES_INVALID_JOB_ID);
return Err(ShareValidationError::InvalidJobId(
ERROR_CODE_SUBMIT_SHARES_INVALID_JOB_ID,
));
};
let extranonce_size = share.extranonce.len();
if extranonce_size != self.rollable_extranonce_size as usize {
self.share_accounting
.increment_rejected_shares(ERROR_CODE_SUBMIT_SHARES_BAD_EXTRANONCE_SIZE);
return Err(ShareValidationError::BadExtranonceSize(
ERROR_CODE_SUBMIT_SHARES_BAD_EXTRANONCE_SIZE,
));
}
let extranonce_prefix = job.get_extranonce_prefix();
let mut full_extranonce = vec![];
full_extranonce.extend_from_slice(extranonce_prefix);
full_extranonce.extend(share.extranonce.as_bytes());
let merkle_root: [u8; 32] = merkle_root_from_path(
&job.get_coinbase_tx_prefix_without_bip141(),
&job.get_coinbase_tx_suffix_without_bip141(),
full_extranonce.as_ref(),
job.get_merkle_path().as_slice(),
)
.ok_or(ShareValidationError::Invalid(
ERROR_CODE_SUBMIT_SHARES_INVALID_SHARE,
))?;
let chain_tip = self
.chain_tip
.as_ref()
.ok_or(ShareValidationError::NoChainTip)?;
let prev_hash = chain_tip.prev_hash();
let nbits = CompactTarget::from_consensus(chain_tip.nbits());
let job_min_ntime = job
.get_min_ntime()
.expect("active and past jobs carry a min_ntime");
if share.ntime < job_min_ntime {
self.share_accounting
.increment_rejected_shares(ERROR_CODE_SUBMIT_SHARES_INVALID_SHARE);
return Err(ShareValidationError::Invalid(
ERROR_CODE_SUBMIT_SHARES_INVALID_SHARE,
));
}
if share.ntime > job_min_ntime.saturating_add(MAX_FUTURE_BLOCK_TIME) {
self.share_accounting
.increment_rejected_shares(ERROR_CODE_SUBMIT_SHARES_INVALID_SHARE);
return Err(ShareValidationError::Invalid(
ERROR_CODE_SUBMIT_SHARES_INVALID_SHARE,
));
}
let version_rolling_mask = if job.version_rolling_allowed() {
VERSION_ROLLING_MASK
} else {
0
};
if (share.version & !version_rolling_mask) != (job.get_version() & !version_rolling_mask) {
if job.version_rolling_allowed() {
self.share_accounting.increment_rejected_shares(
ERROR_CODE_SUBMIT_SHARES_INVALID_NON_ROLLABLE_VERSION_BIT,
);
return Err(ShareValidationError::Invalid(
ERROR_CODE_SUBMIT_SHARES_INVALID_NON_ROLLABLE_VERSION_BIT,
));
}
self.share_accounting
.increment_rejected_shares(ERROR_CODE_VERSION_ROLLING_NOT_ALLOWED);
return Err(ShareValidationError::VersionRollingNotAllowed(
ERROR_CODE_VERSION_ROLLING_NOT_ALLOWED,
));
}
let header = Header {
version: Version::from_consensus(share.version as i32),
prev_blockhash: u256_to_block_hash(prev_hash.clone()),
merkle_root: (*Hash::from_bytes_ref(&merkle_root)).into(),
time: share.ntime,
bits: nbits,
nonce: share.nonce,
};
let share_hash = header.block_hash();
let raw_share_hash: [u8; 32] = *share_hash.to_raw_hash().as_ref();
let share_hash_target = Target::from_le_bytes(raw_share_hash);
let share_hash_as_diff = share_hash_target.difficulty_float();
let network_target = Target::from_compact(nbits);
let share_hash_target_bytes = share_hash_target.to_be_bytes();
let job_target_bytes = job_target.to_be_bytes();
debug!(
"share validation \nshare:\t\t{}\njob target:\t{}\nnetwork target:\t{}",
bytes_to_hex(&share_hash_target_bytes),
bytes_to_hex(&job_target_bytes),
format!("{:x}", network_target)
);
if network_target.is_met_by(share_hash) {
if self
.share_accounting
.is_share_seen(share_hash.to_raw_hash())
{
self.share_accounting
.increment_rejected_shares(ERROR_CODE_SUBMIT_SHARES_DUPLICATE_SHARE);
return Err(ShareValidationError::DuplicateShare(
ERROR_CODE_SUBMIT_SHARES_DUPLICATE_SHARE,
));
}
self.share_accounting.update_share_accounting(
job_target.difficulty_float(),
share.sequence_number,
share_hash.to_raw_hash(),
);
self.share_accounting.increment_blocks_found();
self.share_accounting.mark_batch_acknowledged();
let mut coinbase = vec![];
coinbase.extend(job.get_coinbase_tx_prefix_with_bip141());
coinbase.extend(full_extranonce.clone());
coinbase.extend(job.get_coinbase_tx_suffix_with_bip141());
match job.get_origin() {
JobOrigin::NewTemplate(template) => {
let template_id = template.template_id;
return Ok(ShareValidationResult::BlockFound(
share_hash.to_raw_hash(),
Some(template_id),
coinbase,
));
}
JobOrigin::SetCustomMiningJob(_set_custom_mining_job) => {
return Ok(ShareValidationResult::BlockFound(
share_hash.to_raw_hash(),
None,
coinbase,
));
}
}
}
if share_hash_target <= *job_target {
if self
.share_accounting
.is_share_seen(share_hash.to_raw_hash())
{
self.share_accounting
.increment_rejected_shares(ERROR_CODE_SUBMIT_SHARES_DUPLICATE_SHARE);
return Err(ShareValidationError::DuplicateShare(
ERROR_CODE_SUBMIT_SHARES_DUPLICATE_SHARE,
));
}
self.share_accounting.update_share_accounting(
job_target.difficulty_float(),
share.sequence_number,
share_hash.to_raw_hash(),
);
self.share_accounting.update_best_diff(share_hash_as_diff);
Ok(ShareValidationResult::Valid(share_hash.to_raw_hash()))
} else {
self.share_accounting
.increment_rejected_shares(ERROR_CODE_SUBMIT_SHARES_DIFFICULTY_TOO_LOW);
Err(ShareValidationError::DoesNotMeetTarget(
ERROR_CODE_SUBMIT_SHARES_DIFFICULTY_TOO_LOW,
))
}
}
}
#[cfg(test)]
mod tests {
use crate::{
chain_tip::ChainTip,
extranonce_manager::{
AllocatedExtranoncePrefix, ExtranonceAllocator, ExtranonceAllocatorError,
ExtranoncePrefix, ExtranoncePrefixError,
},
server::{
error::ExtendedChannelError,
extended::ExtendedChannel,
jobs::{
extended::ExtendedJob,
factory::{MAX_COINBASE_PREFIX_SIZE, MAX_SCRIPT_SIG_SIZE},
job_store::{MAX_FUTURE_JOBS, MAX_PAST_JOBS},
},
share_accounting::{ShareValidationError, ShareValidationResult},
},
MAX_EXTRANONCE_LEN,
};
use binary_sv2::{Sv2OptionOwned as Sv2Option, U256Owned as U256};
use bitcoin::{transaction::TxOut, Amount, ScriptBuf, Target};
use mining_sv2::{
NewExtendedMiningJobOwned as NewExtendedMiningJob,
SetCustomMiningJobOwned as SetCustomMiningJob,
SubmitSharesExtendedOwned as SubmitSharesExtended,
ERROR_CODE_OPEN_MINING_CHANNEL_MAX_TARGET_OUT_OF_RANGE,
ERROR_CODE_SUBMIT_SHARES_DIFFICULTY_TOO_LOW,
ERROR_CODE_SUBMIT_SHARES_INVALID_NON_ROLLABLE_VERSION_BIT,
ERROR_CODE_UPDATE_CHANNEL_MAX_TARGET_OUT_OF_RANGE,
};
use std::convert::TryInto;
use template_distribution_sv2::{
NewTemplateOwned as NewTemplate, SetNewPrevHashOwned as SetNewPrevHash,
};
const SATS_AVAILABLE_IN_TEMPLATE: u64 = 5000000000;
#[test]
fn test_future_job_activation_flow() {
let channel_id = 1;
let user_identity = "user_identity".to_string();
let extranonce_prefix = [
83, 116, 114, 97, 116, 117, 109, 32, 86, 50, 32, 83, 82, 73, 32, 80, 111, 111, 108, 0,
0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let max_target = Target::from_le_bytes([0xff; 32]);
let expected_share_per_minute = 1.0;
let nominal_hashrate = 1.0;
let version_rolling_allowed = true;
let rollable_extranonce_size = 4u16;
let share_batch_size = 100;
let mut channel = ExtendedChannel::new(
channel_id,
user_identity,
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
max_target,
nominal_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
None,
None,
None,
)
.unwrap();
let template = NewTemplate {
template_id: 1,
future_template: true,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0]; script_bytes.push(20); script_bytes.extend_from_slice(&pubkey_hash);
let script = ScriptBuf::from(script_bytes);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: script,
}];
assert!(!channel.job_store.has_future_jobs());
channel
.on_new_template(template.clone(), coinbase_reward_outputs)
.unwrap();
assert!(channel.get_active_job().is_none());
let future_job_id = channel
.get_future_job_id_from_template_id(template.template_id)
.unwrap();
let future_job = channel.get_future_job(future_job_id).unwrap().clone();
let expected_job = NewExtendedMiningJob {
channel_id: 1,
job_id: 1,
min_ntime: Sv2Option::new(None),
version: 536870912,
version_rolling_allowed: true,
coinbase_tx_prefix: vec![
2, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 41, 82, 0, 6, 83, 118, 50, 47, 47,
47, 31,
]
.try_into()
.unwrap(),
coinbase_tx_suffix: vec![
255, 255, 255, 255, 2, 0, 242, 5, 42, 1, 0, 0, 0, 22, 0, 20, 235, 225, 183, 220,
194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194, 8, 252, 0, 0, 0,
0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209, 222,
253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180, 139,
235, 216, 54, 151, 78, 140, 249, 0, 0, 0, 0,
]
.try_into()
.unwrap(),
merkle_path: vec![].try_into().unwrap(),
};
assert_eq!(future_job.get_job_message(), &expected_job);
let ntime = 1746839905;
let set_new_prev_hash = SetNewPrevHash {
template_id: 1,
prev_hash: [
200, 53, 253, 129, 214, 31, 43, 84, 179, 58, 58, 76, 128, 213, 24, 53, 38, 144,
205, 88, 172, 20, 251, 22, 217, 141, 21, 221, 21, 0, 0, 0,
]
.into(),
header_timestamp: ntime,
n_bits: 503543726,
target: [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
174, 119, 3, 0, 0,
]
.into(),
};
channel.on_set_new_prev_hash(set_new_prev_hash).unwrap();
assert!(!channel.job_store.has_future_jobs());
let mut previously_future_job = future_job.clone();
previously_future_job.activate(ntime);
let activated_job = channel.get_active_job().unwrap();
assert_eq!(
activated_job.get_job_message(),
previously_future_job.get_job_message()
);
}
#[test]
fn test_non_future_job_creation_flow() {
let channel_id = 1;
let user_identity = "user_identity".to_string();
let extranonce_prefix = [
83, 116, 114, 97, 116, 117, 109, 32, 86, 50, 32, 83, 82, 73, 32, 80, 111, 111, 108, 0,
0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let max_target = Target::from_le_bytes([0xff; 32]);
let expected_share_per_minute = 1.0;
let nominal_hashrate = 1.0;
let version_rolling_allowed = true;
let rollable_extranonce_size = 4u16;
let share_batch_size = 100;
let mut channel = ExtendedChannel::new(
channel_id,
user_identity,
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
max_target,
nominal_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
None,
None,
None,
)
.unwrap();
let ntime = 1746839905;
let prev_hash = [
200, 53, 253, 129, 214, 31, 43, 84, 179, 58, 58, 76, 128, 213, 24, 53, 38, 144, 205,
88, 172, 20, 251, 22, 217, 141, 21, 221, 21, 0, 0, 0,
]
.into();
let n_bits = 503543726;
let chain_tip = ChainTip::new(prev_hash, n_bits, ntime);
channel.set_chain_tip(chain_tip);
let template = NewTemplate {
template_id: 1,
future_template: false,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0]; script_bytes.push(20); script_bytes.extend_from_slice(&pubkey_hash);
let script = ScriptBuf::from(script_bytes);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: script,
}];
channel
.on_new_template(template.clone(), coinbase_reward_outputs)
.unwrap();
assert!(!channel.job_store.has_future_jobs());
let active_job = channel.get_active_job().unwrap().clone();
let expected_job = NewExtendedMiningJob {
channel_id: 1,
job_id: 1,
min_ntime: Sv2Option::new(Some(ntime)),
version: 536870912,
version_rolling_allowed: true,
coinbase_tx_prefix: vec![
2, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 41, 82, 0, 6, 83, 118, 50, 47, 47,
47, 31,
]
.try_into()
.unwrap(),
coinbase_tx_suffix: vec![
255, 255, 255, 255, 2, 0, 242, 5, 42, 1, 0, 0, 0, 22, 0, 20, 235, 225, 183, 220,
194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194, 8, 252, 0, 0, 0,
0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209, 222,
253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180, 139,
235, 216, 54, 151, 78, 140, 249, 0, 0, 0, 0,
]
.try_into()
.unwrap(),
merkle_path: vec![].try_into().unwrap(),
};
assert_eq!(active_job.get_job_message(), &expected_job);
}
#[test]
fn test_coinbase_reward_outputs_sum_above_template_value() {
let channel_id = 1;
let user_identity = "user_identity".to_string();
let extranonce_prefix = [
83, 116, 114, 97, 116, 117, 109, 32, 86, 50, 32, 83, 82, 73, 32, 80, 111, 111, 108, 0,
0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let max_target = Target::from_le_bytes([0xff; 32]);
let expected_share_per_minute = 1.0;
let nominal_hashrate = 1.0;
let version_rolling_allowed = true;
let rollable_extranonce_size = 4u16;
let share_batch_size = 100;
let mut channel = ExtendedChannel::new(
channel_id,
user_identity,
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
max_target,
nominal_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
None,
None,
None,
)
.unwrap();
let template = NewTemplate {
template_id: 1,
future_template: true,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0]; script_bytes.push(20); script_bytes.extend_from_slice(&pubkey_hash);
let script = ScriptBuf::from(script_bytes);
let invalid_coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE + 1),
script_pubkey: script,
}];
let res = channel.on_new_template(template.clone(), invalid_coinbase_reward_outputs);
assert!(res.is_err());
assert!(!channel.job_store.has_future_jobs());
}
#[test]
fn test_share_validation_block_found() {
let channel_id = 1;
let user_identity = "user_identity".to_string();
let extranonce_prefix = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let max_target = Target::from_le_bytes([0xff; 32]);
let expected_share_per_minute = 1.0;
let nominal_hashrate = 1.0;
let version_rolling_allowed = true;
let rollable_extranonce_size = 8u16;
let share_batch_size = 100;
let mut channel = ExtendedChannel::new(
channel_id,
user_identity,
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
max_target,
nominal_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
None,
None,
None,
)
.unwrap();
let template_id = 1;
let template = NewTemplate {
template_id,
future_template: false,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0]; script_bytes.push(20); script_bytes.extend_from_slice(&pubkey_hash);
let script = ScriptBuf::from(script_bytes);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: script,
}];
let ntime = 1745596910;
let prev_hash = [
251, 175, 106, 40, 35, 87, 122, 90, 58, 51, 78, 32, 202, 236, 228, 36, 154, 174, 206,
144, 147, 195, 21, 224, 195, 103, 214, 189, 51, 190, 24, 98,
]
.into();
let n_bits = 545259519;
let chain_tip = ChainTip::new(prev_hash, n_bits, ntime);
channel.set_chain_tip(chain_tip);
channel
.on_new_template(template.clone(), coinbase_reward_outputs)
.unwrap();
let share_valid_block = SubmitSharesExtended {
channel_id,
sequence_number: 0,
job_id: 1,
nonce: 0,
ntime: 1745596971,
version: 536870912,
extranonce: vec![1, 0, 0, 0, 0, 0, 0, 0].try_into().unwrap(),
};
let res = channel.validate_share(share_valid_block.clone());
assert!(matches!(
res,
Ok(ShareValidationResult::BlockFound(_, _, _))
));
assert_eq!(channel.get_share_accounting().get_blocks_found(), 1);
let res = channel.validate_share(share_valid_block);
assert!(matches!(
res.unwrap_err(),
ShareValidationError::DuplicateShare(_)
));
assert_eq!(channel.get_share_accounting().get_blocks_found(), 1);
}
#[test]
fn test_share_validation_ntime_below_min_ntime() {
let channel_id = 1;
let user_identity = "user_identity".to_string();
let extranonce_prefix = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let max_target = Target::from_le_bytes([0xff; 32]);
let expected_share_per_minute = 1.0;
let nominal_hashrate = 1.0;
let version_rolling_allowed = true;
let rollable_extranonce_size = 8u16;
let share_batch_size = 100;
let mut channel = ExtendedChannel::new(
channel_id,
user_identity,
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
max_target,
nominal_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
None,
None,
None,
)
.unwrap();
let template_id = 1;
let template = NewTemplate {
template_id,
future_template: false,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0];
script_bytes.push(20);
script_bytes.extend_from_slice(&pubkey_hash);
let script = ScriptBuf::from(script_bytes);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: script,
}];
let prev_hash = [
251, 175, 106, 40, 35, 87, 122, 90, 58, 51, 78, 32, 202, 236, 228, 36, 154, 174, 206,
144, 147, 195, 21, 224, 195, 103, 214, 189, 51, 190, 24, 98,
]
.into();
let n_bits = 545259519;
let chain_tip = ChainTip::new(prev_hash, n_bits, 1745596972);
channel.set_chain_tip(chain_tip);
channel
.on_new_template(template.clone(), coinbase_reward_outputs)
.unwrap();
let share_below_min_ntime = SubmitSharesExtended {
channel_id,
sequence_number: 0,
job_id: 1,
nonce: 8,
ntime: 1745596971,
version: 536870912,
extranonce: vec![1, 0, 0, 0, 0, 0, 0, 0].try_into().unwrap(),
};
let res = channel.validate_share(share_below_min_ntime);
assert!(matches!(res.unwrap_err(), ShareValidationError::Invalid(_)));
assert_eq!(channel.get_share_accounting().get_blocks_found(), 0);
}
#[test]
fn test_share_validation_does_not_meet_target() {
let channel_id = 1;
let user_identity = "user_identity".to_string();
let extranonce_prefix = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let max_target = Target::from_le_bytes([0xff; 32]);
let expected_share_per_minute = 1.0;
let nominal_hashrate = 100.0; let version_rolling_allowed = true;
let rollable_extranonce_size = 8u16;
let share_batch_size = 100;
let mut channel = ExtendedChannel::new(
channel_id,
user_identity,
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
max_target,
nominal_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
None,
None,
None,
)
.unwrap();
let template_id = 1;
let template = NewTemplate {
template_id,
future_template: false,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0]; script_bytes.push(20); script_bytes.extend_from_slice(&pubkey_hash);
let script = ScriptBuf::from(script_bytes);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: script,
}];
let ntime = 1745596910;
let prev_hash = [
154, 124, 239, 231, 221, 122, 160, 173, 164, 175, 87, 33, 74, 214, 191, 107, 73, 34, 0,
162, 227, 16, 44, 40, 33, 73, 0, 0, 0, 0, 0, 0,
]
.into();
let n_bits = 453040064;
let chain_tip = ChainTip::new(prev_hash, n_bits, ntime);
channel.set_chain_tip(chain_tip);
channel
.on_new_template(template.clone(), coinbase_reward_outputs)
.unwrap();
let share_low_diff = SubmitSharesExtended {
channel_id,
sequence_number: 0,
job_id: 1,
nonce: 0,
ntime: 1745596971,
version: 536870912,
extranonce: vec![1, 0, 0, 0, 0, 0, 0, 0].try_into().unwrap(),
};
let res = channel.validate_share(share_low_diff);
assert!(matches!(
res.unwrap_err(),
ShareValidationError::DoesNotMeetTarget(_)
));
assert_eq!(
channel
.get_share_accounting()
.get_rejected_shares_error_count(ERROR_CODE_SUBMIT_SHARES_DIFFICULTY_TOO_LOW),
1
);
}
#[test]
fn test_share_validation_valid_share() {
let channel_id = 1;
let user_identity = "user_identity".to_string();
let extranonce_prefix = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let max_target = Target::from_le_bytes([0xff; 32]);
let expected_share_per_minute = 1.0;
let nominal_hashrate = 1_000.0; let version_rolling_allowed = true;
let rollable_extranonce_size = 8u16;
let share_batch_size = 100;
let mut channel = ExtendedChannel::new(
channel_id,
user_identity,
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
max_target,
nominal_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
None,
None,
None,
)
.unwrap();
let template_id = 1;
let template = NewTemplate {
template_id,
future_template: false,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0]; script_bytes.push(20); script_bytes.extend_from_slice(&pubkey_hash);
let script = ScriptBuf::from(script_bytes);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: script,
}];
let n_bits = 453040064;
let ntime = 1745611105;
let prev_hash = [
23, 205, 72, 134, 153, 86, 220, 153, 224, 28, 216, 146, 228, 120, 227, 157, 213, 99,
160, 163, 128, 59, 139, 190, 158, 62, 0, 0, 0, 0, 0, 0,
]
.into();
let chain_tip = ChainTip::new(prev_hash, n_bits, ntime);
channel.set_chain_tip(chain_tip);
channel
.on_new_template(template.clone(), coinbase_reward_outputs)
.unwrap();
let valid_share = SubmitSharesExtended {
channel_id,
sequence_number: 1,
job_id: 1,
nonce: 16647,
ntime: 1745611105,
version: 536870912,
extranonce: vec![1, 0, 0, 0, 0, 0, 0, 0].try_into().unwrap(),
};
let res = channel.validate_share(valid_share);
assert!(matches!(res, Ok(ShareValidationResult::Valid(_))));
let repeated_share = SubmitSharesExtended {
channel_id,
sequence_number: 2,
job_id: 1,
nonce: 16647,
ntime: 1745611105,
version: 536870912,
extranonce: vec![1, 0, 0, 0, 0, 0, 0, 0].try_into().unwrap(),
};
let res = channel.validate_share(repeated_share);
assert!(matches!(res, Err(ShareValidationError::DuplicateShare(_))));
}
#[test]
fn test_new_clamps_target_to_max_target() {
let channel_id = 1;
let user_identity = "user_identity".to_string();
let extranonce_prefix = [0, 0, 0, 1].to_vec();
let version_rolling_allowed = true;
let rollable_extranonce_size = 4u16;
let share_batch_size = 100;
let expected_share_per_minute = 1.0;
let very_small_hashrate = 0.1;
let not_so_permissive_max_target = Target::from_le_bytes([
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0x00,
]);
let channel = ExtendedChannel::new(
channel_id,
user_identity,
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
not_so_permissive_max_target,
very_small_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
None,
None,
None,
)
.unwrap();
assert_eq!(
channel.get_requested_max_target(),
¬_so_permissive_max_target
);
assert_eq!(channel.get_target(), ¬_so_permissive_max_target);
}
const POOL_TAG_AT_SCRIPT_SIG_BUDGET: usize = 52;
const EXTRANONCE_PREFIX_LEN_AT_SCRIPT_SIG_BUDGET: usize = 24;
const ROLLABLE_EXTRANONCE_SIZE_AT_SCRIPT_SIG_BUDGET: u16 = 8;
fn new_extended_channel_with_pool_tag(
pool_tag_len: usize,
extranonce_prefix_len: usize,
) -> Result<ExtendedChannel, ExtendedChannelError> {
ExtendedChannel::new(
1,
"user_identity".to_string(),
ExtranoncePrefix::from_wire(vec![0xab; extranonce_prefix_len]).unwrap(),
Target::from_le_bytes([0xff; 32]),
1.0,
true,
ROLLABLE_EXTRANONCE_SIZE_AT_SCRIPT_SIG_BUDGET,
100,
1.0,
Some("x".repeat(pool_tag_len)),
None,
None,
)
}
#[test]
fn test_new_rejects_oversized_script_sig() {
let channel = new_extended_channel_with_pool_tag(
POOL_TAG_AT_SCRIPT_SIG_BUDGET,
EXTRANONCE_PREFIX_LEN_AT_SCRIPT_SIG_BUDGET,
)
.unwrap();
assert_eq!(
channel
.job_factory
.script_sig_size(MAX_COINBASE_PREFIX_SIZE, channel.get_full_extranonce_size()),
MAX_SCRIPT_SIG_SIZE
);
let channel = new_extended_channel_with_pool_tag(
POOL_TAG_AT_SCRIPT_SIG_BUDGET + 1,
EXTRANONCE_PREFIX_LEN_AT_SCRIPT_SIG_BUDGET,
);
assert!(matches!(
channel.unwrap_err(),
ExtendedChannelError::ScriptSigSizeTooLarge
));
let channel = new_extended_channel_with_pool_tag(
POOL_TAG_AT_SCRIPT_SIG_BUDGET,
EXTRANONCE_PREFIX_LEN_AT_SCRIPT_SIG_BUDGET + 1,
);
assert!(matches!(
channel.unwrap_err(),
ExtendedChannelError::ScriptSigSizeTooLarge
));
}
#[test]
fn test_set_extranonce_prefix_rejects_oversized_script_sig() {
let original_prefix_len = 4;
let pool_tag_len = POOL_TAG_AT_SCRIPT_SIG_BUDGET + 1;
let largest_valid_prefix_len = EXTRANONCE_PREFIX_LEN_AT_SCRIPT_SIG_BUDGET - 1;
let mut channel =
new_extended_channel_with_pool_tag(pool_tag_len, original_prefix_len).unwrap();
let original_prefix = channel.get_extranonce_prefix().to_vec();
channel
.set_extranonce_prefix(
AllocatedExtranoncePrefix::for_test(vec![0xcd; largest_valid_prefix_len]).unwrap(),
)
.unwrap();
assert_eq!(
channel.get_extranonce_prefix().len(),
largest_valid_prefix_len
);
channel
.set_extranonce_prefix(
AllocatedExtranoncePrefix::for_test(original_prefix.clone()).unwrap(),
)
.unwrap();
let oversized_prefix = vec![0xcd; largest_valid_prefix_len + 1];
assert!(oversized_prefix.len() <= MAX_EXTRANONCE_LEN as usize);
let res = channel.set_extranonce_prefix(
AllocatedExtranoncePrefix::for_test(oversized_prefix.clone()).unwrap(),
);
assert!(matches!(
res.unwrap_err(),
ExtendedChannelError::ScriptSigSizeTooLarge
));
assert_eq!(channel.get_extranonce_prefix(), &original_prefix[..]);
let res = channel.set_upstream_extranonce_prefix(&oversized_prefix);
assert!(matches!(
res.unwrap_err(),
ExtendedChannelError::ScriptSigSizeTooLarge
));
assert_eq!(channel.get_extranonce_prefix(), &original_prefix[..]);
}
#[test]
fn test_set_extranonce_prefix_enforces_full_extranonce_size_transactionally() {
let original_prefix_len = 4;
let mut channel = new_extended_channel_with_pool_tag(0, original_prefix_len).unwrap();
let largest_valid_prefix_len =
MAX_EXTRANONCE_LEN as usize - ROLLABLE_EXTRANONCE_SIZE_AT_SCRIPT_SIG_BUDGET as usize;
let largest_valid_prefix = vec![0xcd; largest_valid_prefix_len];
channel
.set_extranonce_prefix(
AllocatedExtranoncePrefix::for_test(largest_valid_prefix.clone()).unwrap(),
)
.unwrap();
let result = channel.set_extranonce_prefix(
AllocatedExtranoncePrefix::for_test(vec![0xef; largest_valid_prefix_len + 1]).unwrap(),
);
assert!(matches!(
result,
Err(ExtendedChannelError::ExtranoncePrefixTooLarge)
));
assert_eq!(channel.get_extranonce_prefix(), &largest_valid_prefix);
}
#[test]
fn test_set_upstream_extranonce_prefix_preserves_allocation_transactionally() {
let mut allocator =
ExtranonceAllocator::from_upstream_prefix(vec![0xaa], vec![0xbb], 5, 256).unwrap();
let allocated_prefix = allocator.allocate_extended(2).unwrap();
let old_suffix = allocated_prefix.as_bytes()[1..].to_vec();
let mut channel = ExtendedChannel::new(
1,
"user_identity".to_string(),
allocated_prefix.into(),
Target::from_le_bytes([0xff; 32]),
1.0,
true,
2,
100,
1.0,
None,
None,
None,
)
.unwrap();
allocator.set_upstream_prefix(vec![0xcc, 0xdd]).unwrap();
channel
.set_upstream_extranonce_prefix(allocator.upstream_prefix())
.unwrap();
let mut expected = vec![0xcc, 0xdd];
expected.extend_from_slice(&old_suffix);
assert_eq!(channel.get_extranonce_prefix(), &expected);
assert_eq!(allocator.allocated_count(), 1);
let prefix_before_error = channel.get_extranonce_prefix().to_vec();
let result = channel.set_upstream_extranonce_prefix(&[0xee; 29]);
assert!(matches!(
result,
Err(ExtendedChannelError::ExtranoncePrefixTooLarge)
));
assert_eq!(channel.get_extranonce_prefix(), &prefix_before_error);
assert_eq!(allocator.allocated_count(), 1);
}
#[test]
fn test_update_channel() {
let channel_id = 1;
let user_identity = "user_identity".to_string();
let extranonce_prefix = [
83, 116, 114, 97, 116, 117, 109, 32, 86, 50, 32, 83, 82, 73, 32, 80, 111, 111, 108, 0,
0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let expected_share_per_minute = 1.0;
let initial_hashrate = 10.0;
let version_rolling_allowed = true;
let rollable_extranonce_size = 4u16;
let share_batch_size = 100;
let max_target = Target::from_le_bytes([0xff; 32]);
let mut channel = ExtendedChannel::new(
channel_id,
user_identity,
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
max_target,
initial_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
None,
None,
None,
)
.unwrap();
let initial_target = channel.get_target().clone();
let new_hashrate = 100.0;
channel
.update_channel(new_hashrate, Some(max_target))
.unwrap();
let new_target = channel.get_target().clone();
assert_ne!(initial_target, new_target);
assert_eq!(channel.get_nominal_hashrate(), new_hashrate);
let result = channel.update_channel(-1.0, Some(max_target));
assert!(result.is_err());
assert!(matches!(
result,
Err(ExtendedChannelError::UpdateChannelInvalidNominalHashrate(_))
));
let not_so_permissive_max_target = Target::from_le_bytes([
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0x00,
]);
let very_small_hashrate = 0.1;
let result =
channel.update_channel(very_small_hashrate, Some(not_so_permissive_max_target));
assert!(result.is_ok());
assert_eq!(channel.get_target(), ¬_so_permissive_max_target);
let sufficiently_big_hashrate = 1000.0;
let result = channel.update_channel(
sufficiently_big_hashrate,
Some(not_so_permissive_max_target),
);
assert!(result.is_ok());
}
#[test]
fn test_update_extranonce_prefix() {
let channel_id = 1;
let user_identity = "user_identity".to_string();
let extranonce_prefix = [0, 0, 0, 0, 0, 0, 0, 1].to_vec();
let max_target = Target::from_le_bytes([0xff; 32]);
let expected_share_per_minute = 1.0;
let nominal_hashrate = 1_000.0;
let version_rolling_allowed = true;
let rollable_extranonce_size = 4u16;
let share_batch_size = 100;
let mut channel = ExtendedChannel::new(
channel_id,
user_identity,
ExtranoncePrefix::from_wire(extranonce_prefix.clone()).unwrap(),
max_target,
nominal_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
None,
None,
None,
)
.unwrap();
let current_extranonce_prefix = channel.get_extranonce_prefix();
assert_eq!(current_extranonce_prefix, extranonce_prefix.as_slice());
let new_extranonce_prefix = [0, 0, 0, 0, 0, 0, 0, 0, 0, 2].to_vec();
channel
.set_extranonce_prefix(
AllocatedExtranoncePrefix::for_test(new_extranonce_prefix.clone()).unwrap(),
)
.unwrap();
let current_extranonce_prefix = channel.get_extranonce_prefix();
assert_eq!(current_extranonce_prefix, new_extranonce_prefix.as_slice());
let new_extranonce_prefix_too_large = [
83, 116, 114, 97, 116, 117, 109, 32, 86, 50, 32, 83, 82, 73, 32, 80, 111, 111, 108, 0,
0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0,
]
.to_vec();
assert!(matches!(
ExtranoncePrefix::from_wire(new_extranonce_prefix_too_large.clone()),
Err(ExtranoncePrefixError::ExceedsMaxLength)
));
}
fn extended_channel_with_rotated_extranonce_prefix(
) -> (ExtranonceAllocator, ExtendedChannel, Vec<u8>, u32) {
let total_extranonce_len = 32;
let max_channels = 2;
let min_rollable_size = 8;
let mut allocator =
ExtranonceAllocator::new(vec![], total_extranonce_len, max_channels).unwrap();
let prefix_1 = allocator.allocate_extended(min_rollable_size).unwrap();
let prefix_2 = allocator.allocate_extended(min_rollable_size).unwrap();
assert_ne!(prefix_1.as_bytes(), prefix_2.as_bytes());
assert_eq!(allocator.allocated_count(), 2);
let (mut channel, prefix_1_bytes, job_id) = extended_channel_with_live_job(prefix_1, false);
channel.set_extranonce_prefix(prefix_2).unwrap();
(allocator, channel, prefix_1_bytes, job_id)
}
fn extended_channel_with_live_job(
prefix_1: AllocatedExtranoncePrefix,
future: bool,
) -> (ExtendedChannel, Vec<u8>, u32) {
let prefix_1_bytes = prefix_1.as_bytes().to_vec();
let rollable_extranonce_size = (32 - prefix_1_bytes.len()) as u16;
let mut channel = ExtendedChannel::new_for_pool(
1,
"user_identity".to_string(),
prefix_1,
Target::from_le_bytes([0xff; 32]),
100.0,
true,
rollable_extranonce_size,
100,
1.0,
String::new(),
None,
)
.unwrap();
let ntime = 1745596910;
let prev_hash = [
154, 124, 239, 231, 221, 122, 160, 173, 164, 175, 87, 33, 74, 214, 191, 107, 73, 34, 0,
162, 227, 16, 44, 40, 33, 73, 0, 0, 0, 0, 0, 0,
]
.into();
let n_bits = 453040064;
channel.set_chain_tip(ChainTip::new(prev_hash, n_bits, ntime));
let template = NewTemplate {
template_id: 1,
future_template: future,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0];
script_bytes.push(20);
script_bytes.extend_from_slice(&pubkey_hash);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: ScriptBuf::from(script_bytes),
}];
channel
.on_new_template(template, coinbase_reward_outputs)
.unwrap();
let job_id = if future {
channel.get_future_job_id_from_template_id(1).unwrap()
} else {
channel.get_active_job().unwrap().get_job_id()
};
(channel, prefix_1_bytes, job_id)
}
#[test]
fn test_mixed_prefix_updates_reserve_slot_until_jobs_go_stale() {
for future in [false, true] {
let mut allocator =
ExtranonceAllocator::from_upstream_prefix(vec![0xaa], vec![0xbb], 32, 2).unwrap();
let prefix = allocator.allocate_extended(8).unwrap();
let (mut channel, old_bytes, job_id) = extended_channel_with_live_job(prefix, future);
allocator.set_upstream_prefix(vec![0xcc]).unwrap();
channel.set_upstream_extranonce_prefix(&[0xcc]).unwrap();
assert_eq!(allocator.allocated_count(), 1);
assert_eq!(&channel.get_extranonce_prefix()[1..], &old_bytes[1..]);
let replacement = allocator.allocate_extended(8).unwrap();
channel.set_extranonce_prefix(replacement).unwrap();
let job = if future {
channel.get_future_job(job_id).unwrap()
} else {
channel.get_active_job().unwrap()
};
assert_eq!(job.get_extranonce_prefix(), old_bytes);
assert_eq!(allocator.allocated_count(), 2);
allocator.set_upstream_prefix(vec![0xaa]).unwrap();
assert!(matches!(
allocator.allocate_extended(8),
Err(ExtranonceAllocatorError::CapacityExhausted)
));
if future {
channel
.on_set_new_prev_hash(SetNewPrevHash {
template_id: 1,
prev_hash: [2; 32].into(),
header_timestamp: 1745596970,
n_bits: 453040064,
target: [0xff; 32].into(),
})
.unwrap();
assert_eq!(
channel.get_active_job().unwrap().get_extranonce_prefix(),
old_bytes
);
assert_eq!(allocator.allocated_count(), 2);
}
channel
.on_set_new_prev_hash(SetNewPrevHash {
template_id: 999,
prev_hash: [1; 32].into(),
header_timestamp: 1745597510,
n_bits: 453040064,
target: [0xff; 32].into(),
})
.unwrap();
assert!(channel.get_active_job().is_none());
assert_eq!(allocator.allocated_count(), 1);
let reused = allocator.allocate_extended(8).unwrap();
assert_eq!(reused.as_bytes(), old_bytes);
drop(reused);
drop(channel);
assert_eq!(allocator.allocated_count(), 0);
}
}
#[test]
fn test_rotated_extranonce_prefix_slot_not_reused_while_job_live() {
let (mut allocator, channel, prefix_1_bytes, _job_id) =
extended_channel_with_rotated_extranonce_prefix();
assert_eq!(allocator.allocated_count(), 2);
assert!(matches!(
allocator.allocate_extended(8),
Err(ExtranonceAllocatorError::CapacityExhausted)
));
assert_eq!(
channel.get_active_job().unwrap().get_extranonce_prefix(),
prefix_1_bytes.as_slice()
);
}
#[test]
fn test_retired_extranonce_prefix_released_after_jobs_go_stale() {
let (mut allocator, mut channel, _prefix_1_bytes, job_id) =
extended_channel_with_rotated_extranonce_prefix();
let new_prev_hash = SetNewPrevHash {
template_id: 999,
prev_hash: [
200, 53, 253, 129, 214, 31, 43, 84, 179, 58, 58, 76, 128, 213, 24, 53, 38, 144,
205, 88, 172, 20, 251, 22, 217, 141, 21, 221, 21, 0, 0, 0,
]
.into(),
header_timestamp: 1745596910 + 600,
n_bits: 453040064,
target: [0xff; 32].into(),
};
channel.on_set_new_prev_hash(new_prev_hash).unwrap();
let late_share = SubmitSharesExtended {
channel_id: 1,
sequence_number: 0,
job_id,
nonce: 0,
ntime: 1745596971,
version: 536870912,
extranonce: vec![0; 31].try_into().unwrap(),
};
assert!(matches!(
channel.validate_share(late_share),
Err(ShareValidationError::Stale(_))
));
assert_eq!(allocator.allocated_count(), 1);
assert!(allocator.allocate_extended(8).is_ok());
}
#[test]
fn test_retired_extranonce_prefix_released_after_job_eviction() {
let (mut allocator, mut channel, _prefix_1_bytes, job_id) =
extended_channel_with_rotated_extranonce_prefix();
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0];
script_bytes.push(20);
script_bytes.extend_from_slice(&pubkey_hash);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: ScriptBuf::from(script_bytes),
}];
for template_id in 2..2 + MAX_PAST_JOBS as u64 + 2 {
let template = NewTemplate {
template_id,
future_template: false,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113,
209, 222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153,
98, 180, 139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
channel
.on_new_template(template, coinbase_reward_outputs.clone())
.unwrap();
}
assert!(channel.get_past_job(job_id).is_none());
assert_eq!(allocator.allocated_count(), 1);
assert!(allocator.allocate_extended(8).is_ok());
}
#[test]
fn test_on_group_channel_job_assigns_extranonce_prefix_to_future_job() {
let channel_id = 1;
let user_identity = "user_identity".to_string();
let channel_extranonce_prefix = vec![1, 2, 3, 4, 5, 6, 7];
let max_target = Target::from_le_bytes([0xff; 32]);
let expected_share_per_minute = 1.0;
let nominal_hashrate = 1.0;
let version_rolling_allowed = true;
let rollable_extranonce_size = 4u16;
let share_batch_size = 100;
let mut channel = ExtendedChannel::new(
channel_id,
user_identity,
ExtranoncePrefix::from_wire(channel_extranonce_prefix.clone()).unwrap(),
max_target,
nominal_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
None,
None,
None,
)
.unwrap();
let template = NewTemplate {
template_id: 1,
future_template: true,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0];
script_bytes.push(20);
script_bytes.extend_from_slice(&pubkey_hash);
let script = ScriptBuf::from(script_bytes);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: script,
}];
let group_job = ExtendedJob::from_template(
template.clone(),
vec![], coinbase_reward_outputs,
vec![],
vec![],
NewExtendedMiningJob {
channel_id,
job_id: 1,
min_ntime: Sv2Option::new(None),
version: template.version,
version_rolling_allowed,
coinbase_tx_prefix: vec![].try_into().unwrap(),
coinbase_tx_suffix: vec![].try_into().unwrap(),
merkle_path: vec![].try_into().unwrap(),
},
)
.unwrap();
assert_eq!(group_job.get_extranonce_prefix(), &vec![]);
assert!(!channel.job_store.has_future_jobs());
channel.on_group_channel_job(group_job).unwrap();
assert!(channel.job_store.has_future_jobs());
let future_job_id = channel
.get_future_job_id_from_template_id(template.template_id)
.unwrap();
let stored_job = channel.get_future_job(future_job_id).unwrap();
assert_eq!(
stored_job.get_extranonce_prefix(),
&channel_extranonce_prefix
);
}
#[test]
fn test_on_group_channel_job_assigns_extranonce_prefix_to_active_job() {
let channel_id = 1;
let user_identity = "user_identity".to_string();
let channel_extranonce_prefix = vec![10, 20, 30, 40, 50];
let max_target = Target::from_le_bytes([0xff; 32]);
let expected_share_per_minute = 1.0;
let nominal_hashrate = 1.0;
let version_rolling_allowed = true;
let rollable_extranonce_size = 4u16;
let share_batch_size = 100;
let mut channel = ExtendedChannel::new(
channel_id,
user_identity,
ExtranoncePrefix::from_wire(channel_extranonce_prefix.clone()).unwrap(),
max_target,
nominal_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
None,
None,
None,
)
.unwrap();
let template = NewTemplate {
template_id: 1,
future_template: false, version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0];
script_bytes.push(20);
script_bytes.extend_from_slice(&pubkey_hash);
let script = ScriptBuf::from(script_bytes);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: script,
}];
let ntime = 1746839905;
let group_job = ExtendedJob::from_template(
template.clone(),
vec![], coinbase_reward_outputs,
vec![],
vec![],
NewExtendedMiningJob {
channel_id,
job_id: 1,
min_ntime: Sv2Option::new(Some(ntime)),
version: template.version,
version_rolling_allowed,
coinbase_tx_prefix: vec![].try_into().unwrap(),
coinbase_tx_suffix: vec![].try_into().unwrap(),
merkle_path: vec![].try_into().unwrap(),
},
)
.unwrap();
channel.set_chain_tip(ChainTip::new(U256::from([0; 32]), 0, ntime));
assert_eq!(group_job.get_extranonce_prefix(), &vec![]);
assert!(channel.get_active_job().is_none());
channel.on_group_channel_job(group_job).unwrap();
let active_job = channel.get_active_job().unwrap();
assert_eq!(
active_job.get_extranonce_prefix(),
&channel_extranonce_prefix
);
}
#[test]
fn test_on_group_channel_job_rejects_custom_mining_job() {
let channel_id = 1;
let user_identity = "user_identity".to_string();
let extranonce_prefix = vec![1, 2, 3, 4];
let max_target = Target::from_le_bytes([0xff; 32]);
let expected_share_per_minute = 1.0;
let nominal_hashrate = 1.0;
let version_rolling_allowed = true;
let rollable_extranonce_size = 4u16;
let share_batch_size = 100;
let mut channel = ExtendedChannel::new(
channel_id,
user_identity,
ExtranoncePrefix::from_wire(extranonce_prefix.clone()).unwrap(),
max_target,
nominal_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
None,
None,
None,
)
.unwrap();
let custom_job = SetCustomMiningJob {
channel_id,
request_id: 0,
token: vec![].try_into().unwrap(),
version: 536870912,
prev_hash: [0; 32].into(),
min_ntime: 1746839905,
nbits: 503543726,
coinbase_tx_version: 2,
coinbase_prefix: vec![].try_into().unwrap(),
coinbase_tx_input_n_sequence: 4294967295,
coinbase_tx_outputs: vec![].try_into().unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let extended_job = ExtendedJob::from_custom_job(
custom_job,
vec![], vec![],
vec![],
vec![],
NewExtendedMiningJob {
channel_id,
job_id: 1,
min_ntime: Sv2Option::new(None),
version: 536870912,
version_rolling_allowed,
coinbase_tx_prefix: vec![].try_into().unwrap(),
coinbase_tx_suffix: vec![].try_into().unwrap(),
merkle_path: vec![].try_into().unwrap(),
},
);
let result = channel.on_group_channel_job(extended_job);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ExtendedChannelError::InvalidJobOrigin
));
}
#[test]
fn test_set_new_prev_hash_without_future_jobs_marks_active_as_stale() {
let channel_id = 1;
let user_identity = "user_identity".to_string();
let extranonce_prefix = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let max_target = Target::from_le_bytes([0xff; 32]);
let expected_share_per_minute = 1.0;
let nominal_hashrate = 100.0;
let version_rolling_allowed = true;
let rollable_extranonce_size = 8u16;
let share_batch_size = 100;
let mut channel = ExtendedChannel::new(
channel_id,
user_identity,
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
max_target,
nominal_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
None,
None,
None,
)
.unwrap();
let template_id = 1;
let template = NewTemplate {
template_id,
future_template: false,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0];
script_bytes.push(20);
script_bytes.extend_from_slice(&pubkey_hash);
let script = ScriptBuf::from(script_bytes);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: script,
}];
let ntime = 1745596910;
let prev_hash = [
154, 124, 239, 231, 221, 122, 160, 173, 164, 175, 87, 33, 74, 214, 191, 107, 73, 34, 0,
162, 227, 16, 44, 40, 33, 73, 0, 0, 0, 0, 0, 0,
]
.into();
let n_bits = 453040064;
let chain_tip = ChainTip::new(prev_hash, n_bits, ntime);
channel.set_chain_tip(chain_tip);
channel
.on_new_template(template.clone(), coinbase_reward_outputs)
.unwrap();
let active_job_id = channel.get_active_job().unwrap().get_job_id();
assert!(!channel.job_store.has_future_jobs());
let new_prev_hash = SetNewPrevHash {
template_id: 999,
prev_hash: [
200, 53, 253, 129, 214, 31, 43, 84, 179, 58, 58, 76, 128, 213, 24, 53, 38, 144,
205, 88, 172, 20, 251, 22, 217, 141, 21, 221, 21, 0, 0, 0,
]
.into(),
header_timestamp: ntime + 600,
n_bits,
target: [0xff; 32].into(),
};
channel.on_set_new_prev_hash(new_prev_hash).unwrap();
let late_share = SubmitSharesExtended {
channel_id,
sequence_number: 0,
job_id: active_job_id,
nonce: 0,
ntime: 1745596971,
version: 536870912,
extranonce: vec![1, 0, 0, 0, 0, 0, 0, 0].try_into().unwrap(),
};
let res = channel.validate_share(late_share);
assert!(matches!(res, Err(ShareValidationError::Stale(_))));
}
#[test]
fn test_set_custom_mining_job_chain_tip_change_marks_past_job_stale() {
let channel_id = 1;
let user_identity = "user_identity".to_string();
let extranonce_prefix = vec![1, 2, 3, 4];
let max_target = Target::from_le_bytes([0xff; 32]);
let expected_share_per_minute = 1.0;
let nominal_hashrate = 100.0;
let version_rolling_allowed = true;
let rollable_extranonce_size = 8u16;
let share_batch_size = 100;
let mut channel = ExtendedChannel::new(
channel_id,
user_identity,
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
max_target,
nominal_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
None,
None,
None,
)
.unwrap();
let first_prev_hash = [
154, 124, 239, 231, 221, 122, 160, 173, 164, 175, 87, 33, 74, 214, 191, 107, 73, 34, 0,
162, 227, 16, 44, 40, 33, 73, 0, 0, 0, 0, 0, 0,
];
let second_prev_hash = [
200, 53, 253, 129, 214, 31, 43, 84, 179, 58, 58, 76, 128, 213, 24, 53, 38, 144, 205,
88, 172, 20, 251, 22, 217, 141, 21, 221, 21, 0, 0, 0,
];
let first_job_id = channel
.on_set_custom_mining_job(custom_mining_job(
channel_id,
1,
first_prev_hash,
1745596910,
))
.unwrap();
let second_job_id = channel
.on_set_custom_mining_job(custom_mining_job(
channel_id,
2,
second_prev_hash,
1745596970,
))
.unwrap();
assert_ne!(first_job_id, second_job_id);
assert!(channel.job_store.get_stale_job(first_job_id).is_some());
let late_share = SubmitSharesExtended {
channel_id,
sequence_number: 0,
job_id: first_job_id,
nonce: 0,
ntime: 1745596930,
version: 536870912,
extranonce: vec![1, 0, 0, 0, 0, 0, 0, 0].try_into().unwrap(),
};
let res = channel.validate_share(late_share);
assert!(matches!(res, Err(ShareValidationError::Stale(_))));
}
#[test]
fn test_set_custom_mining_job_same_chain_tip_keeps_past_job() {
let channel_id = 1;
let user_identity = "user_identity".to_string();
let extranonce_prefix = vec![1, 2, 3, 4];
let max_target = Target::from_le_bytes([0xff; 32]);
let expected_share_per_minute = 1.0;
let nominal_hashrate = 100.0;
let version_rolling_allowed = true;
let rollable_extranonce_size = 8u16;
let share_batch_size = 100;
let mut channel = ExtendedChannel::new(
channel_id,
user_identity,
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
max_target,
nominal_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
None,
None,
None,
)
.unwrap();
let prev_hash = [
154, 124, 239, 231, 221, 122, 160, 173, 164, 175, 87, 33, 74, 214, 191, 107, 73, 34, 0,
162, 227, 16, 44, 40, 33, 73, 0, 0, 0, 0, 0, 0,
];
let min_ntime = 1745596910;
let first_job_id = channel
.on_set_custom_mining_job(custom_mining_job(channel_id, 1, prev_hash, min_ntime))
.unwrap();
let second_job_id = channel
.on_set_custom_mining_job(custom_mining_job(channel_id, 2, prev_hash, min_ntime))
.unwrap();
assert_ne!(first_job_id, second_job_id);
assert!(channel.job_store.get_past_job(first_job_id).is_some());
assert!(channel.job_store.get_stale_job(first_job_id).is_none());
}
#[test]
fn test_set_custom_mining_job_chain_tip_change_keeps_all_past_jobs_in_stale_set() {
let channel_id = 1;
let mut channel = ExtendedChannel::new(
channel_id,
"user_identity".to_string(),
ExtranoncePrefix::from_wire(vec![1, 2, 3, 4]).unwrap(),
Target::from_le_bytes([0xff; 32]),
100.0,
true,
8u16,
100,
1.0,
None,
None,
None,
)
.unwrap();
let first_prev_hash = [
154, 124, 239, 231, 221, 122, 160, 173, 164, 175, 87, 33, 74, 214, 191, 107, 73, 34, 0,
162, 227, 16, 44, 40, 33, 73, 0, 0, 0, 0, 0, 0,
];
let second_prev_hash = [
200, 53, 253, 129, 214, 31, 43, 84, 179, 58, 58, 76, 128, 213, 24, 53, 38, 144, 205,
88, 172, 20, 251, 22, 217, 141, 21, 221, 21, 0, 0, 0,
];
let mut same_tip_job_ids = Vec::new();
for request_id in 0..MAX_PAST_JOBS as u32 + 2 {
let job_id = channel
.on_set_custom_mining_job(custom_mining_job(
channel_id,
request_id,
first_prev_hash,
1745596910,
))
.unwrap();
same_tip_job_ids.push(job_id);
}
channel
.on_set_custom_mining_job(custom_mining_job(
channel_id,
MAX_PAST_JOBS as u32 + 2,
second_prev_hash,
1745596970,
))
.unwrap();
for job_id in same_tip_job_ids.iter().rev().take(MAX_PAST_JOBS + 1) {
assert!(channel.job_store.get_stale_job(*job_id).is_some());
}
}
#[test]
fn test_retired_extranonce_prefix_kept_through_future_job_activation() {
let total_extranonce_len = 32;
let max_channels = 2;
let min_rollable_size = 8;
let mut allocator =
ExtranonceAllocator::new(vec![], total_extranonce_len, max_channels).unwrap();
let prefix_1 = allocator.allocate_extended(min_rollable_size).unwrap();
let prefix_2 = allocator.allocate_extended(min_rollable_size).unwrap();
let prefix_1_bytes = prefix_1.as_bytes().to_vec();
let rollable_extranonce_size =
(total_extranonce_len as usize - prefix_1_bytes.len()) as u16;
let mut channel = ExtendedChannel::new_for_pool(
1,
"user_identity".to_string(),
prefix_1,
Target::from_le_bytes([0xff; 32]),
100.0,
true,
rollable_extranonce_size,
100,
1.0,
String::new(),
None,
)
.unwrap();
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0];
script_bytes.push(20);
script_bytes.extend_from_slice(&pubkey_hash);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: ScriptBuf::from(script_bytes),
}];
let template = |template_id: u64, future_template: bool| NewTemplate {
template_id,
future_template,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
channel
.on_new_template(template(100, true), coinbase_reward_outputs.clone())
.unwrap();
channel.set_extranonce_prefix(prefix_2).unwrap();
let ntime = 1745596910;
let prev_hash = [
154, 124, 239, 231, 221, 122, 160, 173, 164, 175, 87, 33, 74, 214, 191, 107, 73, 34, 0,
162, 227, 16, 44, 40, 33, 73, 0, 0, 0, 0, 0, 0,
]
.into();
channel.set_chain_tip(ChainTip::new(prev_hash, 453040064, ntime));
for template_id in 0..MAX_PAST_JOBS as u64 + 2 {
channel
.on_new_template(
template(template_id, false),
coinbase_reward_outputs.clone(),
)
.unwrap();
}
let new_prev_hash = SetNewPrevHash {
template_id: 100,
prev_hash: [
200, 53, 253, 129, 214, 31, 43, 84, 179, 58, 58, 76, 128, 213, 24, 53, 38, 144,
205, 88, 172, 20, 251, 22, 217, 141, 21, 221, 21, 0, 0, 0,
]
.into(),
header_timestamp: ntime + 600,
n_bits: 453040064,
target: [0xff; 32].into(),
};
channel.on_set_new_prev_hash(new_prev_hash).unwrap();
assert_eq!(
channel.get_active_job().unwrap().get_extranonce_prefix(),
prefix_1_bytes.as_slice()
);
assert_eq!(allocator.allocated_count(), 2);
assert!(matches!(
allocator.allocate_extended(min_rollable_size),
Err(ExtranonceAllocatorError::CapacityExhausted)
));
}
#[test]
fn test_share_validation_version_rolling_not_allowed() {
let channel_id = 1;
let user_identity = "user_identity".to_string();
let extranonce_prefix = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let max_target = Target::from_le_bytes([0xff; 32]);
let expected_share_per_minute = 1.0;
let nominal_hashrate = 1.0;
let version_rolling_allowed = false;
let rollable_extranonce_size = 8u16;
let share_batch_size = 100;
let mut channel = ExtendedChannel::new(
channel_id,
user_identity,
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
max_target,
nominal_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
None,
None,
None,
)
.unwrap();
let template_id = 1;
let template = NewTemplate {
template_id,
future_template: false,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0]; script_bytes.push(20); script_bytes.extend_from_slice(&pubkey_hash);
let script = ScriptBuf::from(script_bytes);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: script,
}];
let ntime = 1745596910;
let prev_hash = [
251, 175, 106, 40, 35, 87, 122, 90, 58, 51, 78, 32, 202, 236, 228, 36, 154, 174, 206,
144, 147, 195, 21, 224, 195, 103, 214, 189, 51, 190, 24, 98,
]
.into();
let n_bits = 545259519;
let chain_tip = ChainTip::new(prev_hash, n_bits, ntime);
channel.set_chain_tip(chain_tip);
channel
.on_new_template(template.clone(), coinbase_reward_outputs)
.unwrap();
let share_non_rollable_bit = SubmitSharesExtended {
channel_id,
sequence_number: 0,
job_id: 1,
nonce: 0,
ntime: 1745596971,
version: 536870913,
extranonce: vec![1, 0, 0, 0, 0, 0, 0, 0].try_into().unwrap(),
};
let res = channel.validate_share(share_non_rollable_bit);
assert!(matches!(
res.unwrap_err(),
ShareValidationError::VersionRollingNotAllowed(_)
));
let share_rolled_bit = SubmitSharesExtended {
channel_id,
sequence_number: 1,
job_id: 1,
nonce: 0,
ntime: 1745596971,
version: 0x20000020,
extranonce: vec![1, 0, 0, 0, 0, 0, 0, 0].try_into().unwrap(),
};
let res = channel.validate_share(share_rolled_bit);
assert!(matches!(
res.unwrap_err(),
ShareValidationError::VersionRollingNotAllowed(_)
));
}
#[test]
fn test_share_validation_invalid_non_rollable_version_bit() {
let channel_id = 1;
let user_identity = "user_identity".to_string();
let extranonce_prefix = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let max_target = Target::from_le_bytes([0xff; 32]);
let expected_share_per_minute = 1.0;
let nominal_hashrate = 1.0;
let version_rolling_allowed = true;
let rollable_extranonce_size = 8u16;
let share_batch_size = 100;
let mut channel = ExtendedChannel::new(
channel_id,
user_identity,
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
max_target,
nominal_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
None,
None,
None,
)
.unwrap();
let template_id = 1;
let template = NewTemplate {
template_id,
future_template: false,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0]; script_bytes.push(20); script_bytes.extend_from_slice(&pubkey_hash);
let script = ScriptBuf::from(script_bytes);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: script,
}];
let ntime = 1745596910;
let prev_hash = [
251, 175, 106, 40, 35, 87, 122, 90, 58, 51, 78, 32, 202, 236, 228, 36, 154, 174, 206,
144, 147, 195, 21, 224, 195, 103, 214, 189, 51, 190, 24, 98,
]
.into();
let n_bits = 545259519;
let chain_tip = ChainTip::new(prev_hash, n_bits, ntime);
channel.set_chain_tip(chain_tip);
channel
.on_new_template(template.clone(), coinbase_reward_outputs)
.unwrap();
let share = SubmitSharesExtended {
channel_id,
sequence_number: 0,
job_id: 1,
nonce: 0,
ntime: 1745596971,
version: 536870913,
extranonce: vec![1, 0, 0, 0, 0, 0, 0, 0].try_into().unwrap(),
};
let res = channel.validate_share(share);
let err = res.expect_err("share with non-rollable version bits must be rejected");
match err {
ShareValidationError::Invalid(code) => {
assert_eq!(
code,
ERROR_CODE_SUBMIT_SHARES_INVALID_NON_ROLLABLE_VERSION_BIT
);
}
other => panic!("expected ShareValidationError::Invalid, got {other:?}"),
}
}
#[test]
fn test_share_validation_rollable_version_bits() {
let channel_id = 1;
let user_identity = "user_identity".to_string();
let extranonce_prefix = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let max_target = Target::from_le_bytes([0xff; 32]);
let expected_share_per_minute = 1.0;
let nominal_hashrate = 1.0;
let version_rolling_allowed = true;
let rollable_extranonce_size = 8u16;
let share_batch_size = 100;
let mut channel = ExtendedChannel::new(
channel_id,
user_identity,
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
max_target,
nominal_hashrate,
version_rolling_allowed,
rollable_extranonce_size,
share_batch_size,
expected_share_per_minute,
None,
None,
None,
)
.unwrap();
channel.set_target(max_target).unwrap();
let template_id = 1;
let template = NewTemplate {
template_id,
future_template: false,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0]; script_bytes.push(20); script_bytes.extend_from_slice(&pubkey_hash);
let script = ScriptBuf::from(script_bytes);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: script,
}];
let ntime = 1745596910;
let prev_hash = [
251, 175, 106, 40, 35, 87, 122, 90, 58, 51, 78, 32, 202, 236, 228, 36, 154, 174, 206,
144, 147, 195, 21, 224, 195, 103, 214, 189, 51, 190, 24, 98,
]
.into();
let n_bits = 545259519;
let chain_tip = ChainTip::new(prev_hash, n_bits, ntime);
channel.set_chain_tip(chain_tip);
channel
.on_new_template(template.clone(), coinbase_reward_outputs)
.unwrap();
let rolled_version = 0x20000000 | 0x1fffe0;
let share = SubmitSharesExtended {
channel_id,
sequence_number: 0,
job_id: 1,
nonce: 0,
ntime: 1745596971,
version: rolled_version,
extranonce: vec![1, 0, 0, 0, 0, 0, 0, 0].try_into().unwrap(),
};
assert!(channel.validate_share(share).is_ok());
}
fn custom_mining_job(
channel_id: u32,
request_id: u32,
prev_hash: [u8; 32],
min_ntime: u32,
) -> SetCustomMiningJob {
SetCustomMiningJob {
channel_id,
request_id,
token: vec![request_id as u8].try_into().unwrap(),
version: 536870912,
prev_hash: prev_hash.into(),
min_ntime,
nbits: 453040064,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_n_sequence: 4294967295,
coinbase_tx_outputs: vec![
1u8, 0, 0xf2, 0x05, 0x2a, 0x01, 0, 0, 0, 22, 0, 20, 235, 225, 183, 220, 194, 147,
204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194, 8, 252,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
}
}
#[test]
fn test_future_template_storage_is_bounded() {
let channel_id = 1;
let extranonce_prefix = [
83, 116, 114, 97, 116, 117, 109, 32, 86, 50, 32, 83, 82, 73, 32, 80, 111, 111, 108, 0,
0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let mut channel = ExtendedChannel::new(
channel_id,
"user_identity".to_string(),
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
Target::from_le_bytes([0xff; 32]),
1.0,
true,
4u16,
100,
1.0,
None,
None,
None,
)
.unwrap();
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0]; script_bytes.push(20); script_bytes.extend_from_slice(&pubkey_hash);
let script = ScriptBuf::from(script_bytes);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: script,
}];
let flood_size = 10_000u64;
for template_id in 0..flood_size {
let template = NewTemplate {
template_id,
future_template: true,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113,
209, 222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153,
98, 180, 139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
channel
.on_new_template(template, coinbase_reward_outputs.clone())
.unwrap();
}
for template_id in 0..flood_size - MAX_FUTURE_JOBS as u64 {
assert!(channel
.get_future_job_id_from_template_id(template_id)
.is_none());
}
for template_id in flood_size - MAX_FUTURE_JOBS as u64..flood_size {
assert!(channel
.get_future_job_id_from_template_id(template_id)
.is_some());
}
}
fn retained_past_jobs(max_past_jobs: Option<usize>, templates: u64) -> usize {
let channel_id = 1;
let extranonce_prefix = [
83, 116, 114, 97, 116, 117, 109, 32, 86, 50, 32, 83, 82, 73, 32, 80, 111, 111, 108, 0,
0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let mut channel = ExtendedChannel::new(
channel_id,
"user_identity".to_string(),
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
Target::from_le_bytes([0xff; 32]),
1.0,
true,
4u16,
100,
1.0,
None,
None,
max_past_jobs,
)
.unwrap();
let prev_hash = [
200, 53, 253, 129, 214, 31, 43, 84, 179, 58, 58, 76, 128, 213, 24, 53, 38, 144, 205,
88, 172, 20, 251, 22, 217, 141, 21, 221, 21, 0, 0, 0,
]
.into();
channel.set_chain_tip(ChainTip::new(prev_hash, 503543726, 1747092633));
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0];
script_bytes.push(20);
script_bytes.extend_from_slice(&pubkey_hash);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: ScriptBuf::from(script_bytes),
}];
for template_id in 0..templates {
let template = NewTemplate {
template_id,
future_template: false,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113,
209, 222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153,
98, 180, 139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
channel
.on_new_template(template, coinbase_reward_outputs.clone())
.unwrap();
}
(0..=templates as u32)
.filter(|job_id| channel.get_past_job(*job_id).is_some())
.count()
}
#[test]
fn test_max_past_jobs_override_and_zero_fallback() {
let templates = MAX_PAST_JOBS as u64 + 2;
assert_eq!(retained_past_jobs(None, templates), MAX_PAST_JOBS);
assert_eq!(retained_past_jobs(Some(0), templates), MAX_PAST_JOBS);
assert_eq!(retained_past_jobs(Some(2), templates), 2);
}
#[test]
fn test_past_job_storage_is_bounded() {
let channel_id = 1;
let extranonce_prefix = [
83, 116, 114, 97, 116, 117, 109, 32, 86, 50, 32, 83, 82, 73, 32, 80, 111, 111, 108, 0,
0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let mut channel = ExtendedChannel::new(
channel_id,
"user_identity".to_string(),
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
Target::from_le_bytes([0xff; 32]),
1.0,
true,
4u16,
100,
1.0,
None,
None,
None,
)
.unwrap();
let ntime = 1747092633;
let prev_hash = [
200, 53, 253, 129, 214, 31, 43, 84, 179, 58, 58, 76, 128, 213, 24, 53, 38, 144, 205,
88, 172, 20, 251, 22, 217, 141, 21, 221, 21, 0, 0, 0,
]
.into();
let nbits = 503543726;
channel.set_chain_tip(ChainTip::new(prev_hash, nbits, ntime));
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0]; script_bytes.push(20); script_bytes.extend_from_slice(&pubkey_hash);
let script = ScriptBuf::from(script_bytes);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: script,
}];
let flood_size = 10_000u64;
for template_id in 0..flood_size {
let template = NewTemplate {
template_id,
future_template: false,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113,
209, 222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153,
98, 180, 139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
channel
.on_new_template(template, coinbase_reward_outputs.clone())
.unwrap();
}
let retained = (0..=flood_size as u32)
.filter(|job_id| channel.get_past_job(*job_id).is_some())
.count();
assert_eq!(retained, MAX_PAST_JOBS);
assert!(channel.get_active_job().is_some());
assert_eq!(channel.job_id_to_target.len(), MAX_PAST_JOBS + 1);
}
#[test]
fn test_share_validation_ntime_above_max_future_block_time() {
let channel_id = 1;
let extranonce_prefix = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let mut channel = ExtendedChannel::new(
channel_id,
"user_identity".to_string(),
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
Target::from_le_bytes([0xff; 32]),
1_000.0, true,
8u16,
100,
1.0,
None,
None,
None,
)
.unwrap();
let template = NewTemplate {
template_id: 1,
future_template: false,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0]; script_bytes.push(20); script_bytes.extend_from_slice(&pubkey_hash);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: ScriptBuf::from(script_bytes),
}];
let share_ntime: u32 = 1745611105;
let ntime = share_ntime - crate::MAX_FUTURE_BLOCK_TIME;
let n_bits = 453040064;
let prev_hash = [
23, 205, 72, 134, 153, 86, 220, 153, 224, 28, 216, 146, 228, 120, 227, 157, 213, 99,
160, 163, 128, 59, 139, 190, 158, 62, 0, 0, 0, 0, 0, 0,
]
.into();
channel.set_chain_tip(ChainTip::new(prev_hash, n_bits, ntime));
channel
.on_new_template(template, coinbase_reward_outputs)
.unwrap();
let share = |sequence_number: u32, ntime: u32| SubmitSharesExtended {
channel_id,
sequence_number,
job_id: 1,
nonce: 16647,
ntime,
version: 536870912,
extranonce: vec![1, 0, 0, 0, 0, 0, 0, 0].try_into().unwrap(),
};
let res = channel.validate_share(share(0, share_ntime + 1));
assert!(matches!(res.unwrap_err(), ShareValidationError::Invalid(_)));
let res = channel.validate_share(share(1, u32::MAX));
assert!(matches!(res.unwrap_err(), ShareValidationError::Invalid(_)));
let res = channel.validate_share(share(2, share_ntime));
assert!(matches!(res, Ok(ShareValidationResult::Valid(_))));
}
#[test]
fn test_share_validation_ntime_below_group_job_min_ntime() {
let channel_id = 1;
let extranonce_prefix = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let max_target = Target::from_le_bytes([0xff; 32]);
let mut channel = ExtendedChannel::new(
channel_id,
"user_identity".to_string(),
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
max_target,
1.0,
true,
8u16,
100,
1.0,
None,
None,
None,
)
.unwrap();
channel.set_target(max_target).unwrap();
let template = NewTemplate {
template_id: 1,
future_template: false,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0]; script_bytes.push(20); script_bytes.extend_from_slice(&pubkey_hash);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: ScriptBuf::from(script_bytes),
}];
let n_bits = 453040064;
let prev_hash: U256 = [
154, 124, 239, 231, 221, 122, 160, 173, 164, 175, 87, 33, 74, 214, 191, 107, 73, 34, 0,
162, 227, 16, 44, 40, 33, 73, 0, 0, 0, 0, 0, 0,
]
.into();
let tip_ntime = 1745596910;
channel.set_chain_tip(ChainTip::new(prev_hash.clone(), n_bits, tip_ntime));
let job_min_ntime = tip_ntime + 3;
let mut group_job_factory = crate::server::jobs::factory::JobFactory::new(true, None, None);
let group_job = group_job_factory
.new_extended_job(
99,
Some(ChainTip::new(prev_hash, n_bits, job_min_ntime)),
vec![],
template,
coinbase_reward_outputs,
channel.get_full_extranonce_size(),
)
.unwrap();
assert_eq!(group_job.get_min_ntime(), Some(job_min_ntime));
channel.on_group_channel_job(group_job).unwrap();
let job_id = channel.get_active_job().unwrap().get_job_id();
let share = |sequence_number: u32, ntime: u32| SubmitSharesExtended {
channel_id,
sequence_number,
job_id,
nonce: 0,
ntime,
version: 536870912,
extranonce: vec![1, 0, 0, 0, 0, 0, 0, 0].try_into().unwrap(),
};
let res = channel.validate_share(share(0, job_min_ntime - 1));
assert!(matches!(res.unwrap_err(), ShareValidationError::Invalid(_)));
assert_eq!(channel.get_share_accounting().get_shares_accepted(), 0);
let res =
channel.validate_share(share(2, job_min_ntime + crate::MAX_FUTURE_BLOCK_TIME + 1));
assert!(matches!(res.unwrap_err(), ShareValidationError::Invalid(_)));
let res = channel.validate_share(share(3, job_min_ntime + crate::MAX_FUTURE_BLOCK_TIME));
assert!(matches!(res, Ok(ShareValidationResult::Valid(_))));
let res = channel.validate_share(share(1, job_min_ntime));
assert!(matches!(res, Ok(ShareValidationResult::Valid(_))));
}
#[test]
fn test_min_ntime_only_custom_job_keeps_seen_shares() {
let channel_id = 1;
let max_target = Target::from_le_bytes([0xff; 32]);
let mut channel = ExtendedChannel::new(
channel_id,
"user_identity".to_string(),
ExtranoncePrefix::from_wire(vec![1, 2, 3, 4]).unwrap(),
max_target,
100.0,
true,
8u16,
100,
1.0,
None,
None,
None,
)
.unwrap();
channel.set_target(max_target).unwrap();
let prev_hash = [
154, 124, 239, 231, 221, 122, 160, 173, 164, 175, 87, 33, 74, 214, 191, 107, 73, 34, 0,
162, 227, 16, 44, 40, 33, 73, 0, 0, 0, 0, 0, 0,
];
let min_ntime = 1745596910;
let first_job_id = channel
.on_set_custom_mining_job(custom_mining_job(channel_id, 1, prev_hash, min_ntime))
.unwrap();
let share = |sequence_number: u32, job_id: u32| SubmitSharesExtended {
channel_id,
sequence_number,
job_id,
nonce: 0,
ntime: min_ntime + 1,
version: 536870912,
extranonce: vec![0; 8].try_into().unwrap(),
};
assert!(matches!(
channel.validate_share(share(0, first_job_id)),
Ok(ShareValidationResult::Valid(_))
));
let second_job_id = channel
.on_set_custom_mining_job(custom_mining_job(channel_id, 2, prev_hash, min_ntime + 1))
.unwrap();
assert_ne!(first_job_id, second_job_id);
assert!(channel.job_store.get_stale_job(first_job_id).is_some());
assert!(matches!(
channel.validate_share(share(1, second_job_id)),
Err(ShareValidationError::DuplicateShare(_))
));
assert_eq!(channel.get_share_accounting().get_shares_accepted(), 1);
}
#[test]
fn test_zero_max_target_is_rejected() {
let res = ExtendedChannel::new(
1,
"user_identity".to_string(),
ExtranoncePrefix::from_wire(vec![1, 2, 3, 4]).unwrap(),
Target::ZERO,
1.0,
true,
8u16,
100,
1.0,
None,
None,
None,
);
match res {
Err(ExtendedChannelError::OpenChannelInvalidMaxTarget(code)) => {
assert_eq!(code, ERROR_CODE_OPEN_MINING_CHANNEL_MAX_TARGET_OUT_OF_RANGE);
}
other => panic!("expected OpenChannelInvalidMaxTarget, got {other:?}"),
}
let max_target = Target::from_le_bytes([0xff; 32]);
let nominal_hashrate = 1.0;
let mut channel = ExtendedChannel::new(
1,
"user_identity".to_string(),
ExtranoncePrefix::from_wire(vec![1, 2, 3, 4]).unwrap(),
max_target,
nominal_hashrate,
true,
8u16,
100,
1.0,
None,
None,
None,
)
.unwrap();
let target_before = *channel.get_target();
let res = channel.update_channel(100.0, Some(Target::ZERO));
match res {
Err(ExtendedChannelError::UpdateChannelInvalidMaxTarget(code)) => {
assert_eq!(code, ERROR_CODE_UPDATE_CHANNEL_MAX_TARGET_OUT_OF_RANGE);
}
other => panic!("expected UpdateChannelInvalidMaxTarget, got {other:?}"),
}
assert!(matches!(
channel.set_target(Target::ZERO),
Err(ExtendedChannelError::InvalidTarget)
));
assert_eq!(channel.get_target(), &target_before);
assert_eq!(channel.get_requested_max_target(), &max_target);
assert_eq!(channel.get_nominal_hashrate(), nominal_hashrate);
}
#[test]
fn test_on_group_channel_job_rejects_min_ntime_below_chain_tip() {
let channel_id = 1;
let mut channel = ExtendedChannel::new(
channel_id,
"user_identity".to_string(),
ExtranoncePrefix::from_wire(vec![
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
])
.unwrap(),
Target::from_le_bytes([0xff; 32]),
1.0,
true,
8u16,
100,
1.0,
None,
None,
None,
)
.unwrap();
let template = NewTemplate {
template_id: 1,
future_template: false,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0]; script_bytes.push(20); script_bytes.extend_from_slice(&pubkey_hash);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: ScriptBuf::from(script_bytes),
}];
let n_bits = 453040064;
let prev_hash: U256 = [
154, 124, 239, 231, 221, 122, 160, 173, 164, 175, 87, 33, 74, 214, 191, 107, 73, 34, 0,
162, 227, 16, 44, 40, 33, 73, 0, 0, 0, 0, 0, 0,
]
.into();
let tip_ntime = 1745596910;
channel.set_chain_tip(ChainTip::new(prev_hash.clone(), n_bits, tip_ntime));
let full_extranonce_size = channel.get_full_extranonce_size();
let mut group_job_factory = crate::server::jobs::factory::JobFactory::new(true, None, None);
let mut group_job = |min_ntime: u32| {
group_job_factory
.new_extended_job(
99,
Some(ChainTip::new(prev_hash.clone(), n_bits, min_ntime)),
vec![],
template.clone(),
coinbase_reward_outputs.clone(),
full_extranonce_size,
)
.unwrap()
};
assert!(matches!(
channel.on_group_channel_job(group_job(tip_ntime - 1)),
Err(ExtendedChannelError::JobMinNtimeBelowChainTip)
));
assert!(channel.get_active_job().is_none());
channel.on_group_channel_job(group_job(tip_ntime)).unwrap();
assert!(channel.get_active_job().is_some());
}
#[test]
fn test_group_job_reusing_a_stale_job_id_replaces_the_stale_job() {
let channel_id = 1;
let extranonce_prefix = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let max_target = Target::from_le_bytes([0xff; 32]);
let mut channel = ExtendedChannel::new(
channel_id,
"user_identity".to_string(),
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
max_target,
1.0,
true,
8u16,
100,
1.0,
None,
None,
None,
)
.unwrap();
channel.set_target(max_target).unwrap();
let template = NewTemplate {
template_id: 1,
future_template: false,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0]; script_bytes.push(20); script_bytes.extend_from_slice(&pubkey_hash);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: ScriptBuf::from(script_bytes),
}];
let n_bits = 453040064;
let tip_ntime = 1745596910;
let prev_hash: U256 = [
154, 124, 239, 231, 221, 122, 160, 173, 164, 175, 87, 33, 74, 214, 191, 107, 73, 34, 0,
162, 227, 16, 44, 40, 33, 73, 0, 0, 0, 0, 0, 0,
]
.into();
channel.set_chain_tip(ChainTip::new(prev_hash, n_bits, tip_ntime));
channel
.on_new_template(template.clone(), coinbase_reward_outputs.clone())
.unwrap();
assert_eq!(channel.get_active_job().unwrap().get_job_id(), 1);
let next_prev_hash: U256 = [
200, 53, 253, 129, 214, 31, 43, 84, 179, 58, 58, 76, 128, 213, 24, 53, 38, 144, 205,
88, 172, 20, 251, 22, 217, 141, 21, 221, 21, 0, 0, 0,
]
.into();
let next_tip_ntime = tip_ntime + 600;
channel
.on_set_new_prev_hash(SetNewPrevHash {
template_id: 999,
prev_hash: next_prev_hash.clone(),
header_timestamp: next_tip_ntime,
n_bits,
target: [0xff; 32].into(),
})
.unwrap();
let share = |sequence_number: u32| SubmitSharesExtended {
channel_id,
sequence_number,
job_id: 1,
nonce: 0,
ntime: next_tip_ntime,
version: 536870912,
extranonce: vec![1, 0, 0, 0, 0, 0, 0, 0].try_into().unwrap(),
};
assert!(matches!(
channel.validate_share(share(0)),
Err(ShareValidationError::Stale(_))
));
let mut group_job_factory = crate::server::jobs::factory::JobFactory::new(true, None, None);
let group_job = group_job_factory
.new_extended_job(
99,
Some(ChainTip::new(next_prev_hash, n_bits, next_tip_ntime)),
vec![],
template,
coinbase_reward_outputs,
channel.get_full_extranonce_size(),
)
.unwrap();
assert_eq!(group_job.get_job_id(), 1);
channel.on_group_channel_job(group_job).unwrap();
assert_eq!(channel.get_active_job().unwrap().get_job_id(), 1);
assert!(matches!(
channel.validate_share(share(1)),
Ok(ShareValidationResult::Valid(_))
));
}
#[test]
fn test_group_job_looser_than_the_channel_version_rolling_policy_is_rejected() {
let channel_id = 1;
let extranonce_prefix = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let max_target = Target::from_le_bytes([0xff; 32]);
let mut channel = ExtendedChannel::new(
channel_id,
"user_identity".to_string(),
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
max_target,
1.0,
false,
8u16,
100,
1.0,
None,
None,
None,
)
.unwrap();
let template = |template_id: u64, future_template: bool| NewTemplate {
template_id,
future_template,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0]; script_bytes.push(20); script_bytes.extend_from_slice(&pubkey_hash);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: ScriptBuf::from(script_bytes),
}];
let n_bits = 453040064;
let prev_hash: U256 = [
154, 124, 239, 231, 221, 122, 160, 173, 164, 175, 87, 33, 74, 214, 191, 107, 73, 34, 0,
162, 227, 16, 44, 40, 33, 73, 0, 0, 0, 0, 0, 0,
]
.into();
let tip_ntime = 1745596910;
channel.set_chain_tip(ChainTip::new(prev_hash.clone(), n_bits, tip_ntime));
let mut looser_group_factory =
crate::server::jobs::factory::JobFactory::new(true, None, None);
let active_job = looser_group_factory
.new_extended_job(
99,
Some(ChainTip::new(prev_hash.clone(), n_bits, tip_ntime)),
vec![],
template(1, false),
coinbase_reward_outputs.clone(),
channel.get_full_extranonce_size(),
)
.unwrap();
assert!(active_job.version_rolling_allowed());
assert!(matches!(
channel.on_group_channel_job(active_job),
Err(ExtendedChannelError::GroupJobVersionRollingNotAllowed)
));
assert!(channel.get_active_job().is_none());
let future_job = looser_group_factory
.new_extended_job(
99,
None,
vec![],
template(2, true),
coinbase_reward_outputs.clone(),
channel.get_full_extranonce_size(),
)
.unwrap();
assert!(matches!(
channel.on_group_channel_job(future_job),
Err(ExtendedChannelError::GroupJobVersionRollingNotAllowed)
));
assert!(channel.get_future_job_id_from_template_id(2).is_none());
let mut group_factory = crate::server::jobs::factory::JobFactory::new(false, None, None);
let active_job = group_factory
.new_extended_job(
99,
Some(ChainTip::new(prev_hash, n_bits, tip_ntime)),
vec![],
template(3, false),
coinbase_reward_outputs,
channel.get_full_extranonce_size(),
)
.unwrap();
channel.on_group_channel_job(active_job).unwrap();
assert!(!channel.get_active_job().unwrap().version_rolling_allowed());
}
#[test]
fn test_repeated_prev_hash_keeps_seen_shares() {
let channel_id = 1;
let extranonce_prefix = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let max_target = Target::from_le_bytes([0xff; 32]);
let mut channel = ExtendedChannel::new(
channel_id,
"user_identity".to_string(),
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
max_target,
1.0,
true,
8u16,
100,
1.0,
None,
None,
None,
)
.unwrap();
channel.set_target(max_target).unwrap();
let template = |template_id: u64| NewTemplate {
template_id,
future_template: true,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0]; script_bytes.push(20); script_bytes.extend_from_slice(&pubkey_hash);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: ScriptBuf::from(script_bytes),
}];
let prev_hash: U256 = [
154, 124, 239, 231, 221, 122, 160, 173, 164, 175, 87, 33, 74, 214, 191, 107, 73, 34, 0,
162, 227, 16, 44, 40, 33, 73, 0, 0, 0, 0, 0, 0,
]
.into();
let set_new_prev_hash = |template_id: u64| SetNewPrevHash {
template_id,
prev_hash: prev_hash.clone(),
header_timestamp: 1745596910,
n_bits: 453040064,
target: [0xff; 32].into(),
};
channel
.on_new_template(template(1), coinbase_reward_outputs.clone())
.unwrap();
channel.on_set_new_prev_hash(set_new_prev_hash(1)).unwrap();
let first_job_id = channel.get_active_job().unwrap().get_job_id();
let share = |sequence_number: u32, job_id: u32| SubmitSharesExtended {
channel_id,
sequence_number,
job_id,
nonce: 0,
ntime: 1745596910,
version: 536870912,
extranonce: vec![1, 0, 0, 0, 0, 0, 0, 0].try_into().unwrap(),
};
assert!(matches!(
channel.validate_share(share(0, first_job_id)),
Ok(ShareValidationResult::Valid(_))
));
channel
.on_new_template(template(2), coinbase_reward_outputs)
.unwrap();
channel.on_set_new_prev_hash(set_new_prev_hash(2)).unwrap();
let second_job_id = channel.get_active_job().unwrap().get_job_id();
assert_ne!(first_job_id, second_job_id);
assert!(matches!(
channel.validate_share(share(1, second_job_id)),
Err(ShareValidationError::DuplicateShare(_))
));
assert_eq!(channel.get_share_accounting().get_shares_accepted(), 1);
}
#[test]
fn test_reused_job_id_evicted_from_past_jobs_keeps_the_active_job_target() {
let channel_id = 1;
let extranonce_prefix = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
]
.to_vec();
let max_target = Target::from_le_bytes([0xff; 32]);
let mut channel = ExtendedChannel::new(
channel_id,
"user_identity".to_string(),
ExtranoncePrefix::from_wire(extranonce_prefix).unwrap(),
max_target,
1.0,
true,
8u16,
100,
1.0,
None,
None,
Some(2),
)
.unwrap();
channel.set_target(max_target).unwrap();
let template = |template_id: u64| NewTemplate {
template_id,
future_template: false,
version: 536870912,
coinbase_tx_version: 2,
coinbase_prefix: vec![82, 0].try_into().unwrap(),
coinbase_tx_input_sequence: 4294967295,
coinbase_tx_value_remaining: SATS_AVAILABLE_IN_TEMPLATE,
coinbase_tx_outputs_count: 1,
coinbase_tx_outputs: vec![
0, 0, 0, 0, 0, 0, 0, 0, 38, 106, 36, 170, 33, 169, 237, 226, 246, 28, 63, 113, 209,
222, 253, 63, 169, 153, 223, 163, 105, 83, 117, 92, 105, 6, 137, 121, 153, 98, 180,
139, 235, 216, 54, 151, 78, 140, 249,
]
.try_into()
.unwrap(),
coinbase_tx_locktime: 0,
merkle_path: vec![].try_into().unwrap(),
};
let pubkey_hash = [
235, 225, 183, 220, 194, 147, 204, 170, 14, 231, 67, 168, 111, 137, 223, 130, 88, 194,
8, 252,
];
let mut script_bytes = vec![0]; script_bytes.push(20); script_bytes.extend_from_slice(&pubkey_hash);
let coinbase_reward_outputs = vec![TxOut {
value: Amount::from_sat(SATS_AVAILABLE_IN_TEMPLATE),
script_pubkey: ScriptBuf::from(script_bytes),
}];
let n_bits = 453040064;
let tip_ntime = 1745596910;
let prev_hash: U256 = [
154, 124, 239, 231, 221, 122, 160, 173, 164, 175, 87, 33, 74, 214, 191, 107, 73, 34, 0,
162, 227, 16, 44, 40, 33, 73, 0, 0, 0, 0, 0, 0,
]
.into();
channel.set_chain_tip(ChainTip::new(prev_hash.clone(), n_bits, tip_ntime));
let mut group_job_factory = crate::server::jobs::factory::JobFactory::new(true, None, None);
for template_id in 1..=3 {
let group_job = group_job_factory
.new_extended_job(
99,
Some(ChainTip::new(prev_hash.clone(), n_bits, tip_ntime)),
vec![],
template(template_id),
coinbase_reward_outputs.clone(),
channel.get_full_extranonce_size(),
)
.unwrap();
channel.on_group_channel_job(group_job).unwrap();
}
assert_eq!(channel.get_active_job().unwrap().get_job_id(), 3);
channel
.on_new_template(template(4), coinbase_reward_outputs)
.unwrap();
assert_eq!(channel.get_active_job().unwrap().get_job_id(), 1);
let share = SubmitSharesExtended {
channel_id,
sequence_number: 0,
job_id: 1,
nonce: 0,
ntime: tip_ntime,
version: 536870912,
extranonce: vec![1, 0, 0, 0, 0, 0, 0, 0].try_into().unwrap(),
};
assert!(matches!(
channel.validate_share(share),
Ok(ShareValidationResult::Valid(_))
));
}
}