Skip to main content

bark/exit/models/
mod.rs

1
2mod error;
3mod package;
4mod states;
5
6pub use self::package::{
7	ChildTransactionInfo, ExitCpfpRequest, ExitTransactionPackage, FeeInfo, RbfRequirement,
8	TransactionInfo,
9};
10pub use self::error::ExitError;
11pub use self::states::{
12	ExitTx, ExitTxStatus, ExitTxOrigin, ExitStartState, ExitProcessingState, ExitAwaitingDeltaState,
13	ExitClaimableState, ExitClaimInProgressState, ExitClaimedState, ExitVtxoAlreadySpentState,
14	ExitCanceledState,
15};
16
17use std::fmt;
18
19use ark::VtxoId;
20use bitcoin::Txid;
21
22use bitcoin_ext::{BlockDelta, BlockHeight, BlockRef, TxStatus};
23
24/// A utility type to wrap ExitState children so they can be easily serialized. This also helps with
25/// debugging a lot!
26#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(tag = "type", rename_all = "kebab-case")]
28pub enum ExitState {
29	Start(ExitStartState),
30	Processing(ExitProcessingState),
31	AwaitingDelta(ExitAwaitingDeltaState),
32	Claimable(ExitClaimableState),
33	ClaimInProgress(ExitClaimInProgressState),
34	/// Terminal state: the exit is fully complete, and the VTXO has been claimed by the user or
35	/// spent by another user who owns a VTXO that is deeper in the tree than this one.
36	///
37	/// Note: The circumstances in which the latter can occur are typically when the user has stale
38	/// data and is trying to exit an already-spent VTXO.
39	Claimed(ExitClaimedState),
40	/// Terminal state: the exit cannot proceed because the VTXO has already been spent offchain. A
41	/// user can start a unilateral exit for a VTXO but later spend it via a refresh, arkoor, etc,
42	/// in that situation an exit will enter this state.
43	VtxoAlreadySpent(ExitVtxoAlreadySpentState),
44	/// Resumable state: the user canceled the exit before its final transaction was broadcast;
45	/// ancestor transactions may already be on-chain. The VTXO is untouched and stays spendable,
46	/// so a new exit can be started later.
47	Canceled(ExitCanceledState),
48}
49
50/// A flat, data-free discriminator for [ExitState]. Useful for filtering exits by state without
51/// caring about the per-state payload — e.g.
52/// [crate::persist::BarkPersister::get_exit_vtxo_entries_with_states].
53///
54/// The serde representation matches [ExitState]'s `type` tag (kebab-case), so the two stay in sync.
55#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
56#[serde(rename_all = "kebab-case")]
57pub enum ExitStateKind {
58	Start,
59	Processing,
60	AwaitingDelta,
61	Claimable,
62	ClaimInProgress,
63	Claimed,
64	VtxoAlreadySpent,
65	Canceled,
66}
67
68impl ExitStateKind {
69	/// List of all the different states.
70	pub const ALL: &[ExitStateKind] = &[
71		ExitStateKind::Start,
72		ExitStateKind::Processing,
73		ExitStateKind::AwaitingDelta,
74		ExitStateKind::Claimable,
75		ExitStateKind::ClaimInProgress,
76		ExitStateKind::Claimed,
77		ExitStateKind::VtxoAlreadySpent,
78		ExitStateKind::Canceled,
79	];
80
81	/// List of the states in which an exit is still live and actionable.
82	pub const LIVE_STATES: &[ExitStateKind] = &[
83		ExitStateKind::Start,
84		ExitStateKind::Processing,
85		ExitStateKind::AwaitingDelta,
86		ExitStateKind::Claimable,
87		ExitStateKind::ClaimInProgress,
88	];
89
90	/// List of the states in which an exit is finished and will never progress again. A canceled
91	/// exit's VTXO stays spendable, so a fresh exit can be started for it later.
92	pub const FINISHED_STATES: &[ExitStateKind] = &[
93		ExitStateKind::Claimed,
94		ExitStateKind::VtxoAlreadySpent,
95		ExitStateKind::Canceled,
96	];
97
98	/// The stable string tag for this kind. It matches [ExitState]'s serde `type` discriminator,
99	/// which is what's stored in the `state` JSON column — so it can be used directly to filter
100	/// rows (e.g. `json_extract(state, '$.type')`). Kept in sync with serde by a unit test.
101	pub fn as_str(&self) -> &'static str {
102		match self {
103			ExitStateKind::Start => "start",
104			ExitStateKind::Processing => "processing",
105			ExitStateKind::AwaitingDelta => "awaiting-delta",
106			ExitStateKind::Claimable => "claimable",
107			ExitStateKind::ClaimInProgress => "claim-in-progress",
108			ExitStateKind::Claimed => "claimed",
109			ExitStateKind::VtxoAlreadySpent => "vtxo-already-spent",
110			ExitStateKind::Canceled => "canceled",
111		}
112	}
113}
114
115impl fmt::Display for ExitStateKind {
116	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117		f.write_str(self.as_str())
118	}
119}
120
121impl ExitState {
122	/// Returns the data-free [ExitStateKind] discriminator for this state.
123	pub fn kind(&self) -> ExitStateKind {
124		match self {
125			ExitState::Start(_) => ExitStateKind::Start,
126			ExitState::Processing(_) => ExitStateKind::Processing,
127			ExitState::AwaitingDelta(_) => ExitStateKind::AwaitingDelta,
128			ExitState::Claimable(_) => ExitStateKind::Claimable,
129			ExitState::ClaimInProgress(_) => ExitStateKind::ClaimInProgress,
130			ExitState::Claimed(_) => ExitStateKind::Claimed,
131			ExitState::VtxoAlreadySpent(_) => ExitStateKind::VtxoAlreadySpent,
132			ExitState::Canceled(_) => ExitStateKind::Canceled,
133		}
134	}
135
136	pub fn new_start(tip: BlockHeight) -> Self {
137		ExitState::Start(ExitStartState { tip_height: tip })
138	}
139
140	pub fn new_processing<T: IntoIterator<Item = Txid>>(tip: BlockHeight, txids: T) -> Self {
141		ExitState::Processing(ExitProcessingState {
142			tip_height: tip,
143			transactions: txids.into_iter()
144				.map(|id| ExitTx {
145					txid: id,
146					status: ExitTxStatus::VerifyInputs,
147				})
148				.collect::<Vec<_>>(),
149		})
150	}
151
152	pub fn new_processing_from_transactions(tip: BlockHeight, transactions: Vec<ExitTx>) -> Self {
153		ExitState::Processing(ExitProcessingState {
154			tip_height: tip,
155			transactions,
156		})
157	}
158
159	pub fn new_awaiting_delta(
160		tip: BlockHeight,
161		confirmed_block: BlockRef,
162		wait_delta: BlockDelta
163	) -> Self {
164		debug_assert_ne!(wait_delta, 0, "wait delta must be non-zero");
165		let claimable_height = confirmed_block.height + wait_delta as BlockHeight;
166		ExitState::AwaitingDelta(ExitAwaitingDeltaState {
167			tip_height: tip,
168			confirmed_block,
169			claimable_height,
170		})
171	}
172
173	pub fn new_claimable(
174		tip: BlockHeight,
175		claimable_since: BlockRef,
176		last_scanned_block: Option<BlockRef>
177	) -> Self {
178		ExitState::Claimable(ExitClaimableState {
179			tip_height: tip,
180			claimable_since,
181			last_scanned_block,
182		})
183	}
184
185	pub fn new_claim_in_progress(
186		tip: BlockHeight,
187		claimable_since: BlockRef,
188		claim_txid: Txid
189	) -> Self {
190		ExitState::ClaimInProgress(ExitClaimInProgressState {
191			tip_height: tip,
192			claimable_since,
193			claim_txid,
194		})
195	}
196
197	pub fn new_claimed(tip: BlockHeight, txid: Txid, block: BlockRef) -> Self {
198		ExitState::Claimed(ExitClaimedState {
199			tip_height: tip,
200			txid,
201			block,
202		})
203	}
204
205	pub fn new_vtxo_already_spent(tip: BlockHeight) -> Self {
206		ExitState::VtxoAlreadySpent(ExitVtxoAlreadySpentState { tip_height: tip })
207	}
208
209	pub fn new_canceled(tip: BlockHeight) -> Self {
210		ExitState::Canceled(ExitCanceledState { tip_height: tip })
211	}
212
213	/// Checks if the state is awaiting the confirmation of every exit transaction in the tree and
214	/// the exit delta required for the VTXO to become claimable.
215	///
216	/// Note: This excludes the claimable state, use [ExitState::is_claimable] for that.
217	pub fn is_pending(&self) -> bool {
218		match self {
219			ExitState::Start(_) => true,
220			ExitState::Processing(_) => true,
221			ExitState::AwaitingDelta(_) => true,
222			_ => false,
223		}
224	}
225
226	/// A simple helper for [ExitState::Claimable], at this point an exit can be spent on-chain
227	/// and redeemed into a UTXO controlled by the user.
228	pub fn is_claimable(&self) -> bool {
229		match self {
230			ExitState::Claimable(_) => true,
231			_ => false,
232		}
233	}
234
235	/// Whether the exit is still in its abortable window and can be canceled by the user. This is
236	/// only possible until the final exit transaction is broadcast; ancestor transactions may
237	/// already be on-chain.
238	pub fn is_cancelable(&self) -> bool {
239		match self {
240			ExitState::Start(_) => true,
241			ExitState::Processing(s) => s.transactions.last().map_or(true, |tx| matches!(
242				tx.status,
243				ExitTxStatus::VerifyInputs
244				| ExitTxStatus::AwaitingInputConfirmation { .. }
245				| ExitTxStatus::AwaitingCpfpBroadcast,
246			)),
247			_ => false,
248		}
249	}
250
251	pub fn requires_confirmations(&self) -> bool {
252		match self {
253			ExitState::Processing(s) => {
254				s.transactions.iter().any(|s| match s.status {
255					ExitTxStatus::AwaitingInputConfirmation { .. } => true,
256					ExitTxStatus::AwaitingConfirmation { .. } => true,
257					_ => false,
258				})
259			},
260			ExitState::AwaitingDelta(_) => true,
261			ExitState::ClaimInProgress(_) => true,
262			_ => false,
263		}
264	}
265
266	pub fn claimable_height(&self) -> Option<BlockHeight> {
267		match self {
268			ExitState::AwaitingDelta(s) => Some(s.claimable_height),
269			ExitState::Claimable(s) => Some(s.claimable_since.height),
270			ExitState::ClaimInProgress(s) => Some(s.claimable_since.height),
271			_ => None,
272		}
273	}
274
275	/// True once every exit transaction has confirmed on-chain (i.e. the exit has reached
276	/// at least [`ExitState::AwaitingDelta`]). At that point the VTXO can be considered
277	/// [crate::vtxo::VtxoStateKind::Exited]: the underlying onchain outpoint is committed
278	/// to the exit chain, the server can see it and will refuse to service the VTXO for
279	/// any payment operations offchain, and there's nothing left to undo client-side.
280	///
281	/// Note: we deliberately don't flip the VTXO at `Processing` — even with every tx
282	/// broadcast, mempool eviction is still possible until confirmation, so we hold off
283	/// to keep `Exited` an accurate "this is gone" signal.
284	pub fn warrants_exited_vtxo(&self) -> bool {
285		match self {
286			ExitState::Start(_) => false,
287			ExitState::Processing(_) => false,
288			ExitState::AwaitingDelta(_) => true,
289			ExitState::Claimable(_) => true,
290			ExitState::ClaimInProgress(_) => true,
291			ExitState::Claimed(_) => true,
292			ExitState::VtxoAlreadySpent(_) => false,
293			ExitState::Canceled(_) => false,
294		}
295	}
296}
297
298#[derive(Debug, Clone, PartialEq, Eq)]
299pub struct ExitProgressStatus {
300	/// The ID of the VTXO that is being unilaterally exited
301	pub vtxo_id: VtxoId,
302	/// The current state of the exit transaction
303	pub state: ExitState,
304	/// Any error that occurred during the exit process
305	pub error: Option<ExitError>,
306}
307
308#[derive(Debug, Clone, PartialEq, Eq)]
309pub struct ExitTransactionStatus {
310	/// The ID of the VTXO that is being unilaterally exited
311	pub vtxo_id: VtxoId,
312	/// The current state of the exit transaction
313	pub state: ExitState,
314	/// The history of each state the exit transaction has gone through
315	pub history: Option<Vec<ExitState>>,
316	/// Each exit transaction package required for the unilateral exit
317	pub transactions: Vec<ExitTransactionPackage>,
318}
319
320#[derive(Clone, Copy, Debug,  Eq, PartialEq)]
321pub struct ExitChildStatus {
322	pub txid: Txid,
323	pub status: TxStatus,
324	pub origin: ExitTxOrigin,
325	pub fee_info: Option<FeeInfo>,
326}
327
328#[cfg(test)]
329mod test {
330	use super::*;
331
332	use bitcoin::hashes::Hash;
333
334	fn txid(n: u8) -> Txid {
335		Txid::from_byte_array([n; 32])
336	}
337
338	fn tx(n: u8, status: ExitTxStatus) -> ExitTx {
339		ExitTx { txid: txid(n), status }
340	}
341
342	/// A status representing an exit tx that's been broadcast (its CPFP child is in the mempool).
343	fn broadcast() -> ExitTxStatus {
344		ExitTxStatus::AwaitingConfirmation { child_txid: txid(99), origin: ExitTxOrigin::Mempool }
345	}
346
347	/// One [ExitState] per variant, for tests that need to cover the whole enum.
348	fn all_states() -> [ExitState; 8] {
349		let block_ref = BlockRef { height: 1, hash: bitcoin::BlockHash::all_zeros() };
350		[
351			ExitState::new_start(1),
352			ExitState::new_processing(1, [txid(1)]),
353			ExitState::new_awaiting_delta(1, block_ref, 10),
354			ExitState::new_claimable(1, block_ref, Some(block_ref)),
355			ExitState::new_claim_in_progress(1, block_ref, txid(1)),
356			ExitState::new_claimed(1, txid(1), block_ref),
357			ExitState::new_vtxo_already_spent(1),
358			ExitState::new_canceled(1),
359		]
360	}
361
362	#[test]
363	fn is_cancelable_only_checks_the_final_tx() {
364		// Start is always cancellable — nothing has been built yet.
365		assert!(ExitState::new_start(100).is_cancelable());
366
367		// Processing with nothing broadcast.
368		assert!(ExitState::new_processing_from_transactions(100, vec![
369			tx(1, ExitTxStatus::VerifyInputs),
370			tx(2, ExitTxStatus::AwaitingCpfpBroadcast),
371		]).is_cancelable());
372
373		// Ancestors broadcast but the final (leaf) tx is not — still cancellable, since only the
374		// last transaction actually commits the VTXO on-chain.
375		assert!(ExitState::new_processing_from_transactions(100, vec![
376			tx(1, broadcast()),
377			tx(2, ExitTxStatus::AwaitingCpfpBroadcast),
378		]).is_cancelable());
379
380		// The final tx has been broadcast — no longer cancellable.
381		assert!(!ExitState::new_processing_from_transactions(100, vec![
382			tx(1, broadcast()),
383			tx(2, broadcast()),
384		]).is_cancelable());
385
386		// Terminal / post-broadcast states are never cancellable.
387		assert!(!ExitState::new_canceled(100).is_cancelable());
388		assert!(!ExitState::new_vtxo_already_spent(100).is_cancelable());
389	}
390
391	#[test]
392	fn exit_state_kind_tag_matches_serde() {
393		// ExitStateKind's serde tag must stay in lock-step with ExitState's `type` tag, since the
394		// SQL/default filter relies on them matching.
395		for state in all_states() {
396			let state_tag = serde_json::to_value(&state).unwrap()
397				.get("type").unwrap().as_str().unwrap().to_string();
398			let kind_tag = serde_json::to_value(state.kind()).unwrap()
399				.as_str().unwrap().to_string();
400			assert_eq!(state_tag, kind_tag, "tag mismatch for {:?}", state.kind());
401		}
402
403		// ExitStateKind::as_str backs the SQL `json_extract(state, '$.type')` filter, so it must
404		// equal the serde tag for every kind.
405		for &kind in ExitStateKind::ALL {
406			let serde_tag = serde_json::to_value(kind).unwrap().as_str().unwrap().to_string();
407			assert_eq!(kind.as_str(), serde_tag, "as_str mismatch for {:?}", kind);
408		}
409	}
410
411	#[test]
412	fn exit_state_kind_all_is_exhaustive() {
413		// When this match stops compiling, a variant was added: extend ALL, all_states(),
414		// and LIVE_STATES or FINISHED_STATES.
415		match ExitStateKind::Start {
416			ExitStateKind::Start => {},
417			ExitStateKind::Processing => {},
418			ExitStateKind::AwaitingDelta => {},
419			ExitStateKind::Claimable => {},
420			ExitStateKind::ClaimInProgress => {},
421			ExitStateKind::Claimed => {},
422			ExitStateKind::VtxoAlreadySpent => {},
423			ExitStateKind::Canceled => {},
424		}
425
426		// Every state's kind appears in ALL exactly once.
427		assert_eq!(ExitStateKind::ALL.len(), all_states().len());
428		for state in all_states() {
429			let count = ExitStateKind::ALL.iter().filter(|&&k| k == state.kind()).count();
430			assert_eq!(count, 1, "{:?} should appear exactly once in ALL", state.kind());
431		}
432	}
433
434	#[test]
435	fn exit_state_kind_live_and_finished_states_partition_all() {
436		// Every kind belongs to exactly one of LIVE_STATES and FINISHED_STATES.
437		for &kind in ExitStateKind::ALL {
438			let live = !matches!(
439				kind,
440				ExitStateKind::Claimed
441				| ExitStateKind::VtxoAlreadySpent
442				| ExitStateKind::Canceled,
443			);
444			assert_eq!(
445				ExitStateKind::LIVE_STATES.contains(&kind), live,
446				"LIVE_STATES membership wrong for {:?}", kind,
447			);
448			assert_eq!(
449				ExitStateKind::FINISHED_STATES.contains(&kind), !live,
450				"FINISHED_STATES membership wrong for {:?}", kind,
451			);
452		}
453
454		// Anything is_pending() must be live; the reverse doesn't hold since is_pending()
455		// excludes Claimable and ClaimInProgress.
456		for state in all_states() {
457			if state.is_pending() {
458				assert!(
459					ExitStateKind::LIVE_STATES.contains(&state.kind()),
460					"{:?} is_pending() but its kind is not in LIVE_STATES", state.kind(),
461				);
462			}
463		}
464	}
465}