bark/exit/
vtxo.rs

1//! Unilateral exit tracking and progression for individual VTXOs.
2//!
3//! This module defines types that track the lifecycle of a single [Vtxo] exit, including its current
4//! state, onchain transaction IDs, and a history of prior states for auditing and troubleshooting.
5//!
6//! The primary type is [ExitVtxo], which provides an async [`ExitVtxo::progress`] method to advance
7//! the unilateral exit state machine until completion or until the next step actionable step such
8//! as requiring more onchain funds or waiting for a confirmation.
9//!
10//! See [ExitModel] for persisting the state machine in a database.
11
12use bitcoin::{Amount, FeeRate, Txid};
13use log::{debug, trace};
14
15use ark::{Vtxo, VtxoId};
16
17use crate::exit::models::{ExitError, ExitState};
18use crate::exit::progress::{ExitStateProgress, ProgressContext, ProgressStep};
19use crate::exit::transaction_manager::ExitTransactionManager;
20use crate::onchain::ExitUnilaterally;
21use crate::persist::BarkPersister;
22use crate::persist::models::StoredExit;
23use crate::{Wallet, WalletVtxo};
24
25/// Tracks the exit lifecycle for a single [Vtxo].
26///
27/// An `ExitVtxo` maintains:
28/// - the underlying [Vtxo] being exited,
29/// - the set of related onchain transaction IDs in topographical order,
30/// - the current state [ExitState],
31/// - and a history of prior states for debugging and auditing.
32///
33/// Use [ExitVtxo::progress] to drive the state machine forward. The method is idempotent and will
34/// only persist when a logical state transition occurs.
35#[derive(Debug, Clone)]
36pub struct ExitVtxo {
37	vtxo_id: VtxoId,
38	amount: Amount,
39	state: ExitState,
40	history: Vec<ExitState>,
41	txids: Option<Vec<Txid>>,
42}
43
44impl ExitVtxo {
45	/// Create a new instance for the given [VtxoId] with an initial state of [ExitState::Start].
46	/// The unilateral exit can't progress until [ExitVtxo::initialize] is called.
47	///
48	/// # Parameters
49	/// - `vtxo_id`: the [VtxoId] being exited.
50	/// - `tip`: current chain tip used to initialize the starting state.
51	pub fn new(vtxo: &Vtxo, tip: u32) -> Self {
52		Self {
53			vtxo_id: vtxo.id(),
54			amount: vtxo.amount(),
55			state: ExitState::new_start(tip),
56			history: vec![],
57			txids: None,
58		}
59	}
60
61	/// Reconstruct an `ExitVtxo` from its parts. This leaves the instance in an uninitialized
62	/// state. Useful when loading a tracked exit from storage.
63	///
64	/// # Parameters
65	/// - `entry`: The persisted data to reconstruct this instance from.
66	/// - `vtxo`: The [Vtxo] that this exit is tracking.
67	pub fn from_entry(entry: StoredExit, vtxo: &Vtxo) -> Self {
68		assert_eq!(entry.vtxo_id, vtxo.id());
69		ExitVtxo {
70			vtxo_id: entry.vtxo_id,
71			amount: vtxo.amount(),
72			state: entry.state,
73			history: entry.history,
74			txids: None,
75		}
76	}
77
78	/// Returns the ID of the tracked [Vtxo].
79	pub fn id(&self) -> VtxoId {
80		self.vtxo_id
81	}
82
83	/// Returns the amount being exited.
84	pub fn amount(&self) -> Amount {
85		self.amount
86	}
87
88	/// Returns the current state of the unilateral exit.
89	pub fn state(&self) -> &ExitState {
90		&self.state
91	}
92
93	/// Returns the history of the exit machine in the order that states were observed.
94	pub fn history(&self) -> &Vec<ExitState> {
95		&self.history
96	}
97
98	/// Returns the set of exit-related transaction IDs, these may not be broadcast yet. If the
99	/// instance is not yet initialized, None will be returned.
100	pub fn txids(&self) -> Option<&Vec<Txid>> {
101		self.txids.as_ref()
102	}
103
104	/// True if the exit is currently [ExitState::Claimable] and can be claimed/spent.
105	pub fn is_claimable(&self) -> bool {
106		matches!(self.state, ExitState::Claimable(..))
107	}
108
109	/// True if [ExitVtxo::initialize] has been called and the exit is ready to progress.
110	pub fn is_initialized(&self) -> bool {
111		self.txids.is_some()
112	}
113
114	/// Prepares an [ExitVtxo] for progression by querying the list of transactions required to
115	/// process the unilateral exit and adds them to the exit transaction manager.
116	pub async fn initialize(
117		&mut self,
118		tx_manager: &mut ExitTransactionManager,
119		persister: &dyn BarkPersister,
120		onchain: &dyn ExitUnilaterally,
121	) -> anyhow::Result<(), ExitError> {
122		trace!("Initializing VTXO for exit {}", self.vtxo_id);
123		let vtxo = self.get_vtxo(persister).await?;
124		self.txids = Some(tx_manager.track_vtxo_exits(&vtxo, onchain).await?);
125		Ok(())
126	}
127
128	/// Advances the exit state machine for this [Vtxo].
129	///
130	/// The method:
131	/// - Attempts to transition the unilateral exit state machine.
132	/// - Persists only when a logical state change occurs.
133	///
134	/// Returns:
135	/// - `Ok(())` when no more immediate work is required, such as when we're waiting for a
136	///   confirmation or when the exit is complete.
137	/// - `Err(ExitError)` when an unrecoverable issue occurs, such as requiring more onchain funds
138	///   or if an exit transaction fails to broadcast; if the error includes a newer state, it will
139	///   be committed before returning.
140	///
141	/// Notes:
142	/// - If `fee_rate_override` is `None`, a suitable fee rate will be calculated.
143	pub async fn progress(
144		&mut self,
145		wallet: &Wallet,
146		tx_manager: &mut ExitTransactionManager,
147		onchain: &mut dyn ExitUnilaterally,
148		fee_rate_override: Option<FeeRate>,
149		continue_until_finished: bool,
150	) -> anyhow::Result<(), ExitError> {
151		if self.txids.is_none() {
152			return Err(ExitError::InternalError {
153				error: String::from("Unilateral exit not yet initialized"),
154			});
155		}
156
157		let wallet_vtxo = self.get_vtxo(&*wallet.db).await?;
158		const MAX_ITERATIONS: usize = 100;
159		for _ in 0..MAX_ITERATIONS {
160			let mut context = ProgressContext {
161				vtxo: &wallet_vtxo.vtxo,
162				exit_txids: self.txids.as_ref().unwrap(),
163				wallet,
164				fee_rate: fee_rate_override.unwrap_or(wallet.chain.fee_rates().await.fast),
165				tx_manager,
166			};
167			// Attempt to move to the next state, which may or may not generate a new state
168			trace!("Progressing VTXO {} at height {}", self.id(), wallet.chain.tip().await.unwrap());
169			match self.state.clone().progress(&mut context, onchain).await {
170				Ok(new_state) => {
171					self.update_state_if_newer(new_state, &*wallet.db).await?;
172					if !continue_until_finished {
173						return Ok(());
174					}
175					match ProgressStep::from_exit_state(&self.state) {
176						ProgressStep::Continue => debug!("VTXO {} can continue", self.id()),
177						ProgressStep::Done => return Ok(())
178					}
179				},
180				Err(e) => {
181					// We may need to commit a new state before returning an error
182					if let Some(new_state) = e.state {
183						self.update_state_if_newer(new_state, &*wallet.db).await?;
184					}
185					return Err(e.error);
186				}
187			}
188		}
189		debug_assert!(false, "Exceeded maximum iterations for progressing VTXO {}", self.id());
190		Ok(())
191	}
192
193	pub async fn get_vtxo(&self, persister: &dyn BarkPersister) -> anyhow::Result<WalletVtxo, ExitError> {
194		persister.get_wallet_vtxo(self.vtxo_id).await
195			.map_err(|e| ExitError::InvalidWalletState { error: e.to_string() })?
196			.ok_or_else(|| ExitError::InternalError {
197				error: format!("VTXO for exit couldn't be found: {}", self.vtxo_id)
198			})
199	}
200
201	async fn update_state_if_newer(
202		&mut self,
203		new: ExitState,
204		persister: &dyn BarkPersister,
205	) -> anyhow::Result<(), ExitError> {
206		// We don't want to push a new history item unless the state has changed logically
207		if new != self.state {
208			self.history.push(self.state.clone());
209			self.state = new;
210			self.persist(persister).await
211		} else {
212			Ok(())
213		}
214	}
215
216	async fn persist(&self, persister: &dyn BarkPersister) -> anyhow::Result<(), ExitError> {
217		persister.store_exit_vtxo_entry(&StoredExit::new(self)).await
218			.map_err(|e| ExitError::DatabaseVtxoStoreFailure {
219				vtxo_id: self.id(), error: e.to_string(),
220			})
221	}
222}