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//!    - Use either [Exit::sync] or [Exit::sync_no_progress] to update the state of tracked exits.
35//!    - Periodically call [Exit::progress_exits] to advance the exit process. This will create or
36//!      update transactions, adjust fees for existing transactions, and refresh the status of each
37//!      unilateral exit until it has been confirmed and subsequentially spent onchain.
38//! 3) Inspect status
39//!    - Use [Exit::get_exit_status] for detailed per-VTXO status (optionally including
40//!      history and transactions).
41//!    - Use [Exit::get_exit_vtxos] or [Exit::list_claimable] to browse tracked exits and locate
42//!      those that are fully confirmed onchain.
43//! 4) Claim the exited funds (optional)
44//!    - Once your transaction is confirmed onchain the funds are fully yours. However, recovery
45//!      from seed is not supported. By claiming your VTXO you move them to your onchain wallet.
46//!    - Once claimable, construct a PSBT to drain them with [Exit::drain_exits].
47//!    - Alternatively, you can use [Exit::sign_exit_claim_inputs] to sign the inputs of a given
48//!      PSBT if any are the outputs of a claimable unilateral exit.
49//!
50//! Fees rates
51//! - Suitable fee rates will be calculated based on the current network conditions, however, if you
52//!   wish to override this, you can do so by providing your own [FeeRate] in [Exit::progress_exits]
53//!   and [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};
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 lock_manager = Box::new(MemoryLockManager::new());
81//! #   let bark_wallet = Wallet::open(&mnemonic, db.clone(), config, lock_manager).await.unwrap();
82//! #   let seed = mnemonic.to_seed("");
83//! #   let onchain_wallet = OnchainWallet::load_or_create(Network::Regtest, seed, db).await.unwrap();
84//! #   (bark_wallet, onchain_wallet)
85//! # }
86//! #
87//! # #[tokio::main]
88//! # async fn main() -> anyhow::Result<()> {
89//! let (mut bark_wallet, mut onchain_wallet) = get_wallets().await;
90//!
91//! // Mark all VTXOs for exit.
92//! bark_wallet.exit_mgr().start_exit_for_entire_wallet().await?;
93//!
94//! // Transactions will be broadcast and require confirmations so keep periodically calling this.
95//! bark_wallet.exit_mgr().sync_no_progress(&onchain_wallet).await?;
96//! bark_wallet.exit_mgr().progress_exits(&bark_wallet, &mut onchain_wallet, None).await?;
97//!
98//! // Once all VTXOs are claimable, construct a PSBT to drain them.
99//! let drain_to = bitcoin::Address::from_str("bc1p...")?.assume_checked();
100//! let claimable_outputs = bark_wallet.exit_mgr().list_claimable().await;
101//! let drain_psbt = bark_wallet.exit_mgr().drain_exits(
102//!   &claimable_outputs,
103//!   &bark_wallet,
104//!   drain_to,
105//!   None,
106//! ).await?;
107//!
108//! // Next you should broadcast the PSBT, once it's confirmed the unilateral exit is complete.
109//! // broadcast_psbt(drain_psbt).await?;
110//! #   Ok(())
111//! # }
112//! ```
113
114mod models;
115mod vtxo;
116pub(crate) mod progress;
117pub(crate) mod transaction_manager;
118
119pub use self::models::{
120	ExitTransactionPackage, TransactionInfo, ChildTransactionInfo, ExitError, ExitState,
121	ExitTx, ExitTxStatus, ExitTxOrigin, ExitStartState, ExitProcessingState, ExitAwaitingDeltaState,
122	ExitClaimableState, ExitClaimInProgressState, ExitClaimedState, ExitProgressStatus,
123	ExitTransactionStatus,
124};
125pub use self::vtxo::ExitVtxo;
126
127use std::borrow::Borrow;
128use std::cmp;
129use std::collections::HashMap;
130use std::sync::Arc;
131
132use anyhow::Context;
133use bitcoin::{
134	Address, Amount, FeeRate, Psbt, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness, sighash
135};
136use bitcoin::consensus::Params;
137use log::{error, info, trace, warn};
138
139use ark::{Vtxo, VtxoId};
140use ark::vtxo::Bare;
141use ark::vtxo::policy::signing::VtxoSigner;
142use bitcoin_ext::{BlockHeight, P2TR_DUST};
143
144use crate::Wallet;
145use crate::chain::ChainSource;
146use crate::exit::transaction_manager::ExitTransactionManager;
147use crate::movement::{MovementDestination, MovementStatus, PaymentMethod};
148use crate::movement::manager::MovementManager;
149use crate::movement::update::MovementUpdate;
150use crate::onchain::ExitUnilaterally;
151use crate::persist::BarkPersister;
152use crate::persist::models::StoredExit;
153use crate::psbtext::PsbtInputExt;
154use crate::subsystem::{ExitMovement, Subsystem};
155use crate::vtxo::{VtxoState, VtxoStateKind};
156
157/// Handles the process of ongoing VTXO exits.
158pub(crate) struct ExitInner {
159	tx_manager: ExitTransactionManager,
160	persister: Arc<dyn BarkPersister>,
161	chain_source: Arc<ChainSource>,
162	movement_manager: Arc<MovementManager>,
163
164	exit_vtxos: Vec<ExitVtxo>,
165}
166
167impl ExitInner {
168	/// Starts exits for the given vtxos.
169	/// Used by both [Exit::start_exit_for_vtxos] and [Exit::start_exit_for_entire_wallet].
170	async fn start_exit_for_vtxos(
171		&mut self,
172		vtxos: &[impl Borrow<Vtxo<Bare>>],
173	) -> anyhow::Result<()> {
174		if vtxos.is_empty() {
175			return Ok(());
176		}
177		let tip = self.chain_source.tip().await?;
178		let params = Params::new(self.chain_source.network());
179		for vtxo in vtxos {
180			let vtxo = vtxo.borrow();
181			let vtxo_id = vtxo.id();
182			if self.exit_vtxos.iter().any(|ev| ev.id() == vtxo_id) {
183				warn!("VTXO {} is already in the exit process", vtxo_id);
184				continue;
185			}
186
187			// Pre-flight check: Prevent exiting dust, which causes "zombie" states
188			if vtxo.amount() < P2TR_DUST {
189				return Err(ExitError::DustLimit {
190					vtxo: vtxo.amount(),
191					dust: P2TR_DUST,
192				}.into());
193			}
194
195			// We avoid composing the TXID vector since that requires access to the onchain wallet,
196			// as such the ExitVtxo will be considered uninitialized.
197			trace!("Starting exit for VTXO: {}", vtxo_id);
198			let exit = ExitVtxo::new(vtxo, tip);
199			self.persister.store_exit_vtxo_entry(&StoredExit::new(&exit)).await?;
200			self.persister.update_vtxo_state_checked(
201				vtxo_id, VtxoState::Spent, &VtxoStateKind::UNSPENT_STATES,
202			).await?;
203			self.exit_vtxos.push(exit);
204			trace!("Exit for VTXO started successfully: {}", vtxo_id);
205
206			// Register the movement now so users can be aware of where their funds have gone.
207			let balance = -vtxo.amount().to_signed()?;
208			let script_pubkey = vtxo.output_script_pubkey();
209			let payment_method = match Address::from_script(&script_pubkey, &params) {
210				Ok(addr) => PaymentMethod::Bitcoin(addr.into_unchecked()),
211				Err(e) => {
212					warn!("Unable to convert script pubkey to address: {:#}", e);
213					PaymentMethod::OutputScript(script_pubkey)
214				}
215			};
216
217			// A big reason for creating a finished movement is that we currently don't support
218			// canceling exits. When we do, we can leave this in pending until it's either finished
219			// or canceled by the user.
220			self.movement_manager.new_finished_movement(
221				Subsystem::EXIT,
222				ExitMovement::Exit.to_string(),
223				MovementStatus::Successful,
224				MovementUpdate::new()
225					.intended_and_effective_balance(balance)
226					.consumed_vtxo(vtxo_id)
227					.sent_to([MovementDestination::new(payment_method, vtxo.amount())]),
228			).await.context("Failed to register exit movement")?;
229		}
230		Ok(())
231	}
232
233	/// Initializes pending exits and syncs transaction statuses.
234	/// Used by both [Exit::sync_no_progress] and [Exit::sync].
235	async fn sync_no_progress(&mut self, onchain: &dyn ExitUnilaterally) -> anyhow::Result<()> {
236		let mut exit_vtxos = std::mem::take(&mut self.exit_vtxos);
237		for exit in &mut exit_vtxos {
238			if !exit.is_initialized() {
239				match exit.initialize(&mut self.tx_manager, &*self.persister, onchain).await {
240					Ok(()) => continue,
241					Err(e) => {
242						error!("Error initializing exit for VTXO {}: {:#}", exit.id(), e);
243					}
244				}
245			}
246		}
247		self.exit_vtxos = exit_vtxos;
248		self.tx_manager.sync().await?;
249		Ok(())
250	}
251
252	/// Signs exit claim inputs on a PSBT.
253	/// Used by both [Exit::sign_exit_claim_inputs] and [Exit::drain_exits].
254	async fn sign_exit_claim_inputs(
255		&self,
256		psbt: &mut Psbt,
257		wallet: &Wallet,
258	) -> anyhow::Result<()> {
259		let prevouts = psbt.inputs.iter()
260			.map(|i| i.witness_utxo.clone().unwrap())
261			.collect::<Vec<_>>();
262
263		let prevouts = sighash::Prevouts::All(&prevouts);
264		let mut shc = sighash::SighashCache::new(&psbt.unsigned_tx);
265
266		let claimable = self.exit_vtxos.iter()
267			.filter(|ev| ev.is_claimable())
268			.map(|e| (e.id(), e))
269			.collect::<HashMap<_, _>>();
270
271		for (i, input) in psbt.inputs.iter_mut().enumerate() {
272			let vtxo = input.get_exit_claim_input();
273
274			if let Some(vtxo) = vtxo {
275				let exit_vtxo = claimable.get(&vtxo.id()).context("vtxo is not claimable yet")?;
276
277				let witness = wallet.sign_input(&vtxo, i, &mut shc, &prevouts).await
278					.map_err(|e| ExitError::ClaimSigningError { error: e.to_string() })?;
279
280				input.final_script_witness = Some(witness);
281				let _ = exit_vtxo;
282			}
283		}
284
285		Ok(())
286	}
287}
288
289/// Public handle to the exit subsystem. Wraps [ExitInner] in an [Arc<RwLock>] so all
290/// locking is internal — callers never need to acquire the lock directly.
291pub struct Exit {
292	inner: Arc<tokio::sync::RwLock<ExitInner>>,
293}
294
295impl Exit {
296	pub(crate) async fn new(
297		persister: Arc<dyn BarkPersister>,
298		chain_source: Arc<ChainSource>,
299		movement_manager: Arc<MovementManager>,
300	) -> anyhow::Result<Exit> {
301		let tx_manager = ExitTransactionManager::new(persister.clone(), chain_source.clone())?;
302		let inner = ExitInner {
303			exit_vtxos: Vec::new(),
304			tx_manager,
305			persister,
306			chain_source,
307			movement_manager,
308		};
309		Ok(Exit { inner: Arc::new(tokio::sync::RwLock::new(inner)) })
310	}
311
312	pub(crate) async fn load(&self, onchain: &dyn ExitUnilaterally) -> anyhow::Result<()> {
313		let mut guard = self.inner.write().await;
314		let inner = &mut *guard;
315		let exit_vtxo_entries = inner.persister.get_exit_vtxo_entries().await?;
316		inner.exit_vtxos.reserve(exit_vtxo_entries.len());
317
318		for entry in exit_vtxo_entries {
319			if let Some(vtxo) = inner.persister.get_wallet_vtxo(entry.vtxo_id).await? {
320				let mut exit = ExitVtxo::from_entry(entry, &vtxo);
321				exit.initialize(&mut inner.tx_manager, &*inner.persister, onchain).await?;
322				inner.exit_vtxos.push(exit);
323			} else {
324				error!("VTXO {} is marked for exit but it's missing from the database", entry.vtxo_id);
325			}
326		}
327		Ok(())
328	}
329
330	/// Returns the unilateral exit status for a given VTXO, if any.
331	///
332	/// # Parameters
333	/// - vtxo_id: The ID of the VTXO to check.
334	/// - include_history: Whether to include the full state machine history of the exit
335	/// - include_transactions: Whether to include the full set of transactions related to the exit.
336	pub async fn get_exit_status(
337		&self,
338		vtxo_id: VtxoId,
339		include_history: bool,
340		include_transactions: bool,
341	) -> Result<Option<ExitTransactionStatus>, ExitError> {
342		let guard = self.inner.read().await;
343		match guard.exit_vtxos.iter().find(|ev| ev.id() == vtxo_id) {
344			None => Ok(None),
345			Some(exit) => {
346				let mut txs = Vec::new();
347				if include_transactions {
348					if let Some(txids) = exit.txids() {
349						txs.reserve(txids.len());
350						for txid in txids {
351							txs.push(guard.tx_manager.get_package(*txid)?.read().await.clone());
352						}
353					} else {
354						// Realistically, the only way an exit isn't initialized is if it has been
355						// marked for exit, and we haven't synced the exit system yet. On this basis
356						// we can just return the VTXO transactions since there shouldn't be any
357						// children. We need the full VTXO here for `transactions()`.
358						let exit_vtxo = exit.get_full_vtxo(&*guard.persister).await?;
359						for tx in exit_vtxo.transactions() {
360							txs.push(ExitTransactionPackage {
361								exit: TransactionInfo {
362									txid: tx.tx.compute_txid(),
363									tx: tx.tx,
364								},
365								child: None,
366							})
367						}
368					}
369				}
370				Ok(Some(ExitTransactionStatus {
371					vtxo_id: exit.id(),
372					state: exit.state().clone(),
373					history: if include_history { Some(exit.history().clone()) } else { None },
374					transactions: txs,
375				}))
376			},
377		}
378	}
379
380	/// Returns a clone of the tracked [ExitVtxo] if it exists.
381	pub async fn get_exit_vtxo(&self, vtxo_id: VtxoId) -> Option<ExitVtxo> {
382		let guard = self.inner.read().await;
383		guard.exit_vtxos.iter().find(|ev| ev.id() == vtxo_id).cloned()
384	}
385
386	/// Returns clones of all known unilateral exits in this wallet.
387	pub async fn get_exit_vtxos(&self) -> Vec<ExitVtxo> {
388		let guard = self.inner.read().await;
389		guard.exit_vtxos.clone()
390	}
391
392	/// True if there are any unilateral exits which have been started but are not yet claimable.
393	pub async fn has_pending_exits(&self) -> bool {
394		let guard = self.inner.read().await;
395		guard.exit_vtxos.iter().any(|ev| ev.state().is_pending())
396	}
397
398	/// Returns [None] if the lock is currently held by a writer.
399	pub fn try_pending_total(&self) -> Option<Amount> {
400		self.inner.try_read().ok().map(|guard| {
401			guard.exit_vtxos.iter()
402				.filter_map(|ev| if ev.state().is_pending() { Some(ev.amount()) } else { None })
403				.sum()
404		})
405	}
406
407	/// Returns the earliest block height at which all tracked exits will be claimable
408	pub async fn all_claimable_at_height(&self) -> Option<BlockHeight> {
409		let guard = self.inner.read().await;
410		let mut highest_claimable_height = None;
411		for exit in &guard.exit_vtxos {
412			if matches!(exit.state(), ExitState::Claimed(..)) {
413				continue;
414			}
415			match exit.state().claimable_height() {
416				Some(h) => highest_claimable_height = cmp::max(highest_claimable_height, Some(h)),
417				None => return None,
418			}
419		}
420		highest_claimable_height
421	}
422
423	/// Starts the unilateral exit process for the entire wallet (all eligible VTXOs).
424	///
425	/// It does not block until completion, you must use [Exit::progress_exits] to advance each exit.
426	///
427	/// It's recommended to sync the wallet, by using something like [Wallet::maintenance] being
428	/// doing this.
429	pub async fn start_exit_for_entire_wallet(&self) -> anyhow::Result<()> {
430		let mut guard = self.inner.write().await;
431		let all_vtxos = guard.persister.get_vtxos_by_state(&VtxoStateKind::UNSPENT_STATES).await?
432			.into_iter().map(|v| v.vtxo);
433
434		// Partition: separate eligible VTXOs from dust
435		let (eligible, dust) = all_vtxos.partition::<Vec<_>, _>(|v| v.amount() >= P2TR_DUST);
436
437		// Warn for each dust VTXO individually
438		for vtxo in &dust {
439			warn!(
440				"Skipping dust VTXO {}: {} sats is below the dust limit ({} sats).",
441				vtxo.id(), vtxo.amount().to_sat(), P2TR_DUST.to_sat()
442			);
443		}
444
445		// If everything is dust.
446		if eligible.is_empty() && !dust.is_empty() {
447			warn!(
448				"Exit not started: all {} VTXOs (total {}) are below the dust limit. \
449				To exit and consolidate dust, you need to refresh your VTXOs first \
450				(requires total balance >= {})",
451				dust.len(),
452				dust.iter().map(|v| v.amount()).sum::<Amount>(),
453				P2TR_DUST,
454			);
455			return Ok(());
456		}
457
458		guard.start_exit_for_vtxos(&eligible).await
459	}
460
461	/// Starts the unilateral exit process for the given VTXOs.
462	///
463	/// It does not block until completion, you must use [Exit::progress_exits] to advance each exit.
464	///
465	/// It's recommended to sync the wallet, by using something like [Wallet::maintenance] being
466	/// doing this.
467	pub async fn start_exit_for_vtxos(
468		&self,
469		vtxos: &[impl Borrow<Vtxo<Bare>>],
470	) -> anyhow::Result<()> {
471		let mut guard = self.inner.write().await;
472		guard.start_exit_for_vtxos(vtxos).await
473	}
474
475	/// Reset exit to an empty state. Should be called when dropping VTXOs
476	///
477	/// Note: _This method is **dangerous** and can lead to funds loss. Be cautious._
478	pub(crate) async fn dangerous_clear_exit(&self) -> anyhow::Result<()> {
479		let mut guard = self.inner.write().await;
480		for exit in &guard.exit_vtxos {
481			guard.persister.remove_exit_vtxo_entry(&exit.id()).await?;
482		}
483		guard.exit_vtxos.clear();
484		Ok(())
485	}
486
487	/// Iterates over each registered VTXO and attempts to progress their unilateral exit. Note that
488	/// [Exit::sync] or [Exit::sync_no_progress] should be called before calling this method.
489	///
490	/// # Parameters
491	///
492	/// - `onchain` is used to build the CPFP transaction package we use to broadcast
493	///   the unilateral exit transaction
494	/// - `fee_rate_override` sets the desired fee-rate in sats/kvB to use broadcasting exit
495	///   transactions. Note that due to rules imposed by the network with regard to RBF fee bumping,
496	///   replaced transactions may have a higher fee rate than you specify here.
497	///
498	/// # Returns
499	///
500	/// The exit status of each VTXO being exited which has also not yet been spent
501	pub async fn progress_exits(
502		&self,
503		wallet: &Wallet,
504		onchain: &mut dyn ExitUnilaterally,
505		fee_rate_override: Option<FeeRate>,
506	) -> anyhow::Result<Option<Vec<ExitProgressStatus>>> {
507		let mut guard = self.inner.write().await;
508		let mut exit_vtxos = std::mem::take(&mut guard.exit_vtxos);
509		let mut exit_statuses = Vec::with_capacity(exit_vtxos.len());
510
511		for ev in exit_vtxos.iter_mut() {
512			if !ev.is_initialized() {
513				warn!("Skipping progress of uninitialized unilateral exit {}", ev.id());
514				continue;
515			}
516
517			info!("Progressing exit for VTXO {}", ev.id());
518			let error = match ev.progress(
519				wallet,
520				&mut guard.tx_manager,
521				onchain,
522				fee_rate_override,
523				true,
524			).await {
525				Ok(_) => None,
526				Err(e) => {
527					match &e {
528						ExitError::InsufficientConfirmedFunds { .. } => {
529							warn!("Can't progress exit for VTXO {} at this time: {}", ev.id(), e);
530						},
531						_ => {
532							error!("Error progressing exit for VTXO {}: {}", ev.id(), e);
533						}
534					}
535					Some(e)
536				}
537			};
538			if !matches!(ev.state(), ExitState::Claimed(..)) {
539				exit_statuses.push(ExitProgressStatus {
540					vtxo_id: ev.id(),
541					state: ev.state().clone(),
542					error,
543				});
544			}
545		}
546
547		guard.exit_vtxos = exit_vtxos;
548		Ok(Some(exit_statuses))
549	}
550
551	/// For use when syncing. Pending exits will be initialized, the network status of each
552	/// [ExitTransactionPackage] will be updated, and finally, any unilateral exits that are waiting
553	/// for network updates will be progressed.
554	pub async fn sync(
555		&self,
556		wallet: &Wallet,
557		onchain: &mut dyn ExitUnilaterally,
558	) -> anyhow::Result<()> {
559		let mut guard = self.inner.write().await;
560		guard.sync_no_progress(onchain).await?;
561		let mut exit_vtxos = std::mem::take(&mut guard.exit_vtxos);
562		for exit in &mut exit_vtxos {
563			// If the exit is waiting for new blocks, we should trigger an update
564			if exit.state().requires_network_update() {
565				if let Err(e) = exit.progress(
566					wallet, &mut guard.tx_manager, onchain, None, false,
567				).await {
568					error!("Error syncing exit for VTXO {}: {}", exit.id(), e);
569				}
570			}
571		}
572		guard.exit_vtxos = exit_vtxos;
573		Ok(())
574	}
575
576	/// For use when syncing. Initializes pending exits and syncs any confirmed or broadcast child
577	/// transactions. This differs from [Exit::sync] in that it doesn't update the [ExitState]
578	/// of a unilateral exit. This must be done manually by calling [Exit::progress_exits]. This
579	/// permits the use of a read-only reference to the onchain wallet.
580	pub async fn sync_no_progress(&self, onchain: &dyn ExitUnilaterally) -> anyhow::Result<()> {
581		let mut guard = self.inner.write().await;
582		guard.sync_no_progress(onchain).await
583	}
584
585	/// Lists all exits that are claimable
586	pub async fn list_claimable(&self) -> Vec<ExitVtxo> {
587		let guard = self.inner.read().await;
588		guard.exit_vtxos.iter().filter(|ev| ev.is_claimable()).cloned().collect()
589	}
590
591	/// Sign any inputs of the PSBT that is an exit claim input
592	///
593	/// Can take the result PSBT of [`bdk_wallet::TxBuilder::finish`] on which
594	/// [`crate::onchain::TxBuilderExt::add_exit_claim_inputs`] has been used
595	///
596	/// Note: This doesn't mark the exit output as spent, it's up to the caller to
597	/// do that, or it will be done once the transaction is seen in the network
598	pub async fn sign_exit_claim_inputs(&self, psbt: &mut Psbt, wallet: &Wallet) -> anyhow::Result<()> {
599		let guard = self.inner.read().await;
600		guard.sign_exit_claim_inputs(psbt, wallet).await
601	}
602
603	/// Builds a PSBT that drains the provided claimable unilateral exits to the given address.
604	///
605	/// - `inputs`: Claimable unilateral exits.
606	/// - `wallet`: The bark wallet containing the keys needed to spend the unilateral exits.
607	/// - `address`: Destination address for the claim.
608	/// - `fee_rate_override`: Optional fee rate to use.
609	///
610	/// Returns a PSBT ready to be broadcast.
611	pub async fn drain_exits(
612		&self,
613		inputs: &[impl Borrow<ExitVtxo>],
614		wallet: &Wallet,
615		address: Address,
616		fee_rate_override: Option<FeeRate>,
617	) -> anyhow::Result<Psbt, ExitError> {
618		let guard = self.inner.read().await;
619
620		let tip = guard.chain_source.tip().await
621			.map_err(|e| ExitError::TipRetrievalFailure { error: e.to_string() })?;
622
623		if inputs.is_empty() {
624			return Err(ExitError::ClaimMissingInputs);
625		}
626		let mut vtxos = HashMap::with_capacity(inputs.len());
627		for input in inputs {
628			let i = input.borrow();
629			let vtxo = i.get_full_vtxo(&*guard.persister).await?;
630			vtxos.insert(i.id(), vtxo);
631		}
632
633		let mut tx = {
634			let mut output_amount = Amount::ZERO;
635			let mut tx_ins = Vec::with_capacity(inputs.len());
636			for input in inputs {
637				let input = input.borrow();
638				let vtxo = &vtxos[&input.id()];
639				if !matches!(input.state(), ExitState::Claimable(..)) {
640					return Err(ExitError::VtxoNotClaimable { vtxo: input.id() });
641				}
642
643				output_amount += vtxo.amount();
644
645				let clause = wallet.find_signable_clause(vtxo).await
646					.ok_or(ExitError::ClaimMissingSignableClause { vtxo: vtxo.id() })?;
647
648				tx_ins.push(TxIn {
649					previous_output: vtxo.point(),
650					script_sig: ScriptBuf::default(),
651					sequence: clause.sequence().unwrap_or(Sequence::ZERO),
652					witness: Witness::new(),
653				});
654			}
655
656			let locktime = bitcoin::absolute::LockTime::from_height(tip)
657				.map_err(|e| ExitError::InvalidLocktime { tip, error: e.to_string() })?;
658
659			Transaction {
660				version: bitcoin::transaction::Version(3),
661				lock_time: locktime,
662				input: tx_ins,
663				output: vec![
664					TxOut {
665						script_pubkey: address.script_pubkey(),
666						value: output_amount,
667					},
668				],
669			}
670		};
671
672		// Create a PSBT to determine the weight of the transaction so we can deduct a tx fee
673		let create_psbt = |tx: Transaction| async {
674			let mut psbt = Psbt::from_unsigned_tx(tx)
675				.map_err(|e| ExitError::InternalError {
676					error: format!("Failed to create exit claim PSBT: {}", e),
677				})?;
678			psbt.inputs.iter_mut().zip(inputs).for_each(|(i, e)| {
679				let v = &vtxos[&e.borrow().id()];
680				i.set_exit_claim_input(v);
681				i.witness_utxo = Some(v.txout())
682			});
683			guard.sign_exit_claim_inputs(&mut psbt, wallet).await
684				.map_err(|e| ExitError::ClaimSigningError { error: e.to_string() })?;
685			Ok(psbt)
686		};
687		let fee_amount = {
688			let fee_rate = fee_rate_override
689				.unwrap_or(guard.chain_source.fee_rates().await.regular);
690			fee_rate * create_psbt(tx.clone()).await?
691				.extract_tx()
692				.map_err(|e| ExitError::InternalError {
693					error: format!("Failed to get tx from signed exit claim PSBT: {}", e),
694				})?
695				.weight()
696		};
697
698		// We adjust the drain output to cover the fee
699		let needed = fee_amount + P2TR_DUST;
700		if needed > tx.output[0].value {
701			return Err(ExitError::ClaimFeeExceedsOutput {
702				needed, output: tx.output[0].value,
703			});
704		}
705		tx.output[0].value -= fee_amount;
706
707		// Now create the final signed PSBT
708		create_psbt(tx).await
709	}
710}