Skip to content

Commit a29e90f

Browse files
grunchclaude
andcommitted
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 <id> -i <inv>` 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) <noreply@anthropic.com>
1 parent c08b8a5 commit a29e90f

10 files changed

Lines changed: 316 additions & 24 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ reqwest = { version = "0.12.23", default-features = false, features = [
4646
"json",
4747
"rustls-tls",
4848
] }
49-
mostro-core = "0.11.1"
49+
mostro-core = "0.11.3"
5050
lnurl-rs = { version = "0.9.0", default-features = false, features = ["ureq"] }
5151
pretty_env_logger = "0.5.0"
5252
sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio-rustls"] }

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,7 @@ Every command supports `-h, --help`. The list below is a one-line summary; run `
453453
- `cancel -o <id>` — cancel a pending order or cooperatively cancel later.
454454
- `rate -o <id> -r <1-5>` — rate counterpart.
455455
- `dispute -o <id>` — open a dispute.
456+
- `addbondinvoice -o <id> -i <invoice>` — reply to a bond payout request with an invoice for your share of a slashed bond.
456457
457458
### Messaging
458459
- `getdm [--since <min>] [--from-user]` — fetch recent DMs.

src/cli.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod add_bond_invoice;
12
pub mod add_invoice;
23
pub mod adm_send_dm;
34
pub mod conversation_key;
@@ -17,6 +18,7 @@ pub mod send_msg;
1718
pub mod take_dispute;
1819
pub mod take_order;
1920

