diff --git a/Cargo.lock b/Cargo.lock index 7885583..f417f10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1550,9 +1550,9 @@ dependencies = [ [[package]] name = "mostro-core" -version = "0.11.1" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cd16bab24c530f7bc026014ce4810e6d427abf5f4d5a5977c46ef97bd420ef4" +checksum = "0b972de34af6574c8fbb2a6e8fd6a8e478fd45875ef4eae45165668b30246cf0" dependencies = [ "bitcoin", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 4f5586c..099e18f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,7 @@ reqwest = { version = "0.12.23", default-features = false, features = [ "json", "rustls-tls", ] } -mostro-core = "0.11.1" +mostro-core = "0.11.3" 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/README.md b/README.md index 7ab9150..a3f8337 100644 --- a/README.md +++ b/README.md @@ -453,6 +453,7 @@ Every command supports `-h, --help`. The list below is a one-line summary; run ` - `cancel -o ` — cancel a pending order or cooperatively cancel later. - `rate -o -r <1-5>` — rate counterpart. - `dispute -o ` — open a dispute. +- `addbondinvoice -o -i ` — reply to a bond payout request with an invoice for your share of a slashed bond. ### Messaging - `getdm [--since ] [--from-user]` — fetch recent DMs. diff --git a/src/cli.rs b/src/cli.rs index 5b5a701..dccc495 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,3 +1,4 @@ +pub mod add_bond_invoice; pub mod add_invoice; pub mod adm_send_dm; pub mod conversation_key; @@ -17,6 +18,7 @@ pub mod send_msg; pub mod take_dispute; pub mod take_order; +use crate::cli::add_bond_invoice::execute_add_bond_invoice; use crate::cli::add_invoice::execute_add_invoice; use crate::cli::adm_send_dm::execute_adm_send_dm; use crate::cli::conversation_key::execute_conversation_key; @@ -169,6 +171,15 @@ pub enum Commands { #[arg(short, long)] invoice: String, }, + /// Reply to a bond payout request with an invoice for your share of a slashed bond + AddBondInvoice { + /// Order id + #[arg(short, long)] + order_id: Uuid, + /// Invoice string + #[arg(short, long)] + invoice: String, + }, /// Get the latest direct messages GetDm { /// Since time of the messages in minutes @@ -577,6 +588,9 @@ impl Commands { Commands::AddInvoice { order_id, invoice } => { execute_add_invoice(order_id, invoice, ctx).await } + Commands::AddBondInvoice { order_id, invoice } => { + execute_add_bond_invoice(order_id, invoice, ctx).await + } Commands::Rate { order_id, rating } => execute_rate_user(order_id, rating, ctx).await, // DM retrieval commands diff --git a/src/cli/add_bond_invoice.rs b/src/cli/add_bond_invoice.rs new file mode 100644 index 0000000..83d77f4 --- /dev/null +++ b/src/cli/add_bond_invoice.rs @@ -0,0 +1,106 @@ +use crate::parser::common::{ + create_emoji_field_row, create_field_value_header, create_standard_table, +}; +use crate::util::{print_dm_events, send_dm, wait_for_dm, WaitForDmTimeout}; +use crate::{cli::Context, db::Order, lightning::is_valid_invoice}; +use anyhow::Result; +use mostro_core::prelude::*; +use nostr_sdk::prelude::*; +use uuid::Uuid; + +/// Reply to a Mostro `add-bond-invoice` request: the non-slashed counterparty +/// provides a bolt11 sized at their share of a slashed bond. +/// +/// This is the inbound `add-bond-invoice` request's dual — Mostro asks for a +/// bolt11 (carried as [`Payload::BondPayoutRequest`]) and we answer with the +/// invoice in the standard [`Payload::PaymentRequest`] shape, signed with the +/// order's trade key. See the protocol's "Bond payout invoice" action. +pub async fn execute_add_bond_invoice(order_id: &Uuid, invoice: &str, ctx: &Context) -> Result<()> { + // Get order from order id + let order = Order::get_by_id(&ctx.pool, &order_id.to_string()).await?; + // Get trade keys of specific order (the non-slashed counterparty side) + let trade_keys = order + .trade_keys + .clone() + .ok_or(anyhow::anyhow!("Missing trade keys"))?; + + let order_trade_keys = Keys::parse(&trade_keys)?; + + println!("🪙 Add Bond Payout Invoice"); + println!("═══════════════════════════════════════"); + + let mut table = create_standard_table(); + table.set_header(create_field_value_header()); + table.add_row(create_emoji_field_row( + "📋 ", + "Order ID", + &order_id.to_string(), + )); + table.add_row(create_emoji_field_row( + "🔑 ", + "Trade Keys", + &order_trade_keys.public_key().to_hex(), + )); + table.add_row(create_emoji_field_row( + "🎯 ", + "Target", + &ctx.mostro_pubkey.to_string(), + )); + println!("{table}"); + println!("💡 Sending bond payout invoice to Mostro...\n"); + // The bond payout reply must be a bolt11 sized at the counterparty share. + // Lightning Addresses are not accepted here (the protocol's "Bond payout + // invoice" reply is a bolt11): validate locally so a bad input fails fast + // instead of bouncing back as a `cant-do` / `invalid-invoice` from Mostro. + let invoice = is_valid_invoice(invoice) + .map_err(|e| anyhow::anyhow!("Invalid invoice: {}", e))? + .to_string(); + let payload = Payload::PaymentRequest(None, invoice, None); + + // Create request id + let request_id = Uuid::new_v4().as_u128() as u64; + // Create AddBondInvoice reply message + let add_bond_invoice_message = Message::new_order( + Some(*order_id), + Some(request_id), + None, + Action::AddBondInvoice, + Some(payload), + ); + + // Serialize the message + let message_json = add_bond_invoice_message + .as_json() + .map_err(|_| anyhow::anyhow!("Failed to serialize message"))?; + + // Send the DM + let sent_message = send_dm( + &ctx.client, + &ctx.identity_keys, + &order_trade_keys, + &ctx.mostro_pubkey, + message_json, + None, + false, + ); + + // Wait for a possible reply. On success Mostro pays the invoice from its + // wallet without acknowledging over Nostr, so a *timeout* here is the happy + // path; Mostro only answers with `cant-do` on failure (late reply, wrong + // sender, bad invoice, etc.). Any other error (subscribe/sign/transport) + // means the reply may never have been sent — surface it instead of + // misreporting it as success. + match wait_for_dm(ctx, Some(&order_trade_keys), sent_message).await { + Ok(recv_event) => { + print_dm_events(recv_event, request_id, ctx, Some(&order_trade_keys)).await?; + } + Err(e) if e.downcast_ref::().is_some() => { + println!("✅ Bond payout invoice submitted to Mostro."); + println!("💡 Mostro will pay it from its wallet; no further confirmation is sent."); + println!("💡 Run `get-dm` to check for a `cant-do` response in case of an error."); + } + Err(e) => return Err(e), + } + + Ok(()) +} diff --git a/src/cli/get_dm.rs b/src/cli/get_dm.rs index 9860139..a63e548 100644 --- a/src/cli/get_dm.rs +++ b/src/cli/get_dm.rs @@ -1,12 +1,12 @@ use anyhow::Result; -use mostro_core::prelude::Message; +use mostro_core::prelude::{Action, Message, Payload}; use nostr_sdk::prelude::*; use crate::{ cli::Context, parser::common::{print_key_value, print_section_header}, parser::dms::print_direct_messages, - util::{fetch_events_list, Event, ListKind}, + util::{fetch_bond_claim_window_days, fetch_events_list, Event, ListKind}, }; pub async fn execute_get_dm( @@ -42,6 +42,19 @@ pub async fn execute_get_dm( } } - print_direct_messages(&dm_events, Some(ctx.mostro_pubkey)).await?; + // Only hit the relay for the node's claim window when an inbound bond + // payout request is actually present, so the common get-dm path stays cheap. + let has_bond_payout_request = dm_events.iter().any(|(message, _, _)| { + let inner = message.get_inner_message_kind(); + inner.action == Action::AddBondInvoice + && matches!(inner.payload, Some(Payload::BondPayoutRequest(_))) + }); + let claim_window_days = if has_bond_payout_request { + fetch_bond_claim_window_days(ctx).await + } else { + None + }; + + print_direct_messages(&dm_events, Some(ctx.mostro_pubkey), claim_window_days).await?; Ok(()) } diff --git a/src/parser/dms.rs b/src/parser/dms.rs index aed1fc4..f1a477f 100644 --- a/src/parser/dms.rs +++ b/src/parser/dms.rs @@ -18,7 +18,7 @@ use crate::{ print_payment_method, print_premium, print_required_amount, print_section_header, print_success_message, print_trade_index, }, - util::save_order, + util::{fetch_bond_claim_window_days, save_order}, }; use serde_json; @@ -98,14 +98,80 @@ fn handle_pay_bond_invoice_display(order: &Option) -> String { + let slashed = format_timestamp(slashed_at); + match claim_window_days { + Some(days) => { + let deadline_ts = slashed_at.saturating_add(days.saturating_mul(86_400)); + format!( + "Slashed at {} — forfeit deadline {} ({} day claim window)", + slashed, + format_timestamp(deadline_ts), + days + ) + } + None => format!( + "Slashed at {} — claim window unknown (Mostro info event unavailable)", + slashed + ), + } +} + +/// Display an inbound `add-bond-invoice` request (Mostro → non-slashed +/// counterparty): the order context plus the locally-rendered forfeit deadline. +fn handle_add_bond_invoice_request_display( + req: &BondPayoutRequest, + claim_window_days: Option, +) { + print_section_header("🪙 Bond Payout Invoice Requested"); + if let Some(order_id) = req.order.id { + println!("📋 Order ID: {}", order_id); + } + print_required_amount(req.order.amount); + print_fiat_code(&req.order.fiat_code); + println!("💵 Fiat Amount: {}", req.order.fiat_amount); + print_payment_method(&req.order.payment_method); + println!( + "⏰ {}", + format_bond_forfeit_deadline(req.slashed_at, claim_window_days) + ); + println!(); + println!("💡 A bond on this trade was slashed; you can claim your share."); + println!("💡 Reply before the deadline with a Lightning invoice for the amount above:"); + println!( + " mostro-cli addbondinvoice -o {} -i ", + req.order + .id + .map(|x| x.to_string()) + .unwrap_or_else(|| "".to_string()) + ); + println!(); +} + /// Format payload details for DM table display -fn format_payload_details(payload: &Payload, action: &Action) -> String { +fn format_payload_details( + payload: &Payload, + action: &Action, + claim_window_days: Option, +) -> String { match payload { Payload::TextMessage(t) => format!("✉️ {}", t), Payload::PaymentRequest(_, inv, _) => { // For invoices, show the full invoice without truncation format!("⚡ Lightning Invoice:\n{}", inv) } + Payload::BondPayoutRequest(req) => format!( + "🪙 Bond payout request: {} sats ({})\n⏰ {}", + req.order.amount, + req.order.fiat_code, + format_bond_forfeit_deadline(req.slashed_at, claim_window_days) + ), Payload::Dispute(id, _) => format!("⚖️ Dispute ID: {}", id), Payload::Order(o) if *action == Action::NewOrder => format!( "🆕 New Order: {} {} sats ({})", @@ -537,6 +603,20 @@ pub async fn print_commands_results(message: &MessageKind, ctx: &Context) -> Res print_success_message("Order saved successfully!"); Ok(()) } + // mostro-core 0.11.3: bond payout invoice request sent to the + // non-slashed counterparty after a bond is slashed. Reply with + // `add-bond-invoice` carrying a bolt11 for your share. + Action::AddBondInvoice => match &message.payload { + Some(Payload::BondPayoutRequest(req)) => { + let claim_window_days = fetch_bond_claim_window_days(ctx).await; + handle_add_bond_invoice_request_display(req, claim_window_days); + Ok(()) + } + other => Err(anyhow::anyhow!( + "AddBondInvoice expected Payload::BondPayoutRequest, got: {:?}", + other + )), + }, Action::CantDo => { println!("❌ Action Cannot Be Completed"); println!("═══════════════════════════════════════"); @@ -871,6 +951,7 @@ pub async fn parse_dm_events( pub async fn print_direct_messages( dm: &[(Message, u64, PublicKey)], mostro_pubkey: Option, + claim_window_days: Option, ) -> Result<()> { if dm.is_empty() { println!(); @@ -896,6 +977,7 @@ pub async fn print_direct_messages( Action::NewOrder => "🆕", Action::AddInvoice | Action::PayInvoice => "⚡", Action::PayBondInvoice => "🪙", + Action::AddBondInvoice => "💰", Action::FiatSent | Action::FiatSentOk => "💸", Action::Release | Action::Released => "🔓", Action::Cancel | Action::Canceled => "🚫", @@ -927,7 +1009,7 @@ pub async fn print_direct_messages( // Print details with proper formatting if let Some(payload) = &inner.payload { - let details = format_payload_details(payload, &inner.action); + let details = format_payload_details(payload, &inner.action, claim_window_days); println!("📝 Details:"); for line in details.lines() { println!(" {}", line); diff --git a/src/util/events.rs b/src/util/events.rs index d132428..63bad26 100644 --- a/src/util/events.rs +++ b/src/util/events.rs @@ -83,6 +83,39 @@ pub fn create_filter( } } +/// Fetch the Mostro instance's kind-38385 info event and read the +/// `bond_payout_claim_window_days` tag. +/// +/// Returns `None` when the node publishes no info event, the tag is absent +/// (older daemon or bonds disabled), or the value can't be parsed. Used to +/// render the forfeit deadline for an `add-bond-invoice` request locally, per +/// the protocol's "Bond payout invoice" / "Other events" docs. Best-effort: +/// any relay error degrades to `None` rather than failing the caller. +pub async fn fetch_bond_claim_window_days(ctx: &crate::cli::Context) -> Option { + let filter = Filter::new() + .author(ctx.mostro_pubkey) + .kind(nostr_sdk::Kind::Custom(NOSTR_INFO_EVENT_KIND)); + + let events = ctx + .client + .fetch_events(filter, FETCH_EVENTS_TIMEOUT) + .await + .ok()?; + + // kind-38385 is replaceable, but pick the newest revision by `created_at` + // explicitly: a lagging relay (or several relays at once) can still surface + // an older copy, and a stale claim window would render the wrong, very + // user-facing forfeit deadline. + let event = events.iter().max_by_key(|e| e.created_at)?; + for tag in event.tags.iter() { + let slice = tag.as_slice(); + if slice.first().map(String::as_str) == Some("bond_payout_claim_window_days") { + return slice.get(1).and_then(|v| v.parse::().ok()); + } + } + None +} + #[allow(clippy::too_many_arguments)] pub async fn fetch_events_list( list_kind: ListKind, diff --git a/src/util/messaging.rs b/src/util/messaging.rs index 6b61ea4..f55f645 100644 --- a/src/util/messaging.rs +++ b/src/util/messaging.rs @@ -256,6 +256,25 @@ pub async fn send_plain_text_dm( .await } +/// Distinguishable error returned by [`wait_for_dm`] when no reply arrives +/// within [`FETCH_EVENTS_TIMEOUT`]. +/// +/// Most callers `?`-propagate it like any other error, but flows where "no +/// reply" is the happy path (e.g. `add-bond-invoice`, where Mostro pays the +/// invoice without acking over Nostr) can detect it via +/// `downcast_ref::()` and avoid misreporting genuine +/// subscribe/send/transport failures as success. +#[derive(Debug)] +pub struct WaitForDmTimeout; + +impl std::fmt::Display for WaitForDmTimeout { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Timeout waiting for DM or gift wrap event") + } +} + +impl std::error::Error for WaitForDmTimeout {} + pub async fn wait_for_dm( ctx: &crate::cli::Context, order_trade_keys: Option<&Keys>, @@ -278,23 +297,25 @@ where sent_message.await?; // Wait for the DM or gift wrap event - let event = tokio::time::timeout(super::events::FETCH_EVENTS_TIMEOUT, async move { + let waited = tokio::time::timeout(super::events::FETCH_EVENTS_TIMEOUT, async move { loop { match notifications.recv().await { - Ok(notification) => match notification { - RelayPoolNotification::Event { event, .. } => { - return Ok(*event); - } - _ => continue, - }, + Ok(RelayPoolNotification::Event { event, .. }) => return Ok(*event), + Ok(_) => continue, Err(e) => { return Err(anyhow::anyhow!("Error receiving notification: {:?}", e)); } } } }) - .await? - .map_err(|_| anyhow::anyhow!("Timeout waiting for DM or gift wrap event"))?; + .await; + + // Keep a genuine timeout (the only "no reply" outcome) distinguishable from + // a notification-channel error so callers can treat them differently. + let event = match waited { + Ok(inner) => inner?, + Err(_elapsed) => return Err(WaitForDmTimeout.into()), + }; let mut events = Events::default(); events.insert(event); diff --git a/src/util/mod.rs b/src/util/mod.rs index 1a51850..b4b0bc6 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -6,10 +6,13 @@ pub mod storage; pub mod types; // Re-export commonly used items to preserve existing import paths -pub use events::{create_filter, fetch_events_list, FETCH_EVENTS_TIMEOUT}; +pub use events::{ + create_filter, fetch_bond_claim_window_days, 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_dm, send_plain_text_dm, wait_for_dm, + WaitForDmTimeout, }; pub use misc::{get_mcli_path, uppercase_first}; pub use net::connect_nostr; diff --git a/tests/parser_dms.rs b/tests/parser_dms.rs index 4e48e10..08d3398 100644 --- a/tests/parser_dms.rs +++ b/tests/parser_dms.rs @@ -13,7 +13,7 @@ async fn parse_dm_empty() { #[tokio::test] async fn print_dms_empty() { let msgs: Vec<(Message, u64, PublicKey)> = Vec::new(); - let res = print_direct_messages(&msgs, None).await; + let res = print_direct_messages(&msgs, None, None).await; assert!(res.is_ok()); } @@ -21,7 +21,7 @@ async fn print_dms_empty() { async fn print_dms_with_mostro_pubkey() { let mostro_key = Keys::generate(); let msgs: Vec<(Message, u64, PublicKey)> = Vec::new(); - let res = print_direct_messages(&msgs, Some(mostro_key.public_key())).await; + let res = print_direct_messages(&msgs, Some(mostro_key.public_key()), None).await; assert!(res.is_ok()); } @@ -38,7 +38,7 @@ async fn print_dms_with_single_message() { let timestamp = 1700000000u64; let msgs = vec![(message, timestamp, sender_keys.public_key())]; - let res = print_direct_messages(&msgs, None).await; + let res = print_direct_messages(&msgs, None, None).await; assert!(res.is_ok()); } @@ -50,7 +50,7 @@ async fn print_dms_with_text_payload() { let timestamp = 1700000000u64; let msgs = vec![(message, timestamp, sender_keys.public_key())]; - let res = print_direct_messages(&msgs, None).await; + let res = print_direct_messages(&msgs, None, None).await; assert!(res.is_ok()); } @@ -69,7 +69,7 @@ async fn print_dms_with_payment_request() { let timestamp = 1700000000u64; let msgs = vec![(message, timestamp, sender_keys.public_key())]; - let res = print_direct_messages(&msgs, None).await; + let res = print_direct_messages(&msgs, None, None).await; assert!(res.is_ok()); } @@ -98,7 +98,7 @@ async fn print_dms_with_multiple_messages() { msgs.push((message, timestamp, sender_keys.public_key())); } - let res = print_direct_messages(&msgs, None).await; + let res = print_direct_messages(&msgs, None, None).await; assert!(res.is_ok()); } @@ -117,7 +117,7 @@ async fn print_dms_with_dispute_payload() { let timestamp = 1700000000u64; let msgs = vec![(message, timestamp, sender_keys.public_key())]; - let res = print_direct_messages(&msgs, None).await; + let res = print_direct_messages(&msgs, None, None).await; assert!(res.is_ok()); } @@ -152,7 +152,7 @@ async fn print_dms_with_orders_payload() { let timestamp = 1700000000u64; let msgs = vec![(message, timestamp, sender_keys.public_key())]; - let res = print_direct_messages(&msgs, None).await; + let res = print_direct_messages(&msgs, None, None).await; assert!(res.is_ok()); } @@ -181,7 +181,7 @@ async fn print_dms_distinguishes_mostro() { (msg2, 1700000060u64, sender_keys.public_key()), ]; - let res = print_direct_messages(&msgs, Some(mostro_keys.public_key())).await; + let res = print_direct_messages(&msgs, Some(mostro_keys.public_key()), None).await; assert!(res.is_ok()); } @@ -215,7 +215,7 @@ async fn print_dms_with_restore_session_payload() { let timestamp = 1700000000u64; let msgs = vec![(message, timestamp, sender_keys.public_key())]; - let res = print_direct_messages(&msgs, None).await; + let res = print_direct_messages(&msgs, None, None).await; assert!(res.is_ok()); } @@ -304,7 +304,7 @@ async fn print_dms_with_long_details_truncation() { let timestamp = 1700000000u64; let msgs = vec![(message, timestamp, sender_keys.public_key())]; - let res = print_direct_messages(&msgs, None).await; + let res = print_direct_messages(&msgs, None, None).await; assert!(res.is_ok()); } @@ -322,7 +322,7 @@ async fn print_dms_with_rating_action() { let timestamp = 1700000000u64; let msgs = vec![(message, timestamp, sender_keys.public_key())]; - let res = print_direct_messages(&msgs, None).await; + let res = print_direct_messages(&msgs, None, None).await; assert!(res.is_ok()); } @@ -357,7 +357,48 @@ async fn print_dms_with_add_invoice_action() { let timestamp = 1700000000u64; let msgs = vec![(message, timestamp, sender_keys.public_key())]; - let res = print_direct_messages(&msgs, None).await; + let res = print_direct_messages(&msgs, None, None).await; + assert!(res.is_ok()); +} + +#[tokio::test] +async fn print_dms_with_bond_payout_request() { + let mostro_keys = Keys::generate(); + let order = SmallOrder { + id: Some(uuid::Uuid::new_v4()), + kind: Some(mostro_core::order::Kind::Sell), + status: None, + amount: 500, + fiat_code: "VES".to_string(), + fiat_amount: 100, + payment_method: "face to face".to_string(), + premium: 1, + buyer_trade_pubkey: None, + seller_trade_pubkey: None, + buyer_invoice: None, + created_at: None, + expires_at: None, + min_amount: None, + max_amount: None, + }; + let payload = Payload::BondPayoutRequest(BondPayoutRequest { + order, + slashed_at: 1_734_000_000, + }); + let message = Message::new_order( + Some(uuid::Uuid::new_v4()), + Some(12345), + Some(1), + Action::AddBondInvoice, + Some(payload), + ); + let msgs = vec![(message, 1_734_000_000u64, mostro_keys.public_key())]; + + // With a known claim window the forfeit deadline is rendered locally; with + // an unknown window it degrades gracefully. Both must succeed. + let res = print_direct_messages(&msgs, Some(mostro_keys.public_key()), Some(15)).await; + assert!(res.is_ok()); + let res = print_direct_messages(&msgs, Some(mostro_keys.public_key()), None).await; assert!(res.is_ok()); } @@ -374,6 +415,6 @@ async fn print_dms_with_invalid_timestamp() { let timestamp = 0u64; let msgs = vec![(message, timestamp, sender_keys.public_key())]; - let res = print_direct_messages(&msgs, None).await; + let res = print_direct_messages(&msgs, None, None).await; assert!(res.is_ok()); }