aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/actions/command.rs61
-rw-r--r--src/actions/desktop_notification.rs28
-rw-r--r--src/actions/email.rs142
-rw-r--r--src/actions/mod.rs120
-rw-r--r--src/actions/nostr.rs192
-rw-r--r--src/actions/ntfy.rs119
-rw-r--r--src/actions/telegram.rs56
-rw-r--r--src/actions/terminal_print.rs28
-rw-r--r--src/blockchain.rs96
-rw-r--r--src/config.rs143
-rw-r--r--src/main.rs179
-rw-r--r--src/message.rs203
-rw-r--r--src/wallets.rs230
13 files changed, 1597 insertions, 0 deletions
diff --git a/src/actions/command.rs b/src/actions/command.rs
new file mode 100644
index 0000000..28d951a
--- /dev/null
+++ b/src/actions/command.rs
@@ -0,0 +1,61 @@
+use std::collections::HashMap;
+use std::process::Command;
+
+use super::Action;
+use crate::message::MessageConfig;
+use crate::message::MessageParams;
+use anyhow::Result;
+use async_trait::async_trait;
+use serde::Deserialize;
+
+#[derive(Deserialize, Debug)]
+pub struct CommandConfig {
+ cmd: String,
+ #[serde(default)]
+ args: Vec<String>,
+ #[serde(default)]
+ clear_parent_env: bool,
+ #[serde(default)]
+ envs: HashMap<String, String>,
+ working_dir: Option<String>,
+}
+
+pub struct CommandAction<'a> {
+ message_config: &'a MessageConfig,
+ cmd_config: &'a CommandConfig,
+}
+
+impl<'a> CommandAction<'a> {
+ pub fn new(message_config: &'a MessageConfig, cmd_config: &'a CommandConfig) -> Result<Self> {
+ Ok(Self {
+ message_config,
+ cmd_config,
+ })
+ }
+}
+
+#[async_trait]
+impl Action<'_> for CommandAction<'_> {
+ async fn run(&self, params: Option<&MessageParams<'_, '_>>) -> Result<()> {
+ let mut cmd = Command::new(&self.cmd_config.cmd);
+ for arg in self.cmd_config.args.iter() {
+ cmd.arg(if let Some(p) = params {
+ self.message_config.replace_template_params(arg, p)?
+ } else {
+ arg.clone()
+ });
+ }
+
+ if self.cmd_config.clear_parent_env {
+ cmd.env_clear();
+ }
+ cmd.envs(&self.cmd_config.envs);
+
+ if let Some(working_dir) = &self.cmd_config.working_dir {
+ cmd.current_dir(working_dir);
+ }
+
+ cmd.status()?;
+ Ok(())
+ }
+}
diff --git a/src/actions/desktop_notification.rs b/src/actions/desktop_notification.rs
new file mode 100644
index 0000000..d831a59
--- /dev/null
+++ b/src/actions/desktop_notification.rs
@@ -0,0 +1,28 @@
+use super::Action;
+use crate::message::MessageConfig;
+use crate::message::MessageParams;
+use anyhow::Result;
+use async_trait::async_trait;
+
+#[derive(Debug)]
+pub struct DesktopNotificationAction<'a> {
+ message_config: &'a MessageConfig,
+}
+
+impl<'a> DesktopNotificationAction<'a> {
+ pub fn new(message_config: &'a MessageConfig) -> Self {
+ Self { message_config }
+ }
+}
+
+#[async_trait]
+impl Action<'_> for DesktopNotificationAction<'_> {
+ async fn run(&self, params: Option<&MessageParams<'_, '_>>) -> Result<()> {
+ use notify_rust::Notification;
+ Notification::new()
+ .summary(&self.message_config.subject(params)?)
+ .body(&self.message_config.body(params)?)
+ .show()?;
+ Ok(())
+ }
+}
diff --git a/src/actions/email.rs b/src/actions/email.rs
new file mode 100644
index 0000000..272f650
--- /dev/null
+++ b/src/actions/email.rs
@@ -0,0 +1,142 @@
+use super::Action;
+use crate::message::MessageConfig;
+use crate::message::MessageFormat;
+use crate::message::MessageParams;
+use anyhow::{Context, Result};
+use async_trait::async_trait;
+use lettre::message::header::ContentType;
+use lettre::message::MessageBuilder;
+use lettre::message::MultiPart;
+use lettre::message::SinglePart;
+use lettre::transport::smtp::authentication::Credentials;
+use lettre::transport::smtp::client::Tls;
+use lettre::transport::smtp::client::TlsParametersBuilder;
+use lettre::AsyncSmtpTransport;
+use lettre::AsyncTransport;
+use lettre::Message;
+use lettre::Tokio1Executor;
+use serde::Deserialize;
+
+#[derive(Deserialize, Debug, Copy, Clone)]
+#[serde(rename_all = "lowercase")]
+pub enum EmailConnectionType {
+ Plain,
+ StartTls,
+ Tls,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct EmailConfig {
+ server: String,
+ port: Option<u16>,
+ credentials: Option<Credentials>,
+ connection: Option<EmailConnectionType>,
+ self_signed_cert: Option<bool>,
+ from: String,
+ to: Option<String>,
+}
+
+impl EmailConfig {
+ pub fn server(&self) -> &str {
+ &self.server
+ }
+
+ pub fn connection(&self) -> EmailConnectionType {
+ self.connection.unwrap_or(EmailConnectionType::Tls)
+ }
+
+ pub fn self_signed_cert(&self) -> bool {
+ self.self_signed_cert.unwrap_or(false)
+ }
+
+ pub fn port(&self) -> u16 {
+ self.port.unwrap_or(match self.connection() {
+ EmailConnectionType::Tls => 587,
+ EmailConnectionType::StartTls => 465,
+ EmailConnectionType::Plain => 25,
+ })
+ }
+
+ pub fn to(&self) -> &str {
+ self.to.as_deref().unwrap_or(self.from.as_ref())
+ }
+}
+
+pub struct EmailAction<'a> {
+ message_config: &'a MessageConfig,
+ mailer: AsyncSmtpTransport<Tokio1Executor>,
+ message_builder: MessageBuilder,
+}
+impl<'a> EmailAction<'a> {
+ pub fn new(message_config: &'a MessageConfig, email_config: &'a EmailConfig) -> Result<Self> {
+ let tls_builder = TlsParametersBuilder::new(email_config.server().into())
+ .dangerous_accept_invalid_certs(email_config.self_signed_cert());
+ let tls_parameters = tls_builder.build()?;
+
+ let mut smtp_builder =
+ AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(email_config.server())
+ .port(email_config.port())
+ .tls(match email_config.connection() {
+ EmailConnectionType::Tls => Tls::Wrapper(tls_parameters),
+ EmailConnectionType::StartTls => Tls::Required(tls_parameters),
+ EmailConnectionType::Plain => Tls::None,
+ });
+ if let Some(cred) = &email_config.credentials {
+ smtp_builder = smtp_builder.credentials(cred.clone())
+ }
+ Ok(Self {
+ message_config,
+ mailer: smtp_builder.build(),
+ message_builder: Message::builder()
+ .from(
+ email_config
+ .from
+ .parse()
+ .with_context(|| format!("invalid from address '{}'", email_config.from))?,
+ )
+ .to(email_config
+ .to()
+ .parse()
+ .with_context(|| format!("invalid to address '{}'", email_config.to()))?),
+ })
+ }
+}
+
+#[async_trait]
+impl Action<'_> for EmailAction<'_> {
+ async fn run(&self, params: Option<&MessageParams<'_, '_>>) -> Result<()> {
+ let body = self.message_config.body(params)?;
+ let html_body = match self.message_config.format() {
+ MessageFormat::Markdown => format!(
+ "<!DOCTYPE html><html><body>{}</body></html>",
+ markdown::to_html(&body)
+ ),
+ MessageFormat::Html => body.clone(),
+ MessageFormat::Plain => Default::default(),
+ };
+ let email_builder = self
+ .message_builder
+ .clone()
+ .subject(self.message_config.subject(params)?);
+ let email = match self.message_config.format() {
+ MessageFormat::Plain => email_builder
+ .header(ContentType::TEXT_PLAIN)
+ .body(body.clone())?,
+ MessageFormat::Markdown | MessageFormat::Html => email_builder.multipart(
+ MultiPart::alternative()
+ .singlepart(
+ SinglePart::builder()
+ .header(ContentType::TEXT_PLAIN)
+ .body(body.clone()),
+ )
+ .singlepart(
+ SinglePart::builder()
+ .header(ContentType::TEXT_HTML)
+ .body(html_body.clone()),
+ ),
+ )?,
+ };
+ self.mailer.send(email).await?;
+ Ok(())
+ }
+}
diff --git a/src/actions/mod.rs b/src/actions/mod.rs
new file mode 100644
index 0000000..14ab279
--- /dev/null
+++ b/src/actions/mod.rs
@@ -0,0 +1,120 @@
+use std::fmt;
+
+use anyhow::Result;
+use async_trait::async_trait;
+use serde::Deserialize;
+
+use crate::message::MessageConfig;
+use crate::message::MessageParams;
+
+mod command;
+#[cfg(feature = "desktop")]
+mod desktop_notification;
+#[cfg(feature = "email")]
+mod email;
+#[cfg(feature = "nostr")]
+mod nostr;
+#[cfg(feature = "ntfy")]
+mod ntfy;
+#[cfg(feature = "telegram")]
+mod telegram;
+mod terminal_print;
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "snake_case")]
+#[serde(tag = "type")]
+pub enum AnyActionConfig {
+ TerminalPrint,
+ Command(self::command::CommandConfig),
+ #[cfg(feature = "desktop")]
+ DesktopNotification,
+ #[cfg(feature = "ntfy")]
+ Ntfy(self::ntfy::NtfyConfig),
+ #[cfg(feature = "email")]
+ Email(self::email::EmailConfig),
+ #[cfg(feature = "telegram")]
+ Telegram(self::telegram::TelegramConfig),
+ #[cfg(feature = "nostr")]
+ Nostr(self::nostr::NostrConfig),
+}
+
+impl fmt::Display for AnyActionConfig {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ match self {
+ AnyActionConfig::TerminalPrint => write!(f, "terminal_print"),
+ AnyActionConfig::Command(_) => write!(f, "command"),
+ #[cfg(feature = "desktop")]
+ AnyActionConfig::DesktopNotification => write!(f, "desktop_notification"),
+ #[cfg(feature = "ntfy")]
+ AnyActionConfig::Ntfy(_) => write!(f, "ntfy"),
+ #[cfg(feature = "email")]
+ AnyActionConfig::Email(_) => write!(f, "email"),
+ #[cfg(feature = "telegram")]
+ AnyActionConfig::Telegram(_) => write!(f, "telegram"),
+ #[cfg(feature = "nostr")]
+ AnyActionConfig::Nostr(_) => write!(f, "nostr"),
+ }
+ }
+}
+
+#[async_trait]
+pub trait Action<'a> {
+ async fn run(&self, params: Option<&MessageParams<'_, '_>>) -> Result<()>;
+}
+
+pub async fn get_action<'a>(
+ message_config: &'a MessageConfig,
+ action_config: &'a AnyActionConfig,
+) -> Result<Box<dyn Action<'a> + 'a + Sync>> {
+ Ok(match action_config {
+ AnyActionConfig::TerminalPrint => Box::new(self::terminal_print::TerminalPrintAction::new(
+ message_config,
+ )),
+ AnyActionConfig::Command(config) => {
+ Box::new(self::command::CommandAction::new(message_config, config)?)
+ }
+ #[cfg(feature = "desktop")]
+ AnyActionConfig::DesktopNotification => Box::new(
+ self::desktop_notification::DesktopNotificationAction::new(message_config),
+ ),
+ #[cfg(feature = "ntfy")]
+ AnyActionConfig::Ntfy(config) => {
+ Box::new(self::ntfy::NtfyAction::new(message_config, config)?)
+ }
+ #[cfg(feature = "email")]
+ AnyActionConfig::Email(config) => {
+ Box::new(self::email::EmailAction::new(message_config, config)?)
+ }
+ #[cfg(feature = "telegram")]
+ AnyActionConfig::Telegram(config) => {
+ Box::new(self::telegram::TelegramAction::new(message_config, config)?)
+ }
+ #[cfg(feature = "nostr")]
+ AnyActionConfig::Nostr(config) => {
+ Box::new(self::nostr::NostrAction::new(message_config, config).await?)
+ }
+ })
+}
+
+pub async fn get_actions<'a>(
+ message_config: &'a MessageConfig,
+ actions_config: &'a [AnyActionConfig],
+) -> Vec<Box<dyn Action<'a> + 'a + Sync>> {
+ let mut result: Vec<Box<dyn Action + Sync>> = Default::default();
+
+ // TODO: parallelize this. It's hard because the result vector needs to be shared.
+ for action_config in actions_config {
+ debug!("registering action '{}'", action_config);
+ match get_action(message_config, action_config).await {
+ Ok(action) => {
+ info!("registered action '{}'", action_config);
+ result.push(action);
+ }
+ Err(e) => {
+ warn!("could not register action '{}': {}", action_config, e);
+ }
+ }
+ }
+
+ result
+}
diff --git a/src/actions/nostr.rs b/src/actions/nostr.rs
new file mode 100644
index 0000000..aebccba
--- /dev/null
+++ b/src/actions/nostr.rs
@@ -0,0 +1,192 @@
+use super::Action;
+use crate::message::MessageConfig;
+use crate::message::MessageParams;
+use anyhow::{Context, Result};
+use async_scoped::TokioScope;
+use async_trait::async_trait;
+use const_format::formatcp;
+use nostr_relay_pool::RelayOptions;
+use nostr_sdk::nips::nip05;
+use nostr_sdk::serde_json::from_reader;
+use nostr_sdk::serde_json::to_string;
+use nostr_sdk::Client;
+use nostr_sdk::Keys;
+use nostr_sdk::Metadata;
+use nostr_sdk::PublicKey;
+use nostr_sdk::ToBech32;
+use serde::Deserialize;
+use serde::Serialize;
+use std::fs::File;
+use std::io::BufReader;
+use std::io::Write;
+use std::net::SocketAddr;
+use std::path::PathBuf;
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+struct NostrData {
+ key: String,
+ metadata_set: bool,
+}
+
+impl Default for NostrData {
+ fn default() -> Self {
+ NostrData {
+ key: Keys::generate().secret_key().unwrap().to_bech32().unwrap(),
+ metadata_set: false,
+ }
+ }
+}
+
+fn get_nostr_data_filepath() -> PathBuf {
+ dirs::cache_dir()
+ .unwrap_or(PathBuf::from("cache"))
+ .join(env!("CARGO_PKG_NAME"))
+ .join("nostr.json")
+}
+
+fn get_nostr_data() -> Result<NostrData> {
+ let path = get_nostr_data_filepath();
+ match File::open(&path) {
+ Ok(file) => {
+ let reader = BufReader::new(file);
+ from_reader(reader)
+ .with_context(|| format!("cannot read nostr data from '{}'", path.display()))
+ }
+ Err(_) => {
+ let nostr_data = NostrData::default();
+ let mut file = File::create(&path)?;
+ file.write_all(to_string(&nostr_data)?.as_bytes())
+ .with_context(|| format!("could not write nostr data to '{}'", path.display()))?;
+ Ok(nostr_data)
+ }
+ }
+}
+
+fn get_default_relays() -> Vec<String> {
+ vec![
+ "wss://nostr.bitcoiner.social",
+ "wss://nostr.oxtr.dev",
+ "wss://nostr.orangepill.dev",
+ "wss://relay.damus.io",
+ ]
+ .into_iter()
+ .map(String::from)
+ .collect()
+}
+
+fn get_default_bot_metadata() -> Metadata {
+ Metadata::new()
+ .name(formatcp!("{}bot", env!("CARGO_PKG_NAME")))
+ .display_name(formatcp!("{} bot", env!("CARGO_PKG_NAME")))
+ .about(env!("CARGO_PKG_DESCRIPTION"))
+ .website(env!("CARGO_PKG_REPOSITORY").parse().unwrap())
+ .picture("https://robohash.org/sentrumbot.png".parse().unwrap())
+ .banner(
+ "https://void.cat/d/HX1pPeqz21hvneLDibs5JD.webp"
+ .parse()
+ .unwrap(),
+ )
+ .lud06(formatcp!(
+ "https://sommerfeld.dev/.well-known/lnurlp/{}",
+ env!("CARGO_PKG_NAME")
+ ))
+ .lud16(formatcp!("{}@sommerfeld.dev", env!("CARGO_PKG_NAME")))
+}
+
+fn mark_bot_metadata_as_set(mut nostr_data: NostrData) -> Result<()> {
+ let path = get_nostr_data_filepath();
+ nostr_data.metadata_set = true;
+ let mut file = File::create(&path)?;
+ file.write_all(to_string(&nostr_data)?.as_bytes())
+ .with_context(|| format!("could not write nostr data to '{}'", path.display()))?;
+ Ok(())
+}
+
+#[derive(Deserialize, Debug)]
+pub struct NostrConfig {
+ #[serde(default = "get_default_relays")]
+ relays: Vec<String>,
+ proxy: Option<SocketAddr>,
+ #[serde(default = "get_default_bot_metadata")]
+ bot_metadata: Metadata,
+ #[serde(default)]
+ resend_bot_metadata: bool,
+ recipient: String,
+ #[serde(default)]
+ sealed_dm: bool,
+}
+
+impl NostrConfig {}
+
+pub struct NostrAction<'a> {
+ message_config: &'a MessageConfig,
+ client: Client,
+ recipient: PublicKey,
+ sealed_dm: bool,
+}
+
+impl<'a> NostrAction<'a> {
+ pub async fn new(
+ message_config: &'a MessageConfig,
+ nostr_config: &'a NostrConfig,
+ ) -> Result<Self> {
+ let nostr_data = get_nostr_data()?;
+ let keys = Keys::parse(&nostr_data.key)
+ .with_context(|| format!("could not parse nostr secret key '{}'", nostr_data.key))?;
+
+ let client = Client::new(&keys);
+
+ let relay_opts = RelayOptions::new().read(false).proxy(nostr_config.proxy);
+ TokioScope::scope_and_block(|s| {
+ for relay in nostr_config.relays.iter() {
+ s.spawn(client.add_relay_with_opts(relay.clone(), relay_opts.clone()));
+ }
+ });
+
+ client.connect().await;
+
+ if !nostr_data.metadata_set || nostr_config.resend_bot_metadata {
+ client.set_metadata(&nostr_config.bot_metadata).await?;
+ mark_bot_metadata_as_set(nostr_data)?;
+ }
+
+ let recipient = match PublicKey::parse(&nostr_config.recipient) {
+ Ok(p) => p,
+ Err(e) => {
+ nip05::get_profile(&nostr_config.recipient, nostr_config.proxy)
+ .await
+ .with_context(|| {
+ format!("invalid recipient '{}': {}", nostr_config.recipient, e)
+ })?
+ .public_key
+ }
+ };
+
+ Ok(Self {
+ message_config,
+ client,
+ recipient,
+ sealed_dm: nostr_config.sealed_dm,
+ })
+ }
+}
+
+#[async_trait]
+impl Action<'_> for NostrAction<'_> {
+ async fn run(&self, params: Option<&MessageParams<'_, '_>>) -> Result<()> {
+ let subject = self.message_config.subject(params)?;
+ let body = self.message_config.body(params)?;
+ let message = format!("{}\n{}", subject, body);
+
+ if self.sealed_dm {
+ self.client
+ .send_sealed_msg(self.recipient, message, None)
+ .await?;
+ } else {
+ self.client
+ .send_direct_msg(self.recipient, message, None)
+ .await?;
+ }
+ Ok(())
+ }
+}
diff --git a/src/actions/ntfy.rs b/src/actions/ntfy.rs
new file mode 100644
index 0000000..7d83b87
--- /dev/null
+++ b/src/actions/ntfy.rs
@@ -0,0 +1,119 @@
+use super::Action;
+use crate::message::MessageConfig;
+use crate::message::MessageFormat;
+use crate::message::MessageParams;
+use anyhow::Result;
+use async_trait::async_trait;
+use ntfy::Auth;
+use ntfy::Dispatcher;
+use ntfy::Payload;
+use ntfy::Priority;
+use ntfy::Url;
+use serde::Deserialize;
+
+#[derive(Deserialize, Debug)]
+#[serde(remote = "Priority")]
+#[serde(rename_all = "snake_case")]
+pub enum NtfyPriority {
+ Max = 5,
+ High = 4,
+ Default = 3,
+ Low = 2,
+ Min = 1,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct NtfyCredentials {
+ username: String,
+ password: String,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct NtfyConfig {
+ url: Option<String>,
+ proxy: Option<String>,
+ topic: Option<String>,
+ pub credentials: Option<NtfyCredentials>,
+ #[serde(with = "NtfyPriority")]
+ #[serde(default)]
+ pub priority: Priority,
+ pub tags: Option<Vec<String>>,
+ pub attach: Option<Url>,
+ pub filename: Option<String>,
+ pub delay: Option<String>,
+ pub email: Option<String>,
+}
+
+impl NtfyConfig {
+ pub fn url(&self) -> &str {
+ self.url.as_deref().unwrap_or("https://ntfy.sh")
+ }
+
+ pub fn topic(&self) -> &str {
+ self.topic.as_deref().unwrap_or(env!("CARGO_PKG_NAME"))
+ }
+}
+
+pub struct NtfyAction<'a> {
+ message_config: &'a MessageConfig,
+ dispatcher: Dispatcher,
+ payload_template: Payload,
+}
+
+impl<'a> NtfyAction<'a> {
+ pub fn new(message_config: &'a MessageConfig, ntfy_config: &'a NtfyConfig) -> Result<Self> {
+ let mut dispatcher_builder = Dispatcher::builder(ntfy_config.url());
+ if let Some(cred) = &ntfy_config.credentials {
+ dispatcher_builder =
+ dispatcher_builder.credentials(Auth::new(&cred.username, &cred.password));
+ }
+ if let Some(proxy) = &ntfy_config.proxy {
+ dispatcher_builder = dispatcher_builder.proxy(proxy);
+ }
+
+ let mut payload = Payload::new(ntfy_config.topic())
+ .markdown(match message_config.format() {
+ MessageFormat::Plain => false,
+ MessageFormat::Markdown => true,
+ MessageFormat::Html => true,
+ })
+ .priority(ntfy_config.priority.clone())
+ .tags(
+ ntfy_config
+ .tags
+ .as_deref()
+ .unwrap_or(&["rotating_light".to_string()]),
+ );
+ if let Some(attach) = &ntfy_config.attach {
+ payload = payload.attach(attach.clone());
+ }
+ if let Some(filename) = &ntfy_config.filename {
+ payload = payload.filename(filename.clone());
+ }
+ if let Some(delay) = &ntfy_config.delay {
+ payload = payload.delay(delay.parse()?);
+ }
+ if let Some(email) = &ntfy_config.email {
+ payload = payload.email(email.clone());
+ }
+ Ok(Self {
+ message_config,
+ dispatcher: dispatcher_builder.build()?,
+ payload_template: payload,
+ })
+ }
+}
+
+#[async_trait]
+impl Action<'_> for NtfyAction<'_> {
+ async fn run(&self, params: Option<&MessageParams<'_, '_>>) -> Result<()> {
+ let payload = self
+ .payload_template
+ .clone()
+ .title(self.message_config.subject(params)?)
+ .message(self.message_config.body(params)?)
+ .click(self.message_config.get_tx_url(params)?.parse()?);
+ self.dispatcher.send(&payload).await?;
+ Ok(())
+ }
+}
diff --git a/src/actions/telegram.rs b/src/actions/telegram.rs
new file mode 100644
index 0000000..b489864
--- /dev/null
+++ b/src/actions/telegram.rs
@@ -0,0 +1,56 @@
+use super::Action;
+use crate::message::MessageConfig;
+use crate::message::MessageParams;
+use anyhow::Result;
+use async_trait::async_trait;
+use serde::Deserialize;
+use teloxide::requests::Requester;
+use teloxide::types::UserId;
+use teloxide::Bot;
+
+#[derive(Deserialize, Debug)]
+pub struct TelegramConfig {
+ bot_token: String,
+ user_id: u64,
+}
+
+impl TelegramConfig {
+ pub fn bot_token(&self) -> &str {
+ &self.bot_token
+ }
+
+ pub fn user_id(&self) -> u64 {
+ self.user_id
+ }
+}
+
+pub struct TelegramAction<'a> {
+ message_config: &'a MessageConfig,
+ bot: Bot,
+ user_id: UserId,
+}
+
+impl<'a> TelegramAction<'a> {
+ pub fn new(
+ message_config: &'a MessageConfig,
+ telegram_config: &'a TelegramConfig,
+ ) -> Result<Self> {
+ Ok(Self {
+ message_config,
+ bot: Bot::new(telegram_config.bot_token()),
+ user_id: UserId(telegram_config.user_id()),
+ })
+ }
+}
+
+#[async_trait]
+impl Action<'_> for TelegramAction<'_> {
+ async fn run(&self, params: Option<&MessageParams<'_, '_>>) -> Result<()> {
+ let subject = self.message_config.subject(params)?;
+ let body = self.message_config.body(params)?;
+ self.bot
+ .send_message(self.user_id, format!("{}\n{}", subject, body))
+ .await?;
+ Ok(())
+ }
+}
diff --git a/src/actions/terminal_print.rs b/src/actions/terminal_print.rs
new file mode 100644
index 0000000..02536c7
--- /dev/null
+++ b/src/actions/terminal_print.rs
@@ -0,0 +1,28 @@
+use super::Action;
+use crate::message::MessageConfig;
+use crate::message::MessageParams;
+use anyhow::Result;
+use async_trait::async_trait;
+
+#[derive(Debug)]
+pub struct TerminalPrintAction<'a> {
+ message_config: &'a MessageConfig,
+}
+
+impl<'a> TerminalPrintAction<'a> {
+ pub fn new(message_config: &'a MessageConfig) -> Self {
+ Self { message_config }
+ }
+}
+
+#[async_trait]
+impl Action<'_> for TerminalPrintAction<'_> {
+ async fn run(&self, params: Option<&MessageParams<'_, '_>>) -> Result<()> {
+ println!(
+ "{}\n{}\n",
+ self.message_config.subject(params)?,
+ self.message_config.body(params)?
+ );
+ Ok(())
+ }
+}
diff --git a/src/blockchain.rs b/src/blockchain.rs
new file mode 100644
index 0000000..2f5014b
--- /dev/null
+++ b/src/blockchain.rs
@@ -0,0 +1,96 @@
+use anyhow::{Context, Result};
+use bdk::{
+ bitcoin::Network,
+ blockchain::{ElectrumBlockchain, GetHeight},
+ electrum_client::{Client, ConfigBuilder, Socks5Config},
+};
+use serde::Deserialize;
+
+fn get_default_electrum_server(network: Network) -> &'static str {
+ match network {
+ Network::Bitcoin => "ssl://fulcrum.sethforprivacy.com:50002",
+ Network::Testnet => "ssl://electrum.blockstream.info:60002",
+ Network::Signet => "ssl://mempool.space:60602",
+ _ => panic!("unsupported network"),
+ }
+}
+
+#[derive(Deserialize, Default, Debug)]
+pub struct ElectrumConfig {
+ url: Option<String>,
+
+ network: Option<Network>,
+
+ socks5: Option<String>,
+
+ #[serde(default)]
+ certificate_validation: bool,
+}
+
+impl ElectrumConfig {
+ pub fn url(&self) -> &str {
+ self.url
+ .as_deref()
+ .unwrap_or(get_default_electrum_server(self.network()))
+ }
+
+ pub fn network(&self) -> Network {
+ self.network.unwrap_or(Network::Bitcoin)
+ }
+
+ pub fn certificate_validation(&self) -> bool {
+ self.certificate_validation
+ }
+
+ pub fn socks5(&self) -> Option<Socks5Config> {
+ self.socks5.as_ref().map(Socks5Config::new)
+ }
+}
+
+pub fn get_blockchain(electrum_cfg: &ElectrumConfig) -> Result<ElectrumBlockchain> {
+ let server_cfg = ConfigBuilder::new()
+ .validate_domain(electrum_cfg.certificate_validation())
+ .socks5(electrum_cfg.socks5())
+ .build();
+ let electrum_url = electrum_cfg.url();
+ let client = Client::from_config(electrum_url, server_cfg)
+ .with_context(|| "could not configure electrum client".to_string())?;
+ Ok(ElectrumBlockchain::from(client))
+}
+
+pub struct BlockchainState {
+ height: Option<u32>,
+ url: String,
+ blockchain: ElectrumBlockchain,
+}
+
+impl BlockchainState {
+ pub fn new(electrum_cfg: &ElectrumConfig) -> Result<Self> {
+ Ok(Self {
+ height: Default::default(),
+ url: String::from(electrum_cfg.url()),
+ blockchain: get_blockchain(electrum_cfg)?,
+ })
+ }
+
+ pub fn update_height(&mut self) {
+ match self.blockchain.get_height() {
+ Ok(polled_height) => {
+ match self.height {
+ Some(h) => {
+ if polled_height != h {
+ self.height = Some(polled_height);
+ debug!("current block height: {}", polled_height);
+ }
+ }
+ None => {
+ self.height = Some(polled_height);
+ info!("connected to '{}'", self.url);
+ info!("current block height: {}", polled_height);
+ }
+ };
+ }
+ Err(e) => warn!("could not reach '{}': {}", self.url, e),
+ };
+ }
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..2f3e5ed
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,143 @@
+use std::{
+ env, fs,
+ path::{Path, PathBuf},
+};
+
+use anyhow::{bail, Context, Result};
+use clap::Parser;
+use const_format::{formatcp, map_ascii_case, Case};
+use serde::Deserialize;
+
+use crate::{
+ actions::AnyActionConfig, blockchain::ElectrumConfig, message::MessageConfig,
+ wallets::WalletConfig,
+};
+
+#[derive(Parser, Debug)]
+#[command(version, about)]
+pub struct Args {
+ /// Path to toml configuration file
+ config: Option<String>,
+ /// Perform configured actions on a test notification
+ #[arg(short, long)]
+ test: bool,
+ /// Notify for every past transaction (careful: if you have a long transaction history, this
+ /// can SPAM your configured actions
+ #[arg(short, long)]
+ notify_past_txs: bool,
+}
+
+impl Args {
+ pub fn config(&self) -> Option<&str> {
+ self.config.as_deref()
+ }
+
+ pub fn test(&self) -> bool {
+ self.test
+ }
+
+ pub fn notify_past_txs(&self) -> bool {
+ self.notify_past_txs
+ }
+}
+
+fn get_config_filename() -> &'static str {
+ formatcp!("{}.toml", env!("CARGO_PKG_NAME"))
+}
+
+fn get_config_env_var() -> &'static str {
+ formatcp!(
+ "{}_CONFIG",
+ map_ascii_case!(Case::Upper, env!("CARGO_PKG_NAME"))
+ )
+}
+
+fn get_cwd_config_path() -> PathBuf {
+ PathBuf::from(".").join(get_config_filename())
+}
+
+fn get_config_path_impl(user_config_dir: &Path) -> PathBuf {
+ user_config_dir
+ .join(env!("CARGO_PKG_NAME"))
+ .join(get_config_filename())
+}
+
+fn get_user_config_path() -> Option<PathBuf> {
+ dirs::config_dir().map(|p| get_config_path_impl(&p))
+}
+
+fn get_system_config_path() -> PathBuf {
+ get_config_path_impl(&systemd_directories::config_dir().unwrap_or(PathBuf::from("/etc")))
+}
+
+fn get_config_path(maybe_arg_config: &Option<&str>) -> Result<PathBuf> {
+ if let Some(arg_path) = maybe_arg_config {
+ return Ok(PathBuf::from(arg_path));
+ }
+
+ if let Ok(env_path) = env::var(get_config_env_var()) {
+ return Ok(PathBuf::from(env_path));
+ }
+
+ let cwd_config_path = get_cwd_config_path();
+ if cwd_config_path.try_exists().is_ok_and(|x| x) {
+ return Ok(cwd_config_path);
+ }
+
+ if let Some(user_config_path) = get_user_config_path() {
+ if user_config_path.try_exists().is_ok_and(|x| x) {
+ return Ok(user_config_path);
+ }
+ }
+
+ let system_config_path = get_system_config_path();
+ if system_config_path.try_exists().is_ok_and(|x| x) {
+ return Ok(system_config_path);
+ }
+
+ bail!(
+ "no configuration file was passed as first argument, nor by the '{}' environment variable, nor did one exist in the default search paths: '{}', '{}', '{}'",
+ get_config_env_var(),
+ get_cwd_config_path().display(),
+ get_user_config_path().unwrap_or_default().display(),
+ get_system_config_path().display()
+ );
+}
+
+#[derive(Deserialize, Debug)]
+pub struct Config {
+ wallets: Vec<WalletConfig>,
+ #[serde(default)]
+ electrum: ElectrumConfig,
+ #[serde(default)]
+ message: MessageConfig,
+ #[serde(default)]
+ actions: Vec<AnyActionConfig>,
+}
+
+impl Config {
+ pub fn electrum(&self) -> &ElectrumConfig {
+ &self.electrum
+ }
+
+ pub fn wallets(&self) -> &[WalletConfig] {
+ &self.wallets
+ }
+
+ pub fn message(&self) -> &MessageConfig {
+ &self.message
+ }
+
+ pub fn actions(&self) -> &[AnyActionConfig] {
+ &self.actions
+ }
+}
+
+pub fn get_config(maybe_arg_config: &Option<&str>) -> Result<Config> {
+ let config_path = get_config_path(maybe_arg_config)?;
+ info!("reading configuration from '{}'", config_path.display());
+ let config_content = fs::read_to_string(&config_path)
+ .with_context(|| format!("could not read config file '{}'", config_path.display()))?;
+ toml::from_str(&config_content)
+ .with_context(|| format!("could not parse config file '{}'", config_path.display(),))
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..9b10935
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,179 @@
+extern crate pretty_env_logger;
+#[macro_use]
+extern crate log;
+
+use std::process::exit;
+use std::time::Duration;
+
+use actions::Action;
+use async_scoped::TokioScope;
+use clap::Parser;
+use human_panic::setup_panic;
+
+use anyhow::{bail, Context, Result};
+use tokio::signal::unix::{signal, SignalKind};
+use tokio::time::sleep;
+
+mod actions;
+mod blockchain;
+mod config;
+mod message;
+mod wallets;
+
+use crate::actions::get_actions;
+use crate::message::MessageParams;
+use crate::{
+ blockchain::BlockchainState,
+ config::{get_config, Args},
+ wallets::{get_wallets, SafeWalletInfo},
+};
+
+fn set_logger() {
+ pretty_env_logger::formatted_builder()
+ .filter_module(env!("CARGO_PKG_NAME"), log::LevelFilter::Info)
+ .parse_default_env()
+ .init();
+}
+
+fn set_signal_handlers() -> Result<()> {
+ tokio::spawn(async move {
+ if let Err(e) = tokio::signal::ctrl_c().await {
+ return e;
+ }
+ warn!("received ctrl-c signal. Exiting...");
+ exit(0)
+ });
+ tokio::spawn(async move {
+ let mut stream = match signal(SignalKind::terminate()) {
+ Err(e) => return e,
+ Ok(s) => s,
+ };
+ stream.recv().await;
+ warn!("received process termination signal. Exiting...");
+ exit(0)
+ });
+ tokio::spawn(async move {
+ let mut stream = match signal(SignalKind::hangup()) {
+ Err(e) => return e,
+ Ok(s) => s,
+ };
+ stream.recv().await;
+ warn!("received process hangup signal. Exiting...");
+ exit(0)
+ });
+ Ok(())
+}
+
+async fn run_test_actions(actions: &[&(dyn Action<'_> + Sync)]) {
+ TokioScope::scope_and_block(|s| {
+ for &action in actions {
+ s.spawn(action.run(None));
+ }
+ });
+}
+
+fn get_and_handle_new_txs(
+ wallet_info: &SafeWalletInfo,
+ actions: &[&(dyn Action<'_> + Sync)],
+) -> Result<()> {
+ let mut locked_wallet_info = wallet_info.lock().unwrap();
+ let txs = locked_wallet_info.get_new_txs();
+ TokioScope::scope_and_block(|s| {
+ for tx in txs.iter() {
+ let params = MessageParams::new(tx, &locked_wallet_info);
+ s.spawn(async move {
+ TokioScope::scope_and_block(|s| {
+ for &action in actions {
+ s.spawn(action.run(Some(&params)));
+ }
+ });
+ });
+ }
+ });
+ Ok(())
+}
+
+async fn update_blockchain_thread(blockchain_state: &mut BlockchainState) {
+ loop {
+ blockchain_state.update_height();
+ sleep(Duration::from_secs(60)).await;
+ }
+}
+
+async fn watch_wallet_thread(wallet_info: &SafeWalletInfo, actions: &[&(dyn Action<'_> + Sync)]) {
+ loop {
+ if let Err(e) = get_and_handle_new_txs(wallet_info, actions) {
+ warn!("{:?}", e);
+ }
+ }
+}
+
+async fn initial_wallet_sync(blockchain_state: &mut BlockchainState, wallets: &[SafeWalletInfo]) {
+ TokioScope::scope_and_block(|s| {
+ s.spawn(async { blockchain_state.update_height() });
+ for wallet_info in wallets {
+ s.spawn(async {
+ if let Err(e) = get_and_handle_new_txs(wallet_info, &[]) {
+ warn!("{:?}", e);
+ }
+ });
+ }
+ });
+}
+
+async fn watch_wallets(
+ blockchain_state: &mut BlockchainState,
+ wallets: &[SafeWalletInfo],
+ actions: &[&(dyn Action<'_> + Sync)],
+) {
+ TokioScope::scope_and_block(|s| {
+ s.spawn(update_blockchain_thread(blockchain_state));
+ for wallet_info in wallets {
+ s.spawn(watch_wallet_thread(wallet_info, actions));
+ }
+ });
+}
+
+async fn do_main() -> Result<()> {
+ setup_panic!();
+ let args = Args::parse();
+ set_logger();
+ set_signal_handlers().context("failed to setup a signal termination handler")?;
+
+ let config = get_config(&args.config())?;
+
+ let actions = get_actions(config.message(), config.actions()).await;
+ if actions.is_empty() {
+ bail!("no actions properly configured");
+ }
+ let actions_ref = actions.iter().map(Box::as_ref).collect::<Vec<_>>();
+
+ if args.test() {
+ run_test_actions(&actions_ref).await;
+ return Ok(());
+ }
+
+ let mut blockchain_state = BlockchainState::new(config.electrum())?;
+
+ let wallets = get_wallets(config.wallets(), config.electrum());
+ if wallets.is_empty() {
+ bail!("no wallets properly configured");
+ }
+
+ if !args.notify_past_txs() {
+ info!("initial wallet sync");
+ initial_wallet_sync(&mut blockchain_state, &wallets).await;
+ }
+ info!("listening for new relevant events");
+ watch_wallets(&mut blockchain_state, &wallets, &actions_ref).await;
+
+ Ok(())
+}
+
+#[tokio::main]
+async fn main() {
+ if let Err(e) = do_main().await {
+ error!("{:?}", e);
+ exit(1);
+ }
+}
diff --git a/src/message.rs b/src/message.rs
new file mode 100644
index 0000000..419bb4d
--- /dev/null
+++ b/src/message.rs
@@ -0,0 +1,203 @@
+extern crate chrono;
+extern crate strfmt;
+
+use anyhow::{bail, Context, Result};
+use bdk::{bitcoin::Network, TransactionDetails};
+use chrono::DateTime;
+use serde::Deserialize;
+use strfmt::strfmt;
+
+use crate::wallets::WalletInfo;
+
+pub struct MessageParams<'a, 'b> {
+ tx: &'a TransactionDetails,
+ wallet: &'b str,
+ total_balance: u64,
+ current_height: u32,
+ network: Network,
+}
+
+impl<'a, 'b> MessageParams<'a, 'b> {
+ pub fn new(tx: &'a TransactionDetails, wallet: &'b WalletInfo) -> Self {
+ Self {
+ tx,
+ wallet: wallet.name(),
+ total_balance: wallet.total_balance().unwrap_or_default(),
+ current_height: wallet.get_height().unwrap_or_default(),
+ network: wallet.get_network(),
+ }
+ }
+
+ fn tx_net(&self) -> i64 {
+ (self.tx.received as i64) - (self.tx.sent as i64)
+ }
+
+ fn tx_height(&self) -> Option<u32> {
+ self.tx.confirmation_time.as_ref().map(|x| x.height)
+ }
+
+ fn confs(&self) -> u32 {
+ let current_height = self.current_height;
+ self.tx_height()
+ .map(|h| {
+ if current_height >= h {
+ current_height - h
+ } else {
+ 0
+ }
+ })
+ .unwrap_or_default()
+ }
+
+ fn conf_timestamp(&self) -> String {
+ self.tx
+ .confirmation_time
+ .as_ref()
+ .map(|x| {
+ DateTime::from_timestamp(x.timestamp as i64, 0)
+ .unwrap_or_default()
+ .format("%Y-%m-%d %H:%M:%S UTC")
+ .to_string()
+ })
+ .unwrap_or_default()
+ }
+
+ fn txid(&self) -> String {
+ self.tx.txid.to_string()
+ }
+ fn txid_short(&self) -> String {
+ let txid = self.txid();
+ format!("{}...{}", &txid[..6], &txid[txid.len() - 6..])
+ }
+
+ fn tx(&self) -> &TransactionDetails {
+ self.tx
+ }
+
+ pub fn network(&self) -> Network {
+ self.network
+ }
+}
+
+#[derive(Deserialize, Debug, PartialEq, Copy, Clone)]
+pub enum MessageFormat {
+ Plain,
+ Markdown,
+ Html,
+}
+
+#[derive(Deserialize, Default, Debug)]
+pub struct BlockExplorers {
+ mainnet: Option<String>,
+ testnet: Option<String>,
+ signet: Option<String>,
+}
+
+impl BlockExplorers {
+ fn mainnet(&self) -> &str {
+ self.mainnet
+ .as_deref()
+ .unwrap_or("https://mempool.space/tx/{txid}")
+ }
+
+ fn testnet(&self) -> &str {
+ self.testnet
+ .as_deref()
+ .unwrap_or("https://mempool.space/testnet/tx/{txid}")
+ }
+
+ fn signet(&self) -> &str {
+ self.signet
+ .as_deref()
+ .unwrap_or("https://mempool.space/signet/tx/{txid}")
+ }
+
+ pub fn get_tx_url_template(&self, network: &Network) -> Result<&str> {
+ Ok(match network {
+ Network::Bitcoin => self.mainnet(),
+ Network::Testnet => self.testnet(),
+ Network::Signet => self.signet(),
+ _ => bail!("unsupported network"),
+ })
+ }
+ pub fn get_tx_url(&self, network: &Network, txid: &str) -> Result<String> {
+ let template = self.get_tx_url_template(network)?;
+ strfmt!(template, txid => txid.to_string())
+ .with_context(|| format!("bad block explorer URL template '{}'", template))
+ }
+}
+
+#[derive(Deserialize, Default, Debug)]
+pub struct MessageConfig {
+ subject: Option<String>,
+ body: Option<String>,
+ format: Option<MessageFormat>,
+ #[serde(default)]
+ block_explorers: BlockExplorers,
+}
+
+impl MessageConfig {
+ pub fn subject_template(&self) -> &str {
+ self.subject
+ .as_deref()
+ .unwrap_or("[{wallet}] new transaction")
+ }
+
+ pub fn body_template(&self) -> &str {
+ self.body
+ .as_deref()
+ .unwrap_or("net: {tx_net} sats, balance: {total_balance} sats, txid: {txid_short}")
+ }
+
+ pub fn replace_template_params(
+ &self,
+ template: &str,
+ params: &MessageParams,
+ ) -> Result<String> {
+ strfmt!(template,
+ tx_net => params.tx_net(),
+ wallet => params.wallet.to_string(),
+ total_balance => params.total_balance,
+ txid => params.txid(),
+ txid_short => params.txid_short(),
+ received => params.tx().received,
+ sent => params.tx().sent,
+ fee => params.tx().fee.unwrap_or_default(),
+ current_height => params.current_height,
+ tx_height => params.tx_height().unwrap_or_default(),
+ confs => params.confs(),
+ conf_timestamp => params.conf_timestamp(),
+ tx_url => self.get_tx_url(Some(params))?
+ )
+ .with_context(|| format!("invalid template '{}'", template))
+ }
+
+ pub fn subject(&self, params: Option<&MessageParams>) -> Result<String> {
+ match params {
+ Some(p) => self.replace_template_params(self.subject_template(), p),
+ None => Ok(self.subject_template().to_string()),
+ }
+ }
+
+ pub fn body(&self, params: Option<&MessageParams>) -> Result<String> {
+ match params {
+ Some(p) => self.replace_template_params(self.body_template(), p),
+ None => Ok(self.body_template().to_string()),
+ }
+ }
+
+ #[allow(dead_code)]
+ pub fn format(&self) -> &MessageFormat {
+ self.format.as_ref().unwrap_or(&MessageFormat::Plain)
+ }
+
+ pub fn get_tx_url(&self, params: Option<&MessageParams>) -> Result<String> {
+ match params {
+ Some(p) => self.block_explorers.get_tx_url(&p.network(), &p.txid()),
+ None => Ok(self
+ .block_explorers
+ .get_tx_url_template(&Network::Bitcoin)?
+ .to_string()),
+ }
+ }
+}
diff --git a/src/wallets.rs b/src/wallets.rs
new file mode 100644
index 0000000..ca43e0a
--- /dev/null
+++ b/src/wallets.rs
@@ -0,0 +1,230 @@
+use std::{
+ collections::{hash_map::DefaultHasher, HashSet},
+ hash::{Hash, Hasher},
+ path::PathBuf,
+ sync::{Arc, Mutex},
+};
+
+use anyhow::{Context, Result};
+use bdk::{
+ bitcoin::{bip32::ExtendedPubKey, Network, Txid},
+ blockchain::{ElectrumBlockchain, GetHeight},
+ sled,
+ template::{Bip44Public, Bip49Public, Bip84Public, Bip86Public},
+ KeychainKind, SyncOptions, TransactionDetails, Wallet,
+};
+use serde::Deserialize;
+
+use crate::blockchain::{get_blockchain, ElectrumConfig};
+
+#[derive(Deserialize, Debug, Clone, Copy)]
+#[serde(rename_all = "snake_case")]
+pub enum AddressKind {
+ Legacy,
+ NestedSegwit,
+ Segwit,
+ Taproot,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct XpubSpec {
+ name: String,
+ xpub: String,
+ kind: Option<AddressKind>,
+}
+
+impl XpubSpec {
+ pub fn kind(&self) -> AddressKind {
+ self.kind.unwrap_or(AddressKind::Segwit)
+ }
+
+ pub fn xpub(&self) -> &str {
+ &self.xpub
+ }
+
+ pub fn name(&self) -> &str {
+ &self.name
+ }
+}
+
+#[derive(Deserialize, Debug, Hash)]
+pub struct DescriptorsSpec {
+ name: String,
+ primary: String,
+ change: Option<String>,
+}
+
+impl DescriptorsSpec {
+ pub fn get_hash(&self) -> String {
+ let mut s = DefaultHasher::new();
+ self.hash(&mut s);
+ s.finish().to_string()
+ }
+
+ pub fn primary(&self) -> &str {
+ &self.primary
+ }
+
+ pub fn change(&self) -> Option<&String> {
+ self.change.as_ref()
+ }
+
+ pub fn name(&self) -> &str {
+ &self.name
+ }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(untagged)]
+pub enum WalletConfig {
+ Xpub(XpubSpec),
+ Descriptors(DescriptorsSpec),
+}
+
+impl WalletConfig {
+ pub fn name(&self) -> &str {
+ match self {
+ WalletConfig::Xpub(xpub_spec) => xpub_spec.name(),
+ WalletConfig::Descriptors(descriptors_spec) => descriptors_spec.name(),
+ }
+ }
+}
+
+fn get_cache_dir(db_name: &str) -> PathBuf {
+ dirs::cache_dir()
+ .unwrap_or(PathBuf::from("cache"))
+ .join(env!("CARGO_PKG_NAME"))
+ .join(db_name)
+}
+
+fn get_xpub_wallet(xpub_spec: &XpubSpec, network: Network) -> Result<Wallet<sled::Tree>> {
+ let xpub: ExtendedPubKey = xpub_spec.xpub().parse().unwrap();
+ let fingerprint = xpub.fingerprint();
+ let sled = sled::open(get_cache_dir(&fingerprint.to_string()))?.open_tree("wallet")?;
+ match xpub_spec.kind() {
+ AddressKind::Legacy => Wallet::new(
+ Bip44Public(xpub, fingerprint, KeychainKind::External),
+ Some(Bip44Public(xpub, fingerprint, KeychainKind::Internal)),
+ network,
+ sled,
+ ),
+ AddressKind::NestedSegwit => Wallet::new(
+ Bip49Public(xpub, fingerprint, KeychainKind::External),
+ Some(Bip49Public(xpub, fingerprint, KeychainKind::Internal)),
+ network,
+ sled,
+ ),
+ AddressKind::Segwit => Wallet::new(
+ Bip84Public(xpub, fingerprint, KeychainKind::External),
+ Some(Bip84Public(xpub, fingerprint, KeychainKind::Internal)),
+ network,
+ sled,
+ ),
+ AddressKind::Taproot => Wallet::new(
+ Bip86Public(xpub, fingerprint, KeychainKind::External),
+ Some(Bip86Public(xpub, fingerprint, KeychainKind::Internal)),
+ network,
+ sled,
+ ),
+ }
+ .with_context(|| format!("invalid xpub wallet '{}'", xpub))
+}
+
+fn get_descriptors_wallet(
+ descriptors_spec: &DescriptorsSpec,
+ network: Network,
+) -> Result<Wallet<sled::Tree>> {
+ let sled = sled::open(get_cache_dir(&descriptors_spec.get_hash()))?.open_tree("wallet")?;
+ Wallet::new(
+ descriptors_spec.primary(),
+ descriptors_spec.change().map(String::as_ref),
+ network,
+ sled,
+ )
+ .with_context(|| format!("invalid descriptor wallet '{:?}'", descriptors_spec))
+}
+
+fn get_wallet(wallet_config: &WalletConfig, network: Network) -> Result<Wallet<sled::Tree>> {
+ match &wallet_config {
+ WalletConfig::Xpub(xpub_spec) => get_xpub_wallet(xpub_spec, network),
+ WalletConfig::Descriptors(descriptors_spec) => {
+ get_descriptors_wallet(descriptors_spec, network)
+ }
+ }
+}
+
+pub struct WalletInfo {
+ name: String,
+ wallet: Wallet<sled::Tree>,
+ old_txs: HashSet<Txid>,
+ blockchain: ElectrumBlockchain,
+}
+
+pub type SafeWalletInfo = Arc<Mutex<WalletInfo>>;
+
+impl WalletInfo {
+ pub fn name(&self) -> &str {
+ &self.name
+ }
+
+ pub fn get_height(&self) -> Result<u32, bdk::Error> {
+ self.blockchain.get_height()
+ }
+
+ pub fn get_network(&self) -> Network {
+ self.wallet.network()
+ }
+
+ pub fn total_balance(&self) -> Result<u64, bdk::Error> {
+ self.wallet.get_balance().map(|b| b.get_total())
+ }
+
+ pub fn get_new_txs(&mut self) -> Vec<TransactionDetails> {
+ debug!("[{}] syncing wallet", self.name);
+ if let Err(e) = self.wallet.sync(&self.blockchain, SyncOptions::default()) {
+ warn!("[{}] cannot sync wallet: {}", self.name, e);
+ return Default::default();
+ }
+ let tx_list = match self.wallet.list_transactions(false) {
+ Ok(txs) => txs,
+ Err(e) => {
+ warn!("[{}] cannot retrieve transactions: {}", self.name, e);
+ Default::default()
+ }
+ };
+
+ let new_txs: Vec<TransactionDetails> = tx_list
+ .iter()
+ .filter(|&tx| !self.old_txs.contains(&tx.txid))
+ .cloned()
+ .collect();
+ new_txs.iter().for_each(|tx| {
+ self.old_txs.insert(tx.txid);
+ });
+ new_txs
+ }
+}
+
+pub fn get_wallets(
+ wallet_configs: &[WalletConfig],
+ electrum_cfg: &ElectrumConfig,
+) -> Vec<SafeWalletInfo> {
+ let mut result: Vec<SafeWalletInfo> = vec![];
+ for wallet_config in wallet_configs.iter() {
+ let name = wallet_config.name();
+ match get_wallet(wallet_config, electrum_cfg.network()) {
+ Ok(w) => {
+ result.push(Arc::new(Mutex::new(WalletInfo {
+ name: name.to_string(),
+ wallet: w,
+ old_txs: Default::default(),
+ blockchain: get_blockchain(electrum_cfg).unwrap(),
+ })));
+ }
+ Err(e) => {
+ error!("[{}] cannot setup wallet: {}", name, e);
+ }
+ }
+ }
+ result
+}