Skip to main content

bark_cli/
wallet.rs

1//! Wallet utilities
2//!
3//! Opens a Bark wallet and its on-chain companion from a data directory.
4//!
5//! ## Behavior
6//! - Reads a BIP-39 `mnemonic` file from the provided directory
7//! - Parses `config.toml` into a [`bark::Config`]
8//! - Opens `db.sqlite` as a [`bark::persist::sqlite::SqliteClient`] and loads persisted properties
9//! - Loads or creates the [`bark::onchain::OnchainWallet`]
10//! - Opens the [`bark::Wallet`] bound to the on-chain wallet
11//! - Returns `(bark::Wallet, bark::onchain::OnchainWallet)`
12//!
13//! ## Errors
14//! Returns an [`anyhow::Error`] with context describing the failing step (I/O, parsing,
15//! database access, or wallet initialization).
16//!
17//! ## Example
18//! Open a wallet from a data directory:
19//!
20//! ```rust,no_run
21//! # use std::path::Path;
22//! # use bark_cli::wallet::open_wallet;
23//! # async fn example() -> anyhow::Result<()> {
24//!     let datadir = Path::new("./bark_data");
25//!     let (bark_wallet, onchain_wallet) = open_wallet(datadir).await?.unwrap();
26//!     // Use the wallets...
27//!     Ok(())
28//! # }
29//! ```
30
31use std::path::Path;
32use std::sync::Arc;
33use std::str::FromStr;
34
35use anyhow::{Context, bail};
36use bark::persist::adaptor::StorageAdaptorWrapper;
37use bitcoin::Network;
38use clap::Args;
39use log::{debug, info, warn};
40use tonic::transport::Uri;
41
42use bark::{BarkNetwork, Config, Wallet as BarkWallet};
43use bark::lock_manager::{self, LockManager, PidLockError};
44use bark::lock_manager::pid_flock::LOCK_FILE;
45use bark::onchain::OnchainWallet;
46use bark::persist::BarkPersister;
47use bark::persist::sqlite::SqliteClient;
48use bark::persist::adaptor::filestore::FileStorageAdaptor;
49
50use bitcoin_ext::BlockHeight;
51
52use crate::util;
53
54/// File name of the mnemonic file.
55const MNEMONIC_FILE: &str = "mnemonic";
56
57/// File name of the database file.
58const DB_FILE: &str = "db.sqlite";
59/// File name of the filestore database file.
60const FILESTORE_FILE: &str = "wallet.json";
61
62/// File name of the config file.
63const CONFIG_FILE: &str = "config.toml";
64
65/// File name of the debug log file.
66const DEBUG_LOG_FILE: &str = "debug.log";
67
68/// File name used to persist the auth token in the datadir.
69pub const AUTH_TOKEN_FILE: &str = "auth_token";
70
71/// Process log files that may be written into the datadir by the daemon
72/// framework during testing; they should be ignored like debug.log.
73const STDOUT_LOG_FILE: &str = "stdout.log";
74const STDERR_LOG_FILE: &str = "stderr.log";
75
76/// Take the datadir lock via [`lock_manager::platform_default`], surfacing
77/// the "already held" case as-is so the CLI prints a clean error.
78fn open_lock_manager(datadir: &Path) -> anyhow::Result<Box<dyn LockManager>> {
79	match lock_manager::platform_default(datadir) {
80		Ok(m) => Ok(m),
81		Err(e) if e.is::<PidLockError>() => Err(e),
82		Err(e) => Err(e.context("failed to acquire datadir lock")),
83	}
84}
85
86/// Options to define the initial bark config
87#[derive(Clone, PartialEq, Eq, Default, clap::Args)]
88pub struct ConfigOpts {
89	/// The address of your Ark server.
90	#[arg(long)]
91	pub ark: Option<String>,
92
93	/// The access token for a private server
94	#[arg(long)]
95	pub access_token: Option<String>,
96
97	/// The address of the Esplora HTTP server to use.
98	///
99	/// Either this or the `bitcoind_address` field has to be provided.
100	#[arg(long)]
101	pub esplora: Option<String>,
102
103	/// The address of the bitcoind RPC server to use.
104	///
105	/// Either this or the `esplora_address` field has to be provided.
106	#[arg(long)]
107	pub bitcoind: Option<String>,
108
109	/// The path to the bitcoind rpc cookie file.
110	///
111	/// Only used with `bitcoind_address`.
112	#[arg(long)]
113	pub bitcoind_cookie: Option<String>,
114
115	/// The bitcoind RPC username.
116	///
117	/// Only used with `bitcoind_address`.
118	#[arg(long)]
119	pub bitcoind_user: Option<String>,
120
121	/// The bitcoind RPC password.
122	///
123	/// Only used with `bitcoind_address`.
124	#[arg(long)]
125	pub bitcoind_pass: Option<String>,
126
127	/// SOCKS5 proxy URL (e.g. socks5h://127.0.0.1:9050 for Tor).
128	/// Automatically bypassed for localhost connections.
129	#[arg(long)]
130	pub socks5_proxy: Option<String>,
131}
132
133impl ConfigOpts {
134	/// Fill the default required config fields based on network
135	fn fill_network_defaults(&mut self, net: BarkNetwork) {
136		// Fallback to our default mainnet
137		if net == BarkNetwork::Mainnet {
138			// Only do it when the user did *not* specify either --esplora or --bitcoind.
139			if self.esplora.is_none() && self.bitcoind.is_none() {
140				self.esplora = Some("https://mempool.second.tech/api".to_owned());
141			}
142		}
143
144		// Fallback to our default signet
145		if net == BarkNetwork::Signet {
146			// Only do it when the user did *not* specify either --esplora or --bitcoind.
147			if self.esplora.is_none() && self.bitcoind.is_none() {
148				self.esplora = Some("https://esplora.signet.2nd.dev/".to_owned());
149			}
150
151			if self.ark.is_none() {
152				self.ark = Some("https://ark.signet.2nd.dev/".to_owned());
153			}
154		}
155
156		// Fallback to Mutinynet community Esplora
157		// Only do it when the user did *not* specify either --esplora or --bitcoind.
158		if net == BarkNetwork::Mutinynet && self.esplora.is_none() && self.bitcoind.is_none() {
159			self.esplora = Some("https://mutinynet.com/api".to_owned());
160		}
161	}
162
163	/// Validate the config options are sane
164	fn validate(&self) -> anyhow::Result<()> {
165		if self.esplora.is_none() && self.bitcoind.is_none() {
166			bail!("You need to provide a chain source using either --esplora or --bitcoind");
167		}
168
169		match (
170			self.bitcoind.is_some(),
171			self.bitcoind_cookie.is_some(),
172			self.bitcoind_user.is_some(),
173			self.bitcoind_pass.is_some(),
174		) {
175			(false, false, false, false) => {},
176			(false, _, _, _) => bail!("Provided bitcoind auth args without bitcoind address"),
177			(_, true, false, false) => {},
178			(_, true, _, _) => bail!("Bitcoind user/pass shouldn't be provided together with cookie file"),
179			(_, _, true, true) => {},
180			_ => bail!("When providing --bitcoind, you need to provide auth args as well."),
181		}
182
183		if let Some(ref proxy) = self.socks5_proxy {
184			let uri = proxy.parse::<Uri>().context("invalid socks5 proxy URI")?;
185			let scheme = uri.scheme_str().context("invalid socks5 proxy URI scheme")?;
186			if scheme != "socks5h" {
187				bail!("Only socks5h:// proxies are supported");
188			}
189		}
190
191		Ok(())
192	}
193
194	/// Will write the provided config options to the config
195	///
196	/// Will also load and return the config when loaded from the written file.
197	fn write_to_file(&self, network: Network, path: impl AsRef<Path>) -> anyhow::Result<Config> {
198		use std::fmt::Write;
199
200		let mut conf = String::new();
201		let ark = util::default_scheme("https", self.ark.as_ref().context("missing --ark arg")?)
202			.context("invalid ark server URL")?;
203		writeln!(conf, "server_address = \"{}\"", ark).unwrap();
204
205		if let Some(ref v) = self.access_token {
206			writeln!(conf, "server_access_token = \"{}\"", v).unwrap();
207		}
208		if let Some(ref v) = self.esplora {
209			let url = util::default_scheme("https", v).context("invalid esplora URL")?;
210			writeln!(conf, "esplora_address = \"{}\"", url).unwrap();
211		}
212		if let Some(ref v) = self.bitcoind {
213			let url = util::default_scheme("http", v).context("invalid bitcoind URL")?;
214			writeln!(conf, "bitcoind_address = \"{}\"", url).unwrap();
215		}
216		if let Some(ref v) = self.bitcoind_cookie {
217			writeln!(conf, "bitcoind_cookiefile = \"{}\"", v).unwrap();
218		}
219		if let Some(ref v) = self.bitcoind_user {
220			writeln!(conf, "bitcoind_user = \"{}\"", v).unwrap();
221		}
222		if let Some(ref v) = self.bitcoind_pass {
223			writeln!(conf, "bitcoind_pass = \"{}\"", v).unwrap();
224		}
225		if let Some(ref v) = self.socks5_proxy {
226			writeln!(conf, "socks5_proxy = \"{}\"", v).unwrap();
227		}
228
229		let path = path.as_ref();
230		std::fs::write(path, conf).with_context(|| format!(
231			"error writing new config file to {}", path.display(),
232		))?;
233
234		// new let's try load it to make sure it's sane
235		Ok(Config::load(network, path).context("problematic config flags provided")?)
236	}
237}
238
239#[derive(Args)]
240pub struct CreateOpts {
241	/// Force re-create the wallet even if it already exists.
242	/// Any funds in the old wallet will be lost
243	#[arg(long)]
244	pub force: bool,
245
246	/// Use filestore (JSON file) persistence instead of SQLite.
247	/// This creates a marker file so subsequent commands use the same backend.
248	///
249	/// Warning: do not use this for a production wallet.
250	#[arg(long)]
251	pub use_filestore: bool,
252
253	/// Use bitcoin mainnet
254	#[arg(long)]
255	pub mainnet: bool,
256	/// Use regtest network
257	#[arg(long)]
258	pub regtest: bool,
259	/// Use the official signet network
260	#[arg(long)]
261	pub signet: bool,
262	/// Use mutinynet
263	#[arg(long)]
264	pub mutinynet: bool,
265
266	/// Recover a wallet with an existing mnemonic.
267	/// This currently only works for on-chain funds.
268	#[arg(long)]
269	pub mnemonic: Option<bip39::Mnemonic>,
270
271	/// The wallet/mnemonic's birthday blockheight to start syncing when recovering.
272	#[arg(long)]
273	pub birthday_height: Option<BlockHeight>,
274
275	#[command(flatten)]
276	pub config: ConfigOpts,
277}
278
279/// Checks the config file and maybe cleans it
280/// - returns whether a config file was present
281/// - if clean is false, errors if any file not config or logs is present
282/// - if clean is true, removes all files not config or logs
283async fn check_clean_datadir(datadir: &Path, clean: bool) -> anyhow::Result<bool> {
284	let mut has_config = false;
285	if datadir.exists() {
286		for item in datadir.read_dir().context("error accessing datadir")? {
287			let item = item.context("error reading existing content of datadir")?;
288
289			if item.file_name() == CONFIG_FILE {
290				has_config = true;
291				continue;
292			}
293			if item.file_name() == DEBUG_LOG_FILE
294				|| item.file_name() == STDOUT_LOG_FILE
295				|| item.file_name() == STDERR_LOG_FILE
296			{
297				continue;
298			}
299			if item.file_name() == LOCK_FILE {
300				continue;
301			}
302			if item.file_name() == AUTH_TOKEN_FILE {
303				continue;
304			}
305
306			if !clean {
307				bail!("Datadir has unexpected contents: {}", item.path().display());
308			}
309
310			// otherwise try wipe
311			let file_type = item.file_type().context("error accessing datadir content")?;
312			if file_type.is_dir() {
313				tokio::fs::remove_dir_all(item.path()).await.context("error deleting datadir content")?;
314			} else if file_type.is_file() || file_type.is_symlink() {
315				tokio::fs::remove_file(item.path()).await.context("error deleting datadir content")?;
316			} else {
317				// can't happen
318				bail!("non-existent file type in ");
319			}
320		}
321	}
322	Ok(has_config)
323}
324
325pub async fn create_wallet(datadir: &Path, opts: CreateOpts) -> anyhow::Result<()> {
326	debug!("Creating wallet in {}", datadir.display());
327
328	let net = match (opts.mainnet, opts.signet, opts.regtest, opts.mutinynet) {
329		(true,  false, false, false) => BarkNetwork::Mainnet,
330		(false, true,  false, false) => BarkNetwork::Signet,
331		(false, false, true,  false) => BarkNetwork::Regtest,
332		(false, false, false, true ) => BarkNetwork::Mutinynet,
333		_ => bail!("Specify exactly one of --mainnet, --signet, --regtest or --mutinynet"),
334	};
335
336	// check for non-config file contents in the datadir and wipe if force
337	let config_existed = check_clean_datadir(datadir, opts.force).await?;
338
339	// Everything that errors after this will wipe the datadir again.
340	let result = try_create_wallet(datadir, net, opts).await;
341	if let Err(e) = result {
342		if config_existed {
343			if let Err(e) = check_clean_datadir(datadir, true).await {
344				warn!("Error cleaning datadir after failure: {:#}", e);
345			}
346		} else {
347			if let Err(e) = tokio::fs::remove_dir_all(datadir).await {
348				warn!("Error removing datadir after failure: {:#}", e);
349			}
350		}
351
352		bail!("Error while creating wallet: {:#}", e);
353	}
354	Ok(())
355}
356
357/// In this method we create the wallet and if it fails, the datadir will be wiped again.
358async fn try_create_wallet(
359	datadir: &Path,
360	net: BarkNetwork,
361	mut opts: CreateOpts,
362) -> anyhow::Result<()> {
363	info!("Creating new bark Wallet at {}", datadir.display());
364
365	tokio::fs::create_dir_all(datadir).await.context("can't create dir")?;
366
367	let config_path = datadir.join(CONFIG_FILE);
368	let has_config_args = opts.config != ConfigOpts::default();
369	let config = match (config_path.exists(), has_config_args) {
370		(true, false) => {
371			Config::load(net.as_bitcoin(), &config_path).with_context(|| format!(
372				"error loading existing config file at {}", config_path.display(),
373			))?
374		},
375		(false, true) => {
376			opts.config.fill_network_defaults(net);
377			opts.config.validate().context("invalid config options")?;
378			opts.config.write_to_file(net.as_bitcoin(), config_path)?
379		},
380		(false, false) => bail!("You need to provide config flags or a config file"),
381		(true, true) => bail!("Cannot provide an existing config file and config flags"),
382	};
383
384	// A mnemonic implies that the user wishes to recover an existing wallet.
385	if opts.mnemonic.is_some() {
386		if opts.birthday_height.is_none() {
387			// Only Bitcoin Core requires a birthday height to avoid syncing the entire chain.
388			if config.bitcoind_address.is_some() {
389				bail!("You need to set the --birthday-height field when recovering from mnemonic.");
390			}
391		} else if config.esplora_address.is_some() {
392			warn!("The given --birthday-height will be ignored because you're using Esplora.");
393		}
394		warn!("Recovering from mnemonic currently only supports recovering on-chain funds!");
395	} else {
396		if opts.birthday_height.is_some() {
397			bail!("Can't set --birthday-height if --mnemonic is not set.");
398		}
399	}
400
401	// generate seed
402	let is_new_wallet = opts.mnemonic.is_none();
403	let mnemonic = opts.mnemonic.unwrap_or_else(|| bip39::Mnemonic::generate(12).expect("12 is valid"));
404	let seed = mnemonic.to_seed("");
405	tokio::fs::write(datadir.join(MNEMONIC_FILE), mnemonic.to_string().as_bytes()).await
406		.context("failed to write mnemonic")?;
407
408	// open db
409	let db: Arc<dyn BarkPersister + Send + Sync> = if opts.use_filestore {
410		debug!("Using filestore backend");
411		let adaptor = FileStorageAdaptor::open(datadir.join(FILESTORE_FILE)).await?;
412		Arc::new(StorageAdaptorWrapper::new(adaptor))
413	} else {
414		debug!("Using sqlite backend");
415		Arc::new(SqliteClient::open(datadir.join(DB_FILE))?)
416	};
417
418	let mut onchain = OnchainWallet::load_or_create(net.as_bitcoin(), seed, db.clone()).await?;
419	let lock_manager = open_lock_manager(&datadir)?;
420	let wallet = BarkWallet::create_with_onchain(
421		&mnemonic, net.as_bitcoin(), config, db, lock_manager, &onchain, opts.force,
422	).await.context("error creating wallet")?;
423
424	// Skip initial block sync if we generated a new wallet.
425	let birthday_height = if is_new_wallet {
426		Some(wallet.chain().tip().await?)
427	} else {
428		opts.birthday_height
429	};
430	onchain.initial_wallet_scan(wallet.chain(), birthday_height).await?;
431	Ok(())
432}
433
434pub async fn open_wallet(datadir: &Path) -> anyhow::Result<Option<(BarkWallet, OnchainWallet)>> {
435	debug!("Opening bark wallet in {}", datadir.display());
436
437
438	// read mnemonic file
439	let mnemonic_path = datadir.join(MNEMONIC_FILE);
440
441	if !tokio::fs::try_exists(datadir).await? {
442		return Ok(None);
443	}
444
445	if !tokio::fs::try_exists(&mnemonic_path).await? {
446		return Ok(None);
447	}
448
449	let mnemonic_str = tokio::fs::read_to_string(&mnemonic_path).await
450		.with_context(|| format!("failed to read mnemonic file at {}", mnemonic_path.display()))?;
451	let mnemonic = bip39::Mnemonic::from_str(&mnemonic_str).context("broken mnemonic")?;
452	let seed = mnemonic.to_seed("");
453
454	let use_filestore = datadir.join(FILESTORE_FILE).exists();
455	let db: Arc<dyn BarkPersister + Send + Sync> = if use_filestore {
456		debug!("Using filestore backend");
457		let adaptor = FileStorageAdaptor::open(datadir.join(FILESTORE_FILE)).await?;
458		Arc::new(StorageAdaptorWrapper::new(adaptor))
459	} else {
460		debug!("Using sqlite backend");
461		Arc::new(SqliteClient::open(datadir.join(DB_FILE))?)
462	};
463	let properties = db.read_properties().await?.context("failed to read properties")?;
464
465	// Read the config
466	let config_path = datadir.join("config.toml");
467	let config = Config::load(properties.network, config_path)
468		.context("error loading bark config file")?;
469
470	let bdk_wallet = OnchainWallet::load_or_create(properties.network, seed, db.clone()).await?;
471	let lock_manager = open_lock_manager(datadir)?;
472	let bark_wallet = BarkWallet::open_with_onchain(&mnemonic, db, &bdk_wallet, config, lock_manager).await?;
473
474	if let Err(e) = bark_wallet.require_chainsource_version().await {
475		warn!("{}", e);
476	}
477
478	Ok(Some((bark_wallet, bdk_wallet)))
479}
480