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