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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions sv2/channels-sv2/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ tracing = { version = "0.1"}
bitcoin = { version = "0.32.5" }
primitive-types = "0.13.1"
hashbrown = { version = "0.15.5", optional = true }
bitvec = "1.0.1"


[features]
Expand Down
232 changes: 232 additions & 0 deletions sv2/channels-sv2/src/extranonce_manager.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
use bitvec::prelude::*;

/// Represents a unique extranonce prefix for a client connection.
#[derive(Eq, Hash, PartialEq, Clone, Debug)]
pub struct ExtranoncePrefix {
bit_index: u16,
id: [u8; 4],
Copy link
Collaborator Author

@coleFD coleFD Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

id is hardcoded to 4 bytes because it gives 2 bytes (65,536 connections) which is more than enough, and another 2 bytes for the server_id. This could be changed to support 3 or 4 as well but not sure if it's worth the extra byte. I dont see a reason to support more than 4 bytes

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it gives 2 bytes (65,536 connections)

are we assuming that each connection will only consume one single extranonce_prefix? what if the same connection has multiple channels?

also, what's the rationale behind the number 65,536?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rather than connections, i should say channels or "unique ids".

65,536 is a u16::max (2 bytes). It's fairly easy to make this size configurable to whatever size the user wants with generics.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But an added benefit of restricting the total extranonce-prefix to 4 bytes is that it could be used as a channel id as well

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coleFD what do you think about using 1 byte for the server_id (which would support 256 different servers), and 3 bytes for the extranonce_prefix(es) (which would support up to 16.777.216 different channels)?

Copy link
Collaborator Author

@coleFD coleFD Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that works too. You could allow this to be "configurable" with generics. Or maybe the optimal is somewhere in between like 12 bits for server ID (4096 servers) and 20 bits (1.05M) for the channel component. I think it's just preference. One thing to note is that as the channel component increases, finding a free bit gets slower since it's O(n). at 2 bytes, i benchmarked somewhere around 1-2 micros for finding available bits which is fine considering this is only called once per channel at origination. There may be a better way to start at the last freed bit index as opposed to .skip_while() though. Either way, performance should be considered because it is not uncommon to drop/connect a bunch of miners as fast as possible (ie. curtailment and non-agg proxies) and the connection time across thousands of clients add up.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another thing to note which may point out the benefit to extra serverID bits is it can be used to distinguish between clusters in different regions, like 1 byte serverID + 1 byte cluster ID + 2 bytes channel component. In this case the api can recognize this as 2 bytes server ID + 2 bytes channel component where it is up to the user to ensure the server ID is unique with the cluster ID. This is the route we are going

}
impl PartialOrd for ExtranoncePrefix {
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ExtranoncePrefix {
fn cmp(&self, other: &Self) -> core::cmp::Ordering {
self.bit_index.cmp(&other.bit_index)
}
}

/// Extranonce prefixes are generated using a bit vector where each prefix represents a bit
/// in the vector. The prefix is then composed of the index of the assigned bit
/// and the server ID. When a channel ends, the bit is cleared, allowing it to be reused.
///
/// # Generic:
/// `NUM_BIT_CONTAINERS`: The number of u64 containers allocated for generating unique extranonce prefixes (ex. `ExtranoncePrefixManager<625>` results in `40k` bits for generating extranonce prefixes)
/// The extranonce prefix component of the extranonce1 is capped at 2 bytes, so the maximum useful value for NUM_BIT_CONTAINERS is 1024 (65536 bits).
pub struct ExtranoncePrefixManager<const NUM_BIT_CONTAINERS: usize> {
server_id: Option<u8>,
prefix_bits: BitVec<u64>,
last_freed_bit_index: Option<usize>,
last_allocated_bit_index: Option<usize>,
}

impl<const NUM_BIT_CONTAINERS: usize> ExtranoncePrefixManager<NUM_BIT_CONTAINERS> {
/// NOTE: we are using u64 as the underlying type for the bit vector for performance reasons (~90% faster than u8).
/// The performance is most likely attributed to less underlying container loads. To calculate the total
/// available bits, we take the number of containers and multiply by 64 (bits in a u64).
pub const AVAILABLE_BITS: usize = NUM_BIT_CONTAINERS * 64;
pub fn new(server_id: Option<u8>) -> Self {
assert!(
Self::AVAILABLE_BITS - 1 <= u16::MAX as usize,
"Cannot allocate more than 65536 bits for extranonce prefix"
);
let prefix_bits = bitvec!(u64, Lsb0; 0; Self::AVAILABLE_BITS);
Self {
server_id,
prefix_bits,
last_freed_bit_index: None,
last_allocated_bit_index: None,
}
}

/// this is slow, so we should only use for testing
/// "full" is when there is only one zero bit left in the prefix_bits bit vector
/// (this is for optimization purposes)
#[cfg(test)]
fn is_full(&self) -> bool {
let zero_count = self.prefix_bits.count_ones();
Self::AVAILABLE_BITS - zero_count <= 1
}

pub fn generate_extranonce_prefix(&mut self) -> Option<ExtranoncePrefix> {
// start at the last_allocated_index because it's more likely to have free slots after it that have not just been freed
let start_index = self
.last_allocated_bit_index
.map_or(0, |idx| (idx + 1) % self.prefix_bits.len());
// create an open slot iterator that starts from the last allocated bit index and wraps around
// PERFORMANCE NOTE: `.iter_zeros()` is ~12x more performant that iterating over all bits
let iter_zeros = self.prefix_bits.iter_zeros();
let first_set = iter_zeros.skip_while(|bit_index| *bit_index < start_index); // this only clones the iterator state, not the underlying vector
let second_set = iter_zeros.take_while(|bit_index| *bit_index < start_index);
let shifted_zeros_bit_iter = first_set.chain(second_set);

// search for the first zero bit, skipping the last freed bit index if necessary
let mut bit_index = None;
for zero_bit_index in shifted_zeros_bit_iter {
// this is only a sanity check to prevent reusing the last freed bit index,
// since we start searching from the last set bit index
if let Some(last_freed_bit_index) = self.last_freed_bit_index {
if zero_bit_index == last_freed_bit_index {
continue;
}
}
bit_index = Some(zero_bit_index);
break;
}

// update state if we found a zero bit, else let the caller know to drop the attempted connection
let client_bit_index = if let Some(index) = bit_index {
self.prefix_bits.set(index, true);
self.last_allocated_bit_index = Some(index);
index as u16
} else {
return None;
};

// convert bits to ExtranoncePrefix
let mut extranonce_prefix_bytes = [0u8; 4];
extranonce_prefix_bytes[0..2].copy_from_slice(&(client_bit_index).to_le_bytes());
let server_id_bytes = match self.server_id {
Some(id) => (id as u16).to_be_bytes(),
None => 0u16.to_be_bytes(),
};
extranonce_prefix_bytes[2..4].copy_from_slice(&server_id_bytes);
Some(ExtranoncePrefix {
bit_index: client_bit_index,
id: extranonce_prefix_bytes,
})
}

pub fn free_extranonce_prefix(&mut self, extranonce_prefix: &ExtranoncePrefix) {
self.prefix_bits
.set(extranonce_prefix.bit_index as usize, false);
self.last_freed_bit_index = Some(extranonce_prefix.bit_index as usize);
}
}

#[cfg(test)]
mod test {
use super::*;
use std::collections::HashSet;

#[test]
fn test_extranonce_prefix_manager() {
const ALLOCATED_PREFIX_BITS: usize = 1024; // 1024 * u64 bits for extranonce prefix, allows for 65536 unique IDs
let mut manager = ExtranoncePrefixManager::<{ ALLOCATED_PREFIX_BITS }>::new(Some(0));
assert!(!manager.is_full());
assert_eq!(
ExtranoncePrefixManager::<{ ALLOCATED_PREFIX_BITS }>::AVAILABLE_BITS,
u16::MAX as usize + 1
);

let mut used_ids = HashSet::new();
// Start timing
let start = std::time::Instant::now();

// Generate all available prefixes
for _ in 0..ExtranoncePrefixManager::<{ ALLOCATED_PREFIX_BITS }>::AVAILABLE_BITS {
let extranonce_prefix = manager
.generate_extranonce_prefix()
.expect("Failed to generate extranonce prefix");
assert_eq!(
extranonce_prefix.id.len(),
4,
"ExtranoncePrefix is not 4 bytes"
);
assert!(used_ids.insert(extranonce_prefix));
}

// End timing
let duration = start.elapsed();
let avg = duration.as_secs_f64()
/ ExtranoncePrefixManager::<{ ALLOCATED_PREFIX_BITS }>::AVAILABLE_BITS as f64;
println!(
"Total time: {:?}, average per loop: {:.9} micros",
duration,
avg * 1_000_000.0
);
// try to generate one more extranonce prefix, should fail
let extranonce_prefix = manager.generate_extranonce_prefix();
assert!(extranonce_prefix.is_none());
assert!(manager.is_full());

// free 2 extranonce prefix and check if we can generate a new one
let last_allocated_bit_index = manager
.last_allocated_bit_index
.expect("No last allocated bit index");
let mut extranonce_prefix_sorted = used_ids.iter().cloned().collect::<Vec<_>>();
extranonce_prefix_sorted.sort();
let mut extranonce_prefix_iterator = extranonce_prefix_sorted.iter();
let mut free_extranonce_prefix = ExtranoncePrefix {
bit_index: 0,
id: [0u8; 4],
};
for _i in 0..2 {
let extranonce_prefix = extranonce_prefix_iterator
.next()
.cloned()
.expect("No extranonce prefixes to free");
free_extranonce_prefix = extranonce_prefix.clone();
manager.free_extranonce_prefix(&extranonce_prefix);
}
assert!(!manager.is_full()); // after filling the manager, freeing one it should be full until 2 are freed
let extranonce_prefix = manager
.generate_extranonce_prefix()
.expect("Failed to generate extranonce prefix");
assert!(
extranonce_prefix.bit_index < last_allocated_bit_index as u16,
"ExtranoncePrefix generator did not wrap"
);
assert!(manager.is_full());
assert!(
manager.generate_extranonce_prefix().is_none(),
"extranonce prefixes should be 'full' again"
);
assert_ne!(
extranonce_prefix.bit_index, free_extranonce_prefix.bit_index,
"Even when exhausting the manager, the new extranonce prefix bit index should not be the same as the last freed one"
);
assert_ne!(
extranonce_prefix.id, free_extranonce_prefix.id,
"Even when exhausting the manager, the new extranonce prefix should not be the same as the last freed one"
);

let mut used_ids_iter = used_ids.into_iter();
let first_extranonce_prefix = used_ids_iter
.next()
.expect("No extranonce prefixes to check");
let second_extranonce_prefix = used_ids_iter
.next()
.expect("No second extranonce prefix to check");

// we need to know which extranonce prefix is smaller and which is bigger, so we can ensure we ignore the smaller one in the next test, since
// `generate_extranonce_prefix` iterates zeros from smallest to largest bit_index
let (smaller_extranonce_prefix, bigger_extranonce_prefix) =
if first_extranonce_prefix.bit_index < second_extranonce_prefix.bit_index {
(first_extranonce_prefix, second_extranonce_prefix)
} else {
(second_extranonce_prefix, first_extranonce_prefix)
};

manager.free_extranonce_prefix(&bigger_extranonce_prefix);
manager.free_extranonce_prefix(&smaller_extranonce_prefix);
let new_extranonce_prefix = manager
.generate_extranonce_prefix()
.expect("Failed to generate extranonce prefix after freeing");
assert!(
new_extranonce_prefix.bit_index != smaller_extranonce_prefix.bit_index,
"The new extranonce prefix should pass over the last freed bit index"
);
}
}
1 change: 1 addition & 0 deletions sv2/channels-sv2/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub mod outputs;
pub mod bip141;
pub mod chain_tip;
pub mod client;
pub mod extranonce_manager;
pub mod merkle_root;
pub mod target;

Expand Down
Loading