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
26 changes: 26 additions & 0 deletions dash-spv/src/client/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ pub struct ClientConfig {
/// The client will use the nearest checkpoint at or before this height.
pub start_from_height: Option<u32>,

/// Time-to-live (seconds) for unfunded receive-address reservations,
/// periodically reclaimed by the sweep so a hand-out that is never paid and
/// never released does not pin gap-limit headroom forever. `Some(secs)`
/// enables the sweep with that TTL; `None` disables it. Defaults to one hour.
/// Set with [`Self::with_reservation_ttl_secs`] or clear with
/// [`Self::without_reservation_sweep`].
pub reservation_sweep_ttl_secs: Option<u64>,
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Devnet-only configuration. Must be `Some` iff `network == Network::Devnet`.
pub devnet: Option<DevnetConfig>,
}
Expand All @@ -94,6 +102,7 @@ impl Default for ClientConfig {
max_mempool_transactions: 1000,
fetch_mempool_transactions: true,
start_from_height: None,
reservation_sweep_ttl_secs: Some(3600),
devnet: None,
}
}
Expand Down Expand Up @@ -186,6 +195,19 @@ impl ClientConfig {
self
}

/// Disable the periodic receive-address reservation sweep.
pub fn without_reservation_sweep(mut self) -> Self {
self.reservation_sweep_ttl_secs = None;
self
}

/// Enable the periodic receive-address reservation sweep with the given TTL
/// (seconds): a reservation older than `secs` is reclaimed.
pub fn with_reservation_ttl_secs(mut self, secs: u64) -> Self {
self.reservation_sweep_ttl_secs = Some(secs);
self
}

/// Attach a [`DevnetConfig`]. The network must be `Network::Devnet`.
/// [`validate`](Self::validate) enforces the biconditional.
pub fn with_devnet(mut self, devnet: DevnetConfig) -> Self {
Expand All @@ -208,6 +230,10 @@ impl ClientConfig {
);
}

if self.reservation_sweep_ttl_secs == Some(0) {
return Err("reservation_sweep_ttl_secs must be > 0 when set; use without_reservation_sweep() to disable the sweep".to_string());
}

match (self.network == Network::Devnet, &self.devnet) {
(true, Some(devnet)) => devnet.validate()?,
(true, None) => {
Expand Down
12 changes: 12 additions & 0 deletions dash-spv/src/client/config_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,18 @@ mod tests {
assert!(result.unwrap_err().contains("max_mempool_transactions must be > 0"));
}

#[test]
fn test_validation_rejects_zero_reservation_ttl() {
let config = ClientConfig {
reservation_sweep_ttl_secs: Some(0),
..Default::default()
};

let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("reservation_sweep_ttl_secs must be > 0"));
}

#[test]
fn test_apply_global_overrides_no_devnet_is_noop() {
let tmp = TempDir::new().unwrap();
Expand Down
43 changes: 43 additions & 0 deletions dash-spv/src/client/event_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use std::fmt::Display;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use tokio::sync::{broadcast, mpsc, watch, RwLock};
use tokio::task::JoinHandle;
Expand Down Expand Up @@ -217,6 +218,48 @@ where
})
}

/// How often the reservation-sweep task wakes to reclaim expired reservations.
const RESERVATION_SWEEP_INTERVAL: Duration = Duration::from_secs(60);

/// Periodically reclaim receive-address reservations that were handed out but
/// never funded and never explicitly released, which would otherwise pin
/// gap-limit headroom forever.
///
/// The wallet keeps no clock, so this task supplies `now` from the system clock
/// and the caller-supplied `ttl` (seconds). A system-clock failure yields a
/// `now` of `0`, which the wallet treats as "no clock" and reclaims nothing.
/// Runs until `shutdown` is cancelled.
pub(crate) fn spawn_reservation_sweep<W>(
wallet: Arc<RwLock<W>>,
ttl_secs: u64,
shutdown: CancellationToken,
) -> JoinHandle<()>
where
W: WalletInterface + 'static,
{
const NAME: &str = "Reservation sweep";
tokio::spawn(async move {
tracing::debug!("{} task started", NAME);
let mut interval = tokio::time::interval(RESERVATION_SWEEP_INTERVAL);
loop {
tokio::select! {
_ = interval.tick() => {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let reclaimed = wallet.write().await.sweep_expired_reservations(now, ttl_secs);
if reclaimed > 0 {
tracing::debug!("{}: reclaimed {} expired reservation(s)", NAME, reclaimed);
}
}
_ = shutdown.cancelled() => break,
}
}
tracing::debug!("{} task exiting", NAME);
})
}

