Skip to main content

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}