Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/sell order flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ Typical `Status` values follow the same global order machine as other trades (`w

Phases (labels from `SELL_ORDER_FLOW_STEPS_TAKER`):

0. **Pay anti-abuse bond (taker / buyer)** — *Mostro Phase 1.5+ only, configurable in mostrod*: when the daemon has bonds enabled, the first DM after `take-sell` is `pay-bond-invoice` with `Status::WaitingTakerBond`. Mostrix opens the dedicated **🛡️ Anti-abuse Bond Invoice** popup (`render_pay_bond_invoice` in `src/ui/message_notification.rs`); bond is **locked, not spent** and refunded on normal completion. Unlike the buy-listing taker, a sell-listing taker only ever receives **`PayBondInvoice`** here (never `PayInvoice`). When bonds are **disabled** on the daemon, this phase is skipped and the flow starts directly at step 1 — Mostrix never assumes a bond exists.
1. **Add invoice** — buyer submits a **BOLT11** payment request or a **Lightning address** (`user@domain.com`) when required; Mostrix verifies LNURL-pay metadata (`payRequest`) before sending `AddInvoice` for addresses. Optional default address: **User → Settings → Set Lightning Address** (`settings.toml` field `ln_address`). When that field is non-empty, this phase may show **`ConfirmSavedLnAddressForInvoice`** first (**YES** = immediate **`AddInvoice`** with saved address via **`submit_add_invoice`**; **NO** = manual invoice popup); see **`notifications_ch_mng.rs`**.
0. **Pay anti-abuse bond (taker / buyer)** — *Mostro Phase 1.5+ only, configurable in mostrod*: when the daemon has bonds enabled, the first DM after `take-sell` is `pay-bond-invoice` with `Status::WaitingTakerBond`. Mostrix opens the dedicated **🛡️ Anti-abuse Bond Invoice** popup (`render_pay_bond_invoice` in `src/ui/message_notification.rs`); bond is **locked, not spent** and refunded on normal completion. Bond sats are **popup-only** (`sat_amount`); they are never persisted as the trade `orders.amount`. Unlike the buy-listing taker, a sell-listing taker only ever receives **`PayBondInvoice`** here (never `PayInvoice`). When bonds are **disabled** on the daemon, this phase is skipped and the flow starts directly at step 1 — Mostrix never assumes a bond exists.
1. **Add invoice** — after the bond locks, Mostro sends `AddInvoice` with **trade sats − fee** (not the bond size). The buyer submits a **BOLT11** payment request or a **Lightning address** (`user@domain.com`) for that trade amount; Mostrix verifies LNURL-pay metadata (`payRequest`) before sending `AddInvoice` for addresses. Optional default address: **User → Settings → Set Lightning Address** (`settings.toml` field `ln_address`). When that field is non-empty, this phase may show **`ConfirmSavedLnAddressForInvoice`** first (**YES** = immediate **`AddInvoice`** with saved address via **`submit_add_invoice`**; **NO** = manual invoice popup); see **`notifications_ch_mng.rs`**.
2. **Wait for seller** — seller pays hold / completes prerequisites.
3. **Chat with buyer** — messaging phase (label uses “Buyer” from book side).
4. **Send fiat** — buyer sends fiat.
Expand Down
394 changes: 338 additions & 56 deletions src/util/dm_utils/mod.rs

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions src/util/dm_utils/order_ch_mng.rs
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,11 @@ mod tests {
};
let notification = order_message_to_notification(&order_message);

// Pin the manual-invoice branch so a machine-local saved `ln_address` in
// `~/.mostrix/settings.toml` can't route this to the saved-address confirm popup.
app.buyer_invoice_preference
.insert(order_id, BuyerInvoicePreference::ManualInvoice);

handle_operation_result(
OperationResult::OpenInvoicePopup {
notification,
Expand Down
302 changes: 302 additions & 0 deletions src/util/order_utils/add_invoice_validate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
//! MOSTRO-078 take-sell `AddInvoice` sats validation (sync take path + DM listener).

use anyhow::Result;
use mostro_core::prelude::*;

/// How to verify fixed-price buyer-invoice sats when Mostro fee may be missing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FeeCheck {
/// Sync `take_order`: fee is required; amount must equal book − split fee.
ExactRequired,
/// DM listener after bond: if fee is known, exact match; otherwise accept
/// `0 < returned.amount <= book` after identity/fiat checks.
ExactOrUpperBound,
}

/// Mostro split fee charged to each party (`fee_rate * amount / 2`, rounded).
///
/// Mirrors `mostro::util::get_fee` so the buyer-invoice amount can be checked
/// against the book order the user took (MOSTRO-078).
pub fn mostro_split_fee(amount: i64, fee_rate: f64) -> i64 {
((fee_rate * amount as f64) / 2.0).round() as i64
}

/// Buyer payout invoice sats for a fixed-price take: `amount - split_fee`.
pub fn expected_buyer_invoice_sats(book_amount: i64, fee_rate: f64) -> i64 {
book_amount.saturating_sub(mostro_split_fee(book_amount, fee_rate))
}

/// Cross-check a take-sell `AddInvoice` SmallOrder against the book order the user took.
///
/// Returns the trusted sats amount to show on the AddInvoice popup and to persist.
/// Fixed-price books (`requested.amount > 0`) must equal
/// [`expected_buyer_invoice_sats`] when fee is available; market-price books
/// (`amount == 0`) only require a positive daemon quote after id / kind / status / fiat checks.
///
/// All identity fields on the reply (`id`, `kind`, `status`, `fiat_code`, `fiat_amount`)
/// are required — omitted or empty values are rejected (MOSTRO-078 fail-closed).
///
/// # Errors
///
/// Missing/mismatched id, kind, status, fiat, or sats; missing fee for fixed-price
/// under [`FeeCheck::ExactRequired`]; non-positive market quote (MOSTRO-078).
pub fn validate_take_sell_add_invoice_reply(
requested: &SmallOrder,
returned: &SmallOrder,
take_fiat_amount: Option<i64>,
fee_rate: Option<f64>,
) -> Result<i64> {
validate_take_sell_add_invoice_reply_with_fee_check(
requested,
returned,
take_fiat_amount,
fee_rate,
FeeCheck::ExactRequired,
)
}

/// Same as [`validate_take_sell_add_invoice_reply`] with an explicit [`FeeCheck`] policy.
pub fn validate_take_sell_add_invoice_reply_with_fee_check(
requested: &SmallOrder,
returned: &SmallOrder,
take_fiat_amount: Option<i64>,
fee_rate: Option<f64>,
fee_check: FeeCheck,
) -> Result<i64> {
let req_id = requested
.id
.ok_or_else(|| anyhow::anyhow!("Taken order is missing id"))?;
let ret_id = returned
.id
.ok_or_else(|| anyhow::anyhow!("AddInvoice reply missing order id"))?;
if req_id != ret_id {
return Err(anyhow::anyhow!(
"AddInvoice order id mismatch: took {}, daemon sent {}",
req_id,
ret_id
));
}

let kind = returned
.kind
.ok_or_else(|| anyhow::anyhow!("AddInvoice reply missing order kind"))?;
if requested.kind.is_some_and(|k| k != kind) {
return Err(anyhow::anyhow!(
"AddInvoice order kind mismatch: expected {:?}, got {:?}",
requested.kind,
kind
));
}
if kind != mostro_core::order::Kind::Sell {
return Err(anyhow::anyhow!(
"AddInvoice after take-sell must be a sell order, got {:?}",
kind
));
}

let status = returned
.status
.ok_or_else(|| anyhow::anyhow!("AddInvoice reply missing order status"))?;
if status != Status::WaitingBuyerInvoice {
return Err(anyhow::anyhow!(
"AddInvoice status mismatch: expected WaitingBuyerInvoice, got {:?}",
status
));
}

if returned.fiat_code.is_empty() {
return Err(anyhow::anyhow!("AddInvoice reply missing fiat code"));
}
if returned.fiat_code != requested.fiat_code {
return Err(anyhow::anyhow!(
"AddInvoice fiat code mismatch: expected {}, got {}",
requested.fiat_code,
returned.fiat_code
));
}

let expected_fiat = take_fiat_amount.unwrap_or(requested.fiat_amount);
if expected_fiat <= 0 {
return Err(anyhow::anyhow!(
"AddInvoice expected fiat amount must be positive, got {}",
expected_fiat
));
}
if returned.fiat_amount != expected_fiat {
return Err(anyhow::anyhow!(
"AddInvoice fiat amount mismatch: expected {}, got {}",
expected_fiat,
returned.fiat_amount
));
}

// Fixed-price book orders: buyer invoice is amount − Mostro split fee.
if requested.amount > 0 {
if let Some(rate) = fee_rate {
let expected = expected_buyer_invoice_sats(requested.amount, rate);
if returned.amount != expected {
return Err(anyhow::anyhow!(
"AddInvoice sats mismatch: expected {} (book {} minus fee), got {}",
expected,
requested.amount,
returned.amount
));
}
return Ok(expected);
}
match fee_check {
FeeCheck::ExactRequired => {
return Err(anyhow::anyhow!(
"Cannot verify AddInvoice sats without Mostro fee from instance info"
));
}
FeeCheck::ExactOrUpperBound => {
if returned.amount <= 0 || returned.amount > requested.amount {
return Err(anyhow::anyhow!(
"AddInvoice sats out of range without fee: got {} (book {})",
returned.amount,
requested.amount
));
}
log::warn!(
"AddInvoice sats accepted with upper-bound check only (fee unavailable): {} <= book {}",
returned.amount,
requested.amount
);
return Ok(returned.amount);
}
}
}

// Market-price book (amount == 0): sats are quoted by Mostro; require a positive
// amount and rely on id/fiat/status checks above.
if returned.amount <= 0 {
return Err(anyhow::anyhow!(
"AddInvoice market-price reply missing positive sats amount"
));
}
Ok(returned.amount)
}

#[cfg(test)]
mod tests {
use super::*;

fn sample_small_order(id: uuid::Uuid) -> SmallOrder {
SmallOrder {
id: Some(id),
kind: Some(mostro_core::order::Kind::Sell),
status: Some(Status::WaitingBuyerInvoice),
amount: 21_000,
fiat_code: "USD".to_string(),
fiat_amount: 100,
payment_method: "SEPA".to_string(),
premium: 0,
..Default::default()
}
}

#[test]
fn split_fee_matches_mostro_half_rate_rounding() {
assert_eq!(mostro_split_fee(21_000, 0.01), 105);
assert_eq!(expected_buyer_invoice_sats(21_000, 0.01), 20_895);
}

#[test]
fn validate_add_invoice_accepts_fixed_amount_minus_fee() {
let id = uuid::Uuid::new_v4();
let requested = sample_small_order(id);
let mut returned = sample_small_order(id);
returned.amount = expected_buyer_invoice_sats(21_000, 0.01);
let sats =
validate_take_sell_add_invoice_reply(&requested, &returned, None, Some(0.01)).unwrap();
assert_eq!(sats, 20_895);
}

#[test]
fn validate_add_invoice_rejects_fabricated_sats() {
let id = uuid::Uuid::new_v4();
let requested = sample_small_order(id);
let mut returned = sample_small_order(id);
returned.amount = 1;
let err = validate_take_sell_add_invoice_reply(&requested, &returned, None, Some(0.01))
.expect_err("fabricated sats must fail");
assert!(err.to_string().contains("sats mismatch"));
}

#[test]
fn validate_add_invoice_requires_fee_for_fixed_amount_exact() {
let id = uuid::Uuid::new_v4();
let requested = sample_small_order(id);
let mut returned = sample_small_order(id);
returned.amount = 20_895;
let err = validate_take_sell_add_invoice_reply(&requested, &returned, None, None)
.expect_err("fixed amount without fee must fail");
assert!(err.to_string().contains("fee"));
}

#[test]
fn validate_add_invoice_upper_bound_without_fee() {
let id = uuid::Uuid::new_v4();
let requested = sample_small_order(id);
let mut returned = sample_small_order(id);
returned.amount = 20_895;
let sats = validate_take_sell_add_invoice_reply_with_fee_check(
&requested,
&returned,
None,
None,
FeeCheck::ExactOrUpperBound,
)
.unwrap();
assert_eq!(sats, 20_895);

returned.amount = 21_001;
let err = validate_take_sell_add_invoice_reply_with_fee_check(
&requested,
&returned,
None,
None,
FeeCheck::ExactOrUpperBound,
)
.expect_err("above book must fail");
assert!(err.to_string().contains("out of range"));
}

#[test]
fn validate_add_invoice_market_price_requires_positive_sats() {
let id = uuid::Uuid::new_v4();
let mut requested = sample_small_order(id);
requested.amount = 0;
let mut returned = sample_small_order(id);
returned.amount = 50_000;
let sats = validate_take_sell_add_invoice_reply(&requested, &returned, None, None).unwrap();
assert_eq!(sats, 50_000);

returned.amount = 0;
let err = validate_take_sell_add_invoice_reply(&requested, &returned, None, None)
.expect_err("zero market sats must fail");
assert!(err.to_string().contains("positive sats"));
}

#[test]
fn validate_add_invoice_checks_range_fiat_amount() {
let id = uuid::Uuid::new_v4();
let mut requested = sample_small_order(id);
requested.amount = 0;
requested.min_amount = Some(50);
requested.max_amount = Some(200);
requested.fiat_amount = 0;
let mut returned = sample_small_order(id);
returned.amount = 40_000;
returned.fiat_amount = 75;
let sats =
validate_take_sell_add_invoice_reply(&requested, &returned, Some(75), None).unwrap();
assert_eq!(sats, 40_000);

returned.fiat_amount = 99;
let err = validate_take_sell_add_invoice_reply(&requested, &returned, Some(75), None)
.expect_err("fiat mismatch must fail");
assert!(err.to_string().contains("fiat amount mismatch"));
}
}
18 changes: 17 additions & 1 deletion src/util/order_utils/helper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,10 @@ pub(super) fn build_order_chat_static_header(
}

/// Persist order + track subscription, then build `PaymentRequestRequired` for invoice popups.
///
/// For `PayBondInvoice`, `trade_amount_to_persist` is written to `orders.amount` (book/trade
/// sats, or `0` for market/range). Bond sats stay in `sat_amount` for the bond popup only —
/// never persist the bond floor as the trade amount.
#[allow(clippy::too_many_arguments)]
pub(super) async fn payment_request_operation_result(
inner_action: Action,
Expand All @@ -673,6 +677,7 @@ pub(super) async fn payment_request_operation_result(
is_mine: bool,
dm_subscription_tx: Option<&UnboundedSender<OrderDmSubscriptionCmd>>,
log_prefix: &str,
trade_amount_to_persist: Option<i64>,
) -> Result<OperationResult> {
let popup_action = match inner_action {
Action::PayBondInvoice => Action::PayBondInvoice,
Expand Down Expand Up @@ -704,6 +709,13 @@ pub(super) async fn payment_request_operation_result(
"[{log_prefix}] Action::{popup_action:?} response mapped to effective_order_id={effective_order_id}, trade_index={next_idx}"
);

// Capture bond/invoice sats before overwriting amount for PayBondInvoice persist.
let popup_sat_amount = opt_amount.or(Some(order_to_save.amount));

if matches!(popup_action, Action::PayBondInvoice) {
order_to_save.amount = trade_amount_to_persist.unwrap_or(0);
}

if let Err(e) = save_order(
order_to_save.clone(),
trade_keys,
Expand Down Expand Up @@ -736,7 +748,11 @@ pub(super) async fn payment_request_operation_result(
},
)?;

let sat_amount = opt_amount.or(Some(order_to_save.amount));
let sat_amount = if matches!(popup_action, Action::PayBondInvoice) {
popup_sat_amount
} else {
opt_amount.or(Some(order_to_save.amount))
};

Ok(OperationResult::PaymentRequestRequired {
order: order_to_save,
Expand Down
5 changes: 5 additions & 0 deletions src/util/order_utils/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Order utilities module
mod add_invoice_validate;
mod bond_resolution;
mod execute_add_invoice;
mod execute_admin_add_solver;
Expand All @@ -16,6 +17,10 @@ mod send_new_order;
mod take_order;

// Re-export public functions
pub use add_invoice_validate::{
expected_buyer_invoice_sats, mostro_split_fee, validate_take_sell_add_invoice_reply,
validate_take_sell_add_invoice_reply_with_fee_check, FeeCheck,
};
pub use bond_resolution::BondSlashChoice;
pub use execute_add_invoice::{execute_add_bond_invoice, execute_add_invoice};
pub use execute_admin_add_solver::execute_admin_add_solver;
Expand Down
1 change: 1 addition & 0 deletions src/util/order_utils/send_new_order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ pub async fn send_new_order(
true,
dm_subscription_tx,
"send_new_order",
Some(amount),
)
.await
} else {
Expand Down
Loading