Skip to main content

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}