From a29e90f1a5b6408989d5a38f2344a0ed91e08fe1 Mon Sep 17 00:00:00 2001 From: grunch Date: Thu, 21 May 2026 11:02:48 -0300 Subject: [PATCH 1/4] feat(add-bond-invoice): implement AddBondInvoice action Bump mostro-core 0.11.1 -> 0.11.3, which adds Action::AddBondInvoice and the Payload::BondPayoutRequest variant, and implement both directions of the bond payout invoice flow in the CLI: - Outbound (counterparty -> Mostro): new `addbondinvoice -o -i ` command that replies to a slash with a bolt11 for the counterparty's share, signed with the order's trade key (Payload::PaymentRequest). A timeout while waiting is treated as success since Mostro pays without acknowledging; only `cant-do` is surfaced on failure. - Inbound (Mostro -> counterparty): render the BondPayoutRequest in both print_commands_results and getdm, computing the forfeit deadline locally from the on-wire `slashed_at` (never local clock) plus the node's `bond_payout_claim_window_days` info-event tag. - Add fetch_bond_claim_window_days helper to read the kind-38385 info event tag, queried lazily from getdm only when a request is present. Update README command reference and add a parser test for the request rendering across known/unknown claim windows. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 4 +- Cargo.toml | 2 +- README.md | 1 + src/cli.rs | 14 +++++ src/cli/add_bond_invoice.rs | 109 ++++++++++++++++++++++++++++++++++++ src/cli/get_dm.rs | 19 ++++++- src/parser/dms.rs | 88 ++++++++++++++++++++++++++++- src/util/events.rs | 30 ++++++++++ src/util/mod.rs | 4 +- tests/parser_dms.rs | 69 ++++++++++++++++++----- 10 files changed, 316 insertions(+), 24 deletions(-) create mode 100644 src/cli/add_bond_invoice.rs 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..42dfb97 --- /dev/null +++ b/src/cli/add_bond_invoice.rs @@ -0,0 +1,109 @@ +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}; +use crate::{cli::Context, db::Order, lightning::is_valid_invoice}; +use anyhow::Result; +use lnurl::lightning_address::LightningAddress; +use mostro_core::prelude::*; +use nostr_sdk::prelude::*; +use std::str::FromStr; +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"); + // Parse invoice (Lightning address or BOLT11) and build payload + let ln_addr = LightningAddress::from_str(invoice); + let payload = if ln_addr.is_ok() { + Some(Payload::PaymentRequest(None, invoice.to_string(), None)) + } else { + match is_valid_invoice(invoice) { + Ok(i) => Some(Payload::PaymentRequest(None, i.to_string(), None)), + Err(e) => { + return Err(anyhow::anyhow!("Invalid invoice: {}", e)); + } + } + }; + + // 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, + 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.). + 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(_) => { + 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."); + } + } + + 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..21b5475 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 --orderid {} --invoice ", + 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..93d458a 100644 --- a/src/util/events.rs +++ b/src/util/events.rs @@ -83,6 +83,36 @@ 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)) + .limit(1); + + let events = ctx + .client + .fetch_events(filter, FETCH_EVENTS_TIMEOUT) + .await + .ok()?; + + let event = events.first()?; + 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/mod.rs b/src/util/mod.rs index 1a51850..0f2930a 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -6,7 +6,9 @@ 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, 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()); } From 5c047126fb7f6afd164fb2d237486742f85cf031 Mon Sep 17 00:00:00 2001 From: grunch Date: Thu, 21 May 2026 11:09:05 -0300 Subject: [PATCH 2/4] fix(add-bond-invoice): only treat wait timeout as a successful submission Previously any error from wait_for_dm was reported as "invoice submitted", so a subscribe/sign/transport failure where the reply never went out was misreported as success. Introduce a distinguishable WaitForDmTimeout error and only show the success message on that timeout (the genuine no-reply happy path), propagating every other error. Also drop the unnecessary Option wrapper around the payload, which is always Some by the time it is used (invalid invoices return early). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/add_bond_invoice.rs | 17 +++++++++------- src/util/messaging.rs | 39 ++++++++++++++++++++++++++++--------- src/util/mod.rs | 1 + 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/cli/add_bond_invoice.rs b/src/cli/add_bond_invoice.rs index 42dfb97..68c9a11 100644 --- a/src/cli/add_bond_invoice.rs +++ b/src/cli/add_bond_invoice.rs @@ -1,7 +1,7 @@ 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}; +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 lnurl::lightning_address::LightningAddress; @@ -53,10 +53,10 @@ pub async fn execute_add_bond_invoice(order_id: &Uuid, invoice: &str, ctx: &Cont // Parse invoice (Lightning address or BOLT11) and build payload let ln_addr = LightningAddress::from_str(invoice); let payload = if ln_addr.is_ok() { - Some(Payload::PaymentRequest(None, invoice.to_string(), None)) + Payload::PaymentRequest(None, invoice.to_string(), None) } else { match is_valid_invoice(invoice) { - Ok(i) => Some(Payload::PaymentRequest(None, i.to_string(), None)), + Ok(i) => Payload::PaymentRequest(None, i.to_string(), None), Err(e) => { return Err(anyhow::anyhow!("Invalid invoice: {}", e)); } @@ -71,7 +71,7 @@ pub async fn execute_add_bond_invoice(order_id: &Uuid, invoice: &str, ctx: &Cont Some(request_id), None, Action::AddBondInvoice, - payload, + Some(payload), ); // Serialize the message @@ -91,18 +91,21 @@ pub async fn execute_add_bond_invoice(order_id: &Uuid, invoice: &str, ctx: &Cont ); // 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 + // 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.). + // 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(_) => { + 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/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 0f2930a..b4b0bc6 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -12,6 +12,7 @@ pub use events::{ 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; From 1a7c3e74650db7d51efe1002265493291d1bd69a Mon Sep 17 00:00:00 2001 From: grunch Date: Thu, 21 May 2026 11:45:17 -0300 Subject: [PATCH 3/4] docs(add-bond-invoice): use short flags in the reply hint The inbound bond-payout hint printed the long flag `--orderid` (correct, since the Commands enum's `rename_all = "lower"` renames `order_id` to `--orderid`), but the unusual form is easy to misread. Switch the hint to the short `-o`/`-i` flags, which are unambiguous and match the README's convention for these commands. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/parser/dms.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parser/dms.rs b/src/parser/dms.rs index 21b5475..f1a477f 100644 --- a/src/parser/dms.rs +++ b/src/parser/dms.rs @@ -145,7 +145,7 @@ fn handle_add_bond_invoice_request_display( 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 --orderid {} --invoice ", + " mostro-cli addbondinvoice -o {} -i ", req.order .id .map(|x| x.to_string()) From 0a3d0770d4be08da2aa95fbbf6130cad1301be41 Mon Sep 17 00:00:00 2001 From: grunch Date: Thu, 21 May 2026 12:25:13 -0300 Subject: [PATCH 4/4] fix(add-bond-invoice): pin newest info event and require a bolt11 reply - fetch_bond_claim_window_days: select the kind-38385 info event by max created_at instead of relying on limit(1) + .first(), so a lagging or multi-relay setup can't surface a stale bond_payout_claim_window_days and render the wrong, user-facing forfeit deadline. - execute_add_bond_invoice: the protocol's bond-payout reply is a bolt11, so stop forwarding Lightning Addresses verbatim (they'd bounce back as cant-do/invalid-invoice from Mostro). Validate the bolt11 locally and fail fast with a clear error; drop the now-unused LightningAddress/FromStr imports. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/add_bond_invoice.rs | 22 ++++++++-------------- src/util/events.rs | 9 ++++++--- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/cli/add_bond_invoice.rs b/src/cli/add_bond_invoice.rs index 68c9a11..83d77f4 100644 --- a/src/cli/add_bond_invoice.rs +++ b/src/cli/add_bond_invoice.rs @@ -4,10 +4,8 @@ use crate::parser::common::{ 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 lnurl::lightning_address::LightningAddress; use mostro_core::prelude::*; use nostr_sdk::prelude::*; -use std::str::FromStr; use uuid::Uuid; /// Reply to a Mostro `add-bond-invoice` request: the non-slashed counterparty @@ -50,18 +48,14 @@ pub async fn execute_add_bond_invoice(order_id: &Uuid, invoice: &str, ctx: &Cont )); println!("{table}"); println!("💡 Sending bond payout invoice to Mostro...\n"); - // Parse invoice (Lightning address or BOLT11) and build payload - let ln_addr = LightningAddress::from_str(invoice); - let payload = if ln_addr.is_ok() { - Payload::PaymentRequest(None, invoice.to_string(), None) - } else { - match is_valid_invoice(invoice) { - Ok(i) => Payload::PaymentRequest(None, i.to_string(), None), - Err(e) => { - return Err(anyhow::anyhow!("Invalid invoice: {}", e)); - } - } - }; + // 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; diff --git a/src/util/events.rs b/src/util/events.rs index 93d458a..63bad26 100644 --- a/src/util/events.rs +++ b/src/util/events.rs @@ -94,8 +94,7 @@ pub fn create_filter( 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)) - .limit(1); + .kind(nostr_sdk::Kind::Custom(NOSTR_INFO_EVENT_KIND)); let events = ctx .client @@ -103,7 +102,11 @@ pub async fn fetch_bond_claim_window_days(ctx: &crate::cli::Context) -> Option