channels_sv2 6.0.0

Sv2 Channel Primitives
Documentation
//! Sv2 Group Channel - Mining Client Abstraction.
//!
//! This module provides the [`GroupChannel`] struct, which acts as a mining client's
//! abstraction over the state of a Sv2 group channel. It tracks group-level job state
//! and associated standard and extended channels, but delegates share validation and job lifecycle
//! to the channels themselves.

use super::{HashMap, HashSet};
use crate::client::error::GroupChannelError;
use mining_sv2::{NewExtendedMiningJob, SetNewPrevHash as SetNewPrevHashMp};

/// Mining Client abstraction over the state of an Sv2 Group Channel.
///
/// Tracks:
/// - the group channel's unique `group_channel_id`
/// - associated `channel_ids` (indexed by `channel_id`)
/// - future jobs (indexed by `job_id`, to be activated upon receipt of a
///   [`SetNewPrevHash`](SetNewPrevHashMp) message)
/// - active job
///
/// Does **not** track:
/// - past or stale jobs
/// - share validation state (handled per-channel)
#[derive(Debug, Clone)]
pub struct GroupChannel<'a> {
    /// Unique identifier for the group channel
    group_channel_id: u32,
    /// Set of channel IDs associated with this group channel
    channel_ids: HashSet<u32>,
    /// Future jobs, indexed by job_id, waiting to be activated
    future_jobs: HashMap<u32, NewExtendedMiningJob<'a>>,
    /// Currently active mining job for the group channel
    active_job: Option<NewExtendedMiningJob<'a>>,
    /// Full extranonce size for jobs associated with this group channel.
    /// The constructor initializes this as None, but as new channels are added, we keep this updated.
    /// At no point in time, two channels can belong to the same group while having different full extranonce sizes.
    full_extranonce_size: Option<usize>,
}

impl<'a> GroupChannel<'a> {
    /// Creates a new [`GroupChannel`] with the given group_channel_id.
    pub fn new(group_channel_id: u32) -> Self {
        Self {
            group_channel_id,
            channel_ids: HashSet::new(),
            future_jobs: HashMap::new(),
            active_job: None,
            full_extranonce_size: None,
        }
    }

    /// Adds a channel to the group by its `channel_id` with the specified `full_extranonce_size`.
    /// For extended channels, the `full_extranonce_size` is the sum of its `extranonce_prefix` size and its `rollable_extranonce_size`.
    /// For standard channels, the `full_extranonce_size` is the size of its `extranonce_prefix`.
    ///
    /// If this is the first channel ever added to the group, sets the group's `full_extranonce_size`.
    /// If other channels already exist, validates that the `full_extranonce_size` matches.
    ///
    /// Returns an error if the provided `full_extranonce_size` doesn't match the existing value.
    pub fn add_channel_id(
        &mut self,
        channel_id: u32,
        full_extranonce_size: usize,
    ) -> Result<(), GroupChannelError> {
        self.channel_ids.insert(channel_id);

        match self.full_extranonce_size {
            // if the full extranonce size is already set, check if it matches the new full extranonce size
            Some(existing_size) => {
                if existing_size != full_extranonce_size {
                    return Err(GroupChannelError::FullExtranonceSizeMismatch);
                }
            }
            // if the full extranonce size is not yet set, set it
            None => {
                self.full_extranonce_size = Some(full_extranonce_size);
            }
        }

        Ok(())
    }

    /// Removes a channel from the group channel
    /// channel by its `channel_id`.
    pub fn remove_channel_id(&mut self, channel_id: u32) {
        self.channel_ids.remove(&channel_id);
    }

    /// Returns the group channel ID.
    pub fn get_group_channel_id(&self) -> u32 {
        self.group_channel_id
    }

    /// Returns an iterator over all channel IDs associated with this group channel.
    pub fn get_channel_ids(&self) -> impl Iterator<Item = &u32> + '_ {
        self.channel_ids.iter()
    }

    /// Returns the number of channel IDs associated with this group channel.
    pub fn get_channel_ids_count(&self) -> usize {
        self.channel_ids.len()
    }

    /// Returns `true` if this group channel has no channel IDs associated with it.
    pub fn is_empty(&self) -> bool {
        self.channel_ids.is_empty()
    }

    /// Returns `true` if this group channel contains `channel_id`.
    pub fn has_channel_id(&self, channel_id: u32) -> bool {
        self.channel_ids.contains(&channel_id)
    }

    /// Returns a reference to the current active job, if any.
    pub fn get_active_job(&self) -> Option<&NewExtendedMiningJob<'a>> {
        self.active_job.as_ref()
    }

    /// Returns an iterator over all future jobs, keyed by `job_id`.
    pub fn get_future_jobs(&self) -> impl Iterator<Item = (&u32, &NewExtendedMiningJob<'a>)> + '_ {
        self.future_jobs.iter()
    }

    /// Returns a reference to a future job by `job_id`, if present.
    pub fn get_future_job(&self, job_id: u32) -> Option<&NewExtendedMiningJob<'a>> {
        self.future_jobs.get(&job_id)
    }

    /// Returns the number of future jobs.
    pub fn get_future_jobs_count(&self) -> usize {
        self.future_jobs.len()
    }

    /// Returns the full extranonce size for jobs associated with this group channel.
    pub fn get_full_extranonce_size(&self) -> Option<usize> {
        self.full_extranonce_size
    }

    /// Handles a newly received [`NewExtendedMiningJob`] message from upstream.
    ///
    /// - If `min_ntime` is present, sets this job as active.
    /// - If `min_ntime` is empty, stores it as a future job.
    pub fn on_new_extended_mining_job(
        &mut self,
        new_extended_mining_job: NewExtendedMiningJob<'a>,
    ) {
        match new_extended_mining_job.min_ntime.clone().into_inner() {
            Some(_min_ntime) => {
                self.active_job = Some(new_extended_mining_job);
            }
            None => {
                self.future_jobs
                    .insert(new_extended_mining_job.job_id, new_extended_mining_job);
            }
        }
    }

    /// Handles an upstream [`SetNewPrevHash`](SetNewPrevHashMp) message.
    ///
    /// Activates the future job matching `job_id` from the message, making it the active job.
    /// Clears all other future jobs.
    ///
    /// Returns `Err(GroupChannelError::JobIdNotFound)` if no matching job found.
    pub fn on_set_new_prev_hash(
        &mut self,
        set_new_prev_hash: SetNewPrevHashMp<'a>,
    ) -> Result<(), GroupChannelError> {
        match self.future_jobs.remove(&set_new_prev_hash.job_id) {
            Some(job) => {
                self.active_job = Some(job);
            }
            None => return Err(GroupChannelError::JobIdNotFound),
        }

        // all other future jobs are now useless
        self.future_jobs.clear();
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_add_channel_id() {
        let mut group_channel = GroupChannel::new(1);
        group_channel.add_channel_id(1, 10).unwrap();
        assert_eq!(group_channel.get_full_extranonce_size(), Some(10));

        // add a second channel with the same full extranonce size
        group_channel.add_channel_id(2, 10).unwrap();
        assert_eq!(group_channel.get_full_extranonce_size(), Some(10));

        // add a third channel with a different full extranonce size
        // this should return an error
        assert!(group_channel.add_channel_id(3, 12).is_err());
    }
}