use bitcoin::Address;
use std::collections::{HashMap, HashSet};
use tracing::{debug, warn};
use crate::client::BitcoinClient;
use crate::error::Result;
#[derive(Debug, Clone)]
pub struct GapLimitConfig {
pub gap_limit: u32,
pub scan_change_addresses: bool,
pub min_confirmations: u32,
}
impl Default for GapLimitConfig {
fn default() -> Self {
Self {
gap_limit: 20, scan_change_addresses: true,
min_confirmations: 0, }
}
}
#[derive(Debug, Clone)]
pub struct AddressUsage {
pub address: String,
pub index: u32,
pub is_external: bool,
pub has_transactions: bool,
pub received_amount: u64,
pub confirmations: u32,
}
pub struct GapLimitTracker {
config: GapLimitConfig,
highest_used_external: u32,
highest_used_internal: u32,
used_addresses: HashSet<String>,
address_usage: HashMap<String, AddressUsage>,
}
impl GapLimitTracker {
pub fn new(config: GapLimitConfig) -> Self {
Self {
config,
highest_used_external: 0,
highest_used_internal: 0,
used_addresses: HashSet::new(),
address_usage: HashMap::new(),
}
}
pub fn with_defaults() -> Self {
Self::new(GapLimitConfig::default())
}
pub async fn is_address_used(&self, client: &BitcoinClient, address: &Address) -> Result<bool> {
let received =
client.get_received_by_address(address, Some(self.config.min_confirmations))?;
Ok(received.to_sat() > 0)
}
pub async fn scan_addresses<F>(
&mut self,
client: &BitcoinClient,
is_external: bool,
start_index: u32,
address_generator: F,
) -> Result<Vec<AddressUsage>>
where
F: Fn(u32) -> Result<Address>,
{
let mut found_addresses = Vec::new();
let mut consecutive_unused = 0;
let mut current_index = start_index;
loop {
let address = address_generator(current_index)?;
let received =
client.get_received_by_address(&address, Some(self.config.min_confirmations))?;
let received_sats = received.to_sat();
let has_transactions = received_sats > 0;
if has_transactions {
let usage = AddressUsage {
address: address.to_string(),
index: current_index,
is_external,
has_transactions,
received_amount: received_sats,
confirmations: self.config.min_confirmations,
};
found_addresses.push(usage.clone());
self.address_usage.insert(address.to_string(), usage);
self.used_addresses.insert(address.to_string());
if is_external {
self.highest_used_external = self.highest_used_external.max(current_index);
} else {
self.highest_used_internal = self.highest_used_internal.max(current_index);
}
consecutive_unused = 0;
debug!(
address = %address,
index = current_index,
is_external = is_external,
received_sats = received_sats,
"Found used address"
);
} else {
consecutive_unused += 1;
debug!(
address = %address,
index = current_index,
consecutive_unused = consecutive_unused,
"Address unused"
);
}
if consecutive_unused >= self.config.gap_limit {
debug!(
consecutive_unused = consecutive_unused,
gap_limit = self.config.gap_limit,
"Reached gap limit, stopping scan"
);
break;
}
current_index += 1;
if current_index > 100_000 {
warn!("Scan reached index 100,000, stopping for safety");
break;
}
}
Ok(found_addresses)
}
pub fn get_next_safe_index(&self, is_external: bool) -> u32 {
if is_external {
self.highest_used_external + 1
} else {
self.highest_used_internal + 1
}
}
pub fn validate_gap_limit(&self, index: u32, is_external: bool) -> bool {
let highest_used = if is_external {
self.highest_used_external
} else {
self.highest_used_internal
};
index <= highest_used + self.config.gap_limit
}
pub fn get_usage_stats(&self) -> GapLimitStats {
let external_used = self
.address_usage
.values()
.filter(|u| u.is_external && u.has_transactions)
.count() as u32;
let internal_used = self
.address_usage
.values()
.filter(|u| !u.is_external && u.has_transactions)
.count() as u32;
let total_received = self.address_usage.values().map(|u| u.received_amount).sum();
GapLimitStats {
highest_used_external: self.highest_used_external,
highest_used_internal: self.highest_used_internal,
total_addresses_scanned: self.address_usage.len() as u32,
external_addresses_used: external_used,
internal_addresses_used: internal_used,
total_received_sats: total_received,
gap_limit: self.config.gap_limit,
}
}
pub fn is_used(&self, address: &str) -> bool {
self.used_addresses.contains(address)
}
pub fn get_address_usage(&self, address: &str) -> Option<&AddressUsage> {
self.address_usage.get(address)
}
pub fn mark_as_used(&mut self, address: String, index: u32, is_external: bool) {
let usage = AddressUsage {
address: address.clone(),
index,
is_external,
has_transactions: true, received_amount: 0,
confirmations: 0,
};
self.address_usage.insert(address.clone(), usage);
self.used_addresses.insert(address);
if is_external {
self.highest_used_external = self.highest_used_external.max(index);
} else {
self.highest_used_internal = self.highest_used_internal.max(index);
}
}
pub fn reset(&mut self) {
self.highest_used_external = 0;
self.highest_used_internal = 0;
self.used_addresses.clear();
self.address_usage.clear();
}
}
#[derive(Debug, Clone)]
pub struct GapLimitStats {
pub highest_used_external: u32,
pub highest_used_internal: u32,
pub total_addresses_scanned: u32,
pub external_addresses_used: u32,
pub internal_addresses_used: u32,
pub total_received_sats: u64,
pub gap_limit: u32,
}
impl GapLimitStats {
pub fn is_near_gap_limit(&self, current_index: u32, is_external: bool) -> bool {
let highest_used = if is_external {
self.highest_used_external
} else {
self.highest_used_internal
};
let gap = current_index.saturating_sub(highest_used);
gap >= self.gap_limit * 3 / 4 }
}
pub struct AddressDiscovery {
tracker: GapLimitTracker,
}
impl AddressDiscovery {
pub fn new(config: GapLimitConfig) -> Self {
Self {
tracker: GapLimitTracker::new(config),
}
}
pub fn with_defaults() -> Self {
Self {
tracker: GapLimitTracker::with_defaults(),
}
}
pub async fn discover_addresses<F>(
&mut self,
client: &BitcoinClient,
external_generator: F,
internal_generator: Option<F>,
) -> Result<DiscoveryResult>
where
F: Fn(u32) -> Result<Address>,
{
let external_addresses = self
.tracker
.scan_addresses(client, true, 0, &external_generator)
.await?;
let internal_addresses = if self.tracker.config.scan_change_addresses {
if let Some(internal_gen) = internal_generator {
self.tracker
.scan_addresses(client, false, 0, internal_gen)
.await?
} else {
Vec::new()
}
} else {
Vec::new()
};
let stats = self.tracker.get_usage_stats();
Ok(DiscoveryResult {
external_addresses,
internal_addresses,
stats,
})
}
pub fn tracker(&self) -> &GapLimitTracker {
&self.tracker
}
pub fn tracker_mut(&mut self) -> &mut GapLimitTracker {
&mut self.tracker
}
}
#[derive(Debug, Clone)]
pub struct DiscoveryResult {
pub external_addresses: Vec<AddressUsage>,
pub internal_addresses: Vec<AddressUsage>,
pub stats: GapLimitStats,
}
impl DiscoveryResult {
pub fn all_addresses(&self) -> Vec<&AddressUsage> {
self.external_addresses
.iter()
.chain(self.internal_addresses.iter())
.collect()
}
pub fn total_received(&self) -> u64 {
self.stats.total_received_sats
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gap_limit_config_defaults() {
let config = GapLimitConfig::default();
assert_eq!(config.gap_limit, 20);
assert!(config.scan_change_addresses);
assert_eq!(config.min_confirmations, 0);
}
#[test]
fn test_gap_limit_tracker_initialization() {
let tracker = GapLimitTracker::with_defaults();
assert_eq!(tracker.highest_used_external, 0);
assert_eq!(tracker.highest_used_internal, 0);
assert!(tracker.used_addresses.is_empty());
}
#[test]
fn test_mark_as_used() {
let mut tracker = GapLimitTracker::with_defaults();
tracker.mark_as_used("bc1q...".to_string(), 5, true);
assert_eq!(tracker.highest_used_external, 5);
assert!(tracker.is_used("bc1q..."));
assert_eq!(tracker.get_next_safe_index(true), 6);
}
#[test]
fn test_validate_gap_limit() {
let mut tracker = GapLimitTracker::with_defaults();
tracker.mark_as_used("bc1q1...".to_string(), 5, true);
assert!(tracker.validate_gap_limit(25, true));
assert!(!tracker.validate_gap_limit(26, true));
}
#[test]
fn test_gap_limit_stats() {
let mut tracker = GapLimitTracker::with_defaults();
tracker.mark_as_used("bc1q1...".to_string(), 0, true);
tracker.mark_as_used("bc1q2...".to_string(), 1, true);
tracker.mark_as_used("bc1q3...".to_string(), 0, false);
let stats = tracker.get_usage_stats();
assert_eq!(stats.highest_used_external, 1);
assert_eq!(stats.highest_used_internal, 0);
assert_eq!(stats.external_addresses_used, 2);
assert_eq!(stats.internal_addresses_used, 1);
}
#[test]
fn test_is_near_gap_limit() {
let stats = GapLimitStats {
highest_used_external: 10,
highest_used_internal: 5,
total_addresses_scanned: 50,
external_addresses_used: 11,
internal_addresses_used: 6,
total_received_sats: 1_000_000,
gap_limit: 20,
};
assert!(stats.is_near_gap_limit(25, true));
assert!(!stats.is_near_gap_limit(20, true));
}
#[test]
fn test_address_discovery_initialization() {
let discovery = AddressDiscovery::with_defaults();
assert_eq!(discovery.tracker().highest_used_external, 0);
}
#[test]
fn test_discovery_result_all_addresses() {
let external = vec![AddressUsage {
address: "bc1q1...".to_string(),
index: 0,
is_external: true,
has_transactions: true,
received_amount: 100000,
confirmations: 6,
}];
let internal = vec![AddressUsage {
address: "bc1q2...".to_string(),
index: 0,
is_external: false,
has_transactions: true,
received_amount: 50000,
confirmations: 3,
}];
let stats = GapLimitStats {
highest_used_external: 0,
highest_used_internal: 0,
total_addresses_scanned: 2,
external_addresses_used: 1,
internal_addresses_used: 1,
total_received_sats: 150000,
gap_limit: 20,
};
let result = DiscoveryResult {
external_addresses: external,
internal_addresses: internal,
stats,
};
assert_eq!(result.all_addresses().len(), 2);
assert_eq!(result.total_received(), 150000);
}
#[test]
fn test_tracker_reset() {
let mut tracker = GapLimitTracker::with_defaults();
tracker.mark_as_used("bc1q...".to_string(), 5, true);
assert_eq!(tracker.highest_used_external, 5);
tracker.reset();
assert_eq!(tracker.highest_used_external, 0);
assert!(tracker.used_addresses.is_empty());
}
}