21+
use crate::cli::add_bond_invoice::execute_add_bond_invoice;
2022
use crate::cli::add_invoice::execute_add_invoice;
2123
use crate::cli::adm_send_dm::execute_adm_send_dm;
2224
use crate::cli::conversation_key::execute_conversation_key;
@@ -169,6 +171,15 @@ pub enum Commands {
169171
#[arg(short, long)]
170172
invoice: String,
171173
},
174+
/// Reply to a bond payout request with an invoice for your share of a slashed bond
175+
AddBondInvoice {
176+
/// Order id
177+
#[arg(short, long)]
178+
order_id: Uuid,
179+
/// Invoice string
180+
#[arg(short, long)]
181+
invoice: String,
182+
},
172183
/// Get the latest direct messages
173184
GetDm {
174185
/// Since time of the messages in minutes
@@ -577,6 +588,9 @@ impl Commands {
577588
Commands::AddInvoice { order_id, invoice } => {
578589
execute_add_invoice(order_id, invoice, ctx).await
579590
}
591+
Commands::AddBondInvoice { order_id, invoice } => {
592+
execute_add_bond_invoice(order_id, invoice, ctx).await
593+
}
580594
Commands::Rate { order_id, rating } => execute_rate_user(order_id, rating, ctx).await,
581595

582596
// DM retrieval commands

src/cli/add_bond_invoice.rs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
use crate::parser::common::{
2+
create_emoji_field_row, create_field_value_header, create_standard_table,
3+
};
4+
use crate::util::{print_dm_events, send_dm, wait_for_dm};
5+
use crate::{cli::Context, db::Order, lightning::is_valid_invoice};
6+
use anyhow::Result;
7+
use lnurl::lightning_address::LightningAddress;
8+
use mostro_core::prelude::*;
9+
use nostr_sdk::prelude::*;
10+
use std::str::FromStr;
11+
use uuid::Uuid;
12+
13+
/// Reply to a Mostro `add-bond-invoice` request: the non-slashed counterparty
14+
/// provides a bolt11 sized at their share of a slashed bond.
15+
///
16+
/// This is the inbound `add-bond-invoice` request's dual — Mostro asks for a
17+
/// bolt11 (carried as [`Payload::BondPayoutRequest`]) and we answer with the
18+
/// invoice in the standard [`Payload::PaymentRequest`] shape, signed with the
19+
/// order's trade key. See the protocol's "Bond payout invoice" action.
20+
pub async fn execute_add_bond_invoice(order_id: &Uuid, invoice: &str, ctx: &Context) -> Result<()> {
21+
// Get order from order id
22+
let order = Order::get_by_id(&ctx.pool, &order_id.to_string()).await?;
23+
// Get trade keys of specific order (the non-slashed counterparty side)
24+
let trade_keys = order
25+
.trade_keys
26+
.clone()
27+
.ok_or(anyhow::anyhow!("Missing trade keys"))?;
28+
29+
let order_trade_keys = Keys::parse(&trade_keys)?;
30+
31+
println!("🪙 Add Bond Payout Invoice");
32+
println!("═══════════════════════════════════════");
33+
34+
let mut table = create_standard_table();
35+
table.set_header(create_field_value_header());
36+
table.add_row(create_emoji_field_row(
37+
"📋 ",
38+
"Order ID",
39+
&order_id.to_string(),
40+
));
41+
table.add_row(create_emoji_field_row(
42+
"🔑 ",
43+
"Trade Keys",
44+
&order_trade_keys.public_key().to_hex(),
45+
));
46+
table.add_row(create_emoji_field_row(
47+
"🎯 ",
48+
"Target",
49+
&ctx.mostro_pubkey.to_string(),
50+
));
51+
println!("{table}");
52+
println!("💡 Sending bond payout invoice to Mostro...\n");
53+
// Parse invoice (Lightning address or BOLT11) and build payload
54+
let ln_addr = LightningAddress::from_str(invoice);
55+
let payload = if ln_addr.is_ok() {
56+
Some(Payload::PaymentRequest(None, invoice.to_string(), None))
57+
} else {
58+
match is_valid_invoice(invoice) {
59+
Ok(i) => Some(Payload::PaymentRequest(None, i.to_string(), None)),
60+
Err(e) => {
61+
return Err(anyhow::anyhow!("Invalid invoice: {}", e));
62+
}
63+
}
64+
};
65+
66+
// Create request id
67+
let request_id = Uuid::new_v4().as_u128() as u64;
68+
// Create AddBondInvoice reply message
69+
let add_bond_invoice_message = Message::new_order(
70+
Some(*order_id),
71+
Some(request_id),
72+
None,
73+
Action::AddBondInvoice,
74+
payload,
75+
);
76+
77+
// Serialize the message
78+
let message_json = add_bond_invoice_message
79+
.as_json()
80+
.map_err(|_| anyhow::anyhow!("Failed to serialize message"))?;
81+
82+
// Send the DM
83+
let sent_message = send_dm(
84+
&ctx.client,
85+
&ctx.identity_keys,
86+
&order_trade_keys,
87+
&ctx.mostro_pubkey,
88+
message_json,
89+
None,
90+
false,
91+
);
92+
93+
// Wait for a possible reply. On success Mostro pays the invoice from its
94+
// wallet without acknowledging over Nostr, so a timeout here is the happy
95+
// path; Mostro only answers with `cant-do` on failure (late reply, wrong
96+
// sender, bad invoice, etc.).
97+
match wait_for_dm(ctx, Some(&order_trade_keys), sent_message).await {
98+
Ok(recv_event) => {
99+
print_dm_events(recv_event, request_id, ctx, Some(&order_trade_keys)).await?;
100+
}
101+
Err(_) => {
102+
println!("✅ Bond payout invoice submitted to Mostro.");
103+
println!("💡 Mostro will pay it from its wallet; no further confirmation is sent.");
104+
println!("💡 Run `get-dm` to check for a `cant-do` response in case of an error.");
105+
}
106+
}
107+
108+
Ok(())
109+
}

src/cli/get_dm.rs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
use anyhow::Result;
2-
use mostro_core::prelude::Message;
2+
use mostro_core::prelude::{Action, Message, Payload};
33
use nostr_sdk::prelude::*;
44

55
use crate::{
66
cli::Context,
77
parser::common::{print_key_value, print_section_header},
88
parser::dms::print_direct_messages,
9-
util::{fetch_events_list, Event, ListKind},
9+
util::{fetch_bond_claim_window_days, fetch_events_list, Event, ListKind},
1010
};
1111

1212
pub async fn execute_get_dm(
@@ -42,6 +42,19 @@ pub async fn execute_get_dm(
4242
}
4343
}
4444

45-
print_direct_messages(&dm_events, Some(ctx.mostro_pubkey)).await?;
45+
// Only hit the relay for the node's claim window when an inbound bond
46+
// payout request is actually present, so the common get-dm path stays cheap.
47+
let has_bond_payout_request = dm_events.iter().any(|(message, _, _)| {
48+
let inner = message.get_inner_message_kind();
49+
inner.action == Action::AddBondInvoice
50+
&& matches!(inner.payload, Some(Payload::BondPayoutRequest(_)))
51+
});
52+
let claim_window_days = if has_bond_payout_request {
53+
fetch_bond_claim_window_days(ctx).await
54+
} else {
55+
None
56+
};
57+
58+
print_direct_messages(&dm_events, Some(ctx.mostro_pubkey), claim_window_days).await?;
4659
Ok(())
4760
}

src/parser/dms.rs

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use crate::{
1818
print_payment_method, print_premium, print_required_amount, print_section_header,
1919
print_success_message, print_trade_index,
2020
},
21-
util::save_order,
21+
util::{fetch_bond_claim_window_days, save_order},
2222
};
2323
use serde_json;
2424

