Skip to main content

ark_client/
vtxo_watcher.rs

1//! Background VTXO watcher that auto-delegates and auto-renews VTXOs.
2//!
3//! Full behavior:
4//! - On new VTXOs received: submit them to the delegator service for future renewal
5//! - On new VTXOs received: self-renew VTXOs that are close to expiry (safety net)
6//! - On stream error: reconnect with exponential backoff
7
8use crate::error::ErrorContext;
9use crate::swap_storage::SwapStorage;
10use crate::wallet::OnchainWallet;
11use crate::AnnotatedVtxo;
12use crate::Blockchain;
13use crate::Client;
14use crate::Error;
15use ark_core::intent;
16use ark_core::server::SubscriptionResponse;
17use ark_core::server::VirtualTxOutPoint;
18use ark_core::ArkAddress;
19#[cfg(test)]
20use ark_core::Vtxo;
21use ark_delegator::DelegatorClient;
22use bitcoin::secp256k1::PublicKey;
23use bitcoin::Amount;
24use bitcoin::OutPoint;
25use bitcoin::ScriptBuf;
26use bitcoin::TxOut;
27use futures::StreamExt;
28use rand::rngs::OsRng;
29use std::collections::BTreeMap;
30use std::collections::HashSet;
31use std::sync::Arc;
32use std::time::Duration;
33use tokio::sync::mpsc;
34use tokio::sync::watch;
35
36/// Handle to stop the background VTXO watcher.
37///
38/// Dropping the handle will also stop the watcher.
39pub struct VtxoWatcherHandle {
40    stop_tx: watch::Sender<bool>,
41}
42
43impl VtxoWatcherHandle {
44    /// Stop the background watcher.
45    pub fn stop(self) {
46        let _ = self.stop_tx.send(true);
47    }
48}
49
50impl Drop for VtxoWatcherHandle {
51    fn drop(&mut self) {
52        let _ = self.stop_tx.send(true);
53    }
54}
55
56/// Backoff parameters for reconnection.
57const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
58const MAX_BACKOFF: Duration = Duration::from_secs(30);
59
60/// Periodic key discovery settings for keeping script subscriptions fresh.
61const KEY_DISCOVERY_INTERVAL: Duration = Duration::from_secs(10);
62
63/// How often the background migration arm fires when healthy. The frequent cadence is safe
64/// because [`Client::migrate_deprecated_signer_vtxos`] short-circuits to a no-op
65/// `NothingMigratable` report when the server advertises no deprecated signers or the wallet holds
66/// no pre-cutoff deprecated-signer outputs.
67const MIGRATION_INTERVAL: Duration = Duration::from_secs(60);
68
69/// Exponential-backoff bounds for the migration arm after a failing pass. The cooldown doubles per
70/// consecutive failure, caps at five minutes, and resets to the base on a successful or no-op pass.
71const MIGRATION_BASE_COOLDOWN: Duration = Duration::from_secs(30);
72const MIGRATION_MAX_COOLDOWN: Duration = Duration::from_secs(300);
73
74/// Configuration for [`Client::start_vtxo_watcher`].
75#[derive(Debug, Clone, Copy)]
76pub struct VtxoWatcherConfig {
77    /// When `true` (the default), the watcher runs a periodic
78    /// [`Client::migrate_deprecated_signer_vtxos`] pass that rotates funds off any deprecated
79    /// server signer the wallet still holds pre-cutoff outputs under. Errors are logged and
80    /// swallowed (never killing the loop), and a persistently failing pass backs off
81    /// exponentially. Set to `false` to disable the migration arm entirely; renewal and delegation
82    /// behavior are unaffected either way.
83    pub migrate_deprecated_signers: bool,
84}
85
86impl Default for VtxoWatcherConfig {
87    fn default() -> Self {
88        Self {
89            migrate_deprecated_signers: true,
90        }
91    }
92}
93
94enum WatcherWork {
95    NewVtxos { vtxos: Vec<VirtualTxOutPoint> },
96    RenewTick,
97}
98
99impl<B, W, S> Client<B, W, S>
100where
101    B: Blockchain + Send + Sync + 'static,
102    W: OnchainWallet + Send + Sync + 'static,
103    S: SwapStorage + 'static,
104{
105    /// Start a background task that watches for new VTXOs and:
106    ///
107    /// 1. **Delegates** them to the configured delegator service for future auto-renewal
108    /// 2. **Self-renews** VTXOs that are close to expiry (safety net)
109    /// 3. **Migrates** funds off deprecated server signers on a periodic, backed-off pass (unless
110    ///    disabled via [`VtxoWatcherConfig::migrate_deprecated_signers`])
111    ///
112    /// Reconnects automatically with exponential backoff (1s → 2s → … → 30s) on stream errors.
113    ///
114    /// Requires the client to be wrapped in an `Arc` for shared ownership with the background
115    /// task.
116    ///
117    /// Returns a [`VtxoWatcherHandle`] that stops the watcher when dropped.
118    pub fn start_vtxo_watcher(
119        self: &Arc<Self>,
120        delegator: Arc<DelegatorClient>,
121        config: VtxoWatcherConfig,
122    ) -> VtxoWatcherHandle {
123        let (stop_tx, stop_rx) = watch::channel(false);
124
125        let client = Arc::clone(self);
126        tokio::spawn(async move {
127            run_watcher_loop(client, delegator, config, stop_rx).await;
128            tracing::debug!("VTXO watcher stopped");
129        });
130
131        VtxoWatcherHandle { stop_tx }
132    }
133}
134
135/// Outer loop that reconnects on stream errors with exponential backoff.
136async fn run_watcher_loop<B, W, S>(
137    client: Arc<Client<B, W, S>>,
138    delegator: Arc<DelegatorClient>,
139    config: VtxoWatcherConfig,
140    mut stop_rx: watch::Receiver<bool>,
141) where
142    B: Blockchain + Send + Sync + 'static,
143    W: OnchainWallet + Send + Sync + 'static,
144    S: SwapStorage + 'static,
145{
146    let mut backoff = INITIAL_BACKOFF;
147
148    loop {
149        if *stop_rx.borrow() {
150            return;
151        }
152
153        let addresses = match client.active_offchain_contract_addresses() {
154            Ok(a) => a,
155            Err(e) => {
156                tracing::error!("Failed to get active offchain contracts: {e}");
157                return;
158            }
159        };
160
161        let subscription_id = match client.subscribe_to_scripts(addresses.clone(), None).await {
162            Ok(id) => id,
163            Err(e) => {
164                tracing::warn!("Failed to subscribe: {e}, retrying in {backoff:?}");
165                if wait_or_stop(&mut stop_rx, backoff).await {
166                    return;
167                }
168                backoff = (backoff * 2).min(MAX_BACKOFF);
169                continue;
170            }
171        };
172
173        let mut stream = match client.get_subscription(subscription_id.clone()).await {
174            Ok(s) => s,
175            Err(e) => {
176                tracing::warn!("Failed to get subscription stream: {e}, retrying in {backoff:?}");
177                if wait_or_stop(&mut stop_rx, backoff).await {
178                    return;
179                }
180                backoff = (backoff * 2).min(MAX_BACKOFF);
181                continue;
182            }
183        };
184
185        tracing::info!("VTXO watcher connected");
186        backoff = INITIAL_BACKOFF;
187        let mut subscribed_addrs: HashSet<ArkAddress> = addresses.into_iter().collect();
188        let mut renew_interval = tokio::time::interval(Duration::from_secs(60));
189        let mut discovery_interval = tokio::time::interval(KEY_DISCOVERY_INTERVAL);
190        let (work_tx, mut work_rx) = mpsc::channel::<WatcherWork>(128);
191
192        let worker_handle = tokio::spawn({
193            let client = client.clone();
194            let delegator = delegator.clone();
195            async move {
196                let mut seen_unspent_outpoints = HashSet::<OutPoint>::new();
197
198                while let Some(first) = work_rx.recv().await {
199                    // Handle one guaranteed message to start the batch.
200                    let (mut pending_vtxos, mut should_renew, mut should_sync) = match first {
201                        WatcherWork::NewVtxos { vtxos } => (vtxos, true, false),
202                        WatcherWork::RenewTick => (Vec::new(), true, true),
203                    };
204
205                    // Drain whatever else is already queued without waiting.
206                    while let Ok(work) = work_rx.try_recv() {
207                        match work {
208                            WatcherWork::NewVtxos { vtxos } => {
209                                pending_vtxos.extend(vtxos);
210                                should_renew = true;
211                            }
212                            WatcherWork::RenewTick => {
213                                should_renew = true;
214                                should_sync = true;
215                            }
216                        }
217                    }
218
219                    if should_sync {
220                        match collect_new_delegation_candidates(
221                            &client,
222                            &mut seen_unspent_outpoints,
223                        )
224                        .await
225                        {
226                            Ok(new_candidates) => {
227                                if !new_candidates.is_empty() {
228                                    tracing::debug!(
229                                        count = new_candidates.len(),
230                                        "Found new delegatable VTXOs from failsafe polling"
231                                    );
232                                    pending_vtxos.extend(new_candidates);
233                                }
234                            }
235                            Err(e) => {
236                                tracing::warn!("Failsafe delegation poll failed: {e}");
237                            }
238                        }
239                    }
240
241                    if !pending_vtxos.is_empty() {
242                        let mut deduped = Vec::new();
243                        let mut seen = HashSet::new();
244                        for vtxo in pending_vtxos {
245                            if seen.insert(vtxo.outpoint) {
246                                deduped.push(vtxo);
247                            }
248                        }
249
250                        tracing::debug!(count = deduped.len(), "Processing VTXOs for delegation");
251                        delegate_vtxos(&client, &delegator, &deduped).await;
252                    }
253
254                    if should_renew {
255                        renew_expiring_vtxos(&client).await;
256                    }
257                }
258            }
259        });
260
261        // Independent migration arm: rotates funds off deprecated server signers on its own
262        // self-paced cooldown loop, separate from the renewal/delegation worker and the
263        // subscription stream (it polls wallet state, like renewal). Spawned per connection and
264        // aborted on reconnect/stop so passes never overlap. Disabled entirely when the config
265        // flag is off.
266        let migration_handle = config.migrate_deprecated_signers.then(|| {
267            let client = client.clone();
268            let mut stop_rx = stop_rx.clone();
269            tokio::spawn(async move {
270                run_migration_arm(&client, &mut stop_rx).await;
271            })
272        });
273
274        loop {
275            tokio::select! {
276                _ = stop_rx.changed() => {
277                    drop(work_tx);
278                    let _ = worker_handle.await;
279                    if let Some(handle) = migration_handle {
280                        handle.abort();
281                    }
282                    return;
283                }
284                _ = renew_interval.tick() => {
285                    if work_tx.send(WatcherWork::RenewTick).await.is_err() {
286                        tracing::warn!("VTXO worker channel closed, reconnecting in {backoff:?}");
287                        break;
288                    }
289                }
290                _ = discovery_interval.tick() => {
291                    match refresh_subscription_scripts(
292                        client.as_ref(),
293                        &subscription_id,
294                        &mut subscribed_addrs,
295                    )
296                    .await
297                    {
298                        Ok(()) => {}
299                        Err(e) => {
300                            tracing::warn!("Failed to refresh script subscription: {e}");
301                        }
302                    }
303                }
304                event = stream.next() => {
305                    match event {
306                        Some(Ok(SubscriptionResponse::Heartbeat)) => {}
307                        Some(Ok(SubscriptionResponse::Event(event))) => {
308                            if !event.new_vtxos.is_empty() {
309                                tracing::debug!(
310                                    txid = %event.txid,
311                                    new_vtxos = event.new_vtxos.len(),
312                                    "Received subscription event with new VTXOs"
313                                );
314
315                                if work_tx.send(WatcherWork::NewVtxos {
316                                    vtxos: event.new_vtxos,
317                                })
318                                .await.is_err()
319                                {
320                                    tracing::warn!("VTXO worker channel closed. Reconnecting in {backoff:?}");
321                                    break;
322                                }
323                            }
324                        }
325                        Some(Err(e)) => {
326                            tracing::warn!("VTXO subscription error: {e}, reconnecting in {backoff:?}");
327                            break;
328                        }
329                        None => {
330                            tracing::debug!("VTXO subscription stream ended, reconnecting in {backoff:?}");
331                            break;
332                        }
333                    }
334                }
335            }
336        }
337
338        drop(work_tx);
339        let _ = worker_handle.await;
340        // Abort the per-connection migration arm; the next iteration spawns a fresh one. This
341        // prevents two migration loops racing across a reconnect.
342        if let Some(handle) = migration_handle {
343            handle.abort();
344        }
345
346        if wait_or_stop(&mut stop_rx, backoff).await {
347            return;
348        }
349        backoff = (backoff * 2).min(MAX_BACKOFF);
350    }
351}
352
353/// Background migration arm: periodically rotate funds off deprecated server signers.
354///
355/// On each fire it runs one [`Client::migrate_deprecated_signer_vtxos`] pass and logs the outcome.
356/// Errors are swallowed (never propagated — this must never kill the watcher). Cadence is
357/// [`MIGRATION_INTERVAL`] while healthy; a failing pass backs off exponentially between
358/// [`MIGRATION_BASE_COOLDOWN`] and [`MIGRATION_MAX_COOLDOWN`], resetting to the base interval on a
359/// fully successful or no-op pass. The frequent base cadence is cheap because the migration call is
360/// a no-op (`NothingMigratable`) whenever there is nothing to rotate.
361///
362/// When [`Client::refresh_server_info`] updates the cached `deprecated_signers`, this arm picks up
363/// the freshly advertised deprecated signers on its next pass and migrates.
364async fn run_migration_arm<B, W, S>(client: &Client<B, W, S>, stop_rx: &mut watch::Receiver<bool>)
365where
366    B: Blockchain + Send + Sync + 'static,
367    W: OnchainWallet + Send + Sync + 'static,
368    S: SwapStorage + 'static,
369{
370    // Consecutive-failure count drives the exponential cooldown; `0` means healthy (use the base
371    // interval). Reset to `0` on any fully successful or no-op pass.
372    let mut consecutive_failures: u32 = 0;
373    loop {
374        let delay = migration_delay(consecutive_failures);
375        if wait_or_stop(stop_rx, delay).await {
376            return;
377        }
378
379        let mut rng = OsRng;
380        match client.migrate_deprecated_signer_vtxos(&mut rng).await {
381            Ok(report) => {
382                if report.failed() {
383                    consecutive_failures = consecutive_failures.saturating_add(1);
384                    let next = migration_delay(consecutive_failures);
385                    tracing::warn!(
386                        txids = ?report.settle_txids(),
387                        vtxo_error = ?report.vtxo.error.as_deref(),
388                        boarding_error = ?report.boarding.error.as_deref(),
389                        "Background migration pass had leg failure; backing off {next:?}"
390                    );
391                } else {
392                    if report.rotated() {
393                        tracing::info!(
394                            txids = ?report.settle_txids(),
395                            "Background migration rotated funds off deprecated signer(s)"
396                        );
397                    } else {
398                        tracing::debug!("Background migration pass: nothing to migrate");
399                    }
400                    // Success or no-op: back to the healthy cadence.
401                    consecutive_failures = 0;
402                }
403            }
404            Err(e) => {
405                // Back off so a persistently failing migration does not retry every interval.
406                consecutive_failures = consecutive_failures.saturating_add(1);
407                let next = migration_delay(consecutive_failures);
408                tracing::warn!("Background migration pass failed: {e}; backing off {next:?}");
409            }
410        }
411    }
412}
413
414/// Cooldown before the next migration pass given the consecutive-failure count.
415///
416/// `0` failures → the healthy [`MIGRATION_INTERVAL`]. Otherwise an exponential backoff of
417/// `MIGRATION_BASE_COOLDOWN * 2^(failures - 1)`, saturating at [`MIGRATION_MAX_COOLDOWN`].
418fn migration_delay(consecutive_failures: u32) -> Duration {
419    if consecutive_failures == 0 {
420        return MIGRATION_INTERVAL;
421    }
422    let shift = consecutive_failures - 1;
423    let scaled = MIGRATION_BASE_COOLDOWN
424        .checked_mul(1u32.checked_shl(shift).unwrap_or(u32::MAX))
425        .unwrap_or(MIGRATION_MAX_COOLDOWN);
426    scaled.min(MIGRATION_MAX_COOLDOWN)
427}
428
429/// Wait for the given duration or until stop is signalled. Returns `true` if stopped.
430async fn wait_or_stop(stop_rx: &mut watch::Receiver<bool>, duration: Duration) -> bool {
431    tokio::select! {
432        _ = stop_rx.changed() => true,
433        _ = tokio::time::sleep(duration) => false,
434    }
435}
436
437/// Add newly persisted active contract scripts to an existing subscription.
438async fn refresh_subscription_scripts<B, W, S>(
439    client: &Client<B, W, S>,
440    subscription_id: &str,
441    subscribed_addrs: &mut HashSet<ArkAddress>,
442) -> Result<(), Error>
443where
444    B: Blockchain + Send + Sync + 'static,
445    W: OnchainWallet + Send + Sync + 'static,
446    S: SwapStorage + 'static,
447{
448    let addrs = client.active_offchain_contract_addresses()?;
449    let new_addrs: Vec<_> = addrs
450        .into_iter()
451        .filter(|addr| !subscribed_addrs.contains(addr))
452        .collect();
453
454    if new_addrs.is_empty() {
455        return Ok(());
456    }
457
458    client
459        .subscribe_to_scripts(new_addrs.clone(), Some(subscription_id.to_string()))
460        .await?;
461
462    let added = new_addrs.len();
463    subscribed_addrs.extend(new_addrs);
464    tracing::info!(
465        added,
466        "Updated watcher subscription with newly active contract addresses"
467    );
468
469    Ok(())
470}
471
472/// Enumerate newly seen unspent delegate-eligible VTXOs from wallet state.
473///
474/// This is a failsafe path to catch outputs that may have been missed by subscription timing.
475async fn collect_new_delegation_candidates<B, W, S>(
476    client: &Client<B, W, S>,
477    seen_unspent_outpoints: &mut HashSet<OutPoint>,
478) -> Result<Vec<VirtualTxOutPoint>, Error>
479where
480    B: Blockchain + Send + Sync + 'static,
481    W: OnchainWallet + Send + Sync + 'static,
482    S: SwapStorage + 'static,
483{
484    let vtxo_list = client.list_vtxos().await?;
485
486    let mut current_outpoints = HashSet::new();
487    let mut newly_seen = Vec::new();
488
489    for entry in vtxo_list.all_unspent() {
490        if entry.contract().contract_type != ark_core::contract::ContractType::delegate_vtxo() {
491            continue;
492        }
493
494        current_outpoints.insert(entry.vtxo().outpoint);
495
496        if !seen_unspent_outpoints.contains(&entry.vtxo().outpoint) {
497            newly_seen.push(entry.vtxo().clone());
498        }
499    }
500
501    *seen_unspent_outpoints = current_outpoints;
502
503    Ok(newly_seen)
504}
505
506/// Delegator info cached per delegation batch.
507struct DelegatorState {
508    cosigner_pk: PublicKey,
509    fee: Amount,
510    fee_address_script: ScriptBuf,
511}
512
513/// Fetch and parse delegator info into a usable form.
514async fn fetch_delegator_state(delegator: &DelegatorClient) -> Result<DelegatorState, Error> {
515    let info = delegator
516        .info()
517        .await
518        .context(Error::ad_hoc("failed to get delegator info"))?;
519
520    let cosigner_pk: PublicKey = info
521        .pubkey
522        .parse::<PublicKey>()
523        .context("failed to parse delegator PK")?;
524
525    let fee = info
526        .fee
527        .parse::<u64>()
528        .map(Amount::from_sat)
529        .context("failed to parse delegator fee")?;
530
531    let fee_address_script = info
532        .delegator_address
533        .parse::<ArkAddress>()
534        .context("failed to parse delegator fee address")?
535        .to_p2tr_script_pubkey();
536
537    Ok(DelegatorState {
538        cosigner_pk,
539        fee,
540        fee_address_script,
541    })
542}
543
544/// Number of seconds in a UTC day.
545const SECONDS_PER_DAY: i64 = 86_400;
546
547/// Normalize a unix timestamp (seconds) to UTC midnight of that day.
548fn day_timestamp(ts: i64) -> i64 {
549    ts - ts.rem_euclid(SECONDS_PER_DAY)
550}
551
552/// Group VTXOs by their expiry day (UTC midnight), returning groups sorted by expiry.
553///
554/// Recoverable VTXOs (expired or sub-dust) are collected separately and merged into the earliest
555/// non-recoverable group.
556fn group_by_expiry_day(vtxos: &[AnnotatedVtxo], dust: Amount) -> Vec<(i64, Vec<&AnnotatedVtxo>)> {
557    let mut groups: BTreeMap<i64, Vec<&AnnotatedVtxo>> = BTreeMap::new();
558    let mut recoverable: Vec<&AnnotatedVtxo> = Vec::new();
559
560    for entry in vtxos {
561        if entry.vtxo().is_spent {
562            continue;
563        }
564
565        if entry.contract().contract_type != ark_core::contract::ContractType::delegate_vtxo() {
566            continue;
567        }
568
569        if entry.vtxo().is_recoverable(dust) {
570            recoverable.push(entry);
571        } else if entry.vtxo().expires_at > 0 {
572            let day = day_timestamp(entry.vtxo().expires_at);
573            groups.entry(day).or_default().push(entry);
574        }
575    }
576
577    if !recoverable.is_empty() {
578        if let Some((&earliest_day, _)) = groups.iter().next() {
579            groups.entry(earliest_day).or_default().extend(recoverable);
580        } else {
581            groups.insert(0, recoverable);
582        }
583    }
584
585    groups.into_iter().collect()
586}
587
588/// Calculate the `valid_at` timestamp for a delegation group.
589///
590/// For each non-recoverable VTXO, compute activation at 90% of its full lifetime:
591/// `created_at + (expires_at - created_at) * 0.9`. The earliest of those activations is used.
592///
593/// If the group only contains recoverable/expired VTXOs (or activation is already in the past),
594/// schedule soon (`now + 60s`).
595fn calculate_valid_at(group_vtxos: &[&AnnotatedVtxo], dust: Amount) -> u64 {
596    let now_secs = std::time::SystemTime::now()
597        .duration_since(std::time::UNIX_EPOCH)
598        .unwrap_or_default()
599        .as_secs();
600
601    let earliest_activation = group_vtxos
602        .iter()
603        .filter(|entry| {
604            !entry.vtxo().is_recoverable(dust)
605                && entry.vtxo().created_at > 0
606                && entry.vtxo().expires_at > 0
607                && entry.vtxo().expires_at > entry.vtxo().created_at
608        })
609        .map(|entry| {
610            let created_at = entry.vtxo().created_at as u64;
611            let lifetime = (entry.vtxo().expires_at - entry.vtxo().created_at) as u64;
612            created_at + (lifetime * 9 / 10)
613        })
614        .min();
615
616    match earliest_activation {
617        Some(valid_at) if valid_at > now_secs => valid_at,
618        _ => now_secs + 60,
619    }
620}
621
622/// Submit newly received VTXOs to the delegator service for future auto-renewal.
623///
624/// Only the affected outpoints are delegated; spend metadata is resolved through contracts.
625async fn delegate_vtxos<B, W, S>(
626    client: &Arc<Client<B, W, S>>,
627    delegator: &DelegatorClient,
628    new_vtxos: &[VirtualTxOutPoint],
629) where
630    B: Blockchain + Send + Sync + 'static,
631    W: OnchainWallet + Send + Sync + 'static,
632    S: SwapStorage + 'static,
633{
634    let vtxo_list = match client.list_vtxos().await {
635        Ok(v) => v,
636        Err(e) => {
637            tracing::error!("Failed to list VTXOs for delegation: {e}");
638            return;
639        }
640    };
641
642    // The subscription event tells us which outpoints are new, but we need the full
643    // VirtualTxOutPoint (with expires_at, created_at) from the server for grouping.
644    let new_outpoints: HashSet<_> = new_vtxos.iter().map(|v| v.outpoint).collect();
645    let enriched: Vec<_> = vtxo_list
646        .all_unspent()
647        .filter(|entry| new_outpoints.contains(&entry.vtxo().outpoint))
648        .cloned()
649        .collect();
650
651    let server_info = match client.server_info().await {
652        Ok(server_info) => server_info,
653        Err(e) => {
654            tracing::error!("Failed to read server info for delegation: {e}");
655            return;
656        }
657    };
658
659    let groups = group_by_expiry_day(&enriched, server_info.dust);
660    if groups.is_empty() {
661        tracing::debug!("No delegate-eligible VTXOs after enrichment/grouping; skipping");
662        return;
663    }
664
665    let delegator_state = match fetch_delegator_state(delegator).await {
666        Ok(s) => Arc::new(s),
667        Err(e) => {
668            tracing::error!("{e}");
669            return;
670        }
671    };
672
673    let (to_address, _) = match client.get_offchain_address().await {
674        Ok(v) => v,
675        Err(e) => {
676            tracing::error!("Failed to get offchain address for delegation: {e}");
677            return;
678        }
679    };
680    let dest_script = to_address.to_p2tr_script_pubkey();
681
682    let mut handles = Vec::new();
683
684    for (_day, group_vtxos) in groups {
685        let valid_at = calculate_valid_at(&group_vtxos, server_info.dust);
686
687        let mut vtxo_inputs = Vec::new();
688        let mut total_amount = Amount::ZERO;
689
690        for entry in &group_vtxos {
691            let spend_selection = match entry
692                .spend_selection(ark_core::contract::SpendPathKind::Delegate)
693            {
694                Ok(selection) => selection,
695                Err(e) => {
696                    tracing::warn!(outpoint = %entry.vtxo().outpoint, "Cannot get delegate spend selection: {e}");
697                    continue;
698                }
699            };
700
701            let exit_delay = match entry.exit_delay() {
702                Ok(exit_delay) => exit_delay,
703                Err(e) => {
704                    tracing::warn!(outpoint = %entry.vtxo().outpoint, "Cannot get delegate exit delay: {e}");
705                    continue;
706                }
707            };
708
709            vtxo_inputs.push(intent::Input::new_with_spend_selection(
710                entry.vtxo().outpoint,
711                exit_delay,
712                TxOut {
713                    value: entry.vtxo().amount,
714                    script_pubkey: entry.script_pubkey(),
715                },
716                entry.tapscripts(),
717                spend_selection,
718                entry.vtxo().is_spent,
719                entry.vtxo().is_swept,
720                entry.vtxo().assets.clone(),
721            ));
722
723            total_amount += entry.vtxo().amount;
724        }
725
726        if vtxo_inputs.is_empty() {
727            continue;
728        }
729
730        let fee = delegator_state.fee;
731        if fee >= total_amount {
732            tracing::warn!(
733                %total_amount, %fee,
734                "Delegator fee exceeds VTXO group value, skipping"
735            );
736            continue;
737        }
738        let net_amount = total_amount - fee;
739
740        if net_amount < server_info.dust {
741            tracing::warn!(%net_amount, "Net amount after fee is below dust, skipping");
742            continue;
743        }
744
745        let mut outputs = Vec::new();
746        if fee > Amount::ZERO {
747            outputs.push(intent::Output::Offchain(TxOut {
748                value: fee,
749                script_pubkey: delegator_state.fee_address_script.clone(),
750            }));
751        }
752        outputs.push(intent::Output::Offchain(TxOut {
753            value: net_amount,
754            script_pubkey: dest_script.clone(),
755        }));
756
757        let server_info_forfeit_addr = server_info.forfeit_address.clone();
758        let dust = server_info.dust;
759        let ds = Arc::clone(&delegator_state);
760
761        let delegator = delegator.clone();
762        let client = Arc::clone(client);
763        handles.push(tokio::spawn(async move {
764            delegate_group(
765                &client,
766                &delegator,
767                vtxo_inputs,
768                outputs,
769                ds.cosigner_pk,
770                &server_info_forfeit_addr,
771                dust,
772                valid_at,
773            )
774            .await;
775        }));
776    }
777
778    for handle in handles {
779        let _ = handle.await;
780    }
781}
782
783/// Prepare, sign, and submit a single delegation group.
784async fn delegate_group<B, W, S>(
785    client: &Client<B, W, S>,
786    delegator: &DelegatorClient,
787    vtxo_inputs: Vec<intent::Input>,
788    outputs: Vec<intent::Output>,
789    cosigner_pk: PublicKey,
790    forfeit_address: &bitcoin::Address,
791    dust: Amount,
792    valid_at: u64,
793) where
794    B: Blockchain + Send + Sync + 'static,
795    W: OnchainWallet + Send + Sync + 'static,
796    S: SwapStorage + 'static,
797{
798    let input_count = vtxo_inputs.len();
799
800    let mut delegate = match ark_core::batch::prepare_delegate_psbts_at(
801        vtxo_inputs,
802        outputs,
803        cosigner_pk,
804        forfeit_address,
805        dust,
806        Some(valid_at),
807    ) {
808        Ok(d) => d,
809        Err(e) => {
810            tracing::error!("Failed to prepare delegate PSBTs: {e}");
811            return;
812        }
813    };
814
815    if let Err(e) =
816        client.sign_delegate_psbts(&mut delegate.intent.proof, &mut delegate.forfeit_psbts)
817    {
818        tracing::error!("Failed to sign delegate PSBTs: {e}");
819        return;
820    }
821
822    if let Err(e) = delegator
823        .delegate(&delegate.intent, &delegate.forfeit_psbts, None)
824        .await
825    {
826        tracing::error!("Failed to submit delegation: {e}");
827        return;
828    }
829
830    tracing::info!(
831        vtxo_count = input_count,
832        valid_at,
833        "Delegated VTXO group to delegator service"
834    );
835}
836
837/// Fraction of VTXO lifetime remaining at which we self-renew as a safety net.
838const SELF_RENEW_REMAINING_FRACTION: f64 = 0.10;
839
840/// Select VTXOs that should be self-renewed.
841///
842/// Includes all recoverable VTXOs (expired, swept, or sub-dust) plus VTXOs whose remaining lifetime
843/// is less than [`SELF_RENEW_REMAINING_FRACTION`] of their total lifetime.
844fn select_vtxos_for_self_renewal(vtxos: &[AnnotatedVtxo], dust: Amount, now: i64) -> Vec<OutPoint> {
845    let selected: Vec<_> = vtxos
846        .iter()
847        .filter(|entry| {
848            if entry.vtxo().is_recoverable(dust) {
849                return true;
850            }
851
852            if entry.vtxo().expires_at <= 0 || entry.vtxo().created_at <= 0 {
853                return false;
854            }
855            let total_lifetime = entry.vtxo().expires_at - entry.vtxo().created_at;
856            let remaining = entry.vtxo().expires_at - now;
857            remaining > 0
858                && (remaining as f64) < (total_lifetime as f64 * SELF_RENEW_REMAINING_FRACTION)
859        })
860        .collect();
861
862    let total_amount = selected
863        .iter()
864        .fold(Amount::ZERO, |total, entry| total + entry.vtxo().amount);
865    if total_amount < dust {
866        return Vec::new();
867    }
868
869    selected.iter().map(|entry| entry.vtxo().outpoint).collect()
870}
871
872/// Self-renew VTXOs that are close to expiry or already recoverable.
873async fn renew_expiring_vtxos<B, W, S>(client: &Client<B, W, S>)
874where
875    B: Blockchain + Send + Sync + 'static,
876    W: OnchainWallet + Send + Sync + 'static,
877    S: SwapStorage + 'static,
878{
879    let vtxo_list = match client.list_vtxos().await {
880        Ok(v) => v,
881        Err(e) => {
882            tracing::warn!("Failed to list VTXOs for renewal check: {e}");
883            return;
884        }
885    };
886
887    let server_info = match client.server_info().await {
888        Ok(server_info) => server_info,
889        Err(e) => {
890            tracing::warn!("Failed to read server info for renewal check: {e}");
891            return;
892        }
893    };
894
895    let now = std::time::SystemTime::now()
896        .duration_since(std::time::UNIX_EPOCH)
897        .unwrap_or_default()
898        .as_secs() as i64;
899
900    let unspent: Vec<_> = vtxo_list.all_unspent().cloned().collect();
901    let expiring_outpoints = select_vtxos_for_self_renewal(&unspent, server_info.dust, now);
902
903    if expiring_outpoints.is_empty() {
904        return;
905    }
906
907    tracing::info!(
908        count = expiring_outpoints.len(),
909        "Self-renewing expiring/recoverable VTXOs"
910    );
911
912    let mut rng = OsRng;
913    match client
914        .settle_vtxos(&mut rng, &expiring_outpoints, &[])
915        .await
916    {
917        Ok(Some(txid)) => {
918            tracing::info!(%txid, "Self-renewed expiring VTXOs");
919        }
920        Ok(None) => {}
921        Err(e) => {
922            tracing::warn!("Failed to self-renew VTXOs: {e}");
923        }
924    }
925}
926
927#[cfg(test)]
928mod tests {
929    use super::*;
930    use bitcoin::hashes::Hash;
931    use bitcoin::key::Secp256k1;
932    use bitcoin::Network;
933    use bitcoin::Sequence;
934    use bitcoin::Txid;
935    use bitcoin::XOnlyPublicKey;
936    use std::str::FromStr;
937
938    fn test_keys() -> (XOnlyPublicKey, XOnlyPublicKey, XOnlyPublicKey) {
939        let server = XOnlyPublicKey::from_str(
940            "18845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166",
941        )
942        .unwrap();
943        let owner = XOnlyPublicKey::from_str(
944            "28845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166",
945        )
946        .unwrap();
947        let delegator = XOnlyPublicKey::from_str(
948            "38845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166",
949        )
950        .unwrap();
951        (server, owner, delegator)
952    }
953
954    fn delegated_vtxo() -> (ArkAddress, Vtxo) {
955        let secp = Secp256k1::new();
956        let (server, owner, delegator) = test_keys();
957        let vtxo = Vtxo::new_with_delegator(
958            &secp,
959            server,
960            owner,
961            delegator,
962            Sequence::from_seconds_ceil(86400).unwrap(),
963            Network::Regtest,
964        )
965        .unwrap();
966        (vtxo.to_ark_address(), vtxo)
967    }
968
969    fn mk_contract_vtxo(
970        script: ScriptBuf,
971        amount_sat: u64,
972        expires_at: i64,
973        vout: u32,
974    ) -> AnnotatedVtxo {
975        use ark_core::contract::ContractState;
976        use ark_core::contract::ContractType;
977        use ark_core::contract::DelegateVtxoContract;
978        use ark_core::contract::SpendPath;
979        use ark_core::contract::SpendPathKind;
980        use ark_core::contract::StoredContract;
981
982        let (server, owner, delegator) = test_keys();
983        let contract = DelegateVtxoContract {
984            server,
985            owner,
986            delegator,
987            exit_delay: Sequence::from_seconds_ceil(86400).unwrap(),
988        };
989        let vtxo = VirtualTxOutPoint {
990            outpoint: OutPoint::new(Txid::all_zeros(), vout),
991            created_at: expires_at - 1000,
992            expires_at,
993            amount: Amount::from_sat(amount_sat),
994            script: script.clone(),
995            is_preconfirmed: false,
996            is_swept: false,
997            is_unrolled: false,
998            is_spent: false,
999            spent_by: None,
1000            commitment_txids: vec![],
1001            settled_by: None,
1002            ark_txid: None,
1003            assets: vec![],
1004        };
1005        AnnotatedVtxo::new(
1006            StoredContract {
1007                contract_type: ContractType::delegate_vtxo(),
1008                contract_version: 1,
1009                script_pubkey: script,
1010                state: ContractState::Active,
1011                created_at: 0,
1012                key_index: None,
1013                data: serde_json::to_value(contract).unwrap(),
1014            },
1015            vtxo,
1016            vec![SpendPath::new(
1017                SpendPathKind::Delegate,
1018                ScriptBuf::new(),
1019                dummy_control_block(),
1020            )
1021            .select()],
1022        )
1023    }
1024
1025    fn dummy_control_block() -> bitcoin::taproot::ControlBlock {
1026        let secp = Secp256k1::new();
1027        let internal_key = test_keys().0;
1028        let spend_info = bitcoin::taproot::TaprootBuilder::new()
1029            .add_leaf(0, ScriptBuf::new())
1030            .unwrap()
1031            .finalize(&secp, internal_key)
1032            .unwrap();
1033        spend_info
1034            .control_block(&(ScriptBuf::new(), bitcoin::taproot::LeafVersion::TapScript))
1035            .unwrap()
1036    }
1037
1038    #[test]
1039    fn migration_delay_uses_base_interval_when_healthy() {
1040        assert_eq!(migration_delay(0), MIGRATION_INTERVAL);
1041    }
1042
1043    #[test]
1044    fn migration_delay_backs_off_exponentially_and_caps() {
1045        // 30s * 2^(failures-1): 30s, 60s, 120s, 240s, then saturates at the 5min cap.
1046        assert_eq!(migration_delay(1), MIGRATION_BASE_COOLDOWN);
1047        assert_eq!(migration_delay(2), MIGRATION_BASE_COOLDOWN * 2);
1048        assert_eq!(migration_delay(3), MIGRATION_BASE_COOLDOWN * 4);
1049        assert_eq!(migration_delay(4), MIGRATION_BASE_COOLDOWN * 8);
1050        assert_eq!(migration_delay(5), MIGRATION_MAX_COOLDOWN);
1051        // Large failure counts must not panic (no shift overflow) and stay at the cap.
1052        assert_eq!(migration_delay(100), MIGRATION_MAX_COOLDOWN);
1053        assert_eq!(migration_delay(u32::MAX), MIGRATION_MAX_COOLDOWN);
1054    }
1055
1056    #[test]
1057    fn day_timestamp_normalizes_to_midnight() {
1058        let ts = 1705322700; // 2024-01-15 13:45:00 UTC
1059        let day = day_timestamp(ts);
1060        assert_eq!(day % SECONDS_PER_DAY, 0);
1061        assert!(day <= ts);
1062        assert!(ts - day < SECONDS_PER_DAY);
1063    }
1064
1065    #[test]
1066    fn day_timestamp_already_midnight() {
1067        let ts = SECONDS_PER_DAY * 19738;
1068        assert_eq!(day_timestamp(ts), ts);
1069    }
1070
1071    #[test]
1072    fn group_by_expiry_day_merges_recoverable_into_earliest_group() {
1073        let (addr, _) = delegated_vtxo();
1074        let script = addr.to_p2tr_script_pubkey();
1075
1076        let now = std::time::SystemTime::now()
1077            .duration_since(std::time::UNIX_EPOCH)
1078            .unwrap()
1079            .as_secs() as i64;
1080        let day1_midnight = day_timestamp(now) + SECONDS_PER_DAY;
1081        let day2_midnight = day1_midnight + SECONDS_PER_DAY;
1082
1083        let recoverable = mk_contract_vtxo(script.clone(), 100, day1_midnight + 500, 0); // sub-dust
1084        let non_recoverable_day1 = mk_contract_vtxo(script.clone(), 10_000, day1_midnight + 800, 1);
1085        let non_recoverable_day2 = mk_contract_vtxo(script, 10_000, day2_midnight + 800, 2);
1086
1087        let vtxos = [non_recoverable_day2, recoverable, non_recoverable_day1];
1088        let groups = group_by_expiry_day(&vtxos, Amount::from_sat(500));
1089
1090        assert_eq!(groups.len(), 2);
1091        assert_eq!(groups[0].0, day_timestamp(day1_midnight + 800));
1092        assert_eq!(groups[1].0, day_timestamp(day2_midnight + 800));
1093        assert_eq!(groups[0].1.len(), 2);
1094        assert_eq!(groups[1].1.len(), 1);
1095    }
1096
1097    #[test]
1098    fn calculate_valid_at_for_non_recoverable_group_is_before_expiry() {
1099        let script = ScriptBuf::new();
1100
1101        let now = std::time::SystemTime::now()
1102            .duration_since(std::time::UNIX_EPOCH)
1103            .unwrap()
1104            .as_secs() as i64;
1105
1106        let later = mk_contract_vtxo(script, 10_000, now + 10_000, 1);
1107        let group = vec![&later];
1108
1109        let valid_at = calculate_valid_at(&group, Amount::from_sat(500));
1110
1111        assert!(valid_at > now as u64);
1112        assert!(valid_at < later.vtxo().expires_at as u64);
1113    }
1114
1115    #[test]
1116    fn select_vtxos_for_self_renewal_includes_expired_and_subdust() {
1117        let script = ScriptBuf::new();
1118        let now = std::time::SystemTime::now()
1119            .duration_since(std::time::UNIX_EPOCH)
1120            .unwrap()
1121            .as_secs() as i64;
1122
1123        let expired = mk_contract_vtxo(script.clone(), 10_000, now - 1, 0);
1124        let subdust = mk_contract_vtxo(script.clone(), 100, now + 10_000, 1);
1125        let fresh = mk_contract_vtxo(script, 10_000, now + 10_000, 2);
1126
1127        let selected = select_vtxos_for_self_renewal(
1128            &[expired.clone(), subdust.clone(), fresh],
1129            Amount::from_sat(500),
1130            now,
1131        );
1132
1133        assert_eq!(
1134            selected,
1135            vec![expired.vtxo().outpoint, subdust.vtxo().outpoint]
1136        );
1137    }
1138
1139    #[test]
1140    fn select_vtxos_for_self_renewal_includes_near_expiry() {
1141        let script = ScriptBuf::new();
1142        let now = std::time::SystemTime::now()
1143            .duration_since(std::time::UNIX_EPOCH)
1144            .unwrap()
1145            .as_secs() as i64;
1146
1147        let near_expiry = mk_contract_vtxo(script, 10_000, now + 50, 0);
1148
1149        let selected = select_vtxos_for_self_renewal(
1150            std::slice::from_ref(&near_expiry),
1151            Amount::from_sat(500),
1152            now,
1153        );
1154
1155        assert_eq!(selected, vec![near_expiry.vtxo().outpoint]);
1156    }
1157
1158    #[test]
1159    fn select_vtxos_for_self_renewal_skips_when_total_is_below_dust() {
1160        let script = ScriptBuf::new();
1161        let now = std::time::SystemTime::now()
1162            .duration_since(std::time::UNIX_EPOCH)
1163            .unwrap()
1164            .as_secs() as i64;
1165
1166        let subdust1 = mk_contract_vtxo(script.clone(), 100, now + 10_000, 0);
1167        let subdust2 = mk_contract_vtxo(script, 200, now + 10_000, 1);
1168
1169        let selected =
1170            select_vtxos_for_self_renewal(&[subdust1, subdust2], Amount::from_sat(500), now);
1171
1172        assert!(selected.is_empty());
1173    }
1174
1175    #[test]
1176    fn calculate_valid_at_for_recoverable_only_group_is_soon() {
1177        let script = ScriptBuf::new();
1178
1179        let now = std::time::SystemTime::now()
1180            .duration_since(std::time::UNIX_EPOCH)
1181            .unwrap()
1182            .as_secs() as i64;
1183
1184        let recoverable = mk_contract_vtxo(script, 100, now + 5_000, 0); // sub-dust at dust=500
1185        let group = vec![&recoverable];
1186
1187        let start = std::time::SystemTime::now()
1188            .duration_since(std::time::UNIX_EPOCH)
1189            .unwrap()
1190            .as_secs();
1191        let valid_at = calculate_valid_at(&group, Amount::from_sat(500));
1192        let end = std::time::SystemTime::now()
1193            .duration_since(std::time::UNIX_EPOCH)
1194            .unwrap()
1195            .as_secs();
1196
1197        assert!(valid_at >= start + 60);
1198        assert!(valid_at <= end + 61);
1199    }
1200}