Skip to main content

bark/persist/sqlite/
mod.rs

1//! SQLite persistence backend for Bark.
2//!
3//! This module provides a concrete implementation of the `BarkPersister` trait
4//! backed by a local SQLite database. It encapsulates schema creation and
5//! migrations, typed query helpers, and conversions between in-memory models
6//! and their stored representations. Operations are performed using explicit
7//! connections and transactions to ensure atomic updates across related tables,
8//! covering wallet properties, movements, vtxos and their states, round
9//! lifecycle data, Lightning receives, exit tracking, and sync metadata.
10
11mod convert;
12mod migrations;
13mod query;
14
15
16use std::path::{Path, PathBuf};
17
18use anyhow::Context;
19use bitcoin::{Amount, Txid};
20use bitcoin::secp256k1::PublicKey;
21use chrono::DateTime;
22use lightning_invoice::Bolt11Invoice;
23use log::debug;
24use rusqlite::Connection;
25
26use ark::{Vtxo, VtxoId};
27use ark::lightning::{PaymentHash, Preimage};
28use ark::vtxo::Full;
29
30use crate::WalletProperties;
31use crate::actions::{WalletActionCheckpoint, WalletActionId};
32use crate::exit::{ExitStateKind, ExitTxOrigin};
33use crate::movement::{Movement, MovementId, MovementStatus, MovementSubsystem, PaymentMethod};
34use crate::movement::update::MovementUpdate;
35use crate::persist::{BarkPersister, RoundStateId, StoredRoundState, Unlocked};
36use crate::persist::models::{PaidInvoice, SettledLightningReceive, StoredExit};
37use crate::round::RoundState;
38use crate::vtxo::{VtxoState, VtxoStateKind, WalletVtxo};
39
40
41/// The default sqlite db file for when no file path was provided
42pub const DEFAULT_DB_FILE: &str = "db.sqlite";
43
44/// An implementation of the BarkPersister using rusqlite. Changes are persisted using the given
45/// [PathBuf].
46#[derive(Debug, Clone)]
47pub struct SqliteClient {
48	connection_string: PathBuf,
49}
50
51impl SqliteClient {
52	/// Open a new [SqliteClient] with the given file path
53	pub fn open(db_file: impl AsRef<Path>) -> anyhow::Result<SqliteClient> {
54		let path = db_file.as_ref().to_path_buf();
55
56		debug!("Opening database at {}", path.display());
57		let mut conn = rusqlite::Connection::open(&path)
58			.with_context(|| format!("Error connecting to database {}", path.display()))?;
59
60		// The db holds wallet state. New wallets are hardened at creation; for
61		// older or externally-created dbs we only warn rather than chmod on
62		// every open, which would override a deliberate setup.
63		crate::fs_perms::warn_if_loose(&path, 0o600);
64
65		let migrations = migrations::MigrationContext::new();
66		migrations.do_all_migrations(&mut conn)?;
67
68		Ok( Self { connection_string: path })
69	}
70
71	fn connect(&self) -> anyhow::Result<Connection> {
72		rusqlite::Connection::open(&self.connection_string)
73			.with_context(|| format!("Error connecting to database {}", self.connection_string.display()))
74	}
75}
76
77#[async_trait]
78impl BarkPersister for SqliteClient {
79	async fn init_wallet(&self, properties: &WalletProperties) -> anyhow::Result<()> {
80		let mut conn = self.connect()?;
81		let tx = conn.transaction()?;
82
83		query::set_properties(&tx, properties)?;
84
85		tx.commit()?;
86		Ok(())
87	}
88
89	#[cfg(feature = "onchain-bdk")]
90	async fn initialize_bdk_wallet(&self) -> anyhow::Result<bdk_wallet::ChangeSet> {
91	    let mut conn = self.connect()?;
92		Ok(bdk_wallet::WalletPersister::initialize(&mut conn)?)
93	}
94
95	#[cfg(feature = "onchain-bdk")]
96	async fn store_bdk_wallet_changeset(&self, changeset: &bdk_wallet::ChangeSet) -> anyhow::Result<()> {
97	    let mut conn = self.connect()?;
98		bdk_wallet::WalletPersister::persist(&mut conn, changeset)?;
99		Ok(())
100	}
101
102	async fn read_properties(&self) -> anyhow::Result<Option<WalletProperties>> {
103		let conn = self.connect()?;
104		Ok(query::fetch_properties(&conn)?)
105	}
106
107	async fn set_server_pubkey(&self, server_pubkey: PublicKey) -> anyhow::Result<()> {
108		let conn = self.connect()?;
109		query::set_server_pubkey(&conn, &server_pubkey)?;
110		Ok(())
111	}
112
113	async fn set_server_mailbox_pubkey(&self, server_mailbox_pubkey: PublicKey) -> anyhow::Result<()> {
114		let conn = self.connect()?;
115		query::set_server_mailbox_pubkey(&conn, &server_mailbox_pubkey)?;
116		Ok(())
117	}
118
119	async fn create_new_movement(&self,
120		status: MovementStatus,
121		subsystem: &MovementSubsystem,
122		time: DateTime<chrono::Local>,
123		action_id: Option<&str>,
124	) -> anyhow::Result<MovementId> {
125		let mut conn = self.connect()?;
126		let tx = conn.transaction()?;
127		let movement_id = query::create_new_movement(&tx, status, subsystem, time, action_id)?;
128		tx.commit()?;
129		Ok(movement_id)
130	}
131
132	async fn get_or_create_movement_for_action(
133		&self,
134		subsystem: &MovementSubsystem,
135		time: DateTime<chrono::Local>,
136		action_id: &str,
137		update: MovementUpdate,
138	) -> anyhow::Result<(MovementId, bool)> {
139		let mut conn = self.connect()?;
140		let tx = conn.transaction()?;
141		let result = match query::get_movement_id_by_action(&tx, action_id)? {
142			Some(id) => (id, false),
143			None => {
144				let id = query::create_new_movement(
145					&tx, MovementStatus::Pending, subsystem, time, Some(action_id))?;
146				let mut movement = query::get_movement_by_id(&tx, id)?;
147				update.apply_to(&mut movement, time);
148				query::update_movement(&tx, &movement)?;
149				(id, true)
150			},
151		};
152		tx.commit()?;
153		Ok(result)
154	}
155
156	async fn update_movement(&self, movement: &Movement) -> anyhow::Result<()> {
157		let mut conn = self.connect()?;
158		let tx = conn.transaction()?;
159		query::update_movement(&tx, movement)?;
160		tx.commit()?;
161		Ok(())
162	}
163
164	async fn get_movement_by_id(&self, movement_id: MovementId) -> anyhow::Result<Movement> {
165		let conn = self.connect()?;
166		query::get_movement_by_id(&conn, movement_id)
167	}
168
169	async fn get_all_movements(&self) -> anyhow::Result<Vec<Movement>> {
170		let conn = self.connect()?;
171		query::get_all_movements(&conn)
172	}
173
174	async fn get_movements_by_payment_method(
175		&self,
176		payment_method: &PaymentMethod,
177	) -> anyhow::Result<Vec<Movement>> {
178		let conn = self.connect()?;
179		query::get_movements_by_payment_method(&conn, payment_method)
180	}
181
182	async fn store_round_state(&self, round_state: &RoundState) -> anyhow::Result<RoundStateId> {
183		let conn = self.connect()?;
184		query::store_round_state(&conn, round_state)
185	}
186
187	async fn update_round_state(&self, state: &StoredRoundState) -> anyhow::Result<()> {
188		let conn = self.connect()?;
189		query::update_round_state(&conn, state)?;
190		Ok(())
191	}
192
193	async fn remove_round_state(&self, round_state: &StoredRoundState) -> anyhow::Result<()> {
194		let conn = self.connect()?;
195		query::remove_round_state(&conn, round_state.id())?;
196		Ok(())
197	}
198
199	async fn get_round_state_by_id(&self, id: RoundStateId) -> anyhow::Result<Option<StoredRoundState<Unlocked>>> {
200		let conn = self.connect()?;
201		query::get_round_state_by_id(&conn, id)
202	}
203
204	async fn get_pending_round_state_ids(&self) -> anyhow::Result<Vec<RoundStateId>> {
205		let conn = self.connect()?;
206		query::get_pending_round_state_ids(&conn)
207	}
208
209	async fn store_vtxos(
210		&self,
211		vtxos: &[(&Vtxo<Full>, &VtxoState)],
212	) -> anyhow::Result<()> {
213		let mut conn = self.connect()?;
214		let tx = conn.transaction()?;
215
216		for (vtxo, state) in vtxos {
217			query::store_vtxo_with_initial_state(&tx, vtxo, state)?;
218		}
219		tx.commit()?;
220		Ok(())
221	}
222
223	async fn get_wallet_vtxo(&self, id: VtxoId) -> anyhow::Result<Option<WalletVtxo>> {
224		let conn = self.connect()?;
225		query::get_wallet_vtxo_by_id(&conn, id)
226	}
227
228	async fn get_wallet_vtxos(&self, ids: &[VtxoId]) -> anyhow::Result<Vec<WalletVtxo>> {
229		let conn = self.connect()?;
230		query::get_wallet_vtxos_by_ids(&conn, ids)
231	}
232
233	async fn get_all_vtxos(&self) -> anyhow::Result<Vec<WalletVtxo>> {
234		let conn = self.connect()?;
235		query::get_all_vtxos(&conn)
236	}
237
238	/// Get all VTXOs that are in one of the provided states
239	async fn get_vtxos_by_state(&self, state: &[VtxoStateKind]) -> anyhow::Result<Vec<WalletVtxo>> {
240		let conn = self.connect()?;
241		query::get_vtxos_by_state(&conn, state)
242	}
243
244	async fn has_spent_vtxo(&self, id: VtxoId) -> anyhow::Result<bool> {
245		let conn = self.connect()?;
246		let state : Option<VtxoState> = query::get_vtxo_state(&conn, id)?;
247		let result = state.map(|s| s == VtxoState::Spent).unwrap_or(false);
248		Ok(result)
249	}
250
251	async fn get_full_vtxo(&self, id: VtxoId) -> anyhow::Result<Option<Vtxo<Full>>> {
252		let conn = self.connect()?;
253		query::get_full_vtxo_by_id(&conn, id)
254	}
255
256	async fn get_full_vtxos(&self, ids: &[VtxoId]) -> anyhow::Result<Vec<Vtxo<Full>>> {
257		let conn = self.connect()?;
258		query::get_full_vtxos_by_ids(&conn, ids)
259	}
260
261	async fn remove_vtxo(&self, id: VtxoId) -> anyhow::Result<Option<Vtxo<Full>>> {
262		let mut conn = self.connect()?;
263		let tx = conn.transaction().context("Failed to start transaction")?;
264		let result = query::delete_vtxo(&tx, id);
265		tx.commit().context("Failed to commit transaction")?;
266		result
267	}
268
269	async fn store_vtxo_key(&self, index: u32, public_key: PublicKey) -> anyhow::Result<()> {
270		let conn = self.connect()?;
271		query::store_vtxo_key(&conn, index, public_key)
272	}
273
274	async fn get_last_vtxo_key_index(&self) -> anyhow::Result<Option<u32>> {
275		let conn = self.connect()?;
276		query::get_last_vtxo_key_index(&conn)
277	}
278
279	async fn get_public_key_idx(&self, public_key: &PublicKey) -> anyhow::Result<Option<u32>> {
280		let conn = self.connect()?;
281		query::get_public_key_idx(&conn, public_key)
282	}
283
284	async fn get_mailbox_checkpoint(&self) -> anyhow::Result<u64> {
285		let conn = self.connect()?;
286		query::get_mailbox_checkpoint(&conn)
287	}
288
289	async fn store_mailbox_checkpoint(&self, checkpoint: u64) -> anyhow::Result<()> {
290		let conn = self.connect()?;
291		query::store_mailbox_checkpoint(&conn, checkpoint)?;
292		Ok(())
293	}
294
295	async fn upsert_wallet_action_checkpoint(
296		&self,
297		id: &WalletActionId,
298		checkpoint: &WalletActionCheckpoint,
299	) -> anyhow::Result<()> {
300		let conn = self.connect()?;
301		query::upsert_wallet_action_checkpoint(&conn, id, checkpoint)
302	}
303
304	async fn get_wallet_action_checkpoint(
305		&self,
306		id: &WalletActionId,
307	) -> anyhow::Result<Option<WalletActionCheckpoint>> {
308		let conn = self.connect()?;
309		query::get_wallet_action_checkpoint(&conn, id)
310	}
311
312	async fn get_all_wallet_action_checkpoints(
313		&self,
314	) -> anyhow::Result<Vec<WalletActionCheckpoint>> {
315		let conn = self.connect()?;
316		query::get_all_wallet_action_checkpoints(&conn)
317	}
318
319	async fn remove_wallet_action_checkpoint(
320		&self,
321		id: &WalletActionId,
322	) -> anyhow::Result<()> {
323		let conn = self.connect()?;
324		query::remove_wallet_action_checkpoint(&conn, id)
325	}
326
327	async fn record_paid_invoice(
328		&self,
329		payment_hash: PaymentHash,
330		preimage: Preimage,
331	) -> anyhow::Result<()> {
332		let conn = self.connect()?;
333		query::record_paid_invoice(&conn, payment_hash, preimage)
334	}
335
336	async fn get_paid_invoice(
337		&self,
338		payment_hash: PaymentHash,
339	) -> anyhow::Result<Option<PaidInvoice>> {
340		let conn = self.connect()?;
341		query::get_paid_invoice(&conn, payment_hash)
342	}
343
344	async fn record_settled_lightning_receive(
345		&self,
346		payment_hash: PaymentHash,
347		preimage: Preimage,
348		invoice: &Bolt11Invoice,
349		amount: Amount,
350	) -> anyhow::Result<()> {
351		let conn = self.connect()?;
352		query::record_settled_lightning_receive(&conn, payment_hash, preimage, invoice, amount)
353	}
354
355	async fn get_settled_lightning_receive(
356		&self,
357		payment_hash: PaymentHash,
358	) -> anyhow::Result<Option<SettledLightningReceive>> {
359		let conn = self.connect()?;
360		query::get_settled_lightning_receive(&conn, payment_hash)
361	}
362
363	async fn store_exit_vtxo_entry(&self, exit: &StoredExit) -> anyhow::Result<()> {
364		let mut conn = self.connect()?;
365		let tx = conn.transaction()?;
366		query::store_exit_vtxo_entry(&tx, exit)?;
367		tx.commit()?;
368		Ok(())
369	}
370
371	async fn remove_exit_vtxo_entry(&self, id: &VtxoId) -> anyhow::Result<()> {
372		let mut conn = self.connect()?;
373		let tx = conn.transaction()?;
374		query::remove_exit_vtxo_entry(&tx, &id)?;
375		tx.commit()?;
376		Ok(())
377	}
378
379	async fn get_exit_vtxo_entries(&self) -> anyhow::Result<Vec<StoredExit>> {
380		let conn = self.connect()?;
381		query::get_exit_vtxo_entries(&conn)
382	}
383
384	async fn get_exit_vtxo_entries_with_states(
385		&self,
386		states: &[ExitStateKind],
387	) -> anyhow::Result<Vec<StoredExit>> {
388		let conn = self.connect()?;
389		query::get_exit_vtxo_entries_with_states(&conn, states)
390	}
391
392	async fn get_exit_vtxo_entry(&self, id: &VtxoId) -> anyhow::Result<Option<StoredExit>> {
393		let conn = self.connect()?;
394		query::get_exit_vtxo_entry(&conn, id)
395	}
396
397	async fn store_exit_child_tx(
398		&self,
399		exit_txid: Txid,
400		child_tx: &bitcoin::Transaction,
401		origin: ExitTxOrigin,
402	) -> anyhow::Result<()> {
403		let mut conn = self.connect()?;
404		let tx = conn.transaction()?;
405		query::store_exit_child_tx(&tx, exit_txid, child_tx, origin)?;
406		tx.commit()?;
407		Ok(())
408	}
409
410	async fn get_exit_child_tx(
411		&self,
412		exit_txid: Txid,
413	) -> anyhow::Result<Option<(bitcoin::Transaction, ExitTxOrigin)>> {
414		let conn = self.connect()?;
415		query::get_exit_child_tx(&conn, exit_txid)
416	}
417
418	async fn update_vtxo_state_checked(
419		&self,
420		vtxo_id: VtxoId,
421		new_state: VtxoState,
422		allowed_old_states: &[VtxoStateKind]
423	) -> anyhow::Result<WalletVtxo> {
424		let conn = self.connect()?;
425		query::update_vtxo_state_checked(&conn, vtxo_id, new_state, allowed_old_states)
426	}
427
428	async fn update_vtxo_states_checked(
429		&self,
430		vtxo_ids: &[VtxoId],
431		new_state: VtxoState,
432		allowed_old_states: &[VtxoStateKind],
433	) -> anyhow::Result<()> {
434		let mut conn = self.connect()?;
435		let tx = conn.transaction()?;
436		query::update_vtxo_states_checked(&tx, vtxo_ids, new_state, allowed_old_states)?;
437		tx.commit()?;
438		Ok(())
439	}
440
441	async fn mark_vtxos_registered(&self, vtxo_ids: &[VtxoId]) -> anyhow::Result<()> {
442		let conn = self.connect()?;
443		query::mark_vtxos_registered(&conn, vtxo_ids)
444	}
445
446	async fn get_unregistered_vtxo_ids(&self) -> anyhow::Result<Vec<VtxoId>> {
447		let conn = self.connect()?;
448		query::get_unregistered_vtxo_ids(&conn)
449	}
450
451}
452
453#[cfg(any(test, doc))]
454pub mod helpers {
455	use std::path::PathBuf;
456	use std::str::FromStr;
457
458	use rusqlite::Connection;
459
460	/// Creates an in-memory sqlite connection.
461	///
462	/// It returns a [PathBuf] and a [Connection].
463	/// The user should ensure the [Connection] isn't dropped
464	/// until the test completes. If all connections are dropped during
465	/// the test the entire database might be cleared.
466	#[cfg(any(test, feature = "rand"))]
467	pub fn in_memory_db() -> (PathBuf, Connection) {
468		use rand::{distr, RngExt};
469
470		// All tests run in the same process and share the same
471		// cache. To ensure that each call to `in_memory` results
472		// in a new database a random file-name is generated.
473		//
474		// This database is deleted once all connections are dropped
475		let mut rng = rand::rng();
476		let filename: String = (&mut rng).sample_iter(distr::Alphanumeric)
477			.take(16).map(char::from).collect();
478
479		let connection_string = format!("file:{}?mode=memory&cache=shared", filename);
480		let pathbuf = PathBuf::from_str(&connection_string).unwrap();
481
482		let conn = Connection::open(pathbuf.clone()).unwrap();
483		(pathbuf.clone(), conn)
484	}
485}
486
487#[cfg(test)]
488mod test {
489	use ark::ProtocolEncoding;
490	use ark::test_util::VTXO_VECTORS;
491
492	use crate::{persist::sqlite::helpers::in_memory_db, vtxo::VtxoState};
493
494	use super::*;
495
496	#[tokio::test]
497	async fn test_add_and_retrieve_vtxos() {
498		let vtxo_1 = &VTXO_VECTORS.board_vtxo;
499		let vtxo_2 = &VTXO_VECTORS.arkoor_htlc_out_vtxo;
500		let vtxo_3 = &VTXO_VECTORS.round2_vtxo;
501
502		let (cs, conn) = in_memory_db();
503		let db = SqliteClient::open(cs).unwrap();
504
505		db.store_vtxos(&[
506			(vtxo_1, &VtxoState::Spendable), (vtxo_2, &VtxoState::Spendable)
507		]).await.unwrap();
508
509		// Check that vtxo-1 can be retrieved from the database. Listings
510		// return the bare form, so compare against `to_bare()`.
511		let vtxo_1_db = db.get_wallet_vtxo(vtxo_1.id()).await.expect("No error").expect("A vtxo was found");
512		assert_eq!(vtxo_1_db.vtxo, vtxo_1.to_bare());
513
514		// Hydrating to full should round-trip back to the original bytes.
515		let vtxo_1_full = db.get_full_vtxo(vtxo_1.id()).await.unwrap().unwrap();
516		assert_eq!(vtxo_1_full.serialize(), vtxo_1.serialize());
517
518		// Verify that vtxo 3 is not in the database
519		assert!(db.get_wallet_vtxo(vtxo_3.id()).await.expect("No error").is_none());
520
521		// Verify that we have two entries in the database
522		let vtxos = db.get_vtxos_by_state(&[VtxoStateKind::Spendable]).await.unwrap();
523		assert_eq!(vtxos.len(), 2);
524		assert!(vtxos.iter().any(|v| v.vtxo == vtxo_1.to_bare()));
525		assert!(vtxos.iter().any(|v| v.vtxo == vtxo_2.to_bare()));
526		assert!(!vtxos.iter().any(|v| v.vtxo == vtxo_3.to_bare()));
527
528		// Verify that we can mark a vtxo as spent
529		db.update_vtxo_state_checked(
530			vtxo_1.id(), VtxoState::Spent, &VtxoStateKind::UNSPENT_STATES,
531		).await.unwrap();
532
533		let vtxos = db.get_vtxos_by_state(&[VtxoStateKind::Spendable]).await.unwrap();
534		assert_eq!(vtxos.len(), 1);
535
536		// Add the third entry to the database
537		db.store_vtxos(&[(vtxo_3, &VtxoState::Spendable)]).await.unwrap();
538
539		let vtxos = db.get_vtxos_by_state(&[VtxoStateKind::Spendable]).await.unwrap();
540		assert_eq!(vtxos.len(), 2);
541		assert!(vtxos.iter().any(|v| v.vtxo == vtxo_2.to_bare()));
542		assert!(vtxos.iter().any(|v| v.vtxo == vtxo_3.to_bare()));
543
544		conn.close().unwrap();
545	}
546
547	#[tokio::test]
548	#[cfg(feature = "onchain-bdk")]
549	async fn test_create_wallet_then_load() {
550		use bdk_wallet::chain::DescriptorExt;
551
552		let (connection_string, conn) = in_memory_db();
553
554		let db = SqliteClient::open(connection_string).unwrap();
555		let network = bitcoin::Network::Testnet;
556
557		let seed = bip39::Mnemonic::generate(12).unwrap().to_seed("");
558		let xpriv = bitcoin::bip32::Xpriv::new_master(network, &seed).unwrap();
559
560		let desc = format!("tr({}/84'/0'/0'/*)", xpriv);
561
562		// need to call init before we call store
563		let _ = db.initialize_bdk_wallet().await.unwrap();
564		let mut created = bdk_wallet::Wallet::create_single(desc.clone())
565			.network(network)
566			.create_wallet_no_persist()
567			.unwrap();
568		db.store_bdk_wallet_changeset(&created.take_staged().unwrap()).await.unwrap();
569
570		let loaded = {
571			let changeset = db.initialize_bdk_wallet().await.unwrap();
572			bdk_wallet::Wallet::load()
573				.descriptor(bdk_wallet::KeychainKind::External, Some(desc.clone()))
574				.extract_keys()
575				.check_network(network)
576				.load_wallet_no_persist(changeset)
577				.unwrap()
578		};
579
580		assert!(loaded.is_some());
581		assert_eq!(
582			created.public_descriptor(bdk_wallet::KeychainKind::External).descriptor_id(),
583			loaded.unwrap().public_descriptor(bdk_wallet::KeychainKind::External).descriptor_id()
584		);
585
586		// Explicitly close the connection here
587		// This ensures the database isn't dropped during the test
588		conn.close().unwrap();
589	}
590
591	#[tokio::test]
592	async fn differential_bark_persister_suite() {
593		let (cs, _conn) = helpers::in_memory_db();
594		let sqlite = SqliteClient::open(cs).unwrap();
595		let memory = crate::persist::adaptor::StorageAdaptorWrapper::new(
596			crate::persist::adaptor::memory::MemoryStorageAdaptor::new(),
597		);
598		crate::persist::test_suite::run_all(&sqlite, &memory).await;
599	}
600}