#[cfg(test)]
mod tests {
use std::net::SocketAddr;
Expand Down
12 changes: 12 additions & 0 deletions dash-spv/src/client/sync_coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use tokio_util::sync::CancellationToken;

use super::event_handler::{
spawn_broadcast_monitor, spawn_chainlock_wallet_dispatch, spawn_progress_monitor,
spawn_reservation_sweep,
};
use super::DashSpvClient;
use crate::error::Result;
Expand Down Expand Up @@ -103,6 +104,14 @@ impl<W: WalletInterface, N: NetworkManager, S: StorageManager> DashSpvClient<W,
return Err(e);
}

// Spawn the reservation sweep only after startup succeeds: it mutates
// wallet state, so a slow or failing `start()` must not let it reclaim
// reservations while `run()` is still on its way to returning an error.
let reservation_sweep_task =
self.config.read().await.reservation_sweep_ttl_secs.map(|ttl| {
spawn_reservation_sweep(self.wallet.clone(), ttl, monitor_shutdown.clone())
});

// `start()` flipped the state to `true`. Consume that edge so `changed()`
// only fires on the subsequent transition to `false` (the stop request).
// If a `stop()` already raced in, this reads `false` and the loop's first
Expand Down Expand Up @@ -150,6 +159,9 @@ impl<W: WalletInterface, N: NetworkManager, S: StorageManager> DashSpvClient<W,
wallet_task,
progress_task
);
if let Some(task) = reservation_sweep_task {
let _ = task.await;
}

