mostro 0.18.0

Lightning Network peer-to-peer nostr platform
pub mod app;
mod bitcoin_price;
pub mod cli;
pub mod config;
pub mod db;
pub mod flow;
pub mod lightning;
pub mod lnurl;
pub mod messages;
pub mod nip33;
pub mod price;
pub mod rpc;
pub mod scheduler;
pub mod spam_gate;
pub mod util;

use crate::app::context::AppContext;
use crate::app::run;
use crate::cli::settings_init;
use crate::config::{
    get_db_pool, Settings, DB_POOL, LN_STATUS, MESSAGE_QUEUES, MOSTRO_CONFIG, NOSTR_CLIENT,
};
use crate::db::find_held_invoices;
use crate::lightning::LnStatus;
use crate::lightning::LndConnector;
use crate::rpc::RpcServer;
use nostr_sdk::prelude::*;
use scheduler::start_scheduler;
use std::env;
use std::process::exit;
use std::sync::Arc;
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
use util::{get_nostr_client, invoice_subscribe};

#[tokio::main]
async fn main() -> Result<()> {
    // Clear screen
    clearscreen::clear().expect("Failed to clear screen");

    if cfg!(debug_assertions) {
        // Debug, show all error + mostro logs
        env::set_var("RUST_LOG", "error,mostro=info");
    } else {
        // Release, show only mostro logs
        env::set_var("RUST_LOG", "none,mostro=info");
    }

    // Tracing using RUST_LOG
    tracing_subscriber::registry()
        .with(fmt::layer())
        .with(EnvFilter::from_default_env())
        .init();

    // Init MOSTRO_SETTINGS oncelock with all settings variables from TOML file
    settings_init()?;

    // Build and install the multi-source price manager (spec §9 Phase 1).
    // Done immediately after settings load so every later subsystem
    // (scheduler, util::get_bitcoin_price, RPC) can read prices through it.
    install_price_manager()?;

    // Connect to database
    if DB_POOL.set(db::connect().await?).is_err() {
        tracing::error!("No connection to database - closing Mostro!");
        exit(1);
    };

    // Connect to relays
    if NOSTR_CLIENT.set(util::connect_nostr().await?).is_err() {
        tracing::error!("No connection to nostr relay - closing Mostro!");
        exit(1);
    };

    // Get mostro keys
    let mostro_keys = util::get_keys()?;

    // Subscribe only to the configured transport's kind: 1059 (protocol v1
    // gift wrap) or 14 (protocol v2 NIP-44 direct). See docs/TRANSPORT_V2_SPEC.md.
    // DEPRECATED(v0.19.0, #786): the `transport` knob disappears in v0.19.0
    // and this subscription becomes unconditionally kind 14.
    #[allow(deprecated)]
    let transport = Settings::get_mostro().transport;
    tracing::info!(
        "Transport: {} (protocol v{}, event kind {})",
        transport,
        transport.protocol_version(),
        transport.event_kind().as_u16()
    );
    #[allow(deprecated)]
    if transport == mostro_core::transport::Transport::GiftWrap {
        tracing::warn!(
            "transport = \"gift-wrap\" (protocol v1) is DEPRECATED and will be removed in \
             v0.19.0; mostrod will then run protocol v2 (transport = \"nip44\") only. Switch \
             once the clients your community uses support protocol v2. \
             See https://github.com/MostroP2P/mostro/issues/786"
        );
    }
    let subscription = Filter::new()
        .pubkey(mostro_keys.public_key())
        .kind(transport.event_kind())
        .limit(0);

    let client = match get_nostr_client() {
        Ok(client) => client,
        Err(e) => {
            tracing::error!("Failed to initialize Nostr client. Cannot proceed: {e}");
            // Clean up any resources if needed
            exit(1)
        }
    };

    // Client subscription
    client.subscribe(subscription, None).await?;

    // Publish NIP-01 kind 0 metadata event
    let mostro_settings = Settings::get_mostro();
    let mut has_metadata = false;
    let mut metadata = nostr_sdk::Metadata::new();

    if let Some(ref name) = mostro_settings.name {
        metadata = metadata.name(name);
        has_metadata = true;
    }
    if let Some(ref about) = mostro_settings.about {
        metadata = metadata.about(about);
        has_metadata = true;
    }
    if let Some(ref picture) = mostro_settings.picture {
        if let Ok(url) = nostr_sdk::Url::parse(picture) {
            metadata = metadata.picture(url);
            has_metadata = true;
        } else {
            tracing::warn!("Invalid picture URL in settings: {}", picture);
        }
    }
    if let Some(ref website) = mostro_settings.website {
        if let Ok(url) = nostr_sdk::Url::parse(website) {
            metadata = metadata.website(url);
            has_metadata = true;
        } else {
            tracing::warn!("Invalid website URL in settings: {}", website);
        }
    }

    if has_metadata {
        if let Ok(metadata_ev) = EventBuilder::metadata(&metadata).sign_with_keys(&mostro_keys) {
            let _ = client.send_event(&metadata_ev).await;
            tracing::info!("Published NIP-01 kind 0 metadata event");
        }
    }

    let mut ln_client = LndConnector::new().await?;
    let ln_status = ln_client.get_node_info().await?;
    let ln_status = LnStatus::from_get_info_response(ln_status);
    if LN_STATUS.set(ln_status).is_err() {
        panic!("No connection to LND node - shutting down Mostro!");
    };

    if let Ok(held_invoices) = find_held_invoices(get_db_pool().as_ref()).await {
        for invoice in held_invoices.iter() {
            if let Some(hash) = &invoice.hash {
                tracing::info!("Resubscribing order id - {}", invoice.id);
                if let Err(e) = invoice_subscribe(hash.as_bytes().to_vec(), None).await {
                    tracing::error!("Ln node error {e}")
                }
            }
        }
    }

    // Resubscribe to any in-flight anti-abuse bond hold invoices so a
    // restart doesn't strand a taker who paid the bond just before the
    // daemon went down. Inert when the feature is disabled.
    let bond_pool = get_db_pool();
    if let Err(e) = app::bond::resubscribe_active_bonds(&bond_pool).await {
        tracing::warn!("Failed to resubscribe active bonds: {e}");
    }

    // Start RPC server if enabled
    if RpcServer::is_enabled() {
        let rpc_server = RpcServer::new();
        let rpc_keys = mostro_keys.clone();
        let rpc_pool = get_db_pool();
        let rpc_ln_client = Arc::new(tokio::sync::Mutex::new(ln_client.clone()));

        tokio::spawn(async move {
            match rpc_server.start(rpc_keys, rpc_pool, rpc_ln_client).await {
                Ok(_) => tracing::info!("RPC server started successfully"),
                Err(e) => tracing::error!("RPC server failed to start: {}", e),
            }
        });
    }

    // Install the protocol-v2 anti-spam gate and warm its active-trade-pubkey
    // cache before the event loop starts, so the very first kind-14 events are
    // already pre-filtered against known keys (spec §6 Phase 2). The cache is
    // kept fresh afterwards by `job_refresh_active_pubkeys`. Inert on the v1
    // (gift-wrap) transport, which never consults the gate.
    {
        use crate::spam_gate::{SpamGate, REPLAY_WINDOW_SECS};
        let gate = SpamGate::new(REPLAY_WINDOW_SECS);
        match db::find_active_trade_pubkeys(get_db_pool().as_ref()).await {
            Ok(keys) => {
                tracing::info!(
                    "SpamGate: warming active-trade-pubkey cache ({} keys)",
                    keys.len()
                );
                gate.set_known(keys);
            }
            Err(e) => tracing::warn!("SpamGate: initial cache warm failed: {e}"),
        }
        if gate.install_global().is_err() {
            tracing::warn!("SpamGate already installed");
        }
    }

    // Build AppContext explicitly with all dependencies
    let settings = Arc::new(
        MOSTRO_CONFIG
            .get()
            .expect("MOSTRO_CONFIG not initialized")
            .clone(),
    );
    let ctx = AppContext::new(
        get_db_pool(),
        client.clone(),
        settings,
        MESSAGE_QUEUES.queue_order_msg.clone(),
        mostro_keys.clone(),
    );

    // Start scheduler for tasks
    start_scheduler(ctx.clone()).await;

    // Run the Mostro and be happy!!
    run(ctx, &mut ln_client).await
}

