use std::{
collections::BTreeMap,
fs::{self, File},
io::{Read, Write},
path::PathBuf,
str::FromStr,
};
use miniscript::{
bitcoin::{self, ScriptBuf},
Descriptor, DescriptorPublicKey,
};
use serde::{Deserialize, Serialize};
use crate::{descriptor::ScriptType, signer::HotSigner};
const CONFIG_FILENAME: &str = "config.json";
pub fn datadir(dir_name: &str) -> PathBuf {
#[cfg(target_os = "linux")]
let dir = {
let mut dir = dirs::home_dir().unwrap();
dir.push(dir_name);
dir
};
#[cfg(not(target_os = "linux"))]
let dir = {
let mut dir = dirs::config_dir().unwrap();
dir.push("Qoinstr");
dir
};
maybe_create_dir(&dir);
dir
}
pub fn maybe_create_dir(dir: &PathBuf) {
if !dir.exists() {
#[cfg(unix)]
{
use std::fs::DirBuilder;
use std::os::unix::fs::DirBuilderExt;
let mut builder = DirBuilder::new();
builder.mode(0o700).recursive(true).create(dir).unwrap();
}
#[cfg(not(unix))]
std::fs::create_dir_all(dir).unwrap();
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Config {
#[serde(skip)]
data_dir: PathBuf,
#[serde(skip)]
dir_name: &'static str,
pub account: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub electrum_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub electrum_port: Option<u16>,
pub network: bitcoin::Network,
pub look_ahead: u32,
pub mnemonic: Option<String>,
pub descriptor: Descriptor<DescriptorPublicKey>,
pub persist: bool,
}
impl Config {
pub fn new(
mnemonic: Option<String>,
account: String,
network: bitcoin::Network,
script: ScriptType,
data_dir: PathBuf,
dir_name: &'static str,
persist: bool,
) -> Option<Config> {
let descriptor = match script {
ScriptType::Segwit(_) | ScriptType::Taproot(_) => {
if let Some(mnemo) = &mnemonic {
let signer = HotSigner::new_from_mnemonics(network, mnemo).unwrap();
script.to_descriptor(network, |d| signer.xpub(&d)).unwrap()
} else {
return None;
}
}
ScriptType::Descriptor(descriptor) => descriptor,
};
Some(Config {
data_dir,
dir_name,
account,
electrum_url: None,
electrum_port: None,
network,
look_ahead: 20,
mnemonic,
descriptor,
persist,
})
}
pub fn enable_persist(mut self, persist: bool) -> Self {
self.persist = persist;
self
}
pub fn electrum_url(&self) -> String {
self.electrum_url.clone().unwrap_or_default()
}
pub fn electrum_port(&self) -> String {
self.electrum_port
.map(|v| format!("{v}"))
.unwrap_or_default()
}
pub fn look_ahead(&self) -> String {
self.look_ahead.to_string()
}
pub fn network(&self) -> bitcoin::Network {
self.network
}
pub fn set_electrum_url(&mut self, url: String) {
self.electrum_url = Some(url);
}
pub fn set_electrum_port(&mut self, port: String) {
self.electrum_port = port.parse::<u16>().ok();
}
pub fn set_look_ahead(&mut self, look_ahead: String) {
if let Ok(la) = look_ahead.parse::<u32>() {
self.look_ahead = la;
}
}
pub fn set_network(&mut self, network: bitcoin::Network) {
self.network = network;
}
pub fn set_mnemonic(&mut self, mnemonic: String) {
self.mnemonic = Some(mnemonic);
}
pub fn set_account(&mut self, name: String) {
self.account = name;
}
pub fn to_file(&self) {
let mut path = Self::path(self.data_dir.clone(), self.dir_name, self.account.clone());
maybe_create_dir(&path);
path.push(CONFIG_FILENAME);
log::warn!("Config::to_file() {:?}", path);
let mut file = File::create(path).unwrap();
let content = serde_json::to_string_pretty(&self).unwrap();
file.write_all(content.as_bytes()).unwrap();
}
pub fn dir_name(&self) -> &'static str {
self.dir_name
}
pub fn data_dir(&self) -> PathBuf {
self.data_dir.clone()
}
pub fn list_configs(data_dir: PathBuf, dir_name: &'static str) -> Vec<String> {
let mut path = data_dir.clone();
path.push(dir_name);
let mut out = vec![];
if let Ok(folders) = fs::read_dir(path) {
folders.for_each(|account| {
if let Ok(entry) = account {
if let Ok(md) = entry.metadata() {
if md.is_dir() {
let acc_name = entry.file_name().to_str().unwrap().to_string();
let path = Self::path(data_dir.clone(), dir_name, acc_name.clone());
let parsed = Self::from_file(path);
if !parsed.account.is_empty() {
out.push(acc_name);
}
};
}
}
});
}
out
}
pub fn config_exists(data_dir: PathBuf, dir_name: &'static str, account: String) -> bool {
let mut path = Self::path(data_dir, dir_name, account.clone());
path.push(CONFIG_FILENAME);
path.exists()
}
pub fn path(data_dir: PathBuf, dir_name: &'static str, account: String) -> PathBuf {
let mut dir = data_dir;
dir.push(dir_name);
dir.push(account);
dir
}
pub fn from_file(mut path: PathBuf) -> Self {
path.push(CONFIG_FILENAME);
let mut file = File::open(path).unwrap();
let mut content = String::new();
let _ = file.read_to_string(&mut content);
let conf: Config = serde_json::from_str(&content).unwrap();
conf
}
pub fn transactions_path(&self) -> PathBuf {
let mut path = Self::path(self.data_dir.clone(), self.dir_name, self.account.clone());
path.push("transactions.json");
path
}
pub fn statuses_path(&self) -> PathBuf {
let mut path = Self::path(self.data_dir.clone(), self.dir_name, self.account.clone());
path.push("statuses.json");
path
}
pub fn tip_path(&self) -> PathBuf {
let mut path = Self::path(self.data_dir.clone(), self.dir_name, self.account.clone());
path.push("tip.json");
path
}
pub fn labels_path(&self) -> PathBuf {
let mut path = Self::path(self.data_dir.clone(), self.dir_name, self.account.clone());
path.push("labels.json");
path
}
pub fn persist_tip(&self, receive: u32, change: u32) {
if !self.persist {
return;
}
let file = File::create(self.tip_path());
match file {
Ok(mut file) => {
let tip = Tip { receive, change };
let content = serde_json::to_string_pretty(&tip).expect("cannot fail");
let _ = file.write(content.as_bytes());
}
Err(e) => {
log::error!("Config::persist_tip() fail to open file: {e}");
}
}
}
pub fn tip_from_file(&self) -> Tip {
if let Ok(mut file) = File::open(self.tip_path()) {
let mut content = String::new();
let _ = file.read_to_string(&mut content);
serde_json::from_str(&content).unwrap_or_default()
} else {
Default::default()
}
}
pub fn persist_statuses(&self, statuses: &BTreeMap<ScriptBuf, (Option<String>, u32, u32)>) {
if !self.persist {
return;
}
let file = File::create(self.statuses_path());
match file {
Ok(mut file) => {
let content = serde_json::to_string_pretty(statuses).expect("cannot fail");
let _ = file.write(content.as_bytes());
}
Err(e) => {
log::error!("Config::statuses() fail to open file: {e}");
}
}
}
pub fn statuses_from_file(&self) -> BTreeMap<ScriptBuf, (Option<String>, u32, u32)> {
if let Ok(mut file) = File::open(self.statuses_path()) {
let mut content = String::new();
let _ = file.read_to_string(&mut content);
serde_json::from_str(&content).unwrap_or_default()
} else {
Default::default()
}
}
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Tip {
pub receive: u32,
pub change: u32,
}
pub fn is_descriptor_valid(descriptor: String) -> bool {
Descriptor::<DescriptorPublicKey>::from_str(&descriptor).is_ok()
}
#[cfg(test)]
pub mod tests {
use miniscript::bitcoin::bip32::ChildNumber;
use super::*;
#[test]
fn test_persist() {
let temp = temp_dir::TempDir::new().unwrap();
let path = temp.child("storage");
let mnemonic = bip39::Mnemonic::generate(12).unwrap();
let dir_name = "wallet";
let cfg = Config::new(
Some(mnemonic.to_string()),
"my_account".to_string(),
bitcoin::Network::Regtest,
ScriptType::Segwit(ChildNumber::from_hardened_idx(0).unwrap()),
path.clone(),
dir_name,
true,
)
.unwrap();
cfg.to_file();
let mut path = path.to_path_buf();
path.push(dir_name);
path.push("my_account");
let cfg2 = Config::from_file(path);
assert_eq!(cfg.account, cfg2.account);
assert_eq!(cfg2.account, "my_account")
}
}