Skip to main content

bark/vtxo/
mod.rs

1
2pub mod selection;
3mod signing;
4mod state;
5
6pub use self::selection::{FilterVtxos, RefreshStrategy, VtxoFilter};
7pub use self::state::{VtxoLockHolder, VtxoState, VtxoStateKind, WalletVtxo};
8
9use bitcoin::secp256k1::PublicKey;
10use log::{debug, error, trace};
11use ark::{ProtocolEncoding, Vtxo};
12use ark::vtxo::{Full, VtxoRef};
13use bitcoin_ext::{BlockDelta, BlockHeight};
14
15use crate::Wallet;
16
17/// Validate the tree-level security parameters the server chose for the VTXOs
18/// we're about to accept in a round.
19///
20/// Checks that server pubkey and exit delta match.
21/// Checks that expiry height is above minimum threshold.
22pub(crate) fn validate_vtxo_tree_params(
23	server_pubkey: PublicKey,
24	exit_delta: BlockDelta,
25	expiry_height: BlockHeight,
26	expected_server_pubkey: PublicKey,
27	expected_exit_delta: BlockDelta,
28	min_expiry_height: BlockHeight,
29) -> anyhow::Result<()> {
30	ensure!(server_pubkey == expected_server_pubkey,
31		"round VTXO tree uses an unexpected server pubkey: got {}, expected {}",
32		server_pubkey, expected_server_pubkey,
33	);
34
35	ensure!(exit_delta == expected_exit_delta,
36		"round VTXO tree uses an unexpected exit delta: got {}, expected {}",
37		exit_delta, expected_exit_delta,
38	);
39
40	ensure!(expiry_height >= min_expiry_height,
41		"round VTXO tree expiry height {} is below the required minimum {}; \
42		server may be trying to sweep before we can exit",
43		expiry_height, min_expiry_height,
44	);
45
46	Ok(())
47}
48
49#[derive(Debug, thiserror::Error)]
50pub enum VtxoValidationError {
51	#[error("chain error: {0}")]
52	Chain(anyhow::Error),
53	#[error("anchor not found")]
54	AnchorNotFound,
55	#[error("invalid: {0}")]
56	Invalid(#[from] ark::vtxo::VtxoValidationError),
57}
58
59impl Wallet {
60	/// Attempts to lock VTXOs with the given [VtxoId](ark::VtxoId) values.
61	///
62	/// Only [VtxoStateKind::Spendable] vtxos can be locked; re-locking a
63	/// vtxo that is already in the exact target state (same holder) is a
64	/// no-op success, but any other prior state — including a Locked vtxo
65	/// owned by a different holder — fails. The whole batch is atomic:
66	/// if any vtxo fails the check, no vtxo's state changes.
67	///
68	/// `holder` records which operation is reserving the vtxos so
69	/// "who holds this vtxo?" is a typed lookup. Pass `None` only for
70	/// the narrow window before the operation's holder identity is
71	/// known (e.g. offboard's preparatory arkoor).
72	///
73	/// # Errors
74	/// - If any VTXO is not Spendable (and not already locked by the same holder).
75	/// - If a VTXO doesn't exist.
76	/// - If a database error occurs.
77	pub async fn lock_vtxos(
78		&self,
79		vtxos: impl IntoIterator<Item = impl VtxoRef>,
80		holder: Option<VtxoLockHolder>,
81	) -> anyhow::Result<()> {
82		self.set_vtxo_states(
83			vtxos, &VtxoState::Locked { holder }, &[VtxoStateKind::Spendable],
84		).await
85	}
86
87	/// Marks VTXOs as [VtxoState::Spent].
88	///
89	/// This operation is idempotent: VTXOs already in [VtxoState::Spent] will
90	/// remain spent without inserting a redundant state entry.
91	///
92	/// # Errors
93	/// - If the VTXO doesn't exist.
94	/// - If a database error occurs.
95	pub async fn mark_vtxos_as_spent(
96		&self,
97		vtxos: impl IntoIterator<Item = impl VtxoRef>,
98	) -> anyhow::Result<()> {
99		const ALLOWED: &[VtxoStateKind] = &[
100			VtxoStateKind::Spendable,
101			VtxoStateKind::Locked,
102			VtxoStateKind::Spent,
103		];
104		self.set_vtxo_states(vtxos, &VtxoState::Spent, ALLOWED).await
105	}
106
107	/// Marks VTXOs as [VtxoState::Exited]. Called from the unilateral exit progression once
108	/// every exit transaction has been broadcast — at that point the VTXO is effectively gone
109	/// from the protocol's view, but it shouldn't be confused with a forfeited VTXO.
110	///
111	/// This operation is idempotent: VTXOs already in [VtxoState::Exited] will remain exited
112	/// without inserting a redundant state entry.
113	///
114	/// # Errors
115	/// - If the VTXO is in a state other than `Spendable`, `Locked`, or `Exited`.
116	/// - If the VTXO doesn't exist.
117	/// - If a database error occurs.
118	pub async fn mark_vtxos_as_exited(
119		&self,
120		vtxos: impl IntoIterator<Item = impl VtxoRef>,
121	) -> anyhow::Result<()> {
122		const ALLOWED: &[VtxoStateKind] = &[
123			VtxoStateKind::Spendable,
124			VtxoStateKind::Locked,
125			VtxoStateKind::Exited,
126		];
127		self.set_vtxo_states(vtxos, &VtxoState::Exited, ALLOWED).await
128	}
129
130	/// Updates the state set the [VtxoState] of VTXOs corresponding to each given
131	/// [VtxoId](ark::VtxoId) while validating if the transition is allowed based
132	/// on the current state and allowed transitions.
133	///
134	/// # Parameters
135	/// - `vtxos`: The [VtxoId](ark::VtxoId) of each [Vtxo] to update.
136	/// - `state`: A reference to the new [VtxoState] that the VTXOs should be transitioned to.
137	/// - `allowed_states`: A slice of [VtxoStateKind] representing the permissible current states
138	///   from which the VTXOs are allowed to transition to the given `state`. If an empty
139	///   slice is passed, all states are allowed.
140	///
141	/// # Errors
142	/// - The database operation to update the states fails.
143	/// - The state transition is invalid or does not match the allowed transitions.
144	pub async fn set_vtxo_states(
145		&self,
146		vtxos: impl IntoIterator<Item = impl VtxoRef>,
147		state: &VtxoState,
148		mut allowed_states: &[VtxoStateKind],
149	) -> anyhow::Result<()> {
150		if allowed_states.is_empty() {
151			allowed_states = VtxoStateKind::ALL;
152		}
153
154		let ids: Vec<_> = vtxos.into_iter().map(|v| v.vtxo_id()).collect();
155		self.inner.db.update_vtxo_states_checked(&ids, state.clone(), allowed_states).await
156	}
157
158	/// Stores the given collection of VTXOs in the wallet with an initial state of
159	/// [VtxoState::Locked].
160	///
161	/// It does nothing if the VTXOs already exist.
162	///
163	/// Also posts the vtxo IDs to the server's recovery mailbox (non-critical, errors are logged).
164	///
165	/// # Parameters
166	/// - `vtxos`: The VTXOs to store in the wallet.
167	pub async fn store_locked_vtxos<'a>(
168		&self,
169		vtxos: impl IntoIterator<Item = &'a Vtxo<Full>> + Clone,
170		holder: Option<VtxoLockHolder>,
171	) -> anyhow::Result<()> {
172		self.store_vtxos(vtxos.clone(), &VtxoState::Locked { holder }).await?;
173
174		// Post vtxo IDs to server for recovery (non-critical, just log errors)
175		if let Err(e) = self.post_recovery_vtxo_ids(vtxos.into_iter().map(|v| v.id())).await {
176			error!("Failed to post recovery vtxo IDs to server: {:#}", e);
177		}
178
179		Ok(())
180	}
181
182	/// Stores the given collection of VTXOs in the wallet with an initial state of
183	/// [VtxoState::Spendable].
184	///
185	/// It does nothing if the VTXOs already exist.
186	///
187	/// Also posts the vtxo IDs to the server's recovery mailbox (non-critical, errors are logged).
188	///
189	/// # Parameters
190	/// - `vtxos`: The VTXOs to store in the wallet.
191	pub async fn store_spendable_vtxos<'a>(
192		&self,
193		vtxos: impl IntoIterator<Item = &'a Vtxo<Full>> + Clone,
194	) -> anyhow::Result<()> {
195		self.store_vtxos(vtxos.clone(), &VtxoState::Spendable).await?;
196
197		// Post vtxo IDs to server for recovery (non-critical, just log errors)
198		if let Err(e) = self.post_recovery_vtxo_ids(vtxos.into_iter().map(|v| v.id())).await {
199			error!("Failed to post recovery vtxo IDs to server: {:#}", e);
200		}
201
202		Ok(())
203	}
204
205	/// Stores the given collection of VTXOs in the wallet with an initial state of
206	/// [VtxoState::Spent].
207	///
208	/// It does nothing if the VTXOs already exist.
209	///
210	/// # Parameters
211	/// - `vtxos`: The VTXOs to store in the wallet.
212	pub async fn store_spent_vtxos<'a>(
213		&self,
214		vtxos: impl IntoIterator<Item = &'a Vtxo<Full>>,
215	) -> anyhow::Result<()> {
216		self.store_vtxos(vtxos, &VtxoState::Spent).await
217	}
218
219	/// Stores the given collection of VTXOs in the wallet with the given initial state.
220	///
221	/// It does nothing if the VTXOs already exist.
222	///
223	/// # Parameters
224	/// - `vtxos`: The VTXOs to store in the wallet.
225	/// - `state`: The initial state of the VTXOs.
226	pub async fn store_vtxos<'a>(
227		&self,
228		vtxos: impl IntoIterator<Item = &'a Vtxo<Full>>,
229		state: &VtxoState,
230	) -> anyhow::Result<()> {
231		let vtxos = vtxos.into_iter().map(|v| (v, state)).collect::<Vec<_>>();
232		if let Err(e) = self.inner.db.store_vtxos(&vtxos).await {
233			error!("An error occurred while storing {} VTXOs: {:#}", vtxos.len(), e);
234			error!("Raw VTXOs for debugging:");
235			for (vtxo, _) in vtxos {
236				error!(" - {}", vtxo.serialize_hex());
237			}
238			Err(e)
239		} else {
240			debug!("Stored {} VTXOs", vtxos.len());
241			trace!("New VTXO IDs: {:?}", vtxos.into_iter().map(|(v, _)| v.id()).collect::<Vec<_>>());
242			Ok(())
243		}
244	}
245
246	/// Release `holder`'s lock on the given VTXOs, transitioning each one
247	/// to [VtxoState::Spendable]. `holder` must match the value passed to
248	/// [Self::lock_vtxos] when the lock was taken; a mismatch (including
249	/// `None` vs `Some`) leaves the vtxo untouched. VTXOs not currently
250	/// locked by `holder` are silently skipped, so calling this
251	/// repeatedly is equivalent to calling it once and can safely be
252	/// retried after a crash mid-batch.
253	pub async fn unlock_vtxos(
254		&self,
255		vtxos: impl IntoIterator<Item = impl VtxoRef>,
256		holder: Option<VtxoLockHolder>,
257	) -> anyhow::Result<()> {
258		for v in vtxos {
259			self.inner.db.release_vtxo_lock(v.vtxo_id(), holder.as_ref()).await?;
260		}
261		Ok(())
262	}
263}
264
265#[cfg(test)]
266mod test {
267	use super::*;
268
269	use bip39::rand;
270	use bitcoin::key::Keypair;
271	use bitcoin::secp256k1::Secp256k1;
272
273	use ark::vtxo::policy::MAX_BLOCK_DELTA;
274
275	#[test]
276	fn vtxo_tree_params_accepts_honest_and_rejects_hostile() {
277		let secp = Secp256k1::new();
278		let mut rng = rand::thread_rng();
279		let server = Keypair::new(&secp, &mut rng).public_key();
280		let other = Keypair::new(&secp, &mut rng).public_key();
281
282		let exit_delta: BlockDelta = 144;
283		let min_expiry: BlockHeight = 100_000;
284
285		// Expiry at or above the minimum, with matching pubkey and delta, passes.
286		validate_vtxo_tree_params(server, exit_delta, min_expiry, server, exit_delta, min_expiry)
287			.expect("expiry exactly at the minimum should validate");
288		validate_vtxo_tree_params(
289			server, exit_delta, min_expiry + 5_000, server, exit_delta, min_expiry,
290		).expect("expiry above the minimum should validate");
291
292		// A wrong server pubkey is rejected.
293		assert!(validate_vtxo_tree_params(
294			other, exit_delta, min_expiry, server, exit_delta, min_expiry,
295		).is_err(), "wrong server pubkey must be rejected");
296
297		// An inflated exit delta (hostage) is rejected.
298		assert!(validate_vtxo_tree_params(
299			server, MAX_BLOCK_DELTA, min_expiry, server, exit_delta, min_expiry,
300		).is_err(), "inflated exit delta must be rejected");
301
302		// An expiry below the minimum (e.g. the short-expiry sweep attack) is
303		// rejected, right down to a single block short.
304		assert!(validate_vtxo_tree_params(
305			server, exit_delta, min_expiry - 1, server, exit_delta, min_expiry,
306		).is_err(), "expiry one block below the minimum must be rejected");
307	}
308}