diff --git a/Cargo.lock b/Cargo.lock index a10227d..6e87ff0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,24 +109,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "argon2" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" -dependencies = [ - "base64ct", - "blake2", - "cpufeatures", - "password-hash", -] - -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - [[package]] name = "arrayvec" version = "0.7.6" @@ -241,10 +223,10 @@ dependencies = [ "base58ck", "bech32", "bitcoin-internals 0.3.0", - "bitcoin-io 0.1.4", + "bitcoin-io", "bitcoin-units", "bitcoin_hashes 0.14.1", - "hex-conservative 0.2.2", + "hex-conservative", "hex_lit", "secp256k1", "serde", @@ -268,12 +250,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bitcoin-internals" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90bbbfa552b49101a230fb2668f3f9ef968c81e6f83cf577e1d4b80f689e1aa" - [[package]] name = "bitcoin-internals" version = "0.5.0" @@ -286,15 +262,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" -[[package]] -name = "bitcoin-io" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26792cd2bf245069a1c5acb06aa7ad7abe1de69b507c90b490bca81e0665d0ee" -dependencies = [ - "bitcoin-internals 0.4.2", -] - [[package]] name = "bitcoin-units" version = "0.1.2" @@ -311,21 +278,11 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" dependencies = [ - "bitcoin-io 0.1.4", - "hex-conservative 0.2.2", + "bitcoin-io", + "hex-conservative", "serde", ] -[[package]] -name = "bitcoin_hashes" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e5d09f16329cd545d7e6008b2c6b2af3a90bc678cf41ac3d2f6755943301b16" -dependencies = [ - "bitcoin-io 0.2.0", - "hex-conservative 0.3.2", -] - [[package]] name = "bitcoin_hashes" version = "0.20.0" @@ -345,29 +302,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "blake2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest", -] - -[[package]] -name = "blake3" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" -dependencies = [ - "arrayref", - "arrayvec", - "cc", - "cfg-if", - "constant_time_eq", - "cpufeatures", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -555,12 +489,6 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "constant_time_eq" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1062,15 +990,6 @@ dependencies = [ "arrayvec", ] -[[package]] -name = "hex-conservative" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" -dependencies = [ - "arrayvec", -] - [[package]] name = "hex_lit" version = "0.1.1" @@ -1631,25 +1550,17 @@ dependencies = [ [[package]] name = "mostro-core" -version = "0.6.57" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c030ffbe3f2a86cbf32c0b608bae1f86e67de323762969c6f3c0e1ebe13aee7" +checksum = "47931c8de17481b95e05f2cac0ac8a077247a040631a929bbae656bfb48fc4e2" dependencies = [ - "argon2", - "base64", "bitcoin", - "bitcoin_hashes 0.16.0", - "blake3", - "chacha20poly1305", "chrono", "nostr-sdk", - "rand 0.9.2", - "secrecy", "serde", "serde_json", "uuid", "wasm-bindgen", - "zeroize", ] [[package]] @@ -1660,9 +1571,9 @@ checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" [[package]] name = "nostr" -version = "0.43.1" +version = "0.44.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62a97d745f1bd8d5e05a978632bbb87b0614567d5142906fe7c86fb2440faac6" +checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1" dependencies = [ "base64", "bech32", @@ -1672,6 +1583,7 @@ dependencies = [ "chacha20", "chacha20poly1305", "getrandom 0.2.17", + "hex", "instant", "scrypt", "secp256k1", @@ -1683,24 +1595,34 @@ dependencies = [ [[package]] name = "nostr-database" -version = "0.43.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c75a8c2175d2785ba73cfddef21d1e30da5fbbdf158569b6808ba44973a15b" +checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" dependencies = [ "lru", "nostr", "tokio", ] +[[package]] +name = "nostr-gossip" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +dependencies = [ + "nostr", +] + [[package]] name = "nostr-relay-pool" -version = "0.43.1" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b2f43b70d13dfc50508a13cd902e11f4625312b2ce0e4b7c4c2283fd04001bd" +checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b" dependencies = [ "async-utility", "async-wsocket", "atomic-destructor", + "hex", "lru", "negentropy", "nostr", @@ -1711,15 +1633,17 @@ dependencies = [ [[package]] name = "nostr-sdk" -version = "0.43.0" +version = "0.44.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "599f8963d6a1522a13b1a2b0ea6e168acfc367706606f1d33fa595e91fa22db0" +checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" dependencies = [ "async-utility", "nostr", "nostr-database", + "nostr-gossip", "nostr-relay-pool", "tokio", + "tracing", ] [[package]] @@ -2408,15 +2332,6 @@ dependencies = [ "cc", ] -[[package]] -name = "secrecy" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" -dependencies = [ - "zeroize", -] - [[package]] name = "semver" version = "1.0.27" diff --git a/Cargo.toml b/Cargo.toml index 2bff8be..ce6ae4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.99" clap = { version = "4.5.46", features = ["derive"] } -nostr-sdk = { version = "0.43.0", features = [ +nostr-sdk = { version = "0.44.1", features = [ "nip06", "nip44", "nip59", @@ -46,7 +46,7 @@ reqwest = { version = "0.12.23", default-features = false, features = [ "json", "rustls-tls", ] } -mostro-core = "0.6.56" +mostro-core = "0.9.1" lnurl-rs = { version = "0.9.0", default-features = false, features = ["ureq"] } pretty_env_logger = "0.5.0" sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio-rustls"] } diff --git a/src/cli/add_invoice.rs b/src/cli/add_invoice.rs index 76dc784..2bce349 100644 --- a/src/cli/add_invoice.rs +++ b/src/cli/add_invoice.rs @@ -75,7 +75,6 @@ pub async fn execute_add_invoice(order_id: &Uuid, invoice: &str, ctx: &Context) // Send the DM let sent_message = send_dm( &ctx.client, - Some(&ctx.identity_keys), &order_trade_keys, &ctx.mostro_pubkey, message_json, diff --git a/src/cli/adm_send_dm.rs b/src/cli/adm_send_dm.rs index 9e85d31..bfb56de 100644 --- a/src/cli/adm_send_dm.rs +++ b/src/cli/adm_send_dm.rs @@ -3,7 +3,7 @@ use crate::parser::common::{ create_emoji_field_row, create_field_value_header, create_standard_table, }; use crate::util::messaging::get_admin_keys; -use crate::util::send_admin_gift_wrap_dm; +use crate::util::send_plain_text_dm; use anyhow::Result; use nostr_sdk::prelude::*; @@ -29,7 +29,7 @@ pub async fn execute_adm_send_dm(receiver: PublicKey, ctx: &Context, message: &s println!("{table}"); println!("💡 Sending admin gift wrap message...\n"); - send_admin_gift_wrap_dm(&ctx.client, admin_keys, &receiver, message).await?; + send_plain_text_dm(&ctx.client, admin_keys, &receiver, message).await?; println!( "✅ Admin gift wrap message sent successfully to {}", diff --git a/src/cli/last_trade_index.rs b/src/cli/last_trade_index.rs index d69fd96..e61e734 100644 --- a/src/cli/last_trade_index.rs +++ b/src/cli/last_trade_index.rs @@ -23,11 +23,13 @@ pub async fn execute_last_trade_index( .as_json() .map_err(|_| anyhow::anyhow!("Failed to serialize message"))?; - // Send the last trade index message to Mostro server + // LastTradeIndex is account-scoped: the answer depends on which user + // is asking, and Mostro looks that up by the sender pubkey. Sign with + // `identity_keys` so the request resolves to the account, not to a + // (possibly unregistered) trade key. let sent_message = send_dm( &ctx.client, - Some(identity_keys), - &ctx.trade_keys, + identity_keys, &mostro_key, message_json, None, @@ -42,10 +44,10 @@ pub async fn execute_last_trade_index( println!(); // Wait for incoming DM - let recv_event = wait_for_dm(ctx, Some(&ctx.trade_keys), sent_message).await?; + let recv_event = wait_for_dm(ctx, Some(identity_keys), sent_message).await?; // Parse the incoming DM - let messages = parse_dm_events(recv_event, &ctx.trade_keys, None).await; + let messages = parse_dm_events(recv_event, identity_keys, None).await; if let Some((message, _, _)) = messages.first() { let message = message.get_inner_message_kind(); if message.action == Action::LastTradeIndex { diff --git a/src/cli/new_order.rs b/src/cli/new_order.rs index 5f32cf7..7cb5402 100644 --- a/src/cli/new_order.rs +++ b/src/cli/new_order.rs @@ -183,7 +183,6 @@ pub async fn execute_new_order( // Send the DM let sent_message = send_dm( &ctx.client, - Some(&ctx.identity_keys), &ctx.trade_keys, &ctx.mostro_pubkey, message_json, diff --git a/src/cli/orders_info.rs b/src/cli/orders_info.rs index 905af0b..9abd13a 100644 --- a/src/cli/orders_info.rs +++ b/src/cli/orders_info.rs @@ -44,7 +44,6 @@ pub async fn execute_orders_info(order_ids: &[Uuid], ctx: &Context) -> Result<() // Send the DM let sent_message = send_dm( &ctx.client, - Some(&ctx.identity_keys), &ctx.trade_keys, &ctx.mostro_pubkey, message_json, diff --git a/src/cli/rate_user.rs b/src/cli/rate_user.rs index deb00b6..8f1f41f 100644 --- a/src/cli/rate_user.rs +++ b/src/cli/rate_user.rs @@ -60,7 +60,6 @@ pub async fn execute_rate_user(order_id: &Uuid, rating: &u8, ctx: &Context) -> R let sent_message = send_dm( &ctx.client, - Some(&ctx.identity_keys), &trade_keys, &ctx.mostro_pubkey, rate_message, diff --git a/src/cli/restore.rs b/src/cli/restore.rs index 393f7e2..cf61a69 100644 --- a/src/cli/restore.rs +++ b/src/cli/restore.rs @@ -19,11 +19,13 @@ pub async fn execute_restore( .as_json() .map_err(|_| anyhow::anyhow!("Failed to serialize message"))?; - // Send the restore message to Mostro server + // Restore is account-scoped: Mostro indexes users by their identity + // pubkey, so the whole exchange (send, wait, decrypt) runs on + // `identity_keys` — an unregistered trade key would look like an + // unknown user and recovery would silently return nothing. let sent_message = send_dm( &ctx.client, - Some(identity_keys), - &ctx.trade_keys, + identity_keys, &mostro_key, message_json, None, @@ -49,10 +51,10 @@ pub async fn execute_restore( println!("⏳ Recovering pending orders and disputes...\n"); // Wait for incoming DM - let recv_event = wait_for_dm(ctx, Some(&ctx.trade_keys), sent_message).await?; + let recv_event = wait_for_dm(ctx, Some(identity_keys), sent_message).await?; // Parse the incoming DM - let messages = parse_dm_events(recv_event, &ctx.trade_keys, None).await; + let messages = parse_dm_events(recv_event, identity_keys, None).await; if let Some((message, _, _)) = messages.first() { let message = message.get_inner_message_kind(); if message.action == Action::RestoreSession { diff --git a/src/cli/send_admin_dm_attach.rs b/src/cli/send_admin_dm_attach.rs index 5307b5f..d968c87 100644 --- a/src/cli/send_admin_dm_attach.rs +++ b/src/cli/send_admin_dm_attach.rs @@ -82,7 +82,7 @@ async fn upload_to_blossom(trade_keys: &Keys, encrypted_blob: Vec) -> Result .collect::(); // Expiration: 1 hour from now (BUD-01 requires expiration in the future) - let expiration = Timestamp::from(Timestamp::now().as_u64() + 3600); + let expiration = Timestamp::from_secs(Timestamp::now().as_secs() + 3600); for server in BLOSSOM_SERVERS { let url_str = format!("{}/upload", server.trim_end_matches('/')); diff --git a/src/cli/send_dm.rs b/src/cli/send_dm.rs index ec78378..9742868 100644 --- a/src/cli/send_dm.rs +++ b/src/cli/send_dm.rs @@ -53,16 +53,7 @@ pub async fn execute_send_dm( return Err(anyhow::anyhow!("order {} not found", order_id)); }; - send_dm( - &ctx.client, - Some(&trade_keys), - &trade_keys, - &receiver, - message, - None, - false, - ) - .await?; + send_dm(&ctx.client, &trade_keys, &receiver, message, None, false).await?; println!("✅ Direct message sent successfully!"); diff --git a/src/cli/send_msg.rs b/src/cli/send_msg.rs index eacd3cb..32acd64 100644 --- a/src/cli/send_msg.rs +++ b/src/cli/send_msg.rs @@ -99,7 +99,6 @@ pub async fn execute_send_msg( // Send DM let sent_message = send_dm( &ctx.client, - Some(&ctx.identity_keys), &trade_keys, &ctx.mostro_pubkey, message_json, diff --git a/src/cli/take_dispute.rs b/src/cli/take_dispute.rs index 5600286..68bcaf9 100644 --- a/src/cli/take_dispute.rs +++ b/src/cli/take_dispute.rs @@ -139,11 +139,11 @@ pub async fn execute_take_dispute(dispute_id: &Uuid, ctx: &Context) -> Result<() .as_json() .map_err(|_| anyhow::anyhow!("Failed to serialize message"))?; - // Send the dispute message and wait for response + // Send the dispute message and wait for response. Admin identity + // binds via the rumor/seal/inner-signature produced from `admin_keys`. let sent_message = send_dm( &ctx.client, - Some(admin_keys), - &ctx.trade_keys, + admin_keys, &ctx.mostro_pubkey, take_dispute_message, None, diff --git a/src/cli/take_order.rs b/src/cli/take_order.rs index e9f80bd..a54d780 100644 --- a/src/cli/take_order.rs +++ b/src/cli/take_order.rs @@ -124,7 +124,6 @@ pub async fn execute_take_order( // This is so we can wait for the gift wrap event in the main thread let sent_message = send_dm( &ctx.client, - Some(&ctx.identity_keys), &ctx.trade_keys, &ctx.mostro_pubkey, message_json, diff --git a/src/db.rs b/src/db.rs index 0305d9b..d7a3725 100644 --- a/src/db.rs +++ b/src/db.rs @@ -138,7 +138,7 @@ pub struct User { impl User { pub async fn new(mnemonic: String, pool: &SqlitePool) -> Result { let mut user = User::default(); - let account = NOSTR_REPLACEABLE_EVENT_KIND as u32; + let account = NOSTR_ORDER_EVENT_KIND as u32; let i0_keys = Keys::from_mnemonic_advanced(&mnemonic, None, Some(account), Some(0), Some(0))?; user.i0_pubkey = i0_keys.public_key().to_string(); @@ -216,7 +216,7 @@ impl User { pub async fn get_identity_keys(pool: &SqlitePool) -> Result { let user = User::get(pool).await?; - let account = NOSTR_REPLACEABLE_EVENT_KIND as u32; + let account = NOSTR_ORDER_EVENT_KIND as u32; let keys = Keys::from_mnemonic_advanced(&user.mnemonic, None, Some(account), Some(0), Some(0))?; @@ -235,7 +235,7 @@ impl User { return Err(anyhow::anyhow!("Trade index cannot be negative")); } let user = User::get(pool).await?; - let account = NOSTR_REPLACEABLE_EVENT_KIND as u32; + let account = NOSTR_ORDER_EVENT_KIND as u32; let keys = Keys::from_mnemonic_advanced( &user.mnemonic, None, diff --git a/src/parser/disputes.rs b/src/parser/disputes.rs index 8a0fbae..1859d17 100644 --- a/src/parser/disputes.rs +++ b/src/parser/disputes.rs @@ -20,7 +20,7 @@ pub fn parse_dispute_events(events: Events) -> Vec { if let Ok(mut dispute) = dispute_from_tags(event.tags) { info!("Found Dispute id : {:?}", dispute.id); // Get created at field from Nostr event - dispute.created_at = event.created_at.as_u64() as i64; + dispute.created_at = event.created_at.as_secs() as i64; disputes_list.push(dispute.clone()); } } diff --git a/src/parser/dms.rs b/src/parser/dms.rs index 9bd29af..fdbb0db 100644 --- a/src/parser/dms.rs +++ b/src/parser/dms.rs @@ -756,35 +756,14 @@ pub async fn parse_dm_events( } let (created_at, message, sender) = match dm.kind { - nostr_sdk::Kind::GiftWrap => { - let unwrapped_gift = match nip59::extract_rumor(pubkey, dm).await { - Ok(u) => u, - Err(e) => { - eprintln!( - "Warning: Could not decrypt gift wrap (event {}): {}", - dm.id, e - ); - continue; - } - }; - let (message, _): (Message, Option) = - match serde_json::from_str(&unwrapped_gift.rumor.content) { - Ok(msg) => msg, - Err(e) => { - eprintln!( - "Warning: Could not parse message content (event {}): {}", - dm.id, e - ); - continue; - } - }; - - ( - unwrapped_gift.rumor.created_at, - message, - unwrapped_gift.sender, - ) - } + nostr_sdk::Kind::GiftWrap => match unwrap_message(dm, pubkey).await { + Ok(Some(u)) => (u.created_at, u.message, u.sender), + Ok(None) => continue, // outer NIP-44 failed → not addressed to us + Err(e) => { + eprintln!("Warning: could not unwrap gift wrap (event {}): {e}", dm.id); + continue; + } + }, nostr_sdk::Kind::PrivateDirectMessage => { let ck = if let Ok(ck) = ConversationKey::derive(pubkey.secret_key(), &dm.pubkey) { ck @@ -828,11 +807,11 @@ pub async fn parse_dm_events( .unwrap() .timestamp() as u64; - if created_at.as_u64() < since_time { + if created_at.as_secs() < since_time { continue; } } - direct_messages.push((message, created_at.as_u64(), sender)); + direct_messages.push((message, created_at.as_secs(), sender)); } direct_messages.sort_by(|a, b| a.1.cmp(&b.1)); direct_messages diff --git a/src/parser/orders.rs b/src/parser/orders.rs index 3665103..0ff001c 100644 --- a/src/parser/orders.rs +++ b/src/parser/orders.rs @@ -45,7 +45,7 @@ pub fn parse_orders_events( continue; } // Set created at - order.created_at = Some(event.created_at.as_u64() as i64); + order.created_at = Some(event.created_at.as_secs() as i64); // Update latest order by id latest_by_id .entry(order_id) diff --git a/src/util/events.rs b/src/util/events.rs index e31587b..d6f0006 100644 --- a/src/util/events.rs +++ b/src/util/events.rs @@ -30,7 +30,7 @@ fn create_seven_days_filter(letter: Alphabet, value: String, pubkey: PublicKey) .limit(50) .since(timestamp) .custom_tag(SingleLetterTag::lowercase(letter), value) - .kind(nostr_sdk::Kind::Custom(NOSTR_REPLACEABLE_EVENT_KIND))) + .kind(nostr_sdk::Kind::Custom(NOSTR_ORDER_EVENT_KIND))) } pub fn create_filter( diff --git a/src/util/messaging.rs b/src/util/messaging.rs index f94a48f..80ec448 100644 --- a/src/util/messaging.rs +++ b/src/util/messaging.rs @@ -1,7 +1,6 @@ -use anyhow::{Error, Result}; +use anyhow::Result; use base64::engine::general_purpose; use base64::Engine; -use log::info; use mostro_core::prelude::*; use nip44::v2::{encrypt_to_bytes, ConversationKey}; use nostr_sdk::prelude::*; @@ -11,7 +10,6 @@ use crate::cli::Context; use crate::parser::dms::print_commands_results; use crate::parser::parse_dm_events; use crate::util::events::FETCH_EVENTS_TIMEOUT; -use crate::util::types::MessageType; /// Helper function to retrieve and validate admin keys from context pub fn get_admin_keys(ctx: &Context) -> Result<&Keys> { @@ -152,7 +150,7 @@ pub async fn unwrap_giftwrap_with_shared_key( Ok(( inner_event.content, - inner_event.created_at.as_u64() as i64, + inner_event.created_at.as_secs() as i64, inner_event.pubkey, )) } @@ -163,7 +161,7 @@ pub async fn fetch_gift_wraps_for_shared_key( client: &Client, shared_keys: &Keys, ) -> Result> { - let now = Timestamp::now().as_u64(); + let now = Timestamp::now().as_secs(); let seven_days_secs: u64 = 7 * 24 * 60 * 60; let wide_since = now.saturating_sub(seven_days_secs); @@ -202,60 +200,44 @@ pub async fn fetch_gift_wraps_for_shared_key( Ok(messages) } -pub async fn send_admin_gift_wrap_dm( +/// Internal: wrap a Mostro `Message` via [`wrap_message`] and publish it. +async fn publish_gift_wrap( client: &Client, - admin_keys: &Keys, + signer_keys: &Keys, receiver_pubkey: &PublicKey, - message: &str, + message: &Message, + opts: WrapOptions, ) -> Result<()> { - send_gift_wrap_dm_internal(client, admin_keys, receiver_pubkey, message, true).await -} - -pub async fn send_gift_wrap_dm( - client: &Client, - trade_keys: &Keys, - receiver_pubkey: &PublicKey, - message: &str, -) -> Result<()> { - send_gift_wrap_dm_internal(client, trade_keys, receiver_pubkey, message, false).await + let event = wrap_message(message, signer_keys, *receiver_pubkey, opts) + .await + .map_err(|e| anyhow::anyhow!("Failed to wrap message: {e}"))?; + client.send_event(&event).await?; + Ok(()) } -async fn send_gift_wrap_dm_internal( +/// Send a plain-text DM wrapped as a NIP-59 Gift Wrap using `signer_keys`. +/// +/// The wrap uses `signed = false` so the inner rumor carries `(Message, None)`, +/// matching the behavior of the deleted `send_gift_wrap_dm_internal` helper. +pub async fn send_plain_text_dm( client: &Client, - sender_keys: &Keys, + signer_keys: &Keys, receiver_pubkey: &PublicKey, - message: &str, - is_admin: bool, + text: &str, ) -> Result<()> { - let pow: u8 = var("POW") - .unwrap_or_else(|_| "0".to_string()) - .parse() - .map_err(|e| anyhow::anyhow!("Failed to parse POW: {}", e))?; - + let pow = parse_pow_env()?; let dm_message = Message::new_dm( None, None, Action::SendDm, - Some(Payload::TextMessage(message.to_string())), + Some(Payload::TextMessage(text.to_string())), ); - - let content = serde_json::to_string(&(dm_message, None::))?; - - let rumor = EventBuilder::text_note(content).build(sender_keys.public_key()); - let seal: Event = EventBuilder::seal(sender_keys, receiver_pubkey, rumor) - .await? - .sign(sender_keys) - .await?; - let event = gift_wrap_from_seal_with_pow(receiver_pubkey, &seal, Tags::new(), pow)?; - - let sender_type = if is_admin { "admin" } else { "user" }; - info!( - "Sending {} gift wrap event to {}", - sender_type, receiver_pubkey - ); - client.send_event(&event).await?; - - Ok(()) + let opts = WrapOptions { + pow, + expiration: None, + signed: false, + }; + publish_gift_wrap(client, signer_keys, receiver_pubkey, &dm_message, opts).await } pub async fn wait_for_dm( @@ -303,20 +285,18 @@ where Ok(events) } -fn determine_message_type(to_user: bool, private: bool) -> MessageType { - match (to_user, private) { - (true, _) => MessageType::PrivateDirectMessage, - (false, true) => MessageType::PrivateGiftWrap, - (false, false) => MessageType::SignedGiftWrap, - } +fn parse_pow_env() -> Result { + var("POW") + .unwrap_or_else(|_| "0".to_string()) + .parse::() + .map_err(|e| anyhow::anyhow!("Failed to parse POW: {}", e)) } -fn create_expiration_tags(expiration: Option) -> Tags { - let mut tags: Vec = Vec::with_capacity(1 + usize::from(expiration.is_some())); - if let Some(timestamp) = expiration { - tags.push(Tag::expiration(timestamp)); - } - Tags::from_list(tags) +fn parse_secret_env() -> Result { + var("SECRET") + .unwrap_or_else(|_| "false".to_string()) + .parse::() + .map_err(|e| anyhow::anyhow!("Failed to parse SECRET: {}", e)) } async fn create_private_dm_event( @@ -336,141 +316,43 @@ async fn create_private_dm_event( ) } -/// Builds the published NIP-59 **Gift Wrap** (kind 1059) from a signed **Seal** event. +/// Send a Mostro protocol message to `receiver_pubkey`. /// -/// Rust-nostr’s `EventBuilder::gift_wrap` seals and wraps but does not apply NIP-13 PoW to the -/// outer Gift Wrap; Mostro may require that difficulty on the relay-visible event. This helper -/// mirrors the SDK’s seal→wrap steps: reject non-seal inputs, encrypt the seal JSON to `receiver` -/// with NIP-44 using an **ephemeral** key pair, attach `p` and optional tags, set -/// [`nip59::RANGE_RANDOM_TIMESTAMP_TWEAK`]-style `created_at`, mine with [`EventBuilder::pow`], -/// then sign the wrap with the ephemeral keys. -fn gift_wrap_from_seal_with_pow( - receiver: &PublicKey, - seal: &Event, - extra_tags: impl IntoIterator, - pow: u8, -) -> Result { - if seal.kind != nostr_sdk::Kind::Seal { - return Err(anyhow::anyhow!( - "Expected Seal (kind {}), got kind {}", - nostr_sdk::Kind::Seal.as_u16(), - seal.kind.as_u16(), - )); - } - - let ephem = Keys::generate(); - let content = nip44::encrypt( - ephem.secret_key(), - receiver, - seal.as_json(), - nip44::Version::default(), - )?; - - let mut tags: Vec = extra_tags.into_iter().collect(); - tags.push(Tag::public_key(*receiver)); - - EventBuilder::new(nostr_sdk::Kind::GiftWrap, content) - .tags(tags) - .custom_created_at(Timestamp::tweaked(nip59::RANGE_RANDOM_TIMESTAMP_TWEAK)) - .pow(pow) - .sign_with_keys(&ephem) - .map_err(|e| anyhow::anyhow!("Failed to sign gift wrap: {e}")) -} - -async fn create_gift_wrap_event( - trade_keys: &Keys, - identity_keys: Option<&Keys>, - receiver_pubkey: &PublicKey, - payload: String, - pow: u8, - expiration: Option, - signed: bool, -) -> Result { - let message = Message::from_json(&payload) - .map_err(|e| anyhow::anyhow!("Failed to deserialize message: {e}"))?; - - let content = if signed { - let _identity_keys = identity_keys - .ok_or_else(|| Error::msg("identity_keys required for signed messages"))?; - let sig = Message::sign(payload, trade_keys); - serde_json::to_string(&(message, sig)) - .map_err(|e| anyhow::anyhow!("Failed to serialize message: {e}"))? - } else { - let content: (Message, Option) = (message, None); - serde_json::to_string(&content) - .map_err(|e| anyhow::anyhow!("Failed to serialize message: {e}"))? - }; - - let rumor = EventBuilder::text_note(content).build(trade_keys.public_key()); - - let tags = create_expiration_tags(expiration); - - let signer_keys = if signed { - identity_keys.ok_or_else(|| Error::msg("identity_keys required for signed messages"))? - } else { - trade_keys - }; - - let seal: Event = EventBuilder::seal(signer_keys, receiver_pubkey, rumor) - .await? - .sign(signer_keys) - .await?; - - gift_wrap_from_seal_with_pow(receiver_pubkey, &seal, tags, pow) -} - +/// * `signer_keys` drives the whole NIP-59 pipeline: it authors the inner +/// rumor, signs the seal, and (when `signed` is true) produces the inner +/// tuple signature. Pass admin keys for admin flows and per-order trade +/// keys for user flows. +/// * `to_user` routes the message as a NIP-17 `PrivateDirectMessage` +/// (kind 14) instead of a gift wrap. +/// * Respects `POW` (mined on the outer wrap / DM) and `SECRET` (when true +/// the inner tuple is unsigned). Gift wraps go through +/// [`mostro_core::prelude::wrap_message`]. pub async fn send_dm( client: &Client, - identity_keys: Option<&Keys>, - trade_keys: &Keys, + signer_keys: &Keys, receiver_pubkey: &PublicKey, payload: String, expiration: Option, to_user: bool, ) -> Result<()> { - let pow: u8 = var("POW") - .unwrap_or('0'.to_string()) - .parse() - .map_err(|e| anyhow::anyhow!("Failed to parse POW: {}", e))?; - let private = var("SECRET") - .unwrap_or("false".to_string()) - .parse::() - .map_err(|e| anyhow::anyhow!("Failed to parse SECRET: {}", e))?; + let pow = parse_pow_env()?; - let message_type = determine_message_type(to_user, private); + if to_user { + let event = create_private_dm_event(signer_keys, receiver_pubkey, payload, pow).await?; + client.send_event(&event).await?; + return Ok(()); + } - let event = match message_type { - MessageType::PrivateDirectMessage => { - create_private_dm_event(trade_keys, receiver_pubkey, payload, pow).await? - } - MessageType::PrivateGiftWrap => { - create_gift_wrap_event( - trade_keys, - identity_keys, - receiver_pubkey, - payload, - pow, - expiration, - false, - ) - .await? - } - MessageType::SignedGiftWrap => { - create_gift_wrap_event( - trade_keys, - identity_keys, - receiver_pubkey, - payload, - pow, - expiration, - true, - ) - .await? - } + let message = Message::from_json(&payload) + .map_err(|e| anyhow::anyhow!("Failed to deserialize message: {e}"))?; + let private = parse_secret_env()?; + let opts = WrapOptions { + pow, + expiration, + signed: !private, }; - client.send_event(&event).await?; - Ok(()) + publish_gift_wrap(client, signer_keys, receiver_pubkey, &message, opts).await } pub async fn print_dm_events( @@ -481,29 +363,22 @@ pub async fn print_dm_events( ) -> Result<()> { let trade_keys = order_trade_keys.unwrap_or(&ctx.trade_keys); let messages = parse_dm_events(recv_event, trade_keys, None).await; - if let Some((message, _, _)) = messages.first() { - let message = message.get_inner_message_kind(); - match message.request_id { - Some(id) => { - if request_id == id { - print_commands_results(message, ctx).await?; - } - } - None if message.action == Action::RateReceived - || message.action == Action::NewOrder => - { - print_commands_results(message, ctx).await?; - } - None => { - return Err(anyhow::anyhow!( - "Received response with mismatched request_id. Expected: {}, Got: Null", - request_id, - )); - } - } - } else { - return Err(anyhow::anyhow!("No response received from Mostro")); + let (message, _, _) = messages + .first() + .ok_or_else(|| anyhow::anyhow!("No response received from Mostro"))?; + let inner = message.get_inner_message_kind(); + + match validate_response(message, Some(request_id)) { + Ok(()) => {} + // `mostro_core::nip59::validate_response` intentionally leaves + // `NewOrder` out of the unsolicited-push allow-list. Preserve the + // CLI's legacy tolerance so a child order published after a range + // trade (no `request_id`) still gets printed. + Err(_) if inner.request_id.is_none() && inner.action == Action::NewOrder => {} + Err(e) => return Err(anyhow::anyhow!("Unexpected response from Mostro: {e}")), } + + print_commands_results(inner, ctx).await?; Ok(()) } @@ -530,45 +405,112 @@ mod tests { leading_zero_bits_in_hex(&id_hex) >= difficulty.into() } - #[test] - fn gift_wrap_from_seal_with_pow_builds_gift_wrap_kind() -> Result<()> { - let receiver = Keys::generate().public_key(); - let seal = EventBuilder::new(nostr_sdk::Kind::Seal, "sealed payload") - .sign_with_keys(&Keys::generate())?; + fn sample_protocol_message(request_id: Option) -> Message { + Message::new_order( + None, + request_id, + Some(1), + Action::NewOrder, + Some(Payload::TextMessage("hi".to_string())), + ) + } - let event = gift_wrap_from_seal_with_pow(&receiver, &seal, Tags::new(), 0)?; + // Cryptographic correctness of wrap_message / unwrap_message lives in + // mostro-core. These tests only exercise the CLI wiring: that the + // Message we hand to send_dm survives a wrap→unwrap roundtrip and that + // our WrapOptions knobs (signed, pow) reach the outer event. + + #[tokio::test] + async fn send_dm_gift_wrap_roundtrips_via_unwrap_message() { + let trade_keys = Keys::generate(); + let mostro_keys = Keys::generate(); + let message = sample_protocol_message(Some(42)); + + let event = wrap_message( + &message, + &trade_keys, + mostro_keys.public_key(), + WrapOptions::default(), + ) + .await + .expect("wrap"); assert_eq!(event.kind, nostr_sdk::Kind::GiftWrap); - Ok(()) - } - - #[test] - fn gift_wrap_from_seal_with_pow_meets_requested_difficulty() -> Result<()> { - let receiver = Keys::generate().public_key(); - let seal = EventBuilder::new(nostr_sdk::Kind::Seal, "sealed payload") - .sign_with_keys(&Keys::generate())?; - let pow = 8; - let event = gift_wrap_from_seal_with_pow(&receiver, &seal, Tags::new(), pow)?; + let unwrapped = unwrap_message(&event, &mostro_keys) + .await + .expect("unwrap result") + .expect("addressed to mostro_keys"); + assert_eq!(unwrapped.sender, trade_keys.public_key()); + assert_eq!( + unwrapped.message.as_json().unwrap(), + message.as_json().unwrap() + ); assert!( - event_meets_pow(&event, pow), - "gift wrap id does not satisfy PoW" + unwrapped.signature.is_some(), + "default WrapOptions has signed=true", ); - Ok(()) } - #[test] - fn gift_wrap_from_seal_with_pow_rejects_non_seal() { - let receiver = Keys::generate().public_key(); - let non_seal = EventBuilder::new(nostr_sdk::Kind::TextNote, "not a seal") - .sign_with_keys(&Keys::generate()) - .unwrap(); + #[tokio::test] + async fn secret_env_semantics_drop_inner_signature() { + let trade_keys = Keys::generate(); + let mostro_keys = Keys::generate(); + + let event = wrap_message( + &sample_protocol_message(Some(1)), + &trade_keys, + mostro_keys.public_key(), + WrapOptions { + signed: false, + ..Default::default() + }, + ) + .await + .expect("wrap"); - let err = gift_wrap_from_seal_with_pow(&receiver, &non_seal, Tags::new(), 0).unwrap_err(); - assert!( - err.to_string().to_lowercase().contains("kind"), - "unexpected error: {err}" - ); + let unwrapped = unwrap_message(&event, &mostro_keys).await.unwrap().unwrap(); + assert!(unwrapped.signature.is_none()); + } + + #[tokio::test] + async fn wrap_message_respects_pow_option() { + let trade_keys = Keys::generate(); + let mostro_keys = Keys::generate(); + let pow = 4; + + let event = wrap_message( + &sample_protocol_message(None), + &trade_keys, + mostro_keys.public_key(), + WrapOptions { + pow, + ..Default::default() + }, + ) + .await + .expect("wrap"); + + assert!(event_meets_pow(&event, pow), "PoW not met"); + } + + #[tokio::test] + async fn wrong_keys_yield_none_on_unwrap() { + let trade_keys = Keys::generate(); + let mostro_keys = Keys::generate(); + let stranger = Keys::generate(); + + let event = wrap_message( + &sample_protocol_message(Some(1)), + &trade_keys, + mostro_keys.public_key(), + WrapOptions::default(), + ) + .await + .unwrap(); + + let result = unwrap_message(&event, &stranger).await.expect("no error"); + assert!(result.is_none()); } } diff --git a/src/util/mod.rs b/src/util/mod.rs index 3d78765..1a51850 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -9,8 +9,7 @@ pub mod types; pub use events::{create_filter, fetch_events_list, FETCH_EVENTS_TIMEOUT}; pub use messaging::{ derive_shared_key_hex, derive_shared_keys, keys_from_shared_hex, print_dm_events, - send_admin_chat_message_via_shared_key, send_admin_gift_wrap_dm, send_dm, send_gift_wrap_dm, - wait_for_dm, + send_admin_chat_message_via_shared_key, send_dm, send_plain_text_dm, wait_for_dm, }; pub use misc::{get_mcli_path, uppercase_first}; pub use net::connect_nostr; diff --git a/src/util/storage.rs b/src/util/storage.rs index feddb08..ed237ec 100644 --- a/src/util/storage.rs +++ b/src/util/storage.rs @@ -48,11 +48,11 @@ pub async fn run_simple_order_msg( pub async fn admin_send_dm(ctx: &Context, msg: String) -> Result<()> { // Get admin keys let admin_keys = get_admin_keys(ctx)?; - // Send DM + // Admin identity binds via the rumor author / inner tuple signature + // produced by `wrap_message`, so the admin keys are the sole signer. send_dm( &ctx.client, - Some(admin_keys), - &ctx.trade_keys, + admin_keys, &ctx.mostro_pubkey, msg, None, diff --git a/src/util/types.rs b/src/util/types.rs index 5cc9d05..cf4dd80 100644 --- a/src/util/types.rs +++ b/src/util/types.rs @@ -16,10 +16,3 @@ pub enum ListKind { DirectMessagesAdmin, PrivateDirectMessagesUser, } - -#[derive(Debug, Clone, Copy)] -pub(super) enum MessageType { - PrivateDirectMessage, - PrivateGiftWrap, - SignedGiftWrap, -} diff --git a/tests/parser_dms.rs b/tests/parser_dms.rs index 7be8247..f144d1a 100644 --- a/tests/parser_dms.rs +++ b/tests/parser_dms.rs @@ -198,6 +198,8 @@ async fn print_dms_with_restore_session_payload() { order_id: uuid::Uuid::new_v4(), trade_index: 1, status: "initiated".to_string(), + initiator: None, + solver_pubkey: None, }; let restore_payload = Payload::RestoreData(RestoreSessionInfo { restore_orders: vec![order_info], @@ -226,6 +228,69 @@ async fn parse_dm_with_time_filter() { assert!(out.is_empty()); } +// End-to-end check that parse_dm_events accepts gift wraps produced by the +// centralized `wrap_message` pipeline. This is the receive-side counterpart +// of the wiring tests in src/util/messaging.rs and protects against a future +// drift between how we publish DMs and how we decode them. +#[tokio::test] +async fn parse_dm_events_accepts_wrap_message_output() { + let sender_trade_keys = Keys::generate(); + let receiver_keys = Keys::generate(); + + let inner = Message::new_order( + None, + Some(123), + Some(1), + Action::NewOrder, + Some(Payload::TextMessage("hello".to_string())), + ); + let wrapped = wrap_message( + &inner, + &sender_trade_keys, + receiver_keys.public_key(), + WrapOptions::default(), + ) + .await + .expect("wrap"); + + let mut events = Events::new(&Filter::new()); + events.insert(wrapped); + + let parsed = parse_dm_events(events, &receiver_keys, None).await; + assert_eq!(parsed.len(), 1); + let (message, _, sender) = &parsed[0]; + assert_eq!(sender, &sender_trade_keys.public_key()); + assert_eq!( + message.as_json().unwrap(), + inner.as_json().unwrap(), + "inner message must roundtrip byte-for-byte", + ); +} + +// Gift wraps addressed to somebody else must be silently skipped, not +// treated as protocol violations. +#[tokio::test] +async fn parse_dm_events_skips_events_for_other_keys() { + let sender = Keys::generate(); + let intended_recipient = Keys::generate(); + let eavesdropper = Keys::generate(); + + let wrapped = wrap_message( + &Message::new_order(None, Some(1), Some(1), Action::NewOrder, None), + &sender, + intended_recipient.public_key(), + WrapOptions::default(), + ) + .await + .unwrap(); + + let mut events = Events::new(&Filter::new()); + events.insert(wrapped); + + let parsed = parse_dm_events(events, &eavesdropper, None).await; + assert!(parsed.is_empty()); +} + #[tokio::test] async fn print_dms_with_long_details_truncation() { let sender_keys = Keys::generate();