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
17pub(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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}