use crate::error::ErrorContext;
use crate::key_provider::KeyProvider;
use crate::swap_storage::SwapStorage;
use crate::utils::timeout_op;
use crate::utils::unix_now;
use crate::wallet::BoardingWallet;
use crate::wallet::OnchainWallet;
use crate::Blockchain;
use crate::Client;
use crate::Error;
use ark_core::server::DeprecatedSignerStatus;
use ark_core::ExplorerUtxo;
use bitcoin::Amount;
use bitcoin::OutPoint;
use bitcoin::Txid;
use bitcoin::XOnlyPublicKey;
use std::collections::HashMap;
use std::collections::HashSet;
pub const MAX_VTXOS_PER_SETTLEMENT: usize = 50;
#[derive(Debug, Clone)]
pub struct MigrationVtxoRef {
pub outpoint: OutPoint,
pub amount: Amount,
pub signer_pk: XOnlyPublicKey,
pub cutoff_date: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MigrationSkipReason {
BelowDust,
OversizedOnly,
NothingMigratable,
}
#[derive(Debug, Clone)]
pub struct MigrationLegReport {
pub settle_txid: Option<Txid>,
pub migrated: Vec<MigrationVtxoRef>,
pub deferred: Vec<MigrationVtxoRef>,
pub oversized: Vec<MigrationVtxoRef>,
pub skipped: Option<MigrationSkipReason>,
pub error: Option<String>,
}
impl MigrationLegReport {
fn skipped(reason: MigrationSkipReason) -> Self {
Self {
settle_txid: None,
migrated: Vec::new(),
deferred: Vec::new(),
oversized: Vec::new(),
skipped: Some(reason),
error: None,
}
}
pub fn failed(&self) -> bool {
self.error.is_some()
}
}
#[derive(Debug, Clone)]
pub struct DeprecatedSignerMigrationReport {
pub vtxo: MigrationLegReport,
pub boarding: MigrationLegReport,
}
impl DeprecatedSignerMigrationReport {
fn nothing_migratable() -> Self {
Self {
vtxo: MigrationLegReport::skipped(MigrationSkipReason::NothingMigratable),
boarding: MigrationLegReport::skipped(MigrationSkipReason::NothingMigratable),
}
}
pub fn failed(&self) -> bool {
self.vtxo.failed() || self.boarding.failed()
}
pub fn rotated(&self) -> bool {
self.vtxo.settle_txid.is_some() || self.boarding.settle_txid.is_some()
}
pub fn settle_txids(&self) -> Vec<Txid> {
[self.vtxo.settle_txid, self.boarding.settle_txid]
.into_iter()
.flatten()
.collect()
}
}
#[derive(Debug, Clone)]
struct MigrationLegSizing {
selected: Vec<MigrationVtxoRef>,
deferred: Vec<MigrationVtxoRef>,
oversized: Vec<MigrationVtxoRef>,
skip_reason: Option<MigrationSkipReason>,
}
fn size_migration_leg(
candidates: Vec<MigrationVtxoRef>,
vtxo_max_amount: Option<Amount>,
dust: Amount,
) -> MigrationLegSizing {
if candidates.is_empty() {
return MigrationLegSizing {
selected: Vec::new(),
deferred: Vec::new(),
oversized: Vec::new(),
skip_reason: Some(MigrationSkipReason::NothingMigratable),
};
}
let (oversized, mut sized): (Vec<_>, Vec<_>) = candidates
.into_iter()
.partition(|c| vtxo_max_amount.is_some_and(|max| c.amount > max));
if !oversized.is_empty() {
tracing::warn!(
count = oversized.len(),
?vtxo_max_amount,
"Deprecated-signer migration: inputs exceed the per-output limit and cannot be \
migrated cooperatively; they require a unilateral exit"
);
}
sized.sort_by_key(|c| std::cmp::Reverse(c.amount));
let mut selected: Vec<MigrationVtxoRef> = Vec::new();
let mut deferred: Vec<MigrationVtxoRef> = Vec::new();
let mut aggregate = Amount::ZERO;
for candidate in sized {
if selected.len() >= MAX_VTXOS_PER_SETTLEMENT {
deferred.push(candidate);
continue;
}
let next = aggregate + candidate.amount;
if vtxo_max_amount.is_some_and(|max| next > max) {
deferred.push(candidate);
continue;
}
aggregate = next;
selected.push(candidate);
}
let skip_reason = if selected.is_empty() || aggregate < dust {
if selected.is_empty() && !oversized.is_empty() {
Some(MigrationSkipReason::OversizedOnly)
} else {
Some(MigrationSkipReason::BelowDust)
}
} else {
None
};
MigrationLegSizing {
selected,
deferred,
oversized,
skip_reason,
}
}
fn signer_holds_funds(
spendable_count: usize,
recoverable_count: usize,
boarding_count: usize,
) -> bool {
spendable_count + recoverable_count + boarding_count > 0
}
fn classify_deprecated_signer(cutoff_date: i64, now: i64) -> (DeprecatedSignerStatus, Option<i64>) {
let status = DeprecatedSignerStatus::from_cutoff(cutoff_date, now);
(status, status.seconds_until_cutoff(cutoff_date, now))
}
#[derive(Debug, Clone)]
pub struct DeprecatedSignerReport {
pub signer_pk: XOnlyPublicKey,
pub status: DeprecatedSignerStatus,
pub cutoff_date: i64,
pub seconds_until_cutoff: Option<i64>,
pub vtxo_count: usize,
pub vtxo_value: Amount,
pub boarding_count: usize,
pub boarding_value: Amount,
pub recoverable_count: usize,
pub recoverable_value: Amount,
pub awaiting_sweep_count: usize,
pub awaiting_sweep_value: Amount,
pub next_sweep_eta: Option<i64>,
}
impl<B, W, S, K> Client<B, W, S, K>
where
B: Blockchain,
W: BoardingWallet + OnchainWallet,
S: SwapStorage + 'static,
K: KeyProvider,
{
pub async fn migrate_deprecated_signer_vtxos<R>(
&self,
rng: &mut R,
) -> Result<DeprecatedSignerMigrationReport, Error>
where
R: rand::Rng + rand::CryptoRng + Clone,
{
let server_info = self.server_info()?;
if server_info.deprecated_signers.is_empty() {
return Ok(DeprecatedSignerMigrationReport::nothing_migratable());
}
let now = unix_now()?;
let is_pre_cutoff_deprecated = |server_pk: XOnlyPublicKey| -> Option<i64> {
if !server_info
.signer_status_at(server_pk, now)
.is_pre_cutoff_deprecated()
{
return None;
}
server_info
.deprecated_signers
.iter()
.find(|ds| ds.pk.x_only_public_key().0 == server_pk)
.map(|ds| ds.cutoff_date)
};
let (boarding_inputs, vtxo_inputs, _) =
self.fetch_commitment_transaction_inputs(now).await?;
let (_, script_map) = self.list_vtxos().await?;
let mut vtxo_candidates: Vec<MigrationVtxoRef> = Vec::new();
for input in &vtxo_inputs {
let Some(vtxo) = script_map.get(input.script_pubkey()) else {
tracing::debug!(
outpoint = %input.outpoint(),
"Skipping VTXO with no spend info during migration"
);
continue;
};
if let Some(cutoff_date) = is_pre_cutoff_deprecated(vtxo.server_pk()) {
vtxo_candidates.push(MigrationVtxoRef {
outpoint: input.outpoint(),
amount: input.amount(),
signer_pk: vtxo.server_pk(),
cutoff_date,
});
}
}
let mut boarding_candidates: Vec<MigrationVtxoRef> = Vec::new();
for input in &boarding_inputs {
let signer_pk = input.boarding_output().server_pk();
if let Some(cutoff_date) = is_pre_cutoff_deprecated(signer_pk) {
boarding_candidates.push(MigrationVtxoRef {
outpoint: input.outpoint(),
amount: input.amount(),
signer_pk,
cutoff_date,
});
}
}
if vtxo_candidates.is_empty() && boarding_candidates.is_empty() {
tracing::debug!("No migratable deprecated-signer VTXOs or boarding outputs found");
return Ok(DeprecatedSignerMigrationReport::nothing_migratable());
}
tracing::info!(
num_vtxos = vtxo_candidates.len(),
num_boarding = boarding_candidates.len(),
"Found pre-cutoff deprecated-signer outputs; migrating to current signer"
);
let vtxo_max_amount = server_info.vtxo_max_amount;
let dust = server_info.dust;
let vtxo_leg = self
.run_migration_leg(rng, vtxo_candidates, vtxo_max_amount, dust, true)
.await?;
let boarding_leg = self
.run_migration_leg(rng, boarding_candidates, vtxo_max_amount, dust, false)
.await?;
Ok(DeprecatedSignerMigrationReport {
vtxo: vtxo_leg,
boarding: boarding_leg,
})
}
pub async fn deprecated_signer_status(&self) -> Result<Vec<DeprecatedSignerReport>, Error> {
let server_info = self.server_info()?;
if server_info.deprecated_signers.is_empty() {
return Ok(Vec::new());
}
let now = unix_now()?;
let dust = server_info.dust;
#[derive(Default)]
struct VtxoAgg {
spendable_count: usize,
spendable_value: Amount,
recoverable_count: usize,
recoverable_value: Amount,
next_sweep_eta: Option<i64>,
}
let (vtxo_list, script_map) = self.list_vtxos().await.context("failed to list VTXOs")?;
let mut vtxo_aggs: HashMap<XOnlyPublicKey, VtxoAgg> = HashMap::new();
for v in vtxo_list.all_unspent() {
let Some(vtxo) = script_map.get(&v.script) else {
continue;
};
let agg = vtxo_aggs.entry(vtxo.server_pk()).or_default();
if v.is_recoverable(dust) {
agg.recoverable_count += 1;
agg.recoverable_value += v.amount;
} else {
agg.spendable_count += 1;
agg.spendable_value += v.amount;
agg.next_sweep_eta = Some(match agg.next_sweep_eta {
Some(eta) => eta.min(v.expires_at),
None => v.expires_at,
});
}
}
let mut boarding_aggs: HashMap<XOnlyPublicKey, (usize, Amount)> = HashMap::new();
let mut seen_outpoints = HashSet::new();
for boarding_output in self.inner.wallet.get_boarding_outputs()? {
let outpoints = timeout_op(
self.inner.timeout,
self.blockchain().find_outpoints(boarding_output.address()),
)
.await
.context("failed to find boarding outpoints")??;
for o in outpoints.iter() {
if let ExplorerUtxo {
outpoint,
amount,
confirmation_blocktime: Some(_),
is_spent: false,
..
} = o
{
if !seen_outpoints.insert(*outpoint) {
continue;
}
let entry = boarding_aggs
.entry(boarding_output.server_pk())
.or_insert((0, Amount::ZERO));
entry.0 += 1;
entry.1 += *amount;
}
}
}
let mut reports = Vec::new();
for ds in &server_info.deprecated_signers {
let signer_pk = ds.pk.x_only_public_key().0;
let cutoff_date = ds.cutoff_date;
let (status, seconds_until_cutoff) = classify_deprecated_signer(cutoff_date, now);
let vtxo_agg = vtxo_aggs.get(&signer_pk);
let (boarding_count, boarding_value) = boarding_aggs
.get(&signer_pk)
.copied()
.unwrap_or((0, Amount::ZERO));
let vtxo_count = vtxo_agg.map(|a| a.spendable_count).unwrap_or(0);
let vtxo_value = vtxo_agg.map(|a| a.spendable_value).unwrap_or(Amount::ZERO);
let recoverable_vtxo_count = vtxo_agg.map(|a| a.recoverable_count).unwrap_or(0);
if !signer_holds_funds(vtxo_count, recoverable_vtxo_count, boarding_count) {
continue;
}
let is_expired = status == DeprecatedSignerStatus::Expired;
let recoverable_count = vtxo_agg
.filter(|_| is_expired)
.map(|a| a.recoverable_count)
.unwrap_or(0);
let recoverable_value = vtxo_agg
.filter(|_| is_expired)
.map(|a| a.recoverable_value)
.unwrap_or(Amount::ZERO);
let (awaiting_sweep_count, awaiting_sweep_value, next_sweep_eta) = if is_expired {
(
vtxo_count,
vtxo_value,
vtxo_agg.and_then(|a| a.next_sweep_eta),
)
} else {
(0, Amount::ZERO, None)
};
reports.push(DeprecatedSignerReport {
signer_pk,
status,
cutoff_date,
seconds_until_cutoff,
vtxo_count,
vtxo_value,
boarding_count,
boarding_value,
recoverable_count,
recoverable_value,
awaiting_sweep_count,
awaiting_sweep_value,
next_sweep_eta,
});
}
Ok(reports)
}
async fn run_migration_leg<R>(
&self,
rng: &mut R,
candidates: Vec<MigrationVtxoRef>,
vtxo_max_amount: Option<Amount>,
dust: Amount,
is_vtxo_leg: bool,
) -> Result<MigrationLegReport, Error>
where
R: rand::Rng + rand::CryptoRng + Clone,
{
let MigrationLegSizing {
selected,
deferred,
oversized,
skip_reason,
} = size_migration_leg(candidates, vtxo_max_amount, dust);
if let Some(reason) = skip_reason {
return Ok(MigrationLegReport {
settle_txid: None,
migrated: Vec::new(),
deferred: selected.into_iter().chain(deferred).collect(),
oversized,
skipped: Some(reason),
error: None,
});
}
let selected_outpoints: Vec<OutPoint> = selected.iter().map(|c| c.outpoint).collect();
let settle_result = if is_vtxo_leg {
self.settle_vtxos(rng, &selected_outpoints, &[]).await
} else {
self.settle_vtxos(rng, &[], &selected_outpoints).await
};
Ok(match settle_result {
Ok(settle_txid) => MigrationLegReport {
settle_txid,
migrated: selected,
deferred,
oversized,
skipped: None,
error: None,
},
Err(e) => {
tracing::warn!(error = %e, "Deprecated-signer migration leg failed to settle");
MigrationLegReport {
settle_txid: None,
migrated: Vec::new(),
deferred: selected.into_iter().chain(deferred).collect(),
oversized,
skipped: None,
error: Some(e.to_string()),
}
}
})
}
}
#[cfg(test)]
mod migration_tests {
use super::*;
use bitcoin::hashes::Hash;
use bitcoin::key::Keypair;
use bitcoin::key::Secp256k1;
fn candidate(vout: u32, amount: Amount) -> MigrationVtxoRef {
let secp = Secp256k1::new();
let sk = bitcoin::secp256k1::SecretKey::from_slice(&[7u8; 32]).unwrap();
let signer_pk = Keypair::from_secret_key(&secp, &sk).x_only_public_key().0;
MigrationVtxoRef {
outpoint: OutPoint::new(Txid::from_byte_array([0u8; 32]), vout),
amount,
signer_pk,
cutoff_date: 0,
}
}
fn sat(n: u64) -> Amount {
Amount::from_sat(n)
}
#[test]
fn sizing_empty_candidates_is_nothing_migratable() {
let sizing = size_migration_leg(Vec::new(), Some(sat(1000)), sat(330));
assert!(sizing.selected.is_empty());
assert!(sizing.deferred.is_empty());
assert!(sizing.oversized.is_empty());
assert_eq!(
sizing.skip_reason,
Some(MigrationSkipReason::NothingMigratable)
);
}
#[test]
fn sizing_selects_all_when_within_limits() {
let candidates = vec![candidate(0, sat(500)), candidate(1, sat(400))];
let sizing = size_migration_leg(candidates, Some(sat(1000)), sat(330));
assert_eq!(sizing.selected.len(), 2);
assert!(sizing.deferred.is_empty());
assert!(sizing.oversized.is_empty());
assert_eq!(sizing.skip_reason, None);
assert_eq!(sizing.selected[0].amount, sat(500));
assert_eq!(sizing.selected[1].amount, sat(400));
}
#[test]
fn sizing_caps_to_vtxo_max_deferring_the_rest() {
let candidates = vec![
candidate(0, sat(700)),
candidate(1, sat(700)),
candidate(2, sat(300)),
];
let sizing = size_migration_leg(candidates, Some(sat(1000)), sat(330));
assert_eq!(sizing.selected.len(), 2);
let selected: Vec<_> = sizing.selected.iter().map(|c| c.amount).collect();
assert_eq!(selected, vec![sat(700), sat(300)]);
assert_eq!(sizing.deferred.len(), 1);
assert_eq!(sizing.deferred[0].amount, sat(700));
assert!(sizing.oversized.is_empty());
assert_eq!(sizing.skip_reason, None);
}
#[test]
fn sizing_splits_oversized_inputs() {
let candidates = vec![candidate(0, sat(1500)), candidate(1, sat(600))];
let sizing = size_migration_leg(candidates, Some(sat(1000)), sat(330));
assert_eq!(sizing.oversized.len(), 1);
assert_eq!(sizing.oversized[0].amount, sat(1500));
assert_eq!(sizing.selected.len(), 1);
assert_eq!(sizing.selected[0].amount, sat(600));
assert!(sizing.deferred.is_empty());
assert_eq!(sizing.skip_reason, None);
}
#[test]
fn sizing_oversized_only_when_all_exceed_ceiling() {
let candidates = vec![candidate(0, sat(1500)), candidate(1, sat(2000))];
let sizing = size_migration_leg(candidates, Some(sat(1000)), sat(330));
assert_eq!(sizing.oversized.len(), 2);
assert!(sizing.selected.is_empty());
assert!(sizing.deferred.is_empty());
assert_eq!(sizing.skip_reason, Some(MigrationSkipReason::OversizedOnly));
}
#[test]
fn sizing_skips_below_dust() {
let candidates = vec![candidate(0, sat(200))];
let sizing = size_migration_leg(candidates, Some(sat(1000)), sat(330));
assert_eq!(sizing.skip_reason, Some(MigrationSkipReason::BelowDust));
assert!(sizing.oversized.is_empty());
}
#[test]
fn sizing_defers_beyond_count_cap() {
let candidates: Vec<_> = (0..=MAX_VTXOS_PER_SETTLEMENT as u32)
.map(|i| candidate(i, sat(1)))
.collect();
let sizing = size_migration_leg(candidates, None, sat(1));
assert_eq!(sizing.selected.len(), MAX_VTXOS_PER_SETTLEMENT);
assert_eq!(sizing.deferred.len(), 1);
assert!(sizing.oversized.is_empty());
assert_eq!(sizing.skip_reason, None);
}
#[test]
fn sizing_none_ceiling_means_no_oversized() {
let candidates = vec![candidate(0, sat(10_000_000)), candidate(1, sat(20_000_000))];
let sizing = size_migration_leg(candidates, None, sat(330));
assert!(sizing.oversized.is_empty());
assert_eq!(sizing.selected.len(), 2);
assert_eq!(sizing.skip_reason, None);
}
#[test]
fn classify_cutoff_zero_is_due_now() {
let (status, secs) = classify_deprecated_signer(0, 1_000_000);
assert_eq!(status, DeprecatedSignerStatus::DueNow);
assert_eq!(secs, None);
}
#[test]
fn classify_future_cutoff_is_migratable() {
let now = 1_000_000i64;
let (status, secs) = classify_deprecated_signer(now + 86_400, now);
assert_eq!(status, DeprecatedSignerStatus::Migratable);
assert_eq!(secs, Some(86_400));
}
#[test]
fn classify_exact_cutoff_boundary_is_expired() {
let now = 1_000_000i64;
let (status, secs) = classify_deprecated_signer(now, now);
assert_eq!(status, DeprecatedSignerStatus::Expired);
assert_eq!(secs, None);
}
#[test]
fn classify_past_cutoff_is_expired() {
let now = 1_000_000i64;
let (status, secs) = classify_deprecated_signer(now - 1, now);
assert_eq!(status, DeprecatedSignerStatus::Expired);
assert_eq!(secs, None);
}
#[test]
fn signer_with_only_recoverable_vtxos_is_kept() {
assert!(signer_holds_funds(0, 3, 0));
}
#[test]
fn signer_with_only_spendable_vtxos_is_kept() {
assert!(signer_holds_funds(5, 0, 0));
}
#[test]
fn signer_with_only_boarding_is_kept() {
assert!(signer_holds_funds(0, 0, 2));
}
#[test]
fn signer_with_no_funds_is_dropped() {
assert!(!signer_holds_funds(0, 0, 0));
}
#[test]
fn nothing_migratable_report_is_not_rotated() {
let report = DeprecatedSignerMigrationReport::nothing_migratable();
assert!(!report.failed());
assert!(!report.rotated());
assert!(report.settle_txids().is_empty());
assert_eq!(
report.vtxo.skipped,
Some(MigrationSkipReason::NothingMigratable)
);
assert_eq!(
report.boarding.skipped,
Some(MigrationSkipReason::NothingMigratable)
);
assert!(report.vtxo.migrated.is_empty());
assert!(report.boarding.migrated.is_empty());
}
#[test]
fn migration_report_failed_tracks_leg_errors() {
let report = DeprecatedSignerMigrationReport {
vtxo: MigrationLegReport {
settle_txid: None,
migrated: Vec::new(),
deferred: Vec::new(),
oversized: Vec::new(),
skipped: None,
error: Some("settle failed".to_owned()),
},
boarding: MigrationLegReport::skipped(MigrationSkipReason::NothingMigratable),
};
assert!(report.failed());
assert!(report.vtxo.failed());
assert!(!report.boarding.failed());
assert!(!report.rotated());
}
}