@@ -98,14 +98,80 @@ fn handle_pay_bond_invoice_display(order: &Option<mostro_core::order::SmallOrder
9898
println!();
9999
}
100100

101+
/// Render the forfeit deadline of a bond payout request.
102+
///
103+
/// The protocol mandates computing it from the on-wire `slashed_at` anchor
104+
/// (never the local receive time):
105+
/// `deadline = slashed_at + bond_payout_claim_window_days * 86_400`. The claim
106+
/// window comes from the node's kind-38385 info event and may be unavailable.
107+
fn format_bond_forfeit_deadline(slashed_at: i64, claim_window_days: Option<i64>) -> String {
108+
let slashed = format_timestamp(slashed_at);
109+
match claim_window_days {
110+
Some(days) => {
111+
let deadline_ts = slashed_at.saturating_add(days.saturating_mul(86_400));
112+
format!(
113+
"Slashed at {} — forfeit deadline {} ({} day claim window)",
114+
slashed,
115+
format_timestamp(deadline_ts),
116+
days
117+
)
118+
}
119+
None => format!(
120+
"Slashed at {} — claim window unknown (Mostro info event unavailable)",
121+
slashed
122+
),
123+
}
124+
}
125+
126+
/// Display an inbound `add-bond-invoice` request (Mostro → non-slashed
127+
/// counterparty): the order context plus the locally-rendered forfeit deadline.
128+
fn handle_add_bond_invoice_request_display(
129+
req: &BondPayoutRequest,
130+
claim_window_days: Option<i64>,
131+
) {
132+
print_section_header("🪙 Bond Payout Invoice Requested");
133+
if let Some(order_id) = req.order.id {
134+
println!("📋 Order ID: {}", order_id);
135+
}
136+
print_required_amount(req.order.amount);
137+
print_fiat_code(&req.order.fiat_code);
138+
println!("💵 Fiat Amount: {}", req.order.fiat_amount);
139+
print_payment_method(&req.order.payment_method);
140+
println!(
141+
"⏰ {}",
142+
format_bond_forfeit_deadline(req.slashed_at, claim_window_days)
143+
);
144+
println!();
145+
println!("💡 A bond on this trade was slashed; you can claim your share.");
146+
println!("💡 Reply before the deadline with a Lightning invoice for the amount above:");
147+
println!(
148+
" mostro-cli addbondinvoice --orderid {} --invoice <bolt11>",
149+
req.order
150+
.id
151+
.map(|x| x.to_string())
152+
.unwrap_or_else(|| "<order-id>".to_string())
153+
);
154+
println!();
155+
}
156+
101157
/// Format payload details for DM table display
102-
fn format_payload_details(payload: &Payload, action: &Action) -> String {
158+
fn format_payload_details(
159+
payload: &Payload,
160+
action: &Action,
161+
claim_window_days: Option<i64>,
162+
) -> String {
103163
match payload {
104164
Payload::TextMessage(t) => format!("✉️ {}", t),
105165
Payload::PaymentRequest(_, inv, _) => {
106166
// For invoices, show the full invoice without truncation
107167
format!("⚡ Lightning Invoice:\n{}", inv)
108168
}
169+
Payload::BondPayoutRequest(req) => format!(
170+
"🪙 Bond payout request: {} sats ({})\n⏰ {}",
171+
req.order.amount,
172+
req.order.fiat_code,
173+
format_bond_forfeit_deadline(req.slashed_at, claim_window_days)
174+
),
109175
Payload::Dispute(id, _) => format!("⚖️ Dispute ID: {}", id),
110176
Payload::Order(o) if *action == Action::NewOrder => format!(
111177
"🆕 New Order: {} {} sats ({})",
@@ -537,6 +603,20 @@ pub async fn print_commands_results(message: &MessageKind, ctx: &Context) -> Res
537603
print_success_message("Order saved successfully!");
538604
Ok(())
539605
}
606+
// mostro-core 0.11.3: bond payout invoice request sent to the
607+
// non-slashed counterparty after a bond is slashed. Reply with
608+
// `add-bond-invoice` carrying a bolt11 for your share.
609+
Action::AddBondInvoice => match &message.payload {
610+
Some(Payload::BondPayoutRequest(req)) => {
611+
let claim_window_days = fetch_bond_claim_window_days(ctx).await;
612+
handle_add_bond_invoice_request_display(req, claim_window_days);
613+
Ok(())
614+
}
615+
other => Err(anyhow::anyhow!(
616+
"AddBondInvoice expected Payload::BondPayoutRequest, got: {:?}",
617+
other
618+
)),
619+
},
540620
Action::CantDo => {
541621
println!("❌ Action Cannot Be Completed");
542622
println!("═══════════════════════════════════════");
@@ -871,6 +951,7 @@ pub async fn parse_dm_events(
871951
pub async fn print_direct_messages(
872952
dm: &[(Message, u64, PublicKey)],
873953
mostro_pubkey: Option<PublicKey>,
954+
claim_window_days: Option<i64>,
874955
) -> Result<()> {
875956
if dm.is_empty() {
876957
println!();
@@ -896,6 +977,7 @@ pub async fn print_direct_messages(
896977
Action::NewOrder => "🆕",
897978
Action::AddInvoice | Action::PayInvoice => "⚡",
898979
Action::PayBondInvoice => "🪙",
980+
Action::AddBondInvoice => "💰",
899981
Action::FiatSent | Action::FiatSentOk => "💸",
900982
Action::Release | Action::Released => "🔓",
901983
Action::Cancel | Action::Canceled => "🚫",
@@ -927,7 +1009,7 @@ pub async fn print_direct_messages(
9271009

9281010
// Print details with proper formatting
9291011
if let Some(payload) = &inner.payload {
930-
let details = format_payload_details(payload, &inner.action);
1012+
let details = format_payload_details(payload, &inner.action, claim_window_days);
9311013
println!("📝 Details:");
9321014
for line in details.lines() {
9331015
println!(" {}", line);

src/util/events.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,36 @@ pub fn create_filter(
8383
}
8484
}
8585

86+
/// Fetch the Mostro instance's kind-38385 info event and read the
87+
/// `bond_payout_claim_window_days` tag.
88+
///
89+
/// Returns `None` when the node publishes no info event, the tag is absent
90+
/// (older daemon or bonds disabled), or the value can't be parsed. Used to
91+
/// render the forfeit deadline for an `add-bond-invoice` request locally, per
92+
/// the protocol's "Bond payout invoice" / "Other events" docs. Best-effort:
93+
/// any relay error degrades to `None` rather than failing the caller.
94+
pub async fn fetch_bond_claim_window_days(ctx: &crate::cli::Context) -> Option<i64> {
95+
let filter = Filter::new()
96+
.author(ctx.mostro_pubkey)
97+
.kind(nostr_sdk::Kind::Custom(NOSTR_INFO_EVENT_KIND))
98+
.limit(1);
99+
100+
let events = ctx
101+
.client
102+
.fetch_events(filter, FETCH_EVENTS_TIMEOUT)
103+
.await
104+
.ok()?;
105+
106+
let event = events.first()?;
107+
for tag in event.tags.iter() {
108+
let slice = tag.as_slice();
109+
if slice.first().map(String::as_str) == Some("bond_payout_claim_window_days") {
110+
return slice.get(1).and_then(|v| v.parse::<i64>().ok());
111+
}
112+
}
113+
None
114+
}
115+
86116
#[allow(clippy::too_many_arguments)]
87117
pub async fn fetch_events_list(
88118
list_kind: ListKind,

src/util/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ pub mod storage;
66
pub mod types;
77

88
// Re-export commonly used items to preserve existing import paths
9-
pub use events::{create_filter, fetch_events_list, FETCH_EVENTS_TIMEOUT};
9+
pub use events::{
10+
create_filter, fetch_bond_claim_window_days, fetch_events_list, FETCH_EVENTS_TIMEOUT,
11+
};
1012
pub use messaging::{
1113
derive_shared_key_hex, derive_shared_keys, keys_from_shared_hex, print_dm_events,
1214
send_admin_chat_message_via_shared_key, send_dm, send_plain_text_dm, wait_for_dm,

0 commit comments

Comments
 (0)