Skip to main content

bark/vtxo/
state.rs

1//! VTXO state tracking.
2//!
3//! This module defines the state machine used to track the lifecycle of each individual [Vtxo]
4//! managed by the wallet. A [Vtxo] can be:
5//! - created and ready to spend on Ark: [VtxoStateKind::Spendable]
6//! - owned but not usable because it is locked by subsystem: [VtxoStateKind::Locked]
7//! - consumed (no longer part of the wallet's balance): [VtxoStateKind::Spent]
8//! - taken on-chain via a unilateral exit: [VtxoStateKind::Exited]. Distinct from
9//!   [VtxoStateKind::Spent] so callers can tell whether a VTXO disappeared from the
10//!   wallet because the user forfeited it (round, send) or because the user moved
11//!   it onchain. The server refuses VTXOs once every exit transaction for a VTXO has
12//!   been broadcast, even before the corresponding on-chain UTXO has been claimed, so
13//!   the VTXO will enter [VtxoStateKind::Exited] as soon as we expect a VTXO to become
14//!   unusable offchain.
15//!
16//! Two layers of state are provided:
17//! - [VtxoStateKind]: a compact, serialization-friendly discriminator intended for storage, logs,
18//!   and wire formats. It maps to stable string identifiers via `as_str()`.
19//! - [VtxoState]: A richer state that might include metadata
20//!
21//! [WalletVtxo] pairs a concrete [Vtxo] with its current [VtxoState], providing the primary
22//! representation used by persistence and higher-level wallet logic.
23
24use std::fmt;
25use std::ops::Deref;
26
27use bitcoin::Weight;
28
29use ark::Vtxo;
30use ark::vtxo::{Bare, Full, VtxoRef};
31
32use crate::actions::WalletActionId;
33use crate::movement::MovementId;
34
35/// What kind of entity holds a [VtxoState::Locked] reservation.
36///
37/// The wallet's invariant is "every vtxo lock is owned by exactly one
38/// operation." For subsystems modelled as a [WalletAction] (today: the
39/// lightning send), that's an `Action(id)`. For subsystems that still
40/// run pre-action machinery (round, offboard, board, lightning receive)
41/// the holder is the operation's movement, captured as
42/// `Movement(MovementId)`. As those subsystems get converted to actions,
43/// new variants land here and the migration from `Movement` happens
44/// per-subsystem.
45#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
46#[serde(tag = "type", rename_all = "kebab-case")]
47pub enum VtxoLockHolder {
48	/// A [WalletAction] checkpointed in `bark_wallet_action_checkpoint`.
49	Action { id: WalletActionId },
50	/// A pre-action subsystem (round, offboard, board, lightning
51	/// receive). The movement is used as a stable handle.
52	Movement { id: MovementId },
53}
54
55impl From<MovementId> for VtxoLockHolder {
56	fn from(id: MovementId) -> Self {
57		VtxoLockHolder::Movement { id }
58	}
59}
60
61const SPENDABLE: &'static str = "Spendable";
62const LOCKED: &'static str = "Locked";
63const SPENT: &'static str = "Spent";
64const EXITED: &'static str = "Exited";
65
66/// A compact, serialization-friendly representation of a VTXO's state.
67///
68/// Use [VtxoState::kind] to derive it from a richer [VtxoState].
69#[derive(Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
70pub enum VtxoStateKind {
71	/// The [Vtxo] is available and can be selected as an input for a new offboard/round.
72	Spendable,
73	/// The [Vtxo] is currently locked in an action.
74	Locked,
75	/// The [Vtxo] has been consumed and is no longer part of the wallet's balance.
76	Spent,
77	/// The [Vtxo] has been moved on-chain via a unilateral exit. Like
78	/// [VtxoStateKind::Spent], an `Exited` vtxo is no longer part of the wallet's balance
79	/// and the server will refuse to interact with it; unlike `Spent`, the disappearance
80	/// is the result of the user taking the funds onchain rather than forfeiting them in
81	/// the protocol.
82	Exited,
83}
84
85impl VtxoStateKind {
86	/// Returns a stable string identifier for this state, suitable for DB rows, logs, and APIs.
87	pub fn as_str(&self) -> &str {
88		match self {
89			VtxoStateKind::Spendable => SPENDABLE,
90			VtxoStateKind::Locked => LOCKED,
91			VtxoStateKind::Spent => SPENT,
92			VtxoStateKind::Exited => EXITED,
93		}
94	}
95
96	pub fn as_byte(&self) -> u8 {
97		match self {
98			VtxoStateKind::Spendable => 0,
99			VtxoStateKind::Locked { .. } => 1,
100			VtxoStateKind::Spent => 2,
101			VtxoStateKind::Exited => 3,
102		}
103	}
104
105	/// List of all existing states
106	pub const ALL: &[VtxoStateKind] = &[
107		VtxoStateKind::Spendable,
108		VtxoStateKind::Locked,
109		VtxoStateKind::Spent,
110		VtxoStateKind::Exited,
111	];
112
113	/// List of the different states considered unspent
114	pub const UNSPENT_STATES: &[VtxoStateKind] = &[
115		VtxoStateKind::Spendable,
116		VtxoStateKind::Locked,
117	];
118}
119
120impl fmt::Display for VtxoStateKind {
121	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122	    f.write_str(self.as_str())
123	}
124}
125
126impl fmt::Debug for VtxoStateKind {
127	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128	    f.write_str(self.as_str())
129	}
130}
131
132/// Rich [Vtxo] state carrying additional context needed at runtime.
133#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
134#[serde(tag = "type", rename_all = "kebab-case")]
135pub enum VtxoState {
136	/// The [Vtxo] is available and can be spent in a future round.
137	Spendable,
138	/// The [Vtxo] is currently locked by an operation.
139	///
140	/// `holder` is `None` for the narrow window between creating a
141	/// fresh locked vtxo and pinning it to a specific operation (e.g.
142	/// during the offboard's preparatory arkoor). Production code
143	/// should set the holder explicitly whenever it knows the owner.
144	Locked {
145		holder: Option<VtxoLockHolder>,
146	},
147	/// The [Vtxo] has been consumed.
148	Spent,
149	/// The [Vtxo] is in (or has completed) a unilateral exit. See
150	/// [VtxoStateKind::Exited] for the distinction from [VtxoState::Spent].
151	Exited,
152}
153
154impl VtxoState {
155	/// Returns the compact [VtxoStateKind] discriminator for this rich state.
156	pub fn kind(&self) -> VtxoStateKind {
157		match self {
158			VtxoState::Spendable => VtxoStateKind::Spendable,
159			VtxoState::Locked { .. } => VtxoStateKind::Locked,
160			VtxoState::Spent => VtxoStateKind::Spent,
161			VtxoState::Exited => VtxoStateKind::Exited,
162		}
163	}
164}
165
166/// A wallet-owned [Vtxo] paired with its current tracked state and a small set of
167/// genesis-derived summaries that the wallet would otherwise have to load the full
168/// exit chain for.
169///
170/// The wallet stores [Vtxo<Full>] on disk but listings, balance computations, coin
171/// selection, and refresh-strategy checks all run against this bare representation
172/// to avoid the per-VTXO memory cost (tens of KB at high exit depths). When an
173/// operation actually needs the exit chain — unilateral exit, server registration,
174/// arkoor send, offboard, counterparty-risk checks — call
175/// [crate::Wallet::get_full_vtxo] or
176/// [crate::persist::BarkPersister::get_full_vtxos] to fetch it from disk.
177#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
178pub struct WalletVtxo {
179	/// The underlying [Vtxo] without its genesis chain.
180	#[serde(with = "ark::encode::serde")]
181	pub vtxo: Vtxo<Bare>,
182
183	/// The current tracked state for [`WalletVtxo`].
184	pub state: VtxoState,
185
186	/// Cached `vtxo.exit_depth()` from when the VTXO was inserted into the
187	/// wallet. Genesis is immutable post-creation, so this never drifts.
188	pub exit_depth: u16,
189
190	/// Cached sum of weight units for the unilateral exit transaction chain.
191	///
192	/// Lets the refresh strategy answer "uneconomical to exit" without loading the genesis.
193	pub exit_tx_weight: Weight,
194}
195
196impl VtxoRef for WalletVtxo {
197	fn vtxo_id(&self) -> ark::VtxoId { self.vtxo.id() }
198	fn as_bare_vtxo(&self) -> Option<std::borrow::Cow<'_, Vtxo<Bare>>> {
199		Some(std::borrow::Cow::Borrowed(&self.vtxo))
200	}
201	fn as_full_vtxo(&self) -> Option<&Vtxo<Full>> { None }
202	fn into_full_vtxo(self) -> Option<Vtxo<Full>> { None }
203}
204
205impl<'a> VtxoRef for &'a WalletVtxo {
206	fn vtxo_id(&self) -> ark::VtxoId { self.vtxo.id() }
207	fn as_bare_vtxo(&self) -> Option<std::borrow::Cow<'_, Vtxo<Bare>>> {
208		Some(std::borrow::Cow::Borrowed(&self.vtxo))
209	}
210	fn as_full_vtxo(&self) -> Option<&Vtxo<Full>> { None }
211	fn into_full_vtxo(self) -> Option<Vtxo<Full>> { None }
212}
213
214impl AsRef<Vtxo<Bare>> for WalletVtxo {
215	fn as_ref(&self) -> &Vtxo<Bare> {
216		&self.vtxo
217	}
218}
219
220impl Deref for WalletVtxo {
221	type Target = Vtxo<Bare>;
222
223	fn deref(&self) -> &Vtxo<Bare> {
224		&self.vtxo
225	}
226}
227
228#[cfg(test)]
229mod test {
230	use super::*;
231
232	#[test]
233	fn convert_serialize() {
234		let states = [
235			VtxoStateKind::Spendable,
236			VtxoStateKind::Spent,
237			VtxoStateKind::Locked,
238			VtxoStateKind::Exited,
239		];
240
241		assert_eq!(
242			serde_json::to_string(&states).unwrap(),
243			serde_json::to_string(&[SPENDABLE, SPENT, LOCKED, EXITED]).unwrap(),
244		);
245
246		// If a compiler error occurs,
247		// This is a reminder that you should update the test above
248		match VtxoState::Spent {
249			VtxoState::Spendable => {},
250			VtxoState::Spent => {},
251			VtxoState::Locked { .. } => {},
252			VtxoState::Exited => {},
253		}
254	}
255}