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}