if let Some(ref e) = error {
for handler in handlers.iter() {
Expand Down
15 changes: 3 additions & 12 deletions key-wallet-ffi/src/address_pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,10 +198,6 @@ pub struct FFIAddressInfo {
pub path: *mut c_char,
/// Whether address has been used
pub used: bool,
/// When generated (timestamp)
pub generated_at: u64,
/// When first used (0 if never)
pub used_at: u64,
/// Transaction count
pub tx_count: u32,
/// Total received
Expand Down Expand Up @@ -264,9 +260,7 @@ fn address_info_to_ffi(info: &AddressInfo) -> FFIAddressInfo {
public_key_len,
index: info.index,
path: path_str,
used: info.used,
generated_at: info.generated_at,
used_at: info.used_at.unwrap_or(0),
used: info.is_used(),
tx_count: info.tx_count,
total_received: info.total_received,
total_sent: info.total_sent,
Expand Down Expand Up @@ -879,6 +873,7 @@ pub unsafe extern "C" fn address_info_array_free(infos: *mut *mut FFIAddressInfo
mod tests {
use super::*;
use dash_network::ffi::FFINetwork;
use key_wallet::managed_account::address_pool::AddressState;

#[test]
fn test_address_pool_type_values() {
Expand Down Expand Up @@ -914,9 +909,7 @@ mod tests {
public_key: Some(PublicKeyType::ECDSA(vec![0x02, 0x03, 0x04])),
index: 0,
path: test_path,
used: false,
generated_at: 1234567890,
used_at: None,
state: AddressState::Available,
tx_count: 0,
total_received: 0,
total_sent: 0,
Expand All @@ -931,8 +924,6 @@ mod tests {
// Verify basic fields
assert_eq!(ffi_info.index, 0);
assert!(!ffi_info.used);
assert_eq!(ffi_info.generated_at, 1234567890);
assert_eq!(ffi_info.used_at, 0);
assert_eq!(ffi_info.public_key_len, 3);
assert!(ffi_info.script_pubkey_len > 0);

Expand Down
7 changes: 4 additions & 3 deletions key-wallet-ffi/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,9 +250,10 @@ impl From<key_wallet::Error> for FFIError {
Error::InvalidNetwork => FFIErrorCode::InvalidNetwork,
Error::InvalidAddress(_) => FFIErrorCode::InvalidAddress,
Error::Serialization(_) => FFIErrorCode::SerializationError,
Error::WatchOnly | Error::CoinJoinNotEnabled | Error::NoKeySource => {
FFIErrorCode::InvalidState
}
Error::InvalidState(_)
| Error::WatchOnly
| Error::CoinJoinNotEnabled
| Error::NoKeySource => FFIErrorCode::InvalidState,
Error::Bip32(_)
| Error::Slip10(_)
| Error::BLS(_)
Expand Down
6 changes: 2 additions & 4 deletions key-wallet-manager/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ mod project_derived_addresses_tests {
use super::*;
use key_wallet::account::StandardAccountType;
use key_wallet::bip32::{ChildNumber, DerivationPath};
use key_wallet::managed_account::address_pool::AddressInfo;
use key_wallet::managed_account::address_pool::{AddressInfo, AddressState};
use std::collections::BTreeMap;

/// Compressed encoding of the secp256k1 generator point (G).
Expand Down Expand Up @@ -488,9 +488,7 @@ mod project_derived_addresses_tests {
public_key: Some(PublicKeyType::ECDSA(pubkey_bytes)),
index,
path,
used: false,
generated_at: 0,
used_at: None,
state: AddressState::Available,
tx_count: 0,
total_received: 0,
total_sent: 0,
Expand Down
27 changes: 27 additions & 0 deletions key-wallet-manager/src/process_block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,10 @@ impl<T: WalletInfoInterface + Send + Sync + 'static> WalletInterface for WalletM
self.event_sender.subscribe()
}

fn sweep_expired_reservations(&mut self, now: u64, ttl: u64) -> usize {
self.wallet_infos.values_mut().map(|info| info.sweep_expired_reservations(now, ttl)).sum()
}

fn apply_chain_lock(&mut self, chain_lock: ChainLock) {
for (wallet_id, info) in self.wallet_infos.iter_mut() {
let outcome = info.apply_chain_lock(chain_lock.clone());
Expand Down Expand Up @@ -530,6 +534,29 @@ mod tests {
assert_eq!(manager.last_processed_height(), 0);
}

#[tokio::test]
async fn test_sweep_expired_reservations_fans_out_over_wallets() {
let (mut manager, wallet_id, _addr) = setup_manager_with_wallet();

// Reserve a receive address on the wallet's funding account.
manager
.get_wallet_info_mut(&wallet_id)
.expect("wallet info")
.first_bip44_managed_account_mut()
.expect("managed account")
.next_receive_address_and_reserve(None, 1_000)
.expect("reserve");

// The manager fans out across every managed wallet. Before the ttl
// elapses nothing is reclaimed.
assert_eq!(manager.sweep_expired_reservations(1_000, 500), 0);

// Once the reservation ages past the ttl the manager reclaims it, and a
// follow-up sweep finds nothing left.
assert_eq!(manager.sweep_expired_reservations(2_000, 500), 1);
assert_eq!(manager.sweep_expired_reservations(3_000, 500), 0);
}

#[tokio::test]
async fn test_process_mempool_transaction_emits_event() {
let (mut manager, _wallet_id, addr) = setup_manager_with_wallet();
Expand Down
11 changes: 11 additions & 0 deletions key-wallet-manager/src/wallet_interface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,17 @@ pub trait WalletInterface: Send + Sync + 'static {
0
}

/// Reclaim expired receive-address reservations across every managed
/// wallet, returning the total number reclaimed.
///
/// `now` and `ttl` are caller-supplied (seconds); the wallet keeps no
/// clock. A reservation is reclaimed when `now - reserved_at >= ttl`. The
/// default returns `0` for wallet implementations that do not track
/// reservations.
fn sweep_expired_reservations(&mut self, _now: u64, _ttl: u64) -> usize {
0
}

/// Subscribe to wallet events (e.g. transactions received, balance changes).
fn subscribe_events(&self) -> broadcast::Receiver<WalletEvent>;

Expand Down
3 changes: 3 additions & 0 deletions key-wallet/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ pub enum Error {
Serialization(String),
/// Invalid parameter
InvalidParameter(String),
/// Internal invariant violated (inconsistent wallet state)
InvalidState(String),
/// Watch-only wallet (no private keys available)
WatchOnly,
/// No key source available for address derivation
Expand All @@ -61,6 +63,7 @@ impl fmt::Display for Error {
Error::CoinJoinNotEnabled => write!(f, "CoinJoin not enabled for this account"),
Error::Serialization(s) => write!(f, "Serialization error: {}", s),
Error::InvalidParameter(s) => write!(f, "Invalid parameter: {}", s),
Error::InvalidState(s) => write!(f, "Invalid state: {}", s),
Error::WatchOnly => write!(f, "Watch-only wallet: private keys not available"),
Error::NoKeySource => write!(f, "No key source available for address derivation"),
}
Expand Down
Loading
Loading