1use 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
36pub struct VtxoWatcherHandle {
40 stop_tx: watch::Sender<bool>,
41}
42
43impl VtxoWatcherHandle {
44 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
56const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
58const MAX_BACKOFF: Duration = Duration::from_secs(30);
59
60const KEY_DISCOVERY_INTERVAL: Duration = Duration::from_secs(10);
62
63const MIGRATION_INTERVAL: Duration = Duration::from_secs(60);
68
69const MIGRATION_BASE_COOLDOWN: Duration = Duration::from_secs(30);
72const MIGRATION_MAX_COOLDOWN: Duration = Duration::from_secs(300);
73
74#[derive(Debug, Clone, Copy)]
76pub struct VtxoWatcherConfig {
77 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 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
135async 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 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 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 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 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
353async 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 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 consecutive_failures = 0;
402 }
403 }
404 Err(e) => {
405 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
414fn 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
429async 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
437async 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
472async 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
506struct DelegatorState {
508 cosigner_pk: PublicKey,
509 fee: Amount,
510 fee_address_script: ScriptBuf,
511}
512
513async 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
544const SECONDS_PER_DAY: i64 = 86_400;
546
547fn day_timestamp(ts: i64) -> i64 {
549 ts - ts.rem_euclid(SECONDS_PER_DAY)
550}
551
552fn 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
588fn 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
622async 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 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
783async 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
837const SELF_RENEW_REMAINING_FRACTION: f64 = 0.10;
839
840fn 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
872async 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 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 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; 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); 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); 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}