diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/actions/command.rs | 61 | ||||
| -rw-r--r-- | src/actions/desktop_notification.rs | 28 | ||||
| -rw-r--r-- | src/actions/email.rs | 142 | ||||
| -rw-r--r-- | src/actions/mod.rs | 120 | ||||
| -rw-r--r-- | src/actions/nostr.rs | 192 | ||||
| -rw-r--r-- | src/actions/ntfy.rs | 119 | ||||
| -rw-r--r-- | src/actions/telegram.rs | 56 | ||||
| -rw-r--r-- | src/actions/terminal_print.rs | 28 | ||||
| -rw-r--r-- | src/blockchain.rs | 96 | ||||
| -rw-r--r-- | src/config.rs | 143 | ||||
| -rw-r--r-- | src/main.rs | 179 | ||||
| -rw-r--r-- | src/message.rs | 203 | ||||
| -rw-r--r-- | src/wallets.rs | 230 | 
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(¶ms))); +                    } +                }); +            }); +        } +    }); +    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 +} | 