/// Build the multi-source [`crate::price::PriceManager`] from settings and
/// install it as the process-wide global. When `[price]` is absent in the
/// settings file we synthesise it from the legacy `[mostro]` keys
/// (`bitcoin_price_api_url`, `exchange_rates_update_interval_seconds`,
/// `publish_exchange_rates_to_nostr`) so existing `settings.toml` files keep
/// working byte-for-byte (spec §10.1).
fn install_price_manager() -> std::result::Result<(), Box<dyn std::error::Error>> {
    use crate::price::{synthesise_legacy_price_settings, PriceManager};

    let mostro_settings = Settings::get_mostro();
    let price_settings = match Settings::get_price() {
        Some(p) => {
            // Multi-source mode: the `[price.providers.*]` tables drive
            // aggregation, so the legacy `[mostro].bitcoin_price_api_url` is
            // not consulted here. Surface that explicitly so an operator who
            // still has the legacy key set isn't misled into thinking it
            // takes effect — name the providers actually in play instead.
            let mut enabled: Vec<&str> = p
                .providers
                .iter()
                .filter(|(_, cfg)| cfg.enabled)
                .map(|(id, _)| id.as_str())
                .collect();
            enabled.sort_unstable();
            let enabled = if enabled.is_empty() {
                "<none>".to_string()
            } else {
                enabled.join(", ")
            };
            tracing::warn!(
                "price: legacy `bitcoin_price_api_url` = \"{}\" is ignored for price \
                 aggregation because `[price]` is configured; using enabled providers: {}",
                mostro_settings.bitcoin_price_api_url,
                enabled,
            );
            p.clone()
        }
        None => synthesise_legacy_price_settings(
            &mostro_settings.bitcoin_price_api_url,
            mostro_settings.exchange_rates_update_interval_seconds,
            mostro_settings.publish_exchange_rates_to_nostr,
        ),
    };

    let manager = PriceManager::from_settings(price_settings)
        .map_err(|e| -> Box<dyn std::error::Error> { format!("price: {e}").into() })?;
    manager
        .install_global()
        .map_err(|e| -> Box<dyn std::error::Error> { format!("price: {e}").into() })?;
    tracing::info!("PriceManager installed");
    Ok(())
}

