ark_client/migration.rs
1use crate::error::ErrorContext;
2use crate::swap_storage::SwapStorage;
3use crate::utils::timeout_op;
4use crate::utils::unix_now;
5use crate::wallet::OnchainWallet;
6use crate::Blockchain;
7use crate::Client;
8use crate::Error;
9use ark_core::server::DeprecatedSignerStatus;
10use ark_core::ExplorerUtxo;
11use bitcoin::Amount;
12use bitcoin::OutPoint;
13use bitcoin::Txid;
14use bitcoin::XOnlyPublicKey;
15use std::collections::HashMap;
16use std::collections::HashSet;
17
18/// Maximum number of inputs a single deprecated-signer migration leg will settle in one batch.
19///
20/// A client-side safeguard: it bounds the input count of one
21/// [`Client::migrate_deprecated_signer_vtxos`] leg so a wallet holding many small VTXOs does not
22/// build a batch intent that exceeds the server's transaction-weight limit. Any overflow is
23/// deferred to a later migration cycle (see [`MigrationLegReport::deferred`]).
24pub const MAX_VTXOS_PER_SETTLEMENT: usize = 50;
25
26/// A single VTXO or boarding output referenced in a [`DeprecatedSignerMigrationReport`].
27#[derive(Debug, Clone)]
28pub struct MigrationVtxoRef {
29 /// The input's outpoint.
30 pub outpoint: OutPoint,
31 /// The input's amount.
32 pub amount: Amount,
33 /// The deprecated signer the input was minted under.
34 pub signer_pk: XOnlyPublicKey,
35 /// The signer's advertised cooperative-sign cutoff (Unix seconds); `0` means "rotate now".
36 pub cutoff_date: i64,
37}
38
39/// Why a single migration leg ([`DeprecatedSignerMigrationReport::vtxo`] or
40/// [`DeprecatedSignerMigrationReport::boarding`]) settled nothing.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum MigrationSkipReason {
43 /// The selected aggregate fell below the server's dust floor.
44 BelowDust,
45 /// Every migratable input in the leg individually exceeds the per-output ceiling
46 /// (`vtxo_max_amount`); none can migrate cooperatively, so the leg has only `oversized`
47 /// inputs and submitted nothing.
48 OversizedOnly,
49 /// The leg had no migratable inputs at all.
50 NothingMigratable,
51}
52
53/// Outcome of one [`Client::migrate_deprecated_signer_vtxos`] leg.
54///
55/// Each leg owns its full sizing pipeline and reports independently — a failure or skip in one leg
56/// never suppresses the other. The pipeline is:
57///
58/// 1. inputs whose individual amount exceeds the server's per-output ceiling (`vtxo_max_amount`)
59/// are split out as [`Self::oversized`] — they can never form a `<= ceiling` output and must
60/// exit unilaterally;
61/// 2. the remainder is selected highest-value-first, bounded by both [`MAX_VTXOS_PER_SETTLEMENT`]
62/// and a running aggregate within the ceiling — the overflow lands in [`Self::deferred`] for a
63/// later cycle;
64/// 3. if the selected aggregate is below the dust floor, the leg is [`Self::skipped`] and nothing
65/// is submitted.
66#[derive(Debug, Clone)]
67pub struct MigrationLegReport {
68 /// The settlement TXID, when this leg submitted a batch. `None` on skip.
69 pub settle_txid: Option<Txid>,
70 /// Inputs submitted in this leg's settlement; empty on skip.
71 pub migrated: Vec<MigrationVtxoRef>,
72 /// Migratable inputs deferred to a later cycle by this leg's count or amount caps.
73 pub deferred: Vec<MigrationVtxoRef>,
74 /// Inputs whose value alone exceeds the per-output ceiling; they require a unilateral exit and
75 /// never migrate cooperatively.
76 pub oversized: Vec<MigrationVtxoRef>,
77 /// Why this leg submitted nothing; `None` when a settlement was attempted.
78 pub skipped: Option<MigrationSkipReason>,
79 /// The settlement error, if this leg's `settle_vtxos` call failed. Set independently of the
80 /// other leg — a failure here does not prevent the other leg from running.
81 pub error: Option<String>,
82}
83
84impl MigrationLegReport {
85 /// A leg that submitted nothing for the given reason.
86 fn skipped(reason: MigrationSkipReason) -> Self {
87 Self {
88 settle_txid: None,
89 migrated: Vec::new(),
90 deferred: Vec::new(),
91 oversized: Vec::new(),
92 skipped: Some(reason),
93 error: None,
94 }
95 }
96
97 /// Whether this leg attempted settlement and failed.
98 pub fn failed(&self) -> bool {
99 self.error.is_some()
100 }
101}
102
103/// Result of a [`Client::migrate_deprecated_signer_vtxos`] pass, split into two symmetric legs:
104/// a VTXO leg and a boarding leg. They are never combined into a single intent.
105#[derive(Debug, Clone)]
106pub struct DeprecatedSignerMigrationReport {
107 /// The VTXO migration leg.
108 pub vtxo: MigrationLegReport,
109 /// The boarding-output migration leg.
110 pub boarding: MigrationLegReport,
111}
112
113impl DeprecatedSignerMigrationReport {
114 /// A report where both legs found nothing to migrate (e.g. the server advertises no
115 /// deprecated signers, or the wallet holds no pre-cutoff deprecated-signer outputs).
116 fn nothing_migratable() -> Self {
117 Self {
118 vtxo: MigrationLegReport::skipped(MigrationSkipReason::NothingMigratable),
119 boarding: MigrationLegReport::skipped(MigrationSkipReason::NothingMigratable),
120 }
121 }
122
123 /// Whether any migration leg attempted settlement and failed.
124 pub fn failed(&self) -> bool {
125 self.vtxo.failed() || self.boarding.failed()
126 }
127
128 /// Whether the wallet was rotated off a deprecated signer this pass — i.e. at least one leg
129 /// submitted a settlement.
130 pub fn rotated(&self) -> bool {
131 self.vtxo.settle_txid.is_some() || self.boarding.settle_txid.is_some()
132 }
133
134 /// The settlement TXIDs produced this pass (at most one per leg).
135 pub fn settle_txids(&self) -> Vec<Txid> {
136 [self.vtxo.settle_txid, self.boarding.settle_txid]
137 .into_iter()
138 .flatten()
139 .collect()
140 }
141}
142
143/// Outcome of sizing one migration leg's candidate inputs against the server limits, before any
144/// settlement I/O. Produced by [`size_migration_leg`].
145#[derive(Debug, Clone)]
146struct MigrationLegSizing {
147 /// Inputs chosen to be settled this pass (highest-value-first within the caps).
148 selected: Vec<MigrationVtxoRef>,
149 /// Migratable inputs deferred to a later cycle by the count or aggregate caps.
150 deferred: Vec<MigrationVtxoRef>,
151 /// Inputs whose individual value exceeds the per-output ceiling; they can never form a
152 /// `<= ceiling` output and must exit unilaterally.
153 oversized: Vec<MigrationVtxoRef>,
154 /// Why nothing was selected, if so. `None` when [`Self::selected`] is non-empty and the leg
155 /// should proceed to settle.
156 skip_reason: Option<MigrationSkipReason>,
157}
158
159/// Size one migration leg's candidates against the per-output ceiling (`vtxo_max_amount`) and the
160/// dust floor, without performing any settlement.
161///
162/// This is the pure core of [`Client::run_migration_leg`], factored out so its branching (oversized
163/// split, count cap, running-aggregate ceiling, dust floor, and the skip-reason classification) is
164/// unit-testable without a `Client`/network. The pipeline is:
165///
166/// 1. inputs whose individual amount exceeds `vtxo_max_amount` are split out as `oversized` (a
167/// `None` ceiling means no limit, so nothing is oversized);
168/// 2. the remainder is selected highest-value-first, bounded by both [`MAX_VTXOS_PER_SETTLEMENT`]
169/// (a hard stop) and a running aggregate kept within the ceiling (a skip, so a smaller input
170/// behind a larger one can still get in); the overflow lands in `deferred`;
171/// 3. if nothing was selected, or the selected aggregate is below `dust`, `skip_reason` is set
172/// ([`MigrationSkipReason::OversizedOnly`] when the only candidates were oversized, else
173/// [`MigrationSkipReason::BelowDust`]); an empty candidate list yields
174/// [`MigrationSkipReason::NothingMigratable`].
175fn size_migration_leg(
176 candidates: Vec<MigrationVtxoRef>,
177 vtxo_max_amount: Option<Amount>,
178 dust: Amount,
179) -> MigrationLegSizing {
180 if candidates.is_empty() {
181 return MigrationLegSizing {
182 selected: Vec::new(),
183 deferred: Vec::new(),
184 oversized: Vec::new(),
185 skip_reason: Some(MigrationSkipReason::NothingMigratable),
186 };
187 }
188
189 // (1) Split out inputs whose INDIVIDUAL amount exceeds the per-output ceiling. They can
190 // never form a `<= ceiling` output, so they cannot migrate cooperatively and must exit
191 // unilaterally. Report them rather than dropping them. `None` ceiling => no limit.
192 let (oversized, mut sized): (Vec<_>, Vec<_>) = candidates
193 .into_iter()
194 .partition(|c| vtxo_max_amount.is_some_and(|max| c.amount > max));
195
196 if !oversized.is_empty() {
197 tracing::warn!(
198 count = oversized.len(),
199 ?vtxo_max_amount,
200 "Deprecated-signer migration: inputs exceed the per-output limit and cannot be \
201 migrated cooperatively; they require a unilateral exit"
202 );
203 }
204
205 // (2) Select highest-value-first, bounded by both the count cap and a running aggregate
206 // within the ceiling. Skipped (not stopped) on an aggregate breach so a smaller input
207 // behind an oversized-but-sized one still gets in; the count cap is a hard stop. The rest
208 // is deferred to a later cycle.
209 sized.sort_by_key(|c| std::cmp::Reverse(c.amount));
210
211 let mut selected: Vec<MigrationVtxoRef> = Vec::new();
212 let mut deferred: Vec<MigrationVtxoRef> = Vec::new();
213 let mut aggregate = Amount::ZERO;
214 for candidate in sized {
215 if selected.len() >= MAX_VTXOS_PER_SETTLEMENT {
216 deferred.push(candidate);
217 continue;
218 }
219 let next = aggregate + candidate.amount;
220 if vtxo_max_amount.is_some_and(|max| next > max) {
221 deferred.push(candidate);
222 continue;
223 }
224 aggregate = next;
225 selected.push(candidate);
226 }
227
228 // (3) A migration output equals the gross sum of its inputs (migration is fee-exempt), so a
229 // selected aggregate below dust would be rejected — skip the leg.
230 let skip_reason = if selected.is_empty() || aggregate < dust {
231 // Nothing got selected and the only candidates were oversized => OversizedOnly;
232 // otherwise the (sized) selection summed below dust.
233 if selected.is_empty() && !oversized.is_empty() {
234 Some(MigrationSkipReason::OversizedOnly)
235 } else {
236 Some(MigrationSkipReason::BelowDust)
237 }
238 } else {
239 None
240 };
241
242 MigrationLegSizing {
243 selected,
244 deferred,
245 oversized,
246 skip_reason,
247 }
248}
249
250/// Whether the wallet holds any funds under a (deprecated) signer — spendable VTXOs, recoverable
251/// VTXOs, or boarding outputs — deciding whether the signer is surfaced by
252/// [`Client::deprecated_signer_status`].
253///
254/// Recoverable VTXOs must count: an expired signer whose VTXOs have all become recoverable
255/// (`spendable_count == 0`) still holds funds the user needs surfaced. Counting only spendable
256/// VTXOs would drop such a signer from the report and hide those funds.
257fn signer_holds_funds(
258 spendable_count: usize,
259 recoverable_count: usize,
260 boarding_count: usize,
261) -> bool {
262 spendable_count + recoverable_count + boarding_count > 0
263}
264
265/// Classify a deprecated signer from its advertised cutoff and the current time, returning the
266/// [`DeprecatedSignerStatus`] and `seconds_until_cutoff` hint.
267///
268/// Pure core of [`Client::deprecated_signer_status`], factored out so the classification is
269/// unit-testable without a `Client`/network. Consistent with
270/// [`ark_core::server::Info::signer_status_at`] and the `is_pre_cutoff_deprecated` check in
271/// [`Client::migrate_deprecated_signer_vtxos`]: a `cutoff_date` of `0` is "rotate now"
272/// ([`DeprecatedSignerStatus::DueNow`], still co-signable); a future cutoff is
273/// [`DeprecatedSignerStatus::Migratable`] (with a positive `seconds_until_cutoff`); a passed cutoff
274/// is [`DeprecatedSignerStatus::Expired`].
275fn classify_deprecated_signer(cutoff_date: i64, now: i64) -> (DeprecatedSignerStatus, Option<i64>) {
276 let status = DeprecatedSignerStatus::from_cutoff(cutoff_date, now);
277 (status, status.seconds_until_cutoff(cutoff_date, now))
278}
279
280/// Read-only, per-signer status of the deprecated server signers the wallet currently holds funds
281/// under. Produced by [`Client::deprecated_signer_status`].
282///
283/// This is observability only — building it never moves funds and never settles or migrates. The
284/// `recoverable_*` vs `awaiting_sweep_*` split and `next_sweep_eta` are only populated for
285/// [`DeprecatedSignerStatus::Expired`] signers (the post-cutoff recover-on-sweep lifecycle applies
286/// to VTXOs only).
287#[derive(Debug, Clone)]
288pub struct DeprecatedSignerReport {
289 /// The deprecated signer's x-only key.
290 pub signer_pk: XOnlyPublicKey,
291 /// The signer's status, derived from its cutoff and the current time.
292 pub status: DeprecatedSignerStatus,
293 /// The advertised cooperative-sign cutoff (Unix seconds); `0` means "rotate immediately".
294 pub cutoff_date: i64,
295 /// Seconds until the cutoff (`cutoff_date - now`); `None` when no future cutoff is advertised
296 /// (i.e. `cutoff_date == 0` or already passed).
297 pub seconds_until_cutoff: Option<i64>,
298 /// Number of spendable (non-recoverable) VTXOs the wallet holds under this signer.
299 pub vtxo_count: usize,
300 /// Total value of those spendable VTXOs.
301 pub vtxo_value: Amount,
302 /// Number of confirmed boarding UTXOs the wallet holds under this signer (includes those whose
303 /// own CSV exit window has elapsed — they leave via the unilateral sweep).
304 pub boarding_count: usize,
305 /// Total value of those boarding UTXOs.
306 pub boarding_value: Amount,
307 /// Expired-signer VTXOs already swept/expired and queued for recovery to the active signer.
308 /// Non-zero only on [`DeprecatedSignerStatus::Expired`] rows.
309 pub recoverable_count: usize,
310 /// Total value of the recoverable VTXOs.
311 pub recoverable_value: Amount,
312 /// Expired-signer VTXOs not yet swept; awaiting the server batch sweep before they become
313 /// recoverable. Non-zero only on [`DeprecatedSignerStatus::Expired`] rows.
314 pub awaiting_sweep_count: usize,
315 /// Total value of the awaiting-sweep VTXOs.
316 pub awaiting_sweep_value: Amount,
317 /// Soonest VTXO expiry (Unix seconds) among the awaiting-sweep set, as a recovery ETA hint.
318 /// `None` when there are no awaiting-sweep VTXOs under this signer.
319 pub next_sweep_eta: Option<i64>,
320}
321
322impl<B, W, S> Client<B, W, S>
323where
324 B: Blockchain,
325 W: OnchainWallet,
326 S: SwapStorage + 'static,
327{
328 /// Sweep VTXOs and boarding outputs minted under a *pre-cutoff* deprecated server signer to
329 /// the current signer, then report what moved.
330 ///
331 /// Only deprecated-signer, pre-cutoff inputs are touched — current-signer outputs are left
332 /// untouched (no consolidation, no incidental settlement fee), and past-cutoff outputs are
333 /// skipped automatically by [`Self::fetch_commitment_transaction_inputs`] (the operator won't
334 /// co-sign the old key, so they become recoverable after expiry and exit via the recovery
335 /// path).
336 ///
337 /// Migration runs as two **independent** legs — a VTXO leg and a boarding leg — each routed
338 /// through [`Self::settle_vtxos`] with its own scoped outpoint set. A failure in one leg does
339 /// not suppress the other. Before settling, each leg is sized against the server's per-output
340 /// ceiling (`vtxo_max_amount`) and dust floor (see [`MigrationLegReport`] for the exact
341 /// pipeline): inputs that individually exceed the ceiling are reported as `oversized` (they can
342 /// never form a `<= ceiling` output and must exit unilaterally — they are NOT silently
343 /// dropped); the remainder is selected highest-value-first up to [`MAX_VTXOS_PER_SETTLEMENT`]
344 /// and a running aggregate within the ceiling, deferring the rest to a later cycle; a leg whose
345 /// selected aggregate is below dust is skipped.
346 ///
347 /// When the server advertises no deprecated signers, returns an empty
348 /// [`MigrationSkipReason::NothingMigratable`] report without touching the wallet.
349 pub async fn migrate_deprecated_signer_vtxos<R>(
350 &self,
351 rng: &mut R,
352 ) -> Result<DeprecatedSignerMigrationReport, Error>
353 where
354 R: rand::Rng + rand::CryptoRng + Clone,
355 {
356 // Snapshot the server info once (TOCTOU): the empty-check, the per-input
357 // classification closure, and the leg sizing must all see the same
358 // `deprecated_signers`/`vtxo_max_amount`/`dust` even if a concurrent digest-driven
359 // `refresh_server_info` swaps the snapshot mid-call.
360 let server_info = self.server_info().await?;
361 if server_info.deprecated_signers.is_empty() {
362 return Ok(DeprecatedSignerMigrationReport::nothing_migratable());
363 }
364
365 let now = unix_now()?;
366
367 let is_pre_cutoff_deprecated = |server_pk: XOnlyPublicKey| -> Option<i64> {
368 if !server_info
369 .signer_status_at(server_pk, now)
370 .is_pre_cutoff_deprecated()
371 {
372 return None;
373 }
374
375 server_info
376 .deprecated_signers
377 .iter()
378 .find(|ds| ds.pk.x_only_public_key().0 == server_pk)
379 .map(|ds| ds.cutoff_date)
380 };
381
382 // `fetch_commitment_transaction_inputs` already drops PAST-cutoff deprecated inputs (the
383 // operator won't co-sign the old key). We narrow further to the PRE-cutoff deprecated
384 // inputs, which is exactly the cooperatively-migratable set.
385 let (boarding_inputs, vtxo_inputs, _) = self
386 .fetch_commitment_transaction_inputs(&server_info, now)
387 .await?;
388
389 let contract_vtxos = self.list_vtxos_with_server_info(&server_info).await?;
390
391 // Build the candidate (outpoint, amount, signer, cutoff) list for the VTXO leg.
392 let mut vtxo_candidates: Vec<MigrationVtxoRef> = Vec::new();
393 for input in &vtxo_inputs {
394 let Some(contract_vtxo) = contract_vtxos
395 .all()
396 .find(|entry| entry.vtxo().outpoint == input.outpoint())
397 else {
398 tracing::debug!(
399 outpoint = %input.outpoint(),
400 "Skipping VTXO with no contract during migration"
401 );
402 continue;
403 };
404 let signer_pk = contract_vtxo.server_pk()?;
405 if let Some(cutoff_date) = is_pre_cutoff_deprecated(signer_pk) {
406 vtxo_candidates.push(MigrationVtxoRef {
407 outpoint: input.outpoint(),
408 amount: input.amount(),
409 signer_pk,
410 cutoff_date,
411 });
412 }
413 }
414
415 // Build the candidate list for the boarding leg.
416 let boarding_outputs_by_script = self
417 .boarding_outputs()?
418 .into_iter()
419 .map(|boarding_output| (boarding_output.script_pubkey(), boarding_output))
420 .collect::<HashMap<_, _>>();
421 let mut boarding_candidates: Vec<MigrationVtxoRef> = Vec::new();
422 for input in &boarding_inputs {
423 let Some(boarding_output) = boarding_outputs_by_script.get(input.script_pubkey())
424 else {
425 tracing::debug!(
426 outpoint = %input.outpoint(),
427 "Skipping boarding input with no contract during migration"
428 );
429 continue;
430 };
431 let signer_pk = boarding_output.server_pk();
432 if let Some(cutoff_date) = is_pre_cutoff_deprecated(signer_pk) {
433 boarding_candidates.push(MigrationVtxoRef {
434 outpoint: input.outpoint(),
435 amount: input.amount(),
436 signer_pk,
437 cutoff_date,
438 });
439 }
440 }
441
442 if vtxo_candidates.is_empty() && boarding_candidates.is_empty() {
443 tracing::debug!("No migratable deprecated-signer VTXOs or boarding outputs found");
444 return Ok(DeprecatedSignerMigrationReport::nothing_migratable());
445 }
446
447 tracing::info!(
448 num_vtxos = vtxo_candidates.len(),
449 num_boarding = boarding_candidates.len(),
450 "Found pre-cutoff deprecated-signer outputs; migrating to current signer"
451 );
452
453 let vtxo_max_amount = server_info.vtxo_max_amount;
454 let dust = server_info.dust;
455
456 // Run each leg independently so a failure in one does not suppress the other.
457 let vtxo_leg = self
458 .run_migration_leg(
459 rng,
460 &server_info,
461 vtxo_candidates,
462 vtxo_max_amount,
463 dust,
464 true,
465 )
466 .await?;
467 let boarding_leg = self
468 .run_migration_leg(
469 rng,
470 &server_info,
471 boarding_candidates,
472 vtxo_max_amount,
473 dust,
474 false,
475 )
476 .await?;
477
478 Ok(DeprecatedSignerMigrationReport {
479 vtxo: vtxo_leg,
480 boarding: boarding_leg,
481 })
482 }
483
484 /// Report the per-signer status of every deprecated server signer the wallet currently holds
485 /// funds under, without migrating anything.
486 ///
487 /// This is observability only — it never moves funds and never calls settle or migrate. It is
488 /// the read-only sibling of [`Self::migrate_deprecated_signer_vtxos`]. For each deprecated
489 /// signer it merges the wallet's contract-annotated VTXO holdings and its on-chain boarding
490 /// holdings (grouped by their stored contract signer) into one [`DeprecatedSignerReport`].
491 ///
492 /// Signers under which the wallet holds neither VTXOs nor boarding outputs are omitted. When
493 /// the server advertises no deprecated signers, returns an empty vector without touching the
494 /// chain.
495 ///
496 /// For [`DeprecatedSignerStatus::Expired`] signers the VTXOs are additionally split into the
497 /// already-swept/expired `recoverable_*` set and the not-yet-swept `awaiting_sweep_*` set, and
498 /// `next_sweep_eta` is the soonest VTXO expiry (`expires_at`) among the awaiting set.
499 pub async fn deprecated_signer_status(&self) -> Result<Vec<DeprecatedSignerReport>, Error> {
500 // Snapshot once (TOCTOU): the empty-check and every per-signer classification must see the
501 // same `deprecated_signers`/`dust` even if a concurrent refresh swaps the snapshot.
502 let server_info = self.server_info().await?;
503 if server_info.deprecated_signers.is_empty() {
504 return Ok(Vec::new());
505 }
506
507 let now = unix_now()?;
508 let dust = server_info.dust;
509
510 // Aggregate VTXO holdings per signer in a single pass over all unspent VTXOs.
511 #[derive(Default)]
512 struct VtxoAgg {
513 // Spendable (non-recoverable) VTXOs.
514 spendable_count: usize,
515 spendable_value: Amount,
516 // Already-swept/expired VTXOs (only surfaced for past-cutoff signers).
517 recoverable_count: usize,
518 recoverable_value: Amount,
519 // Soonest expiry among the spendable (awaiting-sweep) VTXOs.
520 next_sweep_eta: Option<i64>,
521 }
522
523 let vtxo_list = self
524 .list_vtxos_with_server_info(&server_info)
525 .await
526 .context("failed to list VTXOs")?;
527 let mut vtxo_aggs: HashMap<XOnlyPublicKey, VtxoAgg> = HashMap::new();
528 for entry in vtxo_list.all_unspent() {
529 let agg = vtxo_aggs.entry(entry.server_pk()?).or_default();
530 if entry.vtxo().is_recoverable(dust) {
531 agg.recoverable_count += 1;
532 agg.recoverable_value += entry.vtxo().amount;
533 } else {
534 agg.spendable_count += 1;
535 agg.spendable_value += entry.vtxo().amount;
536 agg.next_sweep_eta = Some(match agg.next_sweep_eta {
537 Some(eta) => eta.min(entry.vtxo().expires_at),
538 None => entry.vtxo().expires_at,
539 });
540 }
541 }
542
543 // Aggregate confirmed boarding holdings per signer. Mirrors the discovery in
544 // `fetch_commitment_transaction_inputs` (boarding outputs -> `find_outpoints`) but WITHOUT
545 // the cutoff/CSV-claimability filters: the report counts every confirmed, unspent boarding
546 // coin under a signer, including past-cutoff and CSV-expired ones (they still leave via the
547 // unilateral sweep).
548 let mut boarding_aggs: HashMap<XOnlyPublicKey, (usize, Amount)> = HashMap::new();
549 let mut seen_outpoints = HashSet::new();
550 for boarding_output in self.boarding_outputs()? {
551 let outpoints = timeout_op(
552 self.inner.timeout,
553 self.blockchain().find_outpoints(boarding_output.address()),
554 )
555 .await
556 .context("failed to find boarding outpoints")??;
557
558 for o in outpoints.iter() {
559 if let ExplorerUtxo {
560 outpoint,
561 amount,
562 confirmation_blocktime: Some(_),
563 is_spent: false,
564 ..
565 } = o
566 {
567 if !seen_outpoints.insert(*outpoint) {
568 continue;
569 }
570 let entry = boarding_aggs
571 .entry(boarding_output.server_pk())
572 .or_insert((0, Amount::ZERO));
573 entry.0 += 1;
574 entry.1 += *amount;
575 }
576 }
577 }
578
579 let mut reports = Vec::new();
580 for ds in &server_info.deprecated_signers {
581 let signer_pk = ds.pk.x_only_public_key().0;
582 let cutoff_date = ds.cutoff_date;
583
584 // Status + `seconds_until_cutoff`, consistent with `is_signer_past_cutoff_at` /
585 // `is_pre_cutoff_deprecated`: cutoff `0` = rotate-now (still co-signable); a future
586 // cutoff = migratable; a passed cutoff = expired.
587 let (status, seconds_until_cutoff) = classify_deprecated_signer(cutoff_date, now);
588
589 let vtxo_agg = vtxo_aggs.get(&signer_pk);
590 let (boarding_count, boarding_value) = boarding_aggs
591 .get(&signer_pk)
592 .copied()
593 .unwrap_or((0, Amount::ZERO));
594
595 let vtxo_count = vtxo_agg.map(|a| a.spendable_count).unwrap_or(0);
596 let vtxo_value = vtxo_agg.map(|a| a.spendable_value).unwrap_or(Amount::ZERO);
597
598 // Skip signers under which the wallet holds no funds at all.
599 let recoverable_vtxo_count = vtxo_agg.map(|a| a.recoverable_count).unwrap_or(0);
600 if !signer_holds_funds(vtxo_count, recoverable_vtxo_count, boarding_count) {
601 continue;
602 }
603
604 // The recover-on-sweep split applies to past-cutoff (expired) signers only; for still
605 // co-signable signers these stay zero / `None`.
606 let is_expired = status == DeprecatedSignerStatus::Expired;
607 let recoverable_count = vtxo_agg
608 .filter(|_| is_expired)
609 .map(|a| a.recoverable_count)
610 .unwrap_or(0);
611 let recoverable_value = vtxo_agg
612 .filter(|_| is_expired)
613 .map(|a| a.recoverable_value)
614 .unwrap_or(Amount::ZERO);
615 let (awaiting_sweep_count, awaiting_sweep_value, next_sweep_eta) = if is_expired {
616 (
617 vtxo_count,
618 vtxo_value,
619 vtxo_agg.and_then(|a| a.next_sweep_eta),
620 )
621 } else {
622 (0, Amount::ZERO, None)
623 };
624
625 reports.push(DeprecatedSignerReport {
626 signer_pk,
627 status,
628 cutoff_date,
629 seconds_until_cutoff,
630 vtxo_count,
631 vtxo_value,
632 boarding_count,
633 boarding_value,
634 recoverable_count,
635 recoverable_value,
636 awaiting_sweep_count,
637 awaiting_sweep_value,
638 next_sweep_eta,
639 });
640 }
641
642 Ok(reports)
643 }
644
645 /// Size a single migration leg against the server limits and settle the selected inputs.
646 ///
647 /// `is_vtxo_leg` selects which argument of [`Self::settle_vtxos`] the chosen outpoints are
648 /// passed in (VTXO vs boarding);
649 /// the other argument is empty so each leg is a distinct intent.
650 async fn run_migration_leg<R>(
651 &self,
652 rng: &mut R,
653 server_info: &ark_core::server::Info,
654 candidates: Vec<MigrationVtxoRef>,
655 vtxo_max_amount: Option<Amount>,
656 dust: Amount,
657 is_vtxo_leg: bool,
658 ) -> Result<MigrationLegReport, Error>
659 where
660 R: rand::Rng + rand::CryptoRng + Clone,
661 {
662 // Pure sizing (split oversized, cap count + aggregate, dust floor) is factored into
663 // `size_migration_leg` so it can be unit-tested without a `Client`/network. This leg only
664 // adds the I/O: settling the selected inputs and mapping the outcome onto a report.
665 let MigrationLegSizing {
666 selected,
667 deferred,
668 oversized,
669 skip_reason,
670 } = size_migration_leg(candidates, vtxo_max_amount, dust);
671
672 if let Some(reason) = skip_reason {
673 return Ok(MigrationLegReport {
674 settle_txid: None,
675 migrated: Vec::new(),
676 // Surface any sized-but-skipped inputs (e.g. a below-dust selection) as deferred
677 // so a later cycle re-attempts them, matching the settle-error path below. For
678 // OversizedOnly/NothingMigratable `selected` is empty, so this is a no-op there.
679 deferred: selected.into_iter().chain(deferred).collect(),
680 oversized,
681 skipped: Some(reason),
682 error: None,
683 });
684 }
685
686 let selected_outpoints: Vec<OutPoint> = selected.iter().map(|c| c.outpoint).collect();
687 let settle_result = if is_vtxo_leg {
688 self.settle_vtxos_with_server_info(rng, server_info, &selected_outpoints, &[])
689 .await
690 } else {
691 self.settle_vtxos_with_server_info(rng, server_info, &[], &selected_outpoints)
692 .await
693 };
694
695 // Capture (rather than propagate) the settle error so the caller can still run the other
696 // leg — a failure in one leg must not suppress the other.
697 Ok(match settle_result {
698 Ok(settle_txid) => MigrationLegReport {
699 settle_txid,
700 migrated: selected,
701 deferred,
702 oversized,
703 skipped: None,
704 error: None,
705 },
706 Err(e) => {
707 tracing::warn!(error = %e, "Deprecated-signer migration leg failed to settle");
708 MigrationLegReport {
709 settle_txid: None,
710 migrated: Vec::new(),
711 // The selected inputs did not move; surface them as deferred so a retry
712 // re-attempts them.
713 deferred: selected.into_iter().chain(deferred).collect(),
714 oversized,
715 skipped: None,
716 error: Some(e.to_string()),
717 }
718 }
719 })
720 }
721}
722
723/// Unit coverage for the pure deprecated-signer-migration logic: the per-leg sizing pipeline
724/// ([`size_migration_leg`]), the signer classification ([`classify_deprecated_signer`]), and the
725/// empty-`deprecated_signers` short-circuit report ([`DeprecatedSignerMigrationReport`]). These
726/// run without a `Client`/network — they exercise the same branching the regtest e2e tests cover
727/// end-to-end.
728#[cfg(test)]
729mod migration_tests {
730 use super::*;
731 use bitcoin::hashes::Hash;
732 use bitcoin::key::Keypair;
733 use bitcoin::key::Secp256k1;
734
735 /// A migratable candidate of the given amount. Each gets a distinct outpoint (via `vout`) so
736 /// selection order and counts are observable; the signer/cutoff are fixed placeholders the
737 /// sizing logic does not inspect.
738 fn candidate(vout: u32, amount: Amount) -> MigrationVtxoRef {
739 let secp = Secp256k1::new();
740 let sk = bitcoin::secp256k1::SecretKey::from_slice(&[7u8; 32]).unwrap();
741 let signer_pk = Keypair::from_secret_key(&secp, &sk).x_only_public_key().0;
742 MigrationVtxoRef {
743 outpoint: OutPoint::new(Txid::from_byte_array([0u8; 32]), vout),
744 amount,
745 signer_pk,
746 cutoff_date: 0,
747 }
748 }
749
750 fn sat(n: u64) -> Amount {
751 Amount::from_sat(n)
752 }
753
754 // ── size_migration_leg ───────────────────────────────────────────────────
755
756 #[test]
757 fn sizing_empty_candidates_is_nothing_migratable() {
758 let sizing = size_migration_leg(Vec::new(), Some(sat(1000)), sat(330));
759 assert!(sizing.selected.is_empty());
760 assert!(sizing.deferred.is_empty());
761 assert!(sizing.oversized.is_empty());
762 assert_eq!(
763 sizing.skip_reason,
764 Some(MigrationSkipReason::NothingMigratable)
765 );
766 }
767
768 #[test]
769 fn sizing_selects_all_when_within_limits() {
770 let candidates = vec![candidate(0, sat(500)), candidate(1, sat(400))];
771 let sizing = size_migration_leg(candidates, Some(sat(1000)), sat(330));
772 assert_eq!(sizing.selected.len(), 2);
773 assert!(sizing.deferred.is_empty());
774 assert!(sizing.oversized.is_empty());
775 assert_eq!(sizing.skip_reason, None);
776 // Highest-value-first ordering.
777 assert_eq!(sizing.selected[0].amount, sat(500));
778 assert_eq!(sizing.selected[1].amount, sat(400));
779 }
780
781 #[test]
782 fn sizing_caps_to_vtxo_max_deferring_the_rest() {
783 // Ceiling 1000: the 700 fits, the next 700 would push the aggregate to 1400 (> ceiling)
784 // so it is deferred, not stopped — a later 300 still fits under the running aggregate.
785 let candidates = vec![
786 candidate(0, sat(700)),
787 candidate(1, sat(700)),
788 candidate(2, sat(300)),
789 ];
790 let sizing = size_migration_leg(candidates, Some(sat(1000)), sat(330));
791 assert_eq!(sizing.selected.len(), 2);
792 let selected: Vec<_> = sizing.selected.iter().map(|c| c.amount).collect();
793 assert_eq!(selected, vec![sat(700), sat(300)]);
794 assert_eq!(sizing.deferred.len(), 1);
795 assert_eq!(sizing.deferred[0].amount, sat(700));
796 assert!(sizing.oversized.is_empty());
797 assert_eq!(sizing.skip_reason, None);
798 }
799
800 #[test]
801 fn sizing_splits_oversized_inputs() {
802 // 1500 alone exceeds the 1000 ceiling: it can never form a `<= ceiling` output, so it is
803 // reported as oversized (not dropped, not deferred). The 600 still migrates.
804 let candidates = vec![candidate(0, sat(1500)), candidate(1, sat(600))];
805 let sizing = size_migration_leg(candidates, Some(sat(1000)), sat(330));
806 assert_eq!(sizing.oversized.len(), 1);
807 assert_eq!(sizing.oversized[0].amount, sat(1500));
808 assert_eq!(sizing.selected.len(), 1);
809 assert_eq!(sizing.selected[0].amount, sat(600));
810 assert!(sizing.deferred.is_empty());
811 assert_eq!(sizing.skip_reason, None);
812 }
813
814 #[test]
815 fn sizing_oversized_only_when_all_exceed_ceiling() {
816 let candidates = vec![candidate(0, sat(1500)), candidate(1, sat(2000))];
817 let sizing = size_migration_leg(candidates, Some(sat(1000)), sat(330));
818 assert_eq!(sizing.oversized.len(), 2);
819 assert!(sizing.selected.is_empty());
820 assert!(sizing.deferred.is_empty());
821 assert_eq!(sizing.skip_reason, Some(MigrationSkipReason::OversizedOnly));
822 }
823
824 #[test]
825 fn sizing_skips_below_dust() {
826 // Selected aggregate (200) is below the dust floor (330): the leg is skipped as BelowDust
827 // (no oversized inputs involved). The candidate still satisfied the per-input and aggregate
828 // ceilings, so it remains in `selected`; `run_migration_leg` reads `selected` only when
829 // `skip_reason` is `None`, so a BelowDust leg settles nothing.
830 let candidates = vec![candidate(0, sat(200))];
831 let sizing = size_migration_leg(candidates, Some(sat(1000)), sat(330));
832 assert_eq!(sizing.skip_reason, Some(MigrationSkipReason::BelowDust));
833 assert!(sizing.oversized.is_empty());
834 }
835
836 #[test]
837 fn sizing_defers_beyond_count_cap() {
838 // One more candidate than the per-settlement count cap, each tiny so the aggregate ceiling
839 // never binds: exactly MAX_VTXOS_PER_SETTLEMENT are selected and the remainder is deferred.
840 let candidates: Vec<_> = (0..=MAX_VTXOS_PER_SETTLEMENT as u32)
841 .map(|i| candidate(i, sat(1)))
842 .collect();
843 // `None` ceiling => the aggregate cap does not apply; dust floor of 1 sat is met by the
844 // selected aggregate (MAX_VTXOS_PER_SETTLEMENT sats).
845 let sizing = size_migration_leg(candidates, None, sat(1));
846 assert_eq!(sizing.selected.len(), MAX_VTXOS_PER_SETTLEMENT);
847 assert_eq!(sizing.deferred.len(), 1);
848 assert!(sizing.oversized.is_empty());
849 assert_eq!(sizing.skip_reason, None);
850 }
851
852 #[test]
853 fn sizing_none_ceiling_means_no_oversized() {
854 // With no advertised ceiling, no input is ever oversized regardless of size.
855 let candidates = vec![candidate(0, sat(10_000_000)), candidate(1, sat(20_000_000))];
856 let sizing = size_migration_leg(candidates, None, sat(330));
857 assert!(sizing.oversized.is_empty());
858 assert_eq!(sizing.selected.len(), 2);
859 assert_eq!(sizing.skip_reason, None);
860 }
861
862 // ── classify_deprecated_signer ───────────────────────────────────────────
863
864 #[test]
865 fn classify_cutoff_zero_is_due_now() {
866 let (status, secs) = classify_deprecated_signer(0, 1_000_000);
867 assert_eq!(status, DeprecatedSignerStatus::DueNow);
868 assert_eq!(secs, None);
869 }
870
871 #[test]
872 fn classify_future_cutoff_is_migratable() {
873 let now = 1_000_000i64;
874 let (status, secs) = classify_deprecated_signer(now + 86_400, now);
875 assert_eq!(status, DeprecatedSignerStatus::Migratable);
876 assert_eq!(secs, Some(86_400));
877 }
878
879 #[test]
880 fn classify_exact_cutoff_boundary_is_expired() {
881 // cutoff_date <= now (and != 0) => expired. The boundary (cutoff == now) requires
882 // recovery instead of cooperative migration.
883 let now = 1_000_000i64;
884 let (status, secs) = classify_deprecated_signer(now, now);
885 assert_eq!(status, DeprecatedSignerStatus::Expired);
886 assert_eq!(secs, None);
887 }
888
889 #[test]
890 fn classify_past_cutoff_is_expired() {
891 let now = 1_000_000i64;
892 let (status, secs) = classify_deprecated_signer(now - 1, now);
893 assert_eq!(status, DeprecatedSignerStatus::Expired);
894 assert_eq!(secs, None);
895 }
896
897 // ── deprecated_signer_status emptiness skip ──────────────────────────────
898
899 #[test]
900 fn signer_with_only_recoverable_vtxos_is_kept() {
901 // Regression for the report skip dropping an expired signer whose VTXOs are all
902 // recoverable (spendable_count == 0): those funds must still be surfaced.
903 assert!(signer_holds_funds(0, 3, 0));
904 }
905
906 #[test]
907 fn signer_with_only_spendable_vtxos_is_kept() {
908 assert!(signer_holds_funds(5, 0, 0));
909 }
910
911 #[test]
912 fn signer_with_only_boarding_is_kept() {
913 assert!(signer_holds_funds(0, 0, 2));
914 }
915
916 #[test]
917 fn signer_with_no_funds_is_dropped() {
918 assert!(!signer_holds_funds(0, 0, 0));
919 }
920
921 // ── empty-deprecated-signers short-circuit report ────────────────────────
922
923 #[test]
924 fn nothing_migratable_report_is_not_rotated() {
925 // The report `migrate_deprecated_signer_vtxos` returns when the server advertises no
926 // deprecated signers: not rotated, no settle txids, both legs NothingMigratable.
927 let report = DeprecatedSignerMigrationReport::nothing_migratable();
928 assert!(!report.failed());
929 assert!(!report.rotated());
930 assert!(report.settle_txids().is_empty());
931 assert_eq!(
932 report.vtxo.skipped,
933 Some(MigrationSkipReason::NothingMigratable)
934 );
935 assert_eq!(
936 report.boarding.skipped,
937 Some(MigrationSkipReason::NothingMigratable)
938 );
939 assert!(report.vtxo.migrated.is_empty());
940 assert!(report.boarding.migrated.is_empty());
941 }
942
943 #[test]
944 fn migration_report_failed_tracks_leg_errors() {
945 let report = DeprecatedSignerMigrationReport {
946 vtxo: MigrationLegReport {
947 settle_txid: None,
948 migrated: Vec::new(),
949 deferred: Vec::new(),
950 oversized: Vec::new(),
951 skipped: None,
952 error: Some("settle failed".to_owned()),
953 },
954 boarding: MigrationLegReport::skipped(MigrationSkipReason::NothingMigratable),
955 };
956
957 assert!(report.failed());
958 assert!(report.vtxo.failed());
959 assert!(!report.boarding.failed());
960 assert!(!report.rotated());
961 }
962}