Skip to content

Commit 15ffb45

Browse files
authored
Merge pull request #169 from MostroP2P/feat/add-bond-invoice
feat(add-bond-invoice): implement AddBondInvoice action
2 parents c08b8a5 + 0a3d077 commit 15ffb45

11 files changed

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

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 -o {} -i <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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,39 @@ 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+
99+
let events = ctx
100+
.client
101+
.fetch_events(filter, FETCH_EVENTS_TIMEOUT)
102+
.await
103+
.ok()?;
104+
105+
// kind-38385 is replaceable, but pick the newest revision by `created_at`
106+
// explicitly: a lagging relay (or several relays at once) can still surface
107+
// an older copy, and a stale claim window would render the wrong, very
108+
// user-facing forfeit deadline.
109+
let event = events.iter().max_by_key(|e| e.created_at)?;
110+
for tag in event.tags.iter() {
111+
let slice = tag.as_slice();
112+
if slice.first().map(String::as_str) == Some("bond_payout_claim_window_days") {
113+
return slice.get(1).and_then(|v| v.parse::<i64>().ok());
114+
}
115+
}
116+
None
117+
}
118+
86119
#[allow(clippy::too_many_arguments)]
87120
pub async fn fetch_events_list(
88121
list_kind: ListKind,

0 commit comments

Comments
 (0)