#[cfg(test)]
mod tests {
    use mostro_core::message::Message;
    use std::time::{SystemTime, UNIX_EPOCH};

    #[test]
    fn test_message_deserialize_serialize() {
        let sample_message = r#"{"order":{"version":1,"request_id":1,"trade_index":null,"id":"7dd204d2-d06c-4406-a3d9-4415f4a8b9c9","action":"fiat-sent","payload":null}}"#;
        let message = Message::from_json(sample_message).unwrap();
        assert!(message.verify());
        let json_message = message.as_json().unwrap();
        assert_eq!(sample_message, json_message);
    }

    #[test]
    fn test_wrong_message_should_fail() {
        let sample_message = r#"{"order":{"version":1,"request_id":1,"action":"take-sell","payload":{"order":{"kind":"sell","status":"pending","amount":100,"fiat_code":"XXX","fiat_amount":10,"payment_method":"SEPA","premium":1,"buyer_invoice":null,"created_at":1640839235}}}}"#;
        let message = Message::from_json(sample_message).unwrap();
        assert!(!message.verify());
    }

    #[test]
    fn test_fee_rounding() {
        let fee = 0.003 / 2.0;

        let mut amt = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .subsec_micros() as i64;

        // Test 1000 "random" amounts
        for _i in 1..=1000 {
            let fee_calculated = fee * amt as f64;
            let rounded_fee = fee_calculated.round();
            // Seller side
            let seller_total_amt = rounded_fee as i64 + amt;
            assert_eq!(amt, seller_total_amt - rounded_fee as i64);
            // Buyer side

            let buyer_total_amt = amt - rounded_fee as i64;
            assert_eq!(amt, buyer_total_amt + rounded_fee as i64);

            let nonce = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .subsec_millis() as i64;

            amt %= 100_000_i64;
            amt *= (rounded_fee as i64) % 100_i64;
            amt += nonce;
        }
    }

    #[test]
    fn test_debug_log_level_setting() {
        // Test the logical flow of log level setting
        // We can't test the actual environment variable setting since main() has already run

        let debug_log_setting = if cfg!(debug_assertions) {
            "error,mostro=info"
        } else {
            "none,mostro=info"
        };

        // Verify the log settings are correctly defined
        assert!(!debug_log_setting.is_empty());
        assert!(debug_log_setting.contains("mostro=info"));

        if cfg!(debug_assertions) {
            assert!(debug_log_setting.contains("error"));
        } else {
            assert!(debug_log_setting.contains("none"));
        }
    }
}