use super::*;
use std::collections::HashMap;
use std::sync::mpsc::{self, Receiver, TryRecvError};
const SCAN_INTERVAL: Duration = Duration::from_secs(5);
const RETRY_INTERVAL: Duration = Duration::from_secs(60);
const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) struct PaidExitSellerCollector {
active: Option<(String, Receiver<Result<PaidExitCollectChannelOutcome>>)>,
retry_after: HashMap<String, (Instant, Duration)>,
next_scan: Instant,
}
impl PaidExitSellerCollector {
pub(crate) fn new() -> Self {
Self {
active: None,
retry_after: HashMap::new(),
next_scan: Instant::now(),
}
}
pub(crate) fn poll(
&mut self,
app: &AppConfig,
config_path: &Path,
allow_start: bool,
) -> Result<bool> {
let mut changed = false;
if let Some((channel_id, result_rx)) = &self.active {
let result = match result_rx.try_recv() {
Ok(result) => Some(result),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => Some(Err(anyhow!("collection worker stopped"))),
};
if let Some(result) = result {
let channel_id = channel_id.clone();
self.active = None;
match result {
Ok(outcome) => {
self.retry_after.remove(&channel_id);
changed |= outcome.changed;
eprintln!(
"paid-exit: automatically collected seller channel receiver_sat={}",
outcome.close.receiver_sum
);
}
Err(error) => {
let delay = self
.retry_after
.get(&channel_id)
.map_or(RETRY_INTERVAL, |(_, previous)| {
(*previous * 2).min(Duration::from_secs(3600))
});
self.retry_after
.insert(channel_id.clone(), (Instant::now() + delay, delay));
update_paid_route_store(
&paid_route_store_file_path(config_path),
|store| {
if let Some(channel) = store.channels.get_mut(&channel_id) {
channel.error = format!("Automatic collection failed: {error}");
}
Ok(())
},
)?;
eprintln!(
"paid-exit: automatic seller collection failed; retained for retry"
);
}
}
}
}
if !allow_start || self.active.is_some() || Instant::now() < self.next_scan {
return Ok(changed);
}
self.next_scan = Instant::now() + SCAN_INTERVAL;
let store = load_paid_route_store(&paid_route_store_file_path(config_path))?;
let due = store.seller_collection_states(&app.paid_exit, unix_timestamp());
self.retry_after
.retain(|id, _| due.iter().any(|state| state.channel_id == *id));
let Some(state) = due.iter().find(|state| {
state.auto_collect_due
&& self
.retry_after
.get(&state.channel_id)
.is_none_or(|(at, _)| Instant::now() >= *at)
}) else {
return Ok(changed);
};
let channel_id = state.channel_id.clone();
let mint_url = store.channels[&channel_id].mint_url.clone();
let config_path = config_path.to_path_buf();
let (tx, rx) = mpsc::sync_channel(1);
let worker_channel = channel_id.clone();
std::thread::Builder::new()
.name("nvpn-seller-collect".into())
.spawn(move || {
let result = (|| {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(async {
tokio::time::timeout(
ATTEMPT_TIMEOUT,
collect_seller_channel(&config_path, &worker_channel, &mint_url),
)
.await
.context("seller collection timed out")?
})
})();
let _ = tx.send(result);
})
.context("start seller collection worker")?;
self.active = Some((channel_id, rx));
Ok(changed)
}
}
async fn collect_seller_channel(
config_path: &Path,
channel_id: &str,
mint_url: &str,
) -> Result<PaidExitCollectChannelOutcome> {
let directory = paid_exit_wallet_data_dir(config_path);
anyhow::ensure!(
cashu_service::spilman_receiver_key_path(&directory).is_file()
&& cashu_service::spilman_receiver_store_path(&directory).is_file(),
"existing seller receiver is missing; refusing to create a new identity"
);
let receiver = FileSpilmanPaymentReceiver::load(
&directory,
FileSpilmanPaymentReceiverConfig::new([mint_url.to_owned()]),
)
.map_err(|error| anyhow!("{error}"))?;
let close = receiver
.close_cashu_spilman_channel(channel_id)
.await
.map_err(|error| anyhow!("{error}"))?;
paid_exit_finish_seller_collection(
close,
config_path,
&paid_route_store_file_path(config_path),
true,
)
.await
}