Skip to main content

bark/exit/
mod.rs

1//! Unilateral exit management
2//!
3//! This module coordinates unilateral exits of VTXOs back to on-chain bitcoin without
4//! requiring any third-party cooperation. It tracks which VTXOs should be exited, prepares
5//! and signs the required transactions, and drives the process forward until the funds are
6//! confirmed and claimable.
7//!
8//! What this module provides
9//! - Discovery, tracking, and persistence of the exit state for VTXOs.
10//! - Initiation of exits for the entire wallet or a selected set of VTXOs.
11//! - Periodic progress of exits (broadcasting, fee-bumping, and state updates).
12//! - APIs to inspect the current exit status, history, and related transactions.
13//! - Construction and signing of a final claim (drain) transaction once exits become claimable.
14//!
15//! When to use this module
16//! - Whenever VTXOs must be unilaterally moved on-chain, e.g., during counterparty unavailability,
17//!   or when the counterparty turns malicious.
18//!
19//! When not to use this module
20//! - If the server is cooperative. You can always offboard or pay onchain in a way that is much
21//!   cheaper and faster.
22//!
23//! Core types
24//! - [Exit]: High-level coordinator for the exit workflow. It persists state and advances
25//!   unilateral exits until they are claimable.
26//! - [ExitVtxo]: A VTXO marked for, and progressing through, unilateral exit. Each instance exposes
27//!   its current state and related metadata.
28//!
29//! Typical lifecycle
30//! 1) Choose what to exit
31//!    - Mark individual VTXOs for exit with [Exit::start_exit_for_vtxos], or exit everything with
32//!      [Exit::start_exit_for_entire_wallet].
33//! 2) Drive progress
34//!    - Call [Exit::progress_exits] to advance the wallet-agnostic state machine for each exit.
35//!    - To create or fee-bump CPFP transactions using an onchain wallet, call
36//!      [Exit::exits_needing_cpfp] to get pending requests, provide signed CPFPs via
37//!      [Exit::provide_cpfp_tx], then call [Exit::progress_exits] again. Alternatively, use the
38//!      [Exit::progress_exits_with_cpfp] if you have an onchain wallet to make CPFP txs directly.
39//! 3) Inspect status
40//!    - Use [Exit::get_exit_status] for detailed per-VTXO status (optionally including
41//!      history and transactions).
42//!    - Use [Exit::get_exit_vtxos] or [Exit::list_claimable] to browse tracked exits and locate
43//!      those that are fully confirmed onchain.
44//! 4) Claim the exited funds (optional)
45//!    - Once your transaction is confirmed onchain the funds are fully yours. However, recovery
46//!      from seed is not supported. By claiming your VTXO you move them to your onchain wallet.
47//!    - Once claimable, construct a PSBT to drain them with [Exit::drain_exits].
48//!    - Alternatively, you can use [Exit::sign_exit_claim_inputs] to sign the inputs of a given
49//!      PSBT if any are the outputs of a claimable unilateral exit.
50//!
51//! Fee rates
52//! - Suitable fee rates will be calculated based on the current network conditions. To override,
53//!   pass your own [FeeRate] to [Exit::progress_exits_with_cpfp] or [Exit::drain_exits].
54//!
55//! Error handling and persistence
56//! - The coordinator surfaces operational errors via [anyhow::Result] and domain-specific errors
57//!   via [ExitError] where appropriate. Persistent state is kept via the configured persister and
58//!   refreshed against the current chain view provided by the chain source client.
59//!
60//! Minimal example (high-level):
61//! ```no_run
62//! # use std::sync::Arc;
63//! # use std::str::FromStr;
64//! # use std::path::PathBuf;
65//! #
66//! # use bitcoin::Network;
67//! # use tokio::fs;
68//! #
69//! # use bark::{Config, Wallet, WalletSeed, OpenWalletArgs};
70//! # use bark::lock_manager::memory::MemoryLockManager;
71//! # use bark::onchain::OnchainWallet;
72//! # use bark::persist::sqlite::SqliteClient;
73//! #
74//! # async fn get_wallets() -> (Wallet, OnchainWallet) {
75//! #   let datadir = PathBuf::from("./bark");
76//! #   let config = Config::network_default(bitcoin::Network::Bitcoin);
77//! #   let db = Arc::new(SqliteClient::open(datadir.join("db.sqlite")).unwrap());
78//! #   let mnemonic_str = fs::read_to_string(datadir.join("mnemonic")).await.unwrap();
79//! #   let mnemonic = bip39::Mnemonic::from_str(&mnemonic_str).unwrap();
80//! #   let seed = WalletSeed::new_from_mnemonic(Network::Signet, &mnemonic);
81//! #   let bark_wallet = Wallet::open(Network::Signet, seed, config, OpenWalletArgs {
82//! #   	persister: Some(db.clone()),
83//! #   	..Default::default()
84//! #   }).await.unwrap();
85//! #   let seed = mnemonic.to_seed("");
86//! #   let onchain_wallet = OnchainWallet::load_or_create(Network::Regtest, seed, db).await.unwrap();
87//! #   (bark_wallet, onchain_wallet)
88//! # }
89//! #
90//! # #[tokio::main]
91//! # async fn main() -> anyhow::Result<()> {
92//! let (mut bark_wallet, mut onchain_wallet) = get_wallets().await;
93//!
94//! // Mark all VTXOs for exit.
95//! bark_wallet.exit_mgr().start_exit_for_entire_wallet().await?;
96//!
97//! // Transactions will be broadcast and require confirmations so keep periodically calling this.
98//! bark_wallet.exit_mgr().progress_exits_with_cpfp(&bark_wallet, None).await?;
99//!
100//! // Once all VTXOs are claimable, construct a PSBT to drain them.
101//! let drain_to = bitcoin::Address::from_str("bc1p...")?.assume_checked();
102//! let claimable_outputs = bark_wallet.exit_mgr().list_claimable().await;
103//! let drain_psbt = bark_wallet.exit_mgr().drain_exits(
104//!   &claimable_outputs,
105//!   &bark_wallet,
106//!   drain_to,
107//!   None,
108//! ).await?;
109//!
110//! // Next you should broadcast the PSBT, once it's confirmed the unilateral exit is complete.
111//! // broadcast_psbt(drain_psbt).await?;
112//! #   Ok(())
113//! # }
114//! ```
115
116mod models;
117mod vtxo;
118mod estimate;
119pub mod bdk;
120pub(crate) mod progress;
121pub(crate) mod transaction_manager;
122
123pub use self::models::{
124	ExitCpfpRequest, ExitTransactionPackage, FeeInfo, RbfRequirement, TransactionInfo,
125	ChildTransactionInfo, ExitError, ExitState, ExitTx, ExitTxStatus, ExitTxOrigin, ExitStartState,
126	ExitProcessingState, ExitAwaitingDeltaState, ExitClaimableState, ExitClaimInProgressState,
127	ExitClaimedState, ExitVtxoAlreadySpentState, ExitCanceledState, ExitStateKind,
128	ExitProgressStatus, ExitTransactionStatus,
129};
130pub use self::vtxo::ExitVtxo;
131pub use self::estimate::ExitFeeEstimate;
132
133use std::borrow::Borrow;
134use std::cmp;
135use std::collections::HashMap;
136use std::sync::Arc;
137
138use anyhow::Context;
139use bitcoin::{
140	Address, Amount, FeeRate, Psbt, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness, sighash
141};
142use bitcoin::consensus::Params;
143use log::{error, info, trace, warn};
144
145use ark::{Vtxo, VtxoId};
146use ark::vtxo::Bare;
147use ark::vtxo::policy::signing::VtxoSigner;
148use bitcoin_ext::{BlockHeight, P2TR_DUST, TxStatus};
149
150use crate::Wallet;
151use crate::chain::ChainSource;
152use crate::exit::transaction_manager::ExitTransactionManager;
153use crate::movement::{MovementDestination, MovementStatus, PaymentMethod};
154use crate::movement::manager::MovementManager;
155use crate::movement::update::MovementUpdate;
156
157use crate::persist::BarkPersister;
158use crate::persist::models::StoredExit;
159use crate::psbtext::PsbtInputExt;
160use crate::subsystem::{ExitMovement, Subsystem};
161use crate::vtxo::VtxoStateKind;
162
163/// Handles the process of ongoing VTXO exits.
164pub(crate) struct ExitInner {
165	tx_manager: ExitTransactionManager,
166	persister: Arc<dyn BarkPersister>,
167	chain_source: Arc<ChainSource>,
168	movement_manager: Arc<MovementManager>,
169
170	exit_vtxos: Vec<ExitVtxo>,
171}
172
173impl ExitInner {
174	/// Starts exits for the given vtxos.
175	/// Used by both [Exit::start_exit_for_vtxos] and [Exit::start_exit_for_entire_wallet].
176	async fn start_exit_for_vtxos(
177		&mut self,
178		vtxos: &[impl Borrow<Vtxo<Bare>>],
179		skip_standardness_checks: bool,
180	) -> anyhow::Result<()> {
181		if vtxos.is_empty() {
182			return Ok(());
183		}
184		let tip = self.chain_source.tip().await?;
185		let params = Params::new(self.chain_source.network());
186		for vtxo in vtxos {
187			let vtxo = vtxo.borrow();
188			let vtxo_id = vtxo.id();
189			if self.exit_vtxos.iter().any(|ev| ev.id() == vtxo_id) {
190				warn!("VTXO {} is already in the exit process", vtxo_id);
191				continue;
192			}
193
194			if !skip_standardness_checks {
195				// Pre-flight check: Prevent exiting dust, which causes "zombie" states
196				if vtxo.amount() < P2TR_DUST {
197					return Err(ExitError::DustLimit {
198						vtxo: vtxo_id,
199						amount: vtxo.amount(),
200						dust: P2TR_DUST,
201					}.into());
202				}
203
204				// Pre-flight check: refuse to start an exit whose chain is not
205				// standardness-compliant. The exit chain is what we'd broadcast to
206				// claim the funds; if any tx in it carries a sub-dust or
207				// unrecognised-script output the broadcast will be rejected by
208				// public-network relay, so committing CPFP budget to it would just
209				// burn fees. Fetch the genesis via the persister since the
210				// Vtxo<Bare> we get here only carries the leaf info.
211				let full_vtxo = self.persister.get_full_vtxo(vtxo_id).await?
212					.ok_or_else(|| ExitError::InvalidWalletState {
213						error: format!("missing genesis for VTXO {vtxo_id}"),
214					})?;
215				if let Err(error) = full_vtxo.check_standard() {
216					return Err(ExitError::NonStandardVtxo { vtxo: vtxo_id, error }.into());
217				}
218			}
219
220			// Create the movement in a Pending state. It transitions to Successful once the
221			// exit completes (Claimed), or Canceled if we discover the VTXO was already
222			// consumed by something else. We don't touch the VTXO's own state here — that
223			// happens in `progress_exits` once we've actually broadcast the exit chain.
224			let balance = -vtxo.amount().to_signed()?;
225			let script_pubkey = vtxo.output_script_pubkey();
226			let payment_method = match Address::from_script(&script_pubkey, &params) {
227				Ok(addr) => PaymentMethod::Bitcoin(addr.into_unchecked()),
228				Err(e) => {
229					warn!("Unable to convert script pubkey to address: {:#}", e);
230					PaymentMethod::OutputScript(script_pubkey)
231				}
232			};
233
234			let movement_id = self.movement_manager.new_movement_with_update(
235				Subsystem::EXIT,
236				ExitMovement::Exit.to_string(),
237				MovementUpdate::new()
238					.intended_and_effective_balance(balance)
239					.consumed_vtxo(vtxo_id)
240					.sent_to([MovementDestination::new(payment_method, vtxo.amount())]),
241			).await.context("Failed to register exit movement")?;
242
243			// We avoid composing the TXID vector since that requires access to the onchain wallet,
244			// as such the ExitVtxo will be considered uninitialized.
245			trace!("Starting exit for VTXO: {}", vtxo_id);
246			let exit = ExitVtxo::new(vtxo, tip, Some(movement_id));
247			self.persister.store_exit_vtxo_entry(&StoredExit::new(&exit)).await?;
248			self.exit_vtxos.push(exit);
249			trace!("Exit for VTXO started successfully: {}", vtxo_id);
250		}
251		Ok(())
252	}
253
254	/// Initializes pending exits and refreshes the chain view of their transaction packages.
255	async fn refresh_tx_state(&mut self) -> anyhow::Result<()> {
256		let mut exit_vtxos = std::mem::take(&mut self.exit_vtxos);
257		for exit in &mut exit_vtxos {
258			if !exit.is_initialized() {
259				match exit.initialize(&mut self.tx_manager, &*self.persister).await {
260					Ok(()) => continue,
261					Err(e) => {
262						error!("Error initializing exit for VTXO {}: {:#}", exit.id(), e);
263					}
264				}
265			}
266		}
267		self.exit_vtxos = exit_vtxos;
268		self.tx_manager.sync().await?;
269		Ok(())
270	}
271
272	/// Signs exit claim inputs on a PSBT.
273	/// Used by both [Exit::sign_exit_claim_inputs] and [Exit::drain_exits].
274	async fn sign_exit_claim_inputs(
275		&self,
276		psbt: &mut Psbt,
277		wallet: &Wallet,
278	) -> anyhow::Result<()> {
279		let prevouts = psbt.inputs.iter()
280			.map(|i| i.witness_utxo.clone().unwrap())
281			.collect::<Vec<_>>();
282
283		let prevouts = sighash::Prevouts::All(&prevouts);
284		let mut shc = sighash::SighashCache::new(&psbt.unsigned_tx);
285
286		let claimable = self.exit_vtxos.iter()
287			.filter(|ev| ev.is_claimable())
288			.map(|e| (e.id(), e))
289			.collect::<HashMap<_, _>>();
290
291		for (i, input) in psbt.inputs.iter_mut().enumerate() {
292			let vtxo = input.get_exit_claim_input();
293
294			if let Some(vtxo) = vtxo {
295				let exit_vtxo = claimable.get(&vtxo.id()).context("vtxo is not claimable yet")?;
296
297				let witness = wallet.sign_input(&vtxo, i, &mut shc, &prevouts).await
298					.map_err(|e| ExitError::ClaimSigningError { error: e.to_string() })?;
299
300				input.final_script_witness = Some(witness);
301				let _ = exit_vtxo;
302			}
303		}
304
305		Ok(())
306	}
307
308	/// Builds the status for a stored exit. Transactions are taken from the transaction manager
309	/// when tracked (which includes fee data), falling back to the stored child transactions.
310	async fn exit_status(
311		&self,
312		entry: StoredExit,
313		include_history: bool,
314		include_transactions: bool,
315	) -> anyhow::Result<ExitTransactionStatus> {
316		let mut transactions = Vec::new();
317		if include_transactions {
318			let vtxo = self.persister.get_full_vtxo(entry.vtxo_id).await
319				.context("failed to retrieve VTXO for exit")?
320				.with_context(|| format!("failed to retrieve VTXO for exit {}", entry.vtxo_id))?;
321			for tx in vtxo.transactions() {
322				let txid = tx.tx.compute_txid();
323				if let Some(package) = self.tx_manager.try_get_package(txid) {
324					transactions.push(package.read().await.clone());
325					continue;
326				}
327				let child = self.persister.get_exit_child_tx(txid).await
328					.context("failed to retrieve child tx for exit")?;
329				transactions.push(ExitTransactionPackage {
330					exit: TransactionInfo {
331						txid,
332						tx: tx.tx,
333					},
334					child: child.map(|(tx, origin)| ChildTransactionInfo {
335						origin,
336						info: TransactionInfo {
337							txid: tx.compute_txid(),
338							tx,
339						},
340						fee_info: None,
341					}),
342				});
343			}
344		}
345		Ok(ExitTransactionStatus {
346			vtxo_id: entry.vtxo_id,
347			state: entry.state,
348			history: if include_history { Some(entry.history) } else { None },
349			transactions,
350		})
351	}
352
353	/// Builds statuses for the given stored exits.
354	async fn exit_statuses(
355		&self,
356		entries: Vec<StoredExit>,
357		include_history: bool,
358		include_transactions: bool,
359	) -> anyhow::Result<Vec<ExitTransactionStatus>> {
360		let mut statuses = Vec::with_capacity(entries.len());
361		for entry in entries {
362			statuses.push(self.exit_status(entry, include_history, include_transactions).await?);
363		}
364		Ok(statuses)
365	}
366}
367
368/// Public handle to the exit subsystem. Wraps `ExitInner` in an `Arc<RwLock>` so all
369/// locking is internal — callers never need to acquire the lock directly.
370pub struct Exit {
371	inner: Arc<tokio::sync::RwLock<ExitInner>>,
372}
373
374impl Exit {
375	pub(crate) async fn new(
376		persister: Arc<dyn BarkPersister>,
377		chain_source: Arc<ChainSource>,
378		movement_manager: Arc<MovementManager>,
379	) -> anyhow::Result<Exit> {
380		let tx_manager = ExitTransactionManager::new(persister.clone(), chain_source.clone())?;
381		let inner = ExitInner {
382			exit_vtxos: Vec::new(),
383			tx_manager,
384			persister,
385			chain_source,
386			movement_manager,
387		};
388		Ok(Exit { inner: Arc::new(tokio::sync::RwLock::new(inner)) })
389	}
390
391	pub(crate) async fn load(&self) -> anyhow::Result<()> {
392		let mut guard = self.inner.write().await;
393		let inner = &mut *guard;
394
395		// Finished exits never progress again, so don't track them.
396		let exit_vtxo_entries = inner.persister
397			.get_exit_vtxo_entries_with_states(ExitStateKind::LIVE_STATES).await?;
398		inner.exit_vtxos.reserve(exit_vtxo_entries.len());
399
400		for entry in exit_vtxo_entries {
401			if let Some(vtxo) = inner.persister.get_wallet_vtxo(entry.vtxo_id).await? {
402				let mut exit = ExitVtxo::from_entry(entry, &vtxo);
403				exit.initialize(&mut inner.tx_manager, &*inner.persister).await?;
404				inner.exit_vtxos.push(exit);
405			} else {
406				error!("VTXO {} is marked for exit but it's missing from the database", entry.vtxo_id);
407			}
408		}
409		Ok(())
410	}
411
412	/// The default fee rate for broadcasting and CPFP-bumping unilateral exit transactions.
413	///
414	/// Exits are time-critical, so this targets fast (~1 block) confirmation.
415	pub async fn default_exit_fee_rate(&self) -> FeeRate {
416		self.inner.read().await.chain_source.fee_rates().await.fast
417	}
418
419	/// Returns the unilateral exit status for a given VTXO, live or finished, if any.
420	///
421	/// # Parameters
422	/// - vtxo_id: The ID of the VTXO to check.
423	/// - include_history: Whether to include the full state machine history of the exit
424	/// - include_transactions: Whether to include the full set of transactions related to the exit.
425	pub async fn get_exit_status(
426		&self,
427		vtxo_id: VtxoId,
428		include_history: bool,
429		include_transactions: bool,
430	) -> anyhow::Result<Option<ExitTransactionStatus>> {
431		let guard = self.inner.read().await;
432		match guard.persister.get_exit_vtxo_entry(&vtxo_id).await? {
433			None => Ok(None),
434			Some(entry) => {
435				Ok(Some(guard.exit_status(entry, include_history, include_transactions).await?))
436			},
437		}
438	}
439
440	/// Returns a clone of the tracked [ExitVtxo] if it exists.
441	pub async fn get_exit_vtxo(&self, vtxo_id: VtxoId) -> Option<ExitVtxo> {
442		let guard = self.inner.read().await;
443		guard.exit_vtxos.iter().find(|ev| ev.id() == vtxo_id).cloned()
444	}
445
446	/// Returns the IDs of all active unilateral exits in this wallet.
447	pub async fn get_exit_vtxo_ids(&self) -> Vec<VtxoId> {
448		let guard = self.inner.read().await;
449		guard.exit_vtxos.iter().map(|ev| ev.id()).collect()
450	}
451
452	/// Returns clones of all known unilateral exits in this wallet.
453	pub async fn get_exit_vtxos(&self) -> Vec<ExitVtxo> {
454		let guard = self.inner.read().await;
455		guard.exit_vtxos.clone()
456	}
457
458	/// Returns statuses for every exit, live and finished.
459	pub async fn list_all(
460		&self,
461		include_history: bool,
462		include_transactions: bool,
463	) -> anyhow::Result<Vec<ExitTransactionStatus>> {
464		let guard = self.inner.read().await;
465		let entries = guard.persister.get_exit_vtxo_entries().await?;
466		guard.exit_statuses(entries, include_history, include_transactions).await
467	}
468
469	/// Returns statuses for exits that are still progressing.
470	pub async fn list_live(
471		&self,
472		include_history: bool,
473		include_transactions: bool,
474	) -> anyhow::Result<Vec<ExitTransactionStatus>> {
475		let guard = self.inner.read().await;
476		let entries = guard.persister
477			.get_exit_vtxo_entries_with_states(ExitStateKind::LIVE_STATES).await?;
478		guard.exit_statuses(entries, include_history, include_transactions).await
479	}
480
481	/// Returns statuses for exits in a terminal state: claimed, aborted because the VTXO was
482	/// already spent, or canceled.
483	pub async fn list_finished(
484		&self,
485		include_history: bool,
486		include_transactions: bool,
487	) -> anyhow::Result<Vec<ExitTransactionStatus>> {
488		let guard = self.inner.read().await;
489		let entries = guard.persister
490			.get_exit_vtxo_entries_with_states(ExitStateKind::FINISHED_STATES).await?;
491		guard.exit_statuses(entries, include_history, include_transactions).await
492	}
493
494	/// Returns whether a VTXO has an active or completed unilateral exit.
495	pub async fn is_exiting(&self, vtxo_id: VtxoId) -> bool {
496		let guard = self.inner.read().await;
497		let state = guard.exit_vtxos.iter().find(|ev| ev.id() == vtxo_id).map(|ev| ev.state());
498		match state {
499			Some(ExitState::Start(_)) => true,
500			Some(ExitState::Processing(_)) => true,
501			Some(ExitState::AwaitingDelta(_)) => true,
502			Some(ExitState::Claimable(_)) => true,
503			Some(ExitState::ClaimInProgress(_)) => true,
504			Some(ExitState::Claimed(_)) => true,
505			Some(ExitState::VtxoAlreadySpent(_)) => false,
506			Some(ExitState::Canceled(_)) => false,
507			None => false,
508		}
509	}
510
511	/// True if there are any unilateral exits which have been started but are not yet claimable.
512	pub async fn has_pending_exits(&self) -> bool {
513		let guard = self.inner.read().await;
514		guard.exit_vtxos.iter().any(|ev| ev.state().is_pending())
515	}
516
517	/// Total balance held in VTXOs whose exit chain is confirmed onchain but hasn't yet
518	/// been drained back into the onchain wallet (exit state in `{AwaitingDelta,
519	/// Claimable, ClaimInProgress}` — i.e. the VTXO is `Exited` but not yet `Claimed`).
520	///
521	/// Returns [None] if the lock is currently held by a writer.
522	pub fn try_pending_total(&self) -> Option<Amount> {
523		self.inner.try_read().ok().map(|guard| {
524			guard.exit_vtxos.iter()
525				.filter(|ev| matches!(
526					ev.state(),
527					ExitState::AwaitingDelta(_)
528					| ExitState::Claimable(_)
529					| ExitState::ClaimInProgress(_),
530				))
531				.map(|ev| ev.amount())
532				.sum()
533		})
534	}
535
536	/// Returns the earliest block height at which all tracked exits will be claimable
537	pub async fn all_claimable_at_height(&self) -> Option<BlockHeight> {
538		let guard = self.inner.read().await;
539		let mut highest_claimable_height = None;
540		for exit in &guard.exit_vtxos {
541			match exit.state().claimable_height() {
542				Some(h) => highest_claimable_height = cmp::max(highest_claimable_height, Some(h)),
543				None => continue,
544			}
545		}
546		highest_claimable_height
547	}
548
549	/// Starts the unilateral exit process for the entire wallet (all eligible VTXOs).
550	///
551	/// It does not block until completion, you must use [Exit::progress_exits] to advance each exit.
552	///
553	/// It's recommended to sync the wallet, by using something like [Wallet::maintenance] being
554	/// doing this.
555	pub async fn start_exit_for_entire_wallet(&self) -> anyhow::Result<()> {
556		let mut guard = self.inner.write().await;
557		let all_vtxos = guard.persister.get_vtxos_by_state(&VtxoStateKind::UNSPENT_STATES).await?
558			.into_iter();
559
560		// Partition: separate eligible VTXOs from dust
561		let total_vtxos = all_vtxos.len();
562		let mut eligible = Vec::with_capacity(total_vtxos);
563		for v in all_vtxos {
564			// Skip non-standard VTXOs
565			match guard.persister.get_full_vtxo(v.id()).await {
566				Ok(Some(full)) => match full.check_standard() {
567					Ok(()) => eligible.push(v.vtxo),
568					Err(e) => warn!("Skipping non-standard VTXO {}: {:#}", v.id(), e),
569				},
570				Ok(None) => error!("Failed to retrieve full VTXO: {}", v.id()),
571				Err(e) => error!("Failed to retrieve full VTXO {}: {:#}", v.id(), e),
572			}
573		}
574
575		// If everything is dust.
576		let ineligible = total_vtxos - eligible.len();
577		if eligible.is_empty() && ineligible > 0 {
578			warn!(
579				"Exit not started: all {} VTXOs are non-standard. To exit and consolidate you \
580				should try refreshing your VTXOs first",
581				ineligible,
582			);
583			return Ok(());
584		}
585
586		guard.start_exit_for_vtxos(&eligible, false).await
587	}
588
589	/// Starts the unilateral exit process for the given VTXOs.
590	///
591	/// It does not block until completion, you must use [Exit::progress_exits] to advance each exit.
592	///
593	/// It's recommended to sync the wallet, by using something like [Wallet::maintenance] being
594	/// doing this.
595	pub async fn start_exit_for_vtxos(
596		&self,
597		vtxos: &[impl Borrow<Vtxo<Bare>>],
598	) -> anyhow::Result<()> {
599		let mut guard = self.inner.write().await;
600		guard.start_exit_for_vtxos(vtxos, false).await
601	}
602
603	/// Similar to [Exit::start_exit_for_vtxos], but it skips any dust/standardness checks.
604	///
605	/// This should only be used when you are sure that the VTXOs are already onchain, or you are
606	/// able to broadcast to a node which will accept non-standard transactions.
607	pub async fn start_exit_for_vtxos_including_non_standard(
608		&self,
609		vtxos: &[impl Borrow<Vtxo<Bare>>],
610	) -> anyhow::Result<()> {
611		let mut guard = self.inner.write().await;
612		guard.start_exit_for_vtxos(vtxos, true).await
613	}
614
615	/// Cancels the unilateral exit for the given VTXO.
616	///
617	/// Only exits still in their abortable window can be canceled — i.e. before the *final* exit
618	/// transaction has been broadcast (see [ExitState::is_cancelable]); shared ancestor
619	/// transactions may already be on-chain. Because starting an exit never touches the VTXO,
620	/// there's nothing to undo on the VTXO side: it stays spendable and a fresh exit can be
621	/// started for it later.
622	///
623	/// Canceling an already-canceled exit is a no-op, so retries are safe.
624	///
625	/// # Errors
626	/// - [ExitError::NotExiting] if the VTXO never had an exit.
627	/// - [ExitError::CannotCancelExit] if the exit has progressed past its abortable window.
628	/// - [ExitError::ExitTxAlreadyBroadcast] if the final exit tx is already on the network.
629	pub async fn cancel_exit(&self, vtxo_id: VtxoId) -> anyhow::Result<(), ExitError> {
630		let mut guard = self.inner.write().await;
631		let inner = &mut *guard;
632
633		let idx = match inner.exit_vtxos.iter().position(|ev| ev.id() == vtxo_id) {
634			Some(idx) => idx,
635			None => {
636				// Only live exits are in memory; check the store for a finished one.
637				let entry = inner.persister.get_exit_vtxo_entry(&vtxo_id).await
638					.map_err(|e| ExitError::InternalError { error: e.to_string() })?;
639				return match entry.map(|e| e.state.kind()) {
640					Some(ExitStateKind::Canceled) => Ok(()),
641					Some(kind) => Err(ExitError::CannotCancelExit { vtxo: vtxo_id, state: kind }),
642					None => Err(ExitError::NotExiting { vtxo: vtxo_id }),
643				};
644			},
645		};
646
647		if !inner.exit_vtxos[idx].state().is_cancelable() {
648			return Err(ExitError::CannotCancelExit {
649				vtxo: vtxo_id,
650				state: inner.exit_vtxos[idx].state().kind(),
651			});
652		}
653
654		// Double check with the network first before canceling the exit.
655		let leaf_txid = inner.exit_vtxos[idx].get_vtxo(&*inner.persister).await?.point().txid;
656		match inner.tx_manager.sync_exit_tx(leaf_txid).await? {
657			TxStatus::NotFound => {},
658			TxStatus::Mempool | TxStatus::Confirmed(_) => {
659				return Err(ExitError::ExitTxAlreadyBroadcast { vtxo: vtxo_id, txid: leaf_txid });
660			},
661		}
662
663		let tip = inner.chain_source.tip().await
664			.map_err(|e| ExitError::TipRetrievalFailure { error: e.to_string() })?;
665
666		// Record the cancellation first, so it survives even if a later best-effort step fails.
667		inner.exit_vtxos[idx].cancel(tip, &*inner.persister).await?;
668
669		// Stop syncing this exit's transactions. Ancestor txs shared with sibling exits stay.
670		if let Some(txids) = inner.exit_vtxos[idx].txids() {
671			inner.tx_manager.untrack_vtxo_exits(&txids).await;
672		}
673
674		// Finalize the associated movement as Canceled (best-effort, like the other reconcilers).
675		if let Some(movement_id) = inner.exit_vtxos[idx].movement_id() {
676			if let Err(e) = inner.movement_manager
677				.finish_movement(movement_id, MovementStatus::Canceled).await
678			{
679				error!("Failed to finalize exit movement {} as Canceled: {:#}", movement_id, e);
680			}
681		}
682
683		// Drop it from the active set; the canceled row remains on disk for auditing.
684		inner.exit_vtxos.swap_remove(idx);
685		info!("Canceled unilateral exit for VTXO {}", vtxo_id);
686		Ok(())
687	}
688
689	/// Reset exit to an empty state. Should be called when dropping VTXOs
690	///
691	/// Note: _This method is **dangerous** and can lead to funds loss. Be cautious._
692	pub(crate) async fn dangerous_clear_exit(&self) -> anyhow::Result<()> {
693		let mut guard = self.inner.write().await;
694		for exit in &guard.exit_vtxos {
695			guard.persister.remove_exit_vtxo_entry(&exit.id()).await?;
696		}
697		guard.exit_vtxos.clear();
698		Ok(())
699	}
700
701	/// Iterates over each registered VTXO and attempts to progress their unilateral exit.
702	///
703	/// Initializes any pending exits and refreshes the chain view of exit transactions
704	/// before advancing state.
705	///
706	/// If you need to create CPFP transactions using a BDK-backed wallet, call
707	/// [Exit::exits_needing_cpfp] after this, supply the signed CPFPs via [Exit::provide_cpfp_tx],
708	/// then call this method again to advance the state past [ExitTxStatus::AwaitingCpfpBroadcast].
709	///
710	/// # Returns
711	///
712	/// The exit status of each VTXO being exited which has also not yet been spent
713	pub async fn progress_exits(
714		&self,
715		wallet: &Wallet,
716	) -> anyhow::Result<Option<Vec<ExitProgressStatus>>> {
717		let mut guard = self.inner.write().await;
718		guard.refresh_tx_state().await?;
719		let mut exit_vtxos = std::mem::take(&mut guard.exit_vtxos);
720		let mut exit_statuses = Vec::with_capacity(exit_vtxos.len());
721
722		for ev in exit_vtxos.iter_mut() {
723			if !ev.is_initialized() {
724				warn!("Skipping progress of uninitialized unilateral exit {}", ev.id());
725				continue;
726			}
727
728			info!("Progressing exit for VTXO {}", ev.id());
729			let pre_state = ev.state().clone();
730			let error = match ev.progress(
731				wallet,
732				&mut guard.tx_manager,
733				true,
734			).await {
735				Ok(_) => None,
736				Err(e) => {
737					match &e {
738						ExitError::InsufficientConfirmedFunds { .. } => {
739							warn!("Can't progress exit for VTXO {} at this time: {}", ev.id(), e);
740						},
741						_ => {
742							error!("Error progressing exit for VTXO {}: {}", ev.id(), e);
743						}
744					}
745					Some(e)
746				}
747			};
748
749			let state_changed = ev.state() != &pre_state;
750			Self::reconcile_vtxo_and_movement(
751				wallet, &guard.movement_manager, ev, state_changed,
752			).await;
753
754			if !matches!(ev.state(), ExitState::Claimed(..)) {
755				exit_statuses.push(ExitProgressStatus {
756					vtxo_id: ev.id(),
757					state: ev.state().clone(),
758					error,
759				});
760			}
761		}
762
763		guard.exit_vtxos = exit_vtxos;
764		Ok(Some(exit_statuses))
765	}
766
767	/// Maps the current exit state onto the VTXO and movement bookkeeping:
768	/// - mark the VTXO `Exited` once every exit transaction has been broadcast (i.e. past
769	///   `Start`, with `Processing` having all txs broadcast or beyond),
770	/// - finish the movement as `Successful` when we reach `Claimed`,
771	/// - finish the movement as `Canceled` when we detect the VTXO was already spent.
772	///
773	/// All updates are best-effort: failures are logged and don't abort progress. The VTXO
774	/// transition is idempotent; the movement transitions only fire on a fresh state change
775	/// to avoid notification spam.
776	async fn reconcile_vtxo_and_movement(
777		wallet: &Wallet,
778		movements: &MovementManager,
779		ev: &ExitVtxo,
780		state_changed: bool,
781	) {
782		if ev.state().warrants_exited_vtxo() {
783			if let Err(e) = wallet.mark_vtxos_as_exited([ev.id()]).await {
784				error!("Failed to mark VTXO {} as Exited: {:#}", ev.id(), e);
785			}
786		}
787
788		if !state_changed {
789			return;
790		}
791		let Some(movement_id) = ev.movement_id() else { return };
792		let new_status = match ev.state() {
793			ExitState::Claimed(_) => MovementStatus::Successful,
794			ExitState::VtxoAlreadySpent(_) => MovementStatus::Canceled,
795			_ => return,
796		};
797		if let Err(e) = movements.finish_movement(movement_id, new_status).await {
798			error!(
799				"Failed to finalize exit movement {} as {:?}: {:#}",
800				movement_id, new_status, e,
801			);
802		}
803	}
804
805	/// For use when syncing. Pending exits will be initialized, the network status of each
806	/// [ExitTransactionPackage] will be updated, and finally, any unilateral exits that are waiting
807	/// for network updates will be progressed.
808	pub async fn sync(
809		&self,
810		wallet: &Wallet,
811	) -> anyhow::Result<()> {
812		let mut guard = self.inner.write().await;
813		guard.refresh_tx_state().await?;
814		let mut exit_vtxos = std::mem::take(&mut guard.exit_vtxos);
815		for exit in &mut exit_vtxos {
816			if !exit.is_initialized() {
817				warn!("Skipping progress of uninitialized unilateral exit {}", exit.id());
818				continue;
819			}
820
821			let pre_state = exit.state().clone();
822			if let Err(e) = exit.progress(
823				wallet, &mut guard.tx_manager, true,
824			).await {
825				error!("Error syncing exit for VTXO {}: {}", exit.id(), e);
826			}
827			let state_changed = exit.state() != &pre_state;
828			Self::reconcile_vtxo_and_movement(
829				wallet, &guard.movement_manager, exit, state_changed,
830			).await;
831		}
832		guard.exit_vtxos = exit_vtxos;
833		Ok(())
834	}
835
836	/// Returns one [ExitCpfpRequest] for each exit transaction that needs a CPFP child.
837	///
838	/// A request with `rbf_requirement = None` means no CPFP exists yet. A request with
839	/// `rbf_requirement = Some(...)` means a third-party CPFP is already in the mempool;
840	/// the caller can optionally provide a replacement with a higher fee rate.
841	/// Call [Exit::provide_cpfp_tx] to submit the child.
842	pub async fn exits_needing_cpfp(&self) -> Vec<ExitCpfpRequest> {
843		let guard = self.inner.read().await;
844		let mut requests = Vec::new();
845		for ev in &guard.exit_vtxos {
846			let ExitState::Processing(s) = ev.state() else { continue };
847			for tx in &s.transactions {
848				let rbf_requirement = match &tx.status {
849					ExitTxStatus::AwaitingCpfpBroadcast => None,
850					ExitTxStatus::AwaitingConfirmation {..} => {
851						// Read mempool RBF info from the transaction manager; fee info is
852						// tracked on the child independently of its origin. If we don't have
853						// it yet (e.g. ancestor info call hasn't run), skip this round — the
854						// next sync will populate it.
855						match guard.tx_manager.get_child_status(tx.txid).await {
856							Ok(Some(c)) => match c.fee_info {
857								Some(fi) => Some(RbfRequirement {
858									min_fee_rate: fi.fee_rate,
859									current_package_fee: fi.total_fee,
860								}),
861								None => continue,
862							},
863							_ => continue,
864						}
865					},
866					_ => continue,
867				};
868				let package = match guard.tx_manager.get_package(tx.txid) {
869					Ok(p) => p,
870					Err(_) => continue,
871				};
872				let exit_tx = package.read().await.exit.tx.clone();
873				requests.push(ExitCpfpRequest {
874					vtxo_id: ev.id(),
875					exit_tx,
876					rbf_requirement,
877				});
878			}
879		}
880		requests
881	}
882
883	/// Submit a signed CPFP child transaction for a given exit transaction.
884	///
885	/// The child must spend the P2A anchor output of the parent exit transaction identified by
886	/// `exit_txid`. The package is broadcast immediately and the state advances to
887	/// [ExitTxStatus::AwaitingConfirmation]. The child is persisted so it survives restarts.
888	///
889	/// # TODO
890	/// `wallet` is required here only because [ExitVtxo::progress] calls `get_vtxo(&wallet.db)`
891	/// and `tip_height()` unconditionally, even though neither is needed for the
892	/// `AwaitingCpfpBroadcast → AwaitingConfirmation` transition. The fix is to make [ExitVtxo::progress]
893	/// take `persister` and `chain_source` separately instead of the full wallet, and call
894	/// `tip_height()` lazily only where needed.
895	pub async fn provide_cpfp_tx(
896		&self,
897		wallet: &Wallet,
898		exit_txid: Txid,
899		child_tx: Transaction,
900	) -> anyhow::Result<(), ExitError> {
901		let origin = ExitTxOrigin::Wallet { confirmed_in: None };
902		let mut guard = self.inner.write().await;
903		let inner = &mut *guard;
904
905		// Broadcast the package first and only commit the child if it's accepted
906		if !inner.tx_manager.broadcast_and_set_child(exit_txid, child_tx, origin).await? {
907			return Ok(());
908		}
909
910		for ev in inner.exit_vtxos.iter_mut() {
911			let ExitState::Processing(s) = ev.state() else { continue };
912			let has_tx = s.transactions.iter().any(|tx| tx.txid == exit_txid);
913			if has_tx {
914				if let Err(e) = ev.progress(wallet, &mut inner.tx_manager, false).await {
915					warn!("Failed to progress exit for {} after CPFP: {}", exit_txid, e);
916				}
917				break;
918			}
919		}
920
921		Ok(())
922	}
923
924	/// Lists all exits that are claimable
925	pub async fn list_claimable(&self) -> Vec<ExitVtxo> {
926		let guard = self.inner.read().await;
927		guard.exit_vtxos.iter().filter(|ev| ev.is_claimable()).cloned().collect()
928	}
929
930	/// Sign any inputs of the PSBT that is an exit claim input
931	///
932	/// Can take the result PSBT of [`bdk_wallet::TxBuilder::finish`] on which
933	/// [`crate::onchain::TxBuilderExt::add_exit_claim_inputs`] has been used
934	///
935	/// Note: This doesn't mark the exit output as spent, it's up to the caller to
936	/// do that, or it will be done once the transaction is seen in the network
937	pub async fn sign_exit_claim_inputs(&self, psbt: &mut Psbt, wallet: &Wallet) -> anyhow::Result<()> {
938		let guard = self.inner.read().await;
939		guard.sign_exit_claim_inputs(psbt, wallet).await
940	}
941
942	/// Builds a PSBT that drains the provided claimable unilateral exits to the given address.
943	///
944	/// - `inputs`: Claimable unilateral exits.
945	/// - `wallet`: The bark wallet containing the keys needed to spend the unilateral exits.
946	/// - `address`: Destination address for the claim.
947	/// - `fee_rate_override`: Optional fee rate to use.
948	///
949	/// Returns a PSBT ready to be broadcast.
950	pub async fn drain_exits(
951		&self,
952		inputs: &[impl Borrow<ExitVtxo>],
953		wallet: &Wallet,
954		address: Address,
955		fee_rate_override: Option<FeeRate>,
956	) -> anyhow::Result<Psbt, ExitError> {
957		let guard = self.inner.read().await;
958
959		let tip = guard.chain_source.tip().await
960			.map_err(|e| ExitError::TipRetrievalFailure { error: e.to_string() })?;
961
962		if inputs.is_empty() {
963			return Err(ExitError::ClaimMissingInputs);
964		}
965		let mut vtxos = HashMap::with_capacity(inputs.len());
966		for input in inputs {
967			let i = input.borrow();
968			let vtxo = i.get_full_vtxo(&*guard.persister).await?;
969			vtxos.insert(i.id(), vtxo);
970		}
971
972		let mut tx = {
973			let mut output_amount = Amount::ZERO;
974			let mut tx_ins = Vec::with_capacity(inputs.len());
975			for input in inputs {
976				let input = input.borrow();
977				let vtxo = &vtxos[&input.id()];
978				if !matches!(input.state(), ExitState::Claimable(..)) {
979					return Err(ExitError::VtxoNotClaimable { vtxo: input.id() });
980				}
981
982				output_amount += vtxo.amount();
983
984				let clause = wallet.find_signable_clause(vtxo).await
985					.ok_or(ExitError::ClaimMissingSignableClause { vtxo: vtxo.id() })?;
986
987				tx_ins.push(TxIn {
988					previous_output: vtxo.point(),
989					script_sig: ScriptBuf::default(),
990					sequence: clause.sequence().unwrap_or(Sequence::ZERO),
991					witness: Witness::new(),
992				});
993			}
994
995			let locktime = bitcoin::absolute::LockTime::from_height(tip)
996				.map_err(|e| ExitError::InvalidLocktime { tip, error: e.to_string() })?;
997
998			Transaction {
999				version: bitcoin::transaction::Version::TWO,
1000				lock_time: locktime,
1001				input: tx_ins,
1002				output: vec![
1003					TxOut {
1004						script_pubkey: address.script_pubkey(),
1005						value: output_amount,
1006					},
1007				],
1008			}
1009		};
1010
1011		// Create a PSBT to determine the weight of the transaction so we can deduct a tx fee
1012		let create_psbt = |tx: Transaction| async {
1013			let mut psbt = Psbt::from_unsigned_tx(tx)
1014				.map_err(|e| ExitError::InternalError {
1015					error: format!("Failed to create exit claim PSBT: {}", e),
1016				})?;
1017			psbt.inputs.iter_mut().zip(inputs).for_each(|(i, e)| {
1018				let v = &vtxos[&e.borrow().id()];
1019				i.set_exit_claim_input(v);
1020				i.witness_utxo = Some(v.txout())
1021			});
1022			guard.sign_exit_claim_inputs(&mut psbt, wallet).await
1023				.map_err(|e| ExitError::ClaimSigningError { error: e.to_string() })?;
1024			Ok(psbt)
1025		};
1026		let fee_amount = {
1027			let fee_rate = fee_rate_override
1028				.unwrap_or(guard.chain_source.fee_rates().await.regular);
1029			fee_rate * create_psbt(tx.clone()).await?
1030				.extract_tx()
1031				.map_err(|e| ExitError::InternalError {
1032					error: format!("Failed to get tx from signed exit claim PSBT: {}", e),
1033				})?
1034				.weight()
1035		};
1036
1037		// We adjust the drain output to cover the fee
1038		let needed = fee_amount + P2TR_DUST;
1039		if needed > tx.output[0].value {
1040			return Err(ExitError::ClaimFeeExceedsOutput {
1041				needed, output: tx.output[0].value,
1042			});
1043		}
1044		tx.output[0].value -= fee_amount;
1045
1046		// Now create the final signed PSBT
1047		create_psbt(tx).await
1048	}
1049}
1050