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
840async fn renew_expiring_vtxos<B, W, S>(client: &Client<B, W, S>)
845where
846 B: Blockchain + Send + Sync + 'static,
847 W: OnchainWallet + Send + Sync + 'static,
848 S: SwapStorage + 'static,
849{
850 let vtxo_list = match client.list_vtxos().await {
851 Ok(v) => v,
852 Err(e) => {
853 tracing::warn!("Failed to list VTXOs for renewal check: {e}");
854 return;
855 }
856 };
857
858 let now = std::time::SystemTime::now()
859 .duration_since(std::time::UNIX_EPOCH)
860 .unwrap_or_default()
861 .as_secs() as i64;
862
863 let expiring_outpoints: Vec<OutPoint> = vtxo_list
864 .all_unspent()
865 .filter(|entry| {
866 if entry.vtxo().expires_at <= 0 || entry.vtxo().created_at <= 0 {
867 return false;
868 }
869 let total_lifetime = entry.vtxo().expires_at - entry.vtxo().created_at;
870 let remaining = entry.vtxo().expires_at - now;
871 remaining > 0
872 && (remaining as f64) < (total_lifetime as f64 * SELF_RENEW_REMAINING_FRACTION)
873 })
874 .map(|entry| entry.vtxo().outpoint)
875 .collect();
876
877 if expiring_outpoints.is_empty() {
878 return;
879 }
880
881 tracing::info!(
882 count = expiring_outpoints.len(),
883 "Self-renewing expiring VTXOs"
884 );
885
886 let mut rng = OsRng;
887 match client
888 .settle_vtxos(&mut rng, &expiring_outpoints, &[])
889 .await
890 {
891 Ok(Some(txid)) => {
892 tracing::info!(%txid, "Self-renewed expiring VTXOs");
893 }
894 Ok(None) => {}
895 Err(e) => {
896 tracing::warn!("Failed to self-renew VTXOs: {e}");
897 }
898 }
899}
900
901#[cfg(test)]
902mod tests {
903 use super::*;
904 use bitcoin::hashes::Hash;
905 use bitcoin::key::Secp256k1;
906 use bitcoin::Network;
907 use bitcoin::Sequence;
908 use bitcoin::Txid;
909 use bitcoin::XOnlyPublicKey;
910 use std::str::FromStr;
911
912 fn test_keys() -> (XOnlyPublicKey, XOnlyPublicKey, XOnlyPublicKey) {
913 let server = XOnlyPublicKey::from_str(
914 "18845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166",
915 )
916 .unwrap();
917 let owner = XOnlyPublicKey::from_str(
918 "28845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166",
919 )
920 .unwrap();
921 let delegator = XOnlyPublicKey::from_str(
922 "38845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166",
923 )
924 .unwrap();
925 (server, owner, delegator)
926 }
927
928 fn delegated_vtxo() -> (ArkAddress, Vtxo) {
929 let secp = Secp256k1::new();
930 let (server, owner, delegator) = test_keys();
931 let vtxo = Vtxo::new_with_delegator(
932 &secp,
933 server,
934 owner,
935 delegator,
936 Sequence::from_seconds_ceil(86400).unwrap(),
937 Network::Regtest,
938 )
939 .unwrap();
940 (vtxo.to_ark_address(), vtxo)
941 }
942
943 fn mk_contract_vtxo(
944 script: ScriptBuf,
945 amount_sat: u64,
946 expires_at: i64,
947 vout: u32,
948 ) -> AnnotatedVtxo {
949 use ark_core::contract::ContractState;
950 use ark_core::contract::ContractType;
951 use ark_core::contract::DelegateVtxoContract;
952 use ark_core::contract::SpendPath;
953 use ark_core::contract::SpendPathKind;
954 use ark_core::contract::StoredContract;
955
956 let (server, owner, delegator) = test_keys();
957 let contract = DelegateVtxoContract {
958 server,
959 owner,
960 delegator,
961 exit_delay: Sequence::from_seconds_ceil(86400).unwrap(),
962 };
963 let vtxo = VirtualTxOutPoint {
964 outpoint: OutPoint::new(Txid::all_zeros(), vout),
965 created_at: expires_at - 1000,
966 expires_at,
967 amount: Amount::from_sat(amount_sat),
968 script: script.clone(),
969 is_preconfirmed: false,
970 is_swept: false,
971 is_unrolled: false,
972 is_spent: false,
973 spent_by: None,
974 commitment_txids: vec![],
975 settled_by: None,
976 ark_txid: None,
977 assets: vec![],
978 };
979 AnnotatedVtxo::new(
980 StoredContract {
981 contract_type: ContractType::delegate_vtxo(),
982 contract_version: 1,
983 script_pubkey: script,
984 state: ContractState::Active,
985 created_at: 0,
986 key_index: None,
987 data: serde_json::to_value(contract).unwrap(),
988 },
989 vtxo,
990 vec![SpendPath::new(
991 SpendPathKind::Delegate,
992 ScriptBuf::new(),
993 dummy_control_block(),
994 )
995 .select()],
996 )
997 }
998
999 fn dummy_control_block() -> bitcoin::taproot::ControlBlock {
1000 let secp = Secp256k1::new();
1001 let internal_key = test_keys().0;
1002 let spend_info = bitcoin::taproot::TaprootBuilder::new()
1003 .add_leaf(0, ScriptBuf::new())
1004 .unwrap()
1005 .finalize(&secp, internal_key)
1006 .unwrap();
1007 spend_info
1008 .control_block(&(ScriptBuf::new(), bitcoin::taproot::LeafVersion::TapScript))
1009 .unwrap()
1010 }
1011
1012 #[test]
1013 fn migration_delay_uses_base_interval_when_healthy() {
1014 assert_eq!(migration_delay(0), MIGRATION_INTERVAL);
1015 }
1016
1017 #[test]
1018 fn migration_delay_backs_off_exponentially_and_caps() {
1019 assert_eq!(migration_delay(1), MIGRATION_BASE_COOLDOWN);
1021 assert_eq!(migration_delay(2), MIGRATION_BASE_COOLDOWN * 2);
1022 assert_eq!(migration_delay(3), MIGRATION_BASE_COOLDOWN * 4);
1023 assert_eq!(migration_delay(4), MIGRATION_BASE_COOLDOWN * 8);
1024 assert_eq!(migration_delay(5), MIGRATION_MAX_COOLDOWN);
1025 assert_eq!(migration_delay(100), MIGRATION_MAX_COOLDOWN);
1027 assert_eq!(migration_delay(u32::MAX), MIGRATION_MAX_COOLDOWN);
1028 }
1029
1030 #[test]
1031 fn day_timestamp_normalizes_to_midnight() {
1032 let ts = 1705322700; let day = day_timestamp(ts);
1034 assert_eq!(day % SECONDS_PER_DAY, 0);
1035 assert!(day <= ts);
1036 assert!(ts - day < SECONDS_PER_DAY);
1037 }
1038
1039 #[test]
1040 fn day_timestamp_already_midnight() {
1041 let ts = SECONDS_PER_DAY * 19738;
1042 assert_eq!(day_timestamp(ts), ts);
1043 }
1044
1045 #[test]
1046 fn group_by_expiry_day_merges_recoverable_into_earliest_group() {
1047 let (addr, _) = delegated_vtxo();
1048 let script = addr.to_p2tr_script_pubkey();
1049
1050 let now = std::time::SystemTime::now()
1051 .duration_since(std::time::UNIX_EPOCH)
1052 .unwrap()
1053 .as_secs() as i64;
1054 let day1_midnight = day_timestamp(now) + SECONDS_PER_DAY;
1055 let day2_midnight = day1_midnight + SECONDS_PER_DAY;
1056
1057 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);
1059 let non_recoverable_day2 = mk_contract_vtxo(script, 10_000, day2_midnight + 800, 2);
1060
1061 let vtxos = [non_recoverable_day2, recoverable, non_recoverable_day1];
1062 let groups = group_by_expiry_day(&vtxos, Amount::from_sat(500));
1063
1064 assert_eq!(groups.len(), 2);
1065 assert_eq!(groups[0].0, day_timestamp(day1_midnight + 800));
1066 assert_eq!(groups[1].0, day_timestamp(day2_midnight + 800));
1067 assert_eq!(groups[0].1.len(), 2);
1068 assert_eq!(groups[1].1.len(), 1);
1069 }
1070
1071 #[test]
1072 fn calculate_valid_at_for_non_recoverable_group_is_before_expiry() {
1073 let script = ScriptBuf::new();
1074
1075 let now = std::time::SystemTime::now()
1076 .duration_since(std::time::UNIX_EPOCH)
1077 .unwrap()
1078 .as_secs() as i64;
1079
1080 let later = mk_contract_vtxo(script, 10_000, now + 10_000, 1);
1081 let group = vec![&later];
1082
1083 let valid_at = calculate_valid_at(&group, Amount::from_sat(500));
1084
1085 assert!(valid_at > now as u64);
1086 assert!(valid_at < later.vtxo().expires_at as u64);
1087 }
1088
1089 #[test]
1090 fn calculate_valid_at_for_recoverable_only_group_is_soon() {
1091 let script = ScriptBuf::new();
1092
1093 let now = std::time::SystemTime::now()
1094 .duration_since(std::time::UNIX_EPOCH)
1095 .unwrap()
1096 .as_secs() as i64;
1097
1098 let recoverable = mk_contract_vtxo(script, 100, now + 5_000, 0); let group = vec![&recoverable];
1100
1101 let start = std::time::SystemTime::now()
1102 .duration_since(std::time::UNIX_EPOCH)
1103 .unwrap()
1104 .as_secs();
1105 let valid_at = calculate_valid_at(&group, Amount::from_sat(500));
1106 let end = std::time::SystemTime::now()
1107 .duration_since(std::time::UNIX_EPOCH)
1108 .unwrap()
1109 .as_secs();
1110
1111 assert!(valid_at >= start + 60);
1112 assert!(valid_at <= end + 61);
1113 }
1114}