Skip to main content

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, Txid};
13use log::{debug, trace};
14
15use ark::{Vtxo, VtxoId};
16use ark::vtxo::{Bare, Full};
17
18use crate::exit::models::{ExitError, ExitState};
19use crate::exit::progress::{ExitStateProgress, ProgressContext, ProgressStep};
20use crate::exit::transaction_manager::ExitTransactionManager;
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<Bare>, 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: &WalletVtxo) -> 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	) -> anyhow::Result<(), ExitError> {
121		trace!("Initializing VTXO for exit {}", self.vtxo_id);
122		let vtxo = self.get_full_vtxo(persister).await?;
123		self.txids = Some(tx_manager.track_vtxo_exits(&vtxo).await?);
124		Ok(())
125	}
126
127	/// Advances the exit state machine for this [Vtxo].
128	///
129	/// The method:
130	/// - Attempts to transition the unilateral exit state machine.
131	/// - Persists only when a logical state change occurs.
132	///
133	/// Returns:
134	/// - `Ok(())` when no more immediate work is required, such as when we're waiting for a
135	///   confirmation or when the exit is complete.
136	/// - `Err(ExitError)` when an unrecoverable issue occurs, such as requiring more onchain funds
137	///   or if an exit transaction fails to broadcast; if the error includes a newer state, it will
138	///   be committed before returning.
139	///
140	pub async fn progress(
141		&mut self,
142		wallet: &Wallet,
143		tx_manager: &mut ExitTransactionManager,
144		continue_until_finished: bool,
145	) -> anyhow::Result<(), ExitError> {
146		if self.txids.is_none() {
147			return Err(ExitError::InternalError {
148				error: String::from("Unilateral exit not yet initialized"),
149			});
150		}
151
152		let vtxo = self.get_vtxo(&*wallet.inner.db).await?;
153		const MAX_ITERATIONS: usize = 100;
154		for _ in 0..MAX_ITERATIONS {
155			let mut context = ProgressContext {
156				vtxo: &vtxo,
157				exit_txids: self.txids.as_ref().unwrap(),
158				wallet,
159				tx_manager,
160			};
161			// Attempt to move to the next state, which may or may not generate a new state
162			trace!("Progressing VTXO {} at height {}", self.id(), wallet.inner.chain.tip().await.unwrap());
163			match self.state.clone().progress(&mut context).await {
164				Ok(new_state) => {
165					self.update_state_if_newer(new_state, &*wallet.inner.db).await?;
166					if !continue_until_finished {
167						return Ok(());
168					}
169					match ProgressStep::from_exit_state(&self.state) {
170						ProgressStep::Continue => debug!("VTXO {} can continue", self.id()),
171						ProgressStep::Done => return Ok(())
172					}
173				},
174				Err(e) => {
175					// We may need to commit a new state before returning an error
176					if let Some(new_state) = e.state {
177						self.update_state_if_newer(new_state, &*wallet.inner.db).await?;
178					}
179					return Err(e.error);
180				}
181			}
182		}
183		debug_assert!(false, "Exceeded maximum iterations for progressing VTXO {}", self.id());
184		Ok(())
185	}
186
187	pub async fn get_vtxo(&self, persister: &dyn BarkPersister) -> anyhow::Result<WalletVtxo, ExitError> {
188		persister.get_wallet_vtxo(self.vtxo_id).await
189			.map_err(|e| ExitError::InvalidWalletState { error: e.to_string() })?
190			.ok_or_else(|| ExitError::InternalError {
191				error: format!("VTXO for exit couldn't be found: {}", self.vtxo_id)
192			})
193	}
194
195	/// Hydrate the underlying [Vtxo] including its full unilateral exit
196	/// chain. Used by exit-specific code paths that need to construct or
197	/// inspect the actual exit transactions.
198	pub async fn get_full_vtxo(
199		&self,
200		persister: &dyn BarkPersister,
201	) -> anyhow::Result<Vtxo<Full>, ExitError> {
202		persister.get_full_vtxo(self.vtxo_id).await
203			.map_err(|e| ExitError::InvalidWalletState { error: e.to_string() })?
204			.ok_or_else(|| ExitError::InternalError {
205				error: format!("VTXO for exit couldn't be found: {}", self.vtxo_id)
206			})
207	}
208
209	async fn update_state_if_newer(
210		&mut self,
211		new: ExitState,
212		persister: &dyn BarkPersister,
213	) -> anyhow::Result<(), ExitError> {
214		// We don't want to push a new history item unless the state has changed logically
215		if new != self.state {
216			self.history.push(self.state.clone());
217			self.state = new;
218			self.persist(persister).await
219		} else {
220			Ok(())
221		}
222	}
223
224	async fn persist(&self, persister: &dyn BarkPersister) -> anyhow::Result<(), ExitError> {
225		persister.store_exit_vtxo_entry(&StoredExit::new(self)).await
226			.map_err(|e| ExitError::DatabaseVtxoStoreFailure {
227				vtxo_id: self.id(), error: e.to_string(),
228			})
229	}
230}