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