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