diff --git a/docs/sell order flow.md b/docs/sell order flow.md index e6f9488..c9b91a7 100644 --- a/docs/sell order flow.md +++ b/docs/sell order flow.md @@ -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. diff --git a/src/util/dm_utils/mod.rs b/src/util/dm_utils/mod.rs index 7808aa4..1d85e78 100644 --- a/src/util/dm_utils/mod.rs +++ b/src/util/dm_utils/mod.rs @@ -44,7 +44,7 @@ use crate::util::filters::filter_protocol_dm_from_mostro; use crate::util::mostro_info::{nostr_pow_for_protocol_dm, MostroInstanceInfo}; use crate::util::order_utils::{ inferred_status_from_trade_action, map_action_to_status, should_apply_status_transition, - should_strictly_advance_status, + should_strictly_advance_status, validate_take_sell_add_invoice_reply_with_fee_check, FeeCheck, }; use futures::StreamExt; use std::collections::BTreeSet; @@ -550,6 +550,9 @@ async fn drop_pre_active_taker_take( /// Refreshes the local `orders` row from embedded order data on trade DMs that carry a full /// `SmallOrder` (e.g. `add-invoice`, `pay-invoice`, `buyer-took-order`, `hold-invoice-payment-accepted`). +/// +/// `trusted_add_invoice_sats` β€” when set for `AddInvoice`, persist that amount (MOSTRO-078 / +/// post-bond validation) instead of the daemon payload or a stale bond amount. async fn upsert_order_from_trade_dm( pool: &sqlx::SqlitePool, order_id: Uuid, @@ -557,36 +560,49 @@ async fn upsert_order_from_trade_dm( payload: &Option, request_id: Option, trade_keys: &Keys, + trusted_add_invoice_sats: Option, ) { let (label, small_order) = match (action, payload.as_ref()) { (Action::AddInvoice, Some(Payload::Order(o))) => { // MOSTRO-078: never hydrate SQLite from unvalidated daemon sats. // TrackOrder-before-save_order has no local row yet β€” defer until - // take_order persists a trusted amount. If a row exists but amount - // is still 0, wait as well. + // take_order persists a row. Post-bond range rows may have amount 0; + // hydrate only when validation supplied trusted_add_invoice_sats. let Ok(existing) = Order::get_by_id(pool, &order_id.to_string()).await else { log::info!( - "Deferring AddInvoice DB hydration for order {} until a local trusted amount exists", + "Deferring AddInvoice DB hydration for order {} until a local row exists", order_id ); return; }; - if existing.amount <= 0 { + let trusted = if let Some(sats) = trusted_add_invoice_sats { + sats + } else if existing.amount > 0 { + // Keep a previously trusted trade amount over a forged payload. + existing.amount + } else { log::info!( - "Deferring AddInvoice DB hydration for order {}: local amount is not trusted yet", + "Deferring AddInvoice DB hydration for order {}: no trusted sats yet", order_id ); return; - } + }; let mut order = o.clone(); - order.amount = existing.amount; + order.amount = trusted; ("AddInvoice", order) } (Action::PayInvoice, Some(Payload::PaymentRequest(Some(o), _, _))) => { ("PayInvoice", o.clone()) } (Action::PayBondInvoice, Some(Payload::PaymentRequest(Some(o), _, _))) => { - ("PayBondInvoice", o.clone()) + // Bond SmallOrder.amount is the bond floor β€” never write it as trade sats. + let mut order = o.clone(); + if let Ok(existing) = Order::get_by_id(pool, &order_id.to_string()).await { + order.amount = existing.amount; + } else { + order.amount = 0; + } + ("PayBondInvoice", order) } (Action::BuyerTookOrder, Some(Payload::Order(o))) => ("BuyerTookOrder", o.clone()), (Action::HoldInvoicePaymentAccepted, Some(Payload::Order(o))) => { @@ -677,6 +693,7 @@ async fn revert_maker_to_pending_on_book_republish( &inner_kind.payload, inner_kind.request_id, trade_keys, + None, ) .await; if let Err(e) = update_order_status(pool, &order_id.to_string(), Status::Pending).await { @@ -911,6 +928,62 @@ fn is_take_sell_buyer_waiting_invoice( && matches!(order_status, Some(Status::WaitingBuyerInvoice) | None) } +/// Resolve trusted buyer-invoice sats for take-sell `AddInvoice` on the DM path. +/// +/// - If a prior Messages row already carried a trusted `AddInvoice` amount, reuse it. +/// - Otherwise (typical post-`PayBondInvoice` path) validate the daemon payload against +/// the local book row. Bond sats must never be treated as trusted here. +fn resolve_take_sell_add_invoice_trusted_sats( + action: &Action, + payload: &Option, + db_order: Option<&Order>, + is_mine: Option, + prior_action: Option<&Action>, + prior_sat_amount: Option, +) -> Option { + let kind_from_db = db_order.and_then(|r| { + r.kind + .as_ref() + .and_then(|s| mostro_core::order::Kind::from_str(s).ok()) + }); + let kind_from_payload = small_order_ref_from_payload(payload).and_then(|o| o.kind); + let order_kind = kind_from_db.or(kind_from_payload); + let status_from_db = db_order.and_then(order_status_from_row); + let status_from_payload = small_order_ref_from_payload(payload).and_then(|o| o.status); + let order_status = status_from_payload.or(status_from_db); + + if !is_take_sell_buyer_waiting_invoice(action, is_mine, order_kind, order_status) { + return None; + } + + if matches!(prior_action, Some(Action::AddInvoice)) { + return prior_sat_amount; + } + + let Payload::Order(returned) = payload.as_ref()? else { + return None; + }; + let requested = db_order.map(small_order_from_db_order)?; + let take_fiat = (requested.fiat_amount > 0).then_some(requested.fiat_amount); + + match validate_take_sell_add_invoice_reply_with_fee_check( + &requested, + returned, + take_fiat, + None, + FeeCheck::ExactOrUpperBound, + ) { + Ok(sats) => Some(sats), + Err(e) => { + log::warn!( + "Rejecting untrusted take-sell AddInvoice sats for order {:?}: {e}", + requested.id + ); + None + } + } +} + /// Handle a single decoded trade DM for a given order/trade index. #[allow(clippy::too_many_arguments)] async fn handle_trade_dm_for_order( @@ -980,6 +1053,52 @@ async fn handle_trade_dm_for_order( return; } + // Prior Messages snapshot before upsert β€” needed to avoid treating bond sats as + // trusted AddInvoice amounts (post-PayBondInvoice take-sell path). + let existing_message_data = { + let messages_lock = match messages.lock() { + Ok(g) => g, + Err(e) => { + crate::util::request_fatal_restart(format!( + "Mostrix encountered an internal error (poisoned messages lock: {e}). Please restart the app." + )); + return; + } + }; + messages_lock + .iter() + .filter(|m| m.order_id == Some(order_id)) + .max_by_key(|m| m.timestamp) + .map(|m| PriorMessageSnapshot { + timestamp: m.timestamp, + action: m.message.get_inner_message_kind().action.clone(), + sat_amount: m.sat_amount, + buyer_invoice: m.buyer_invoice.clone(), + auto_popup_shown: m.auto_popup_shown, + order_kind: m.order_kind, + is_mine: m.is_mine, + order_status: m.order_status, + order_snapshot: m.order_snapshot.clone(), + buyer_reputation: m.buyer_reputation.clone(), + seller_reputation: m.seller_reputation.clone(), + }) + }; + let prior_sat_amount = existing_message_data.as_ref().and_then(|p| p.sat_amount); + let prior_action = existing_message_data.as_ref().map(|p| &p.action); + let is_mine_for_gate = db_order + .as_ref() + .map(|o| o.is_mine) + .or(existing_message_data.as_ref().and_then(|p| p.is_mine)); + + let trusted_add_invoice_sats = resolve_take_sell_add_invoice_trusted_sats( + &action, + &inner_kind.payload, + db_order.as_ref(), + is_mine_for_gate, + prior_action, + prior_sat_amount, + ); + if !matches!(action, Action::CantDo) && !taker_reputation_peer { upsert_order_from_trade_dm( pool, @@ -988,6 +1107,7 @@ async fn handle_trade_dm_for_order( &inner_kind.payload, inner_kind.request_id, trade_keys, + trusted_add_invoice_sats, ) .await; } @@ -1044,14 +1164,13 @@ async fn handle_trade_dm_for_order( maybe_track_order_chat(pool, order_id, trade_keys).await; // Extract invoice and sat_amount from payload based on action type. - // For `PayBondInvoice` mostrod populates the bond satoshis in the third - // `Option` field of `Payload::PaymentRequest` (the SmallOrder is - // `None` per mostro-core 0.11.0 wire format); for `PayInvoice` it may come - // either as that explicit override or via the embedded order's `amount`. + // For `PayBondInvoice` mostrod puts bond sats in `SmallOrder.amount` (third + // `PaymentRequest` field is usually `None`). For `PayInvoice` the amount may + // come from that override or the embedded order's `amount`. // - // MOSTRO-078: `AddInvoice` sats from `Payload::Order` are **not** trusted on - // this listener path for take-sell waiting-buyer framing β€” see - // [`is_take_sell_buyer_waiting_invoice`] / `effective_sat_amount` below. + // MOSTRO-078: `AddInvoice` sats from `Payload::Order` are validated before + // framing on the take-sell waiting-buyer path β€” see + // [`resolve_take_sell_add_invoice_trusted_sats`] / `effective_sat_amount`. let (sat_amount, invoice) = match &action { Action::PayInvoice | Action::PayBondInvoice => match &inner_kind.payload { Some(Payload::PaymentRequest(opt_order, invoice, opt_amount)) => { @@ -1081,37 +1200,6 @@ async fn handle_trade_dm_for_order( return; } - // Lock `messages` only long enough to extract comparison data, then drop it - // before touching `pending_notifications` to avoid lock-order deadlocks. - let existing_message_data = { - let messages_lock = match messages.lock() { - Ok(g) => g, - Err(e) => { - crate::util::request_fatal_restart(format!( - "Mostrix encountered an internal error (poisoned messages lock: {e}). Please restart the app." - )); - return; - } - }; - messages_lock - .iter() - .filter(|m| m.order_id == Some(order_id)) - .max_by_key(|m| m.timestamp) - .map(|m| PriorMessageSnapshot { - timestamp: m.timestamp, - action: m.message.get_inner_message_kind().action.clone(), - sat_amount: m.sat_amount, - buyer_invoice: m.buyer_invoice.clone(), - auto_popup_shown: m.auto_popup_shown, - order_kind: m.order_kind, - is_mine: m.is_mine, - order_status: m.order_status, - order_snapshot: m.order_snapshot.clone(), - buyer_reputation: m.buyer_reputation.clone(), - seller_reputation: m.seller_reputation.clone(), - }) - }; - // Only increment pending notifications if this is a truly new message. // Relay delivery can be out-of-order: a later protocol step may carry an older Nostr // `created_at` than a message we already stored. If we only compared timestamps, @@ -1127,7 +1215,6 @@ async fn handle_trade_dm_for_order( } }; - let prior_sat_amount = existing_message_data.as_ref().and_then(|p| p.sat_amount); let prior_invoice = existing_message_data .as_ref() .and_then(|p| p.buyer_invoice.clone()); @@ -1225,9 +1312,13 @@ async fn handle_trade_dm_for_order( effective_order_status, ); let effective_sat_amount = if take_sell_waiting { - // MOSTRO-078: ignore daemon Payload::Order.amount until execute-path - // (or a prior trusted Messages row) supplies sats. - prior_sat_amount + // Prefer validated post-bond / listener sats; only reuse prior when it was + // already a trusted AddInvoice (not PayBondInvoice bond floor). + trusted_add_invoice_sats.or(if matches!(prior_action, Some(Action::AddInvoice)) { + prior_sat_amount + } else { + None + }) } else { sat_amount.or(prior_sat_amount) }; @@ -1250,9 +1341,9 @@ async fn handle_trade_dm_for_order( ); if take_sell_waiting { // Keep snapshot sats aligned with framing: never leave a fabricated daemon - // amount for Messages Enter to fall back to when sat_amount is absent. + // amount (or bond floor) for Messages Enter to fall back to. if let Some(ref mut snap) = effective_order_snapshot { - snap.amount = prior_sat_amount.unwrap_or(0); + snap.amount = effective_sat_amount.unwrap_or(0); } } @@ -2615,9 +2706,9 @@ mod tests { default_dm_expiration, effective_is_mine_for_trade_dm_message, handle_trade_dm_for_order, is_own_signed_v2_outbound, is_pre_active_maker_listing, is_pre_active_taker_take, is_take_sell_buyer_waiting_invoice, is_taker_reputation_peer_dm, - new_order_would_regress_messages_row, satisfy_pending_waiters_for_event, - small_order_pending_from_new_order_payload, trade_dm_replay_dispatch_mode, - trade_dm_replay_fetch_filter, trade_message_is_terminal, + new_order_would_regress_messages_row, resolve_take_sell_add_invoice_trusted_sats, + satisfy_pending_waiters_for_event, small_order_pending_from_new_order_payload, + trade_dm_replay_dispatch_mode, trade_dm_replay_fetch_filter, trade_message_is_terminal, trade_message_should_untrack_order_chat, upsert_order_from_trade_dm, TradeDmReplayDispatchMode, STARTUP_TRADE_DM_FETCH_LIMIT, }; @@ -3540,6 +3631,7 @@ mod tests { })), Some(1), &trade_keys, + None, ) .await; @@ -3611,6 +3703,7 @@ mod tests { })), Some(1), &trade_keys, + None, ) .await; @@ -3621,6 +3714,195 @@ mod tests { assert_ne!(stored.amount, forged_amount); } + #[test] + fn resolve_add_invoice_after_bond_uses_trade_sats_not_bond() { + // Range take-sell: local amount is 0 (book), prior Messages action was + // PayBondInvoice with bond sat_amount β€” must validate daemon trade quote. + let order_id = Uuid::new_v4(); + let trade_sats = 79_600_i64; + let bond_sats = 1_000_i64; + let db_row = Order { + id: Some(order_id.to_string()), + kind: Some("sell".to_string()), + status: Some("waiting-taker-bond".to_string()), + amount: 0, + fiat_code: "USD".to_string(), + min_amount: Some(50), + max_amount: Some(200), + fiat_amount: 75, + payment_method: "SEPA".to_string(), + premium: 0, + trade_keys: None, + counterparty_pubkey: None, + order_chat_shared_key_hex: None, + dispute_id: None, + solver_pubkey: None, + dispute_chat_shared_key_hex: None, + is_mine: false, + buyer_invoice: None, + request_id: None, + trade_index: Some(3), + created_at: None, + expires_at: None, + last_seen_dm_ts: None, + }; + let payload = Some(Payload::Order(SmallOrder { + id: Some(order_id), + kind: Some(mostro_core::order::Kind::Sell), + status: Some(Status::WaitingBuyerInvoice), + amount: trade_sats, + fiat_code: "USD".to_string(), + fiat_amount: 75, + payment_method: "SEPA".to_string(), + ..Default::default() + })); + let trusted = resolve_take_sell_add_invoice_trusted_sats( + &Action::AddInvoice, + &payload, + Some(&db_row), + Some(false), + Some(&Action::PayBondInvoice), + Some(bond_sats), + ); + assert_eq!(trusted, Some(trade_sats)); + assert_ne!(trusted, Some(bond_sats)); + } + + #[tokio::test] + async fn add_invoice_dm_hydrates_zero_local_amount_with_trusted_sats() { + let pool = sqlx::SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory database"); + sqlx::query( + r#" + CREATE TABLE orders ( + id TEXT PRIMARY KEY, kind TEXT, status TEXT, amount INTEGER NOT NULL, + fiat_code TEXT NOT NULL, min_amount INTEGER, max_amount INTEGER, + fiat_amount INTEGER NOT NULL, payment_method TEXT NOT NULL, + premium INTEGER NOT NULL, trade_keys TEXT, counterparty_pubkey TEXT, + order_chat_shared_key_hex TEXT, dispute_id TEXT, solver_pubkey TEXT, + dispute_chat_shared_key_hex TEXT, is_mine INTEGER NOT NULL, + buyer_invoice TEXT, request_id INTEGER, trade_index INTEGER, + created_at INTEGER, expires_at INTEGER, last_seen_dm_ts INTEGER, + buyer_reputation TEXT, seller_reputation TEXT + ) + "#, + ) + .execute(&pool) + .await + .expect("orders table"); + + let order_id = Uuid::new_v4(); + let trade_sats = 79_600_i64; + let trade_keys = Keys::generate(); + sqlx::query( + "INSERT INTO orders (id, kind, status, amount, fiat_code, fiat_amount, \ + payment_method, premium, trade_keys, is_mine, trade_index) \ + VALUES (?, 'sell', 'waiting-taker-bond', 0, 'USD', 75, 'SEPA', 0, ?, 0, 3)", + ) + .bind(order_id.to_string()) + .bind(trade_keys.secret_key().to_secret_hex()) + .execute(&pool) + .await + .expect("seed post-bond range row"); + + upsert_order_from_trade_dm( + &pool, + order_id, + &Action::AddInvoice, + &Some(Payload::Order(SmallOrder { + id: Some(order_id), + kind: Some(mostro_core::order::Kind::Sell), + status: Some(Status::WaitingBuyerInvoice), + amount: trade_sats, + fiat_code: "USD".to_string(), + fiat_amount: 75, + payment_method: "SEPA".to_string(), + ..Default::default() + })), + Some(1), + &trade_keys, + Some(trade_sats), + ) + .await; + + let stored = Order::get_by_id(&pool, &order_id.to_string()) + .await + .expect("order present"); + assert_eq!(stored.amount, trade_sats); + } + + #[tokio::test] + async fn pay_bond_invoice_dm_does_not_overwrite_trade_amount_with_bond() { + let pool = sqlx::SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory database"); + sqlx::query( + r#" + CREATE TABLE orders ( + id TEXT PRIMARY KEY, kind TEXT, status TEXT, amount INTEGER NOT NULL, + fiat_code TEXT NOT NULL, min_amount INTEGER, max_amount INTEGER, + fiat_amount INTEGER NOT NULL, payment_method TEXT NOT NULL, + premium INTEGER NOT NULL, trade_keys TEXT, counterparty_pubkey TEXT, + order_chat_shared_key_hex TEXT, dispute_id TEXT, solver_pubkey TEXT, + dispute_chat_shared_key_hex TEXT, is_mine INTEGER NOT NULL, + buyer_invoice TEXT, request_id INTEGER, trade_index INTEGER, + created_at INTEGER, expires_at INTEGER, last_seen_dm_ts INTEGER, + buyer_reputation TEXT, seller_reputation TEXT + ) + "#, + ) + .execute(&pool) + .await + .expect("orders table"); + + let order_id = Uuid::new_v4(); + let book_amount = 21_000_i64; + let bond_sats = 1_000_i64; + let trade_keys = Keys::generate(); + sqlx::query( + "INSERT INTO orders (id, kind, status, amount, fiat_code, fiat_amount, \ + payment_method, premium, trade_keys, is_mine, trade_index) \ + VALUES (?, 'sell', 'waiting-taker-bond', ?, 'USD', 100, 'SEPA', 0, ?, 0, 3)", + ) + .bind(order_id.to_string()) + .bind(book_amount) + .bind(trade_keys.secret_key().to_secret_hex()) + .execute(&pool) + .await + .expect("seed book amount row"); + + upsert_order_from_trade_dm( + &pool, + order_id, + &Action::PayBondInvoice, + &Some(Payload::PaymentRequest( + Some(SmallOrder { + id: Some(order_id), + kind: Some(mostro_core::order::Kind::Sell), + status: Some(Status::WaitingTakerBond), + amount: bond_sats, + fiat_code: "USD".to_string(), + fiat_amount: 100, + payment_method: "SEPA".to_string(), + ..Default::default() + }), + "lnbc1bond".to_string(), + None, + )), + Some(1), + &trade_keys, + None, + ) + .await; + + let stored = Order::get_by_id(&pool, &order_id.to_string()) + .await + .expect("order present"); + assert_eq!(stored.amount, book_amount); + assert_ne!(stored.amount, bond_sats); + } + #[test] fn default_dm_expiration_is_thirty_days_ahead() { let now = Timestamp::now().as_secs(); diff --git a/src/util/dm_utils/order_ch_mng.rs b/src/util/dm_utils/order_ch_mng.rs index 4621096..464fb93 100644 --- a/src/util/dm_utils/order_ch_mng.rs +++ b/src/util/dm_utils/order_ch_mng.rs @@ -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, diff --git a/src/util/order_utils/add_invoice_validate.rs b/src/util/order_utils/add_invoice_validate.rs new file mode 100644 index 0000000..9dbb745 --- /dev/null +++ b/src/util/order_utils/add_invoice_validate.rs @@ -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, + fee_rate: Option, +) -> Result { + 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, + fee_rate: Option, + fee_check: FeeCheck, +) -> Result { + 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")); + } +} diff --git a/src/util/order_utils/helper.rs b/src/util/order_utils/helper.rs index 92486d2..a0152d4 100644 --- a/src/util/order_utils/helper.rs +++ b/src/util/order_utils/helper.rs @@ -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, @@ -673,6 +677,7 @@ pub(super) async fn payment_request_operation_result( is_mine: bool, dm_subscription_tx: Option<&UnboundedSender>, log_prefix: &str, + trade_amount_to_persist: Option, ) -> Result { let popup_action = match inner_action { Action::PayBondInvoice => Action::PayBondInvoice, @@ -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, @@ -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, diff --git a/src/util/order_utils/mod.rs b/src/util/order_utils/mod.rs index 26e94a0..421a62f 100644 --- a/src/util/order_utils/mod.rs +++ b/src/util/order_utils/mod.rs @@ -1,4 +1,5 @@ // Order utilities module +mod add_invoice_validate; mod bond_resolution; mod execute_add_invoice; mod execute_admin_add_solver; @@ -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; diff --git a/src/util/order_utils/send_new_order.rs b/src/util/order_utils/send_new_order.rs index 3a88b46..1597ae1 100644 --- a/src/util/order_utils/send_new_order.rs +++ b/src/util/order_utils/send_new_order.rs @@ -226,6 +226,7 @@ pub async fn send_new_order( true, dm_subscription_tx, "send_new_order", + Some(amount), ) .await } else { diff --git a/src/util/order_utils/take_order.rs b/src/util/order_utils/take_order.rs index 0521d08..c3a7065 100644 --- a/src/util/order_utils/take_order.rs +++ b/src/util/order_utils/take_order.rs @@ -10,6 +10,7 @@ use crate::util::dm_utils::{ parse_dm_events, send_dm, send_track_order_cmd, wait_for_dm, FETCH_EVENTS_TIMEOUT, }; use crate::util::mostro_info::MostroInstanceInfo; +use crate::util::order_utils::add_invoice_validate::validate_take_sell_add_invoice_reply; use crate::util::order_utils::helper::{handle_mostro_response, payment_request_operation_result}; use crate::util::OrderDmSubscriptionCmd; use tokio::sync::mpsc::UnboundedSender; @@ -37,6 +38,24 @@ fn create_take_order_payload( } } +/// Fixed-price take-sell requires the Mostro fee to validate the follow-up +/// `AddInvoice` net; a missing fee is a daemon defect with no post-send recovery. +fn ensure_fee_for_fixed_take_sell( + action: &Action, + order: &SmallOrder, + mostro_instance: Option<&MostroInstanceInfo>, +) -> Result<()> { + if matches!(action, Action::TakeSell) + && order.amount > 0 + && mostro_instance.and_then(|info| info.fee).is_none() + { + return Err(anyhow::anyhow!( + "Cannot take fixed-price sell order without Mostro instance fee" + )); + } + Ok(()) +} + /// Take an order from the order book. /// /// On take-sell without a buyer invoice, Mostro replies with `AddInvoice` + @@ -80,6 +99,9 @@ pub async fn take_order( .id .ok_or_else(|| anyhow::anyhow!("Order ID is missing"))?; + // Fail before any external side effect when a fixed-price take-sell lacks the fee. + ensure_fee_for_fixed_take_sell(&action, order, mostro_instance)?; + // Reserve the next trade index atomically; propagate DB errors (e.g. SQLITE_BUSY). let (next_idx, trade_keys) = User::reserve_next_trade_index(pool, 1).await?; @@ -240,6 +262,10 @@ async fn process_take_order_reply( invoice, amount, } => { + // PayBondInvoice SmallOrder.amount is the bond floor (often 1000), not + // trade sats β€” persist the book amount; bond stays in sat_amount only. + let trade_amount_to_persist = + matches!(action, Action::PayBondInvoice).then_some(requested.amount); payment_request_operation_result( action, order, @@ -253,6 +279,7 @@ async fn process_take_order_reply( false, dm_subscription_tx, "take_order", + trade_amount_to_persist, ) .await } @@ -309,135 +336,6 @@ fn normalize_taken_order(mut order: SmallOrder, fallback_order_id: uuid::Uuid) - order } -/// 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). -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`. -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`]; 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; -/// non-positive market quote (MOSTRO-078). -fn validate_take_sell_add_invoice_reply( - requested: &SmallOrder, - returned: &SmallOrder, - take_fiat_amount: Option, - fee_rate: Option, -) -> Result { - 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 { - let Some(rate) = fee_rate else { - return Err(anyhow::anyhow!( - "Cannot verify AddInvoice sats without Mostro fee from instance info" - )); - }; - 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); - } - - // 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) -} - async fn persist_taken_order( returned_order: SmallOrder, fallback_order_id: uuid::Uuid, @@ -541,6 +439,54 @@ mod tests { } } + #[test] + fn fixed_take_sell_without_fee_is_rejected() { + let order = sample_small_order(uuid::Uuid::new_v4()); + let info = MostroInstanceInfo { + fee: None, + ..Default::default() + }; + let err = ensure_fee_for_fixed_take_sell(&Action::TakeSell, &order, Some(&info)) + .expect_err("fixed-price take-sell without fee must fail"); + assert!(err.to_string().contains("fee")); + } + + #[test] + fn fixed_take_sell_missing_instance_is_rejected() { + let order = sample_small_order(uuid::Uuid::new_v4()); + let err = ensure_fee_for_fixed_take_sell(&Action::TakeSell, &order, None) + .expect_err("fixed-price take-sell without instance must fail"); + assert!(err.to_string().contains("fee")); + } + + #[test] + fn fixed_take_sell_with_fee_is_allowed() { + let order = sample_small_order(uuid::Uuid::new_v4()); + let info = MostroInstanceInfo { + fee: Some(0.01), + ..Default::default() + }; + ensure_fee_for_fixed_take_sell(&Action::TakeSell, &order, Some(&info)) + .expect("fixed-price take-sell with fee must pass"); + } + + #[test] + fn range_take_sell_without_fee_is_allowed() { + let mut order = sample_small_order(uuid::Uuid::new_v4()); + // Range/market: net is deferred to AddInvoice, so no fee is needed pre-send. + order.amount = 0; + ensure_fee_for_fixed_take_sell(&Action::TakeSell, &order, None) + .expect("range take-sell needs no fee before send"); + } + + #[test] + fn take_buy_without_fee_is_allowed() { + let mut order = sample_small_order(uuid::Uuid::new_v4()); + order.kind = Some(mostro_core::order::Kind::Buy); + ensure_fee_for_fixed_take_sell(&Action::TakeBuy, &order, None) + .expect("take-buy needs no take-sell fee preflight"); + } + #[test] fn map_take_reply_add_invoice_order_is_not_success() { let order = sample_small_order(uuid::Uuid::new_v4()); @@ -604,165 +550,4 @@ mod tests { other => panic!("expected OpenInvoicePopup, got {other:?}"), } } - - #[test] - fn split_fee_matches_mostro_half_rate_rounding() { - // fee_rate 0.01 β†’ 0.5% per party on 21_000 = 105 - 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; // shave - 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_rejects_id_mismatch() { - let requested = sample_small_order(uuid::Uuid::new_v4()); - let returned = sample_small_order(uuid::Uuid::new_v4()); - let err = validate_take_sell_add_invoice_reply(&requested, &returned, None, Some(0.01)) - .expect_err("id mismatch must fail"); - assert!(err.to_string().contains("order id mismatch")); - } - - #[test] - fn validate_add_invoice_rejects_wrong_status() { - 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); - returned.status = Some(Status::Success); - let err = validate_take_sell_add_invoice_reply(&requested, &returned, None, Some(0.01)) - .expect_err("wrong status must fail"); - assert!(err.to_string().contains("status mismatch")); - } - - #[test] - fn validate_add_invoice_requires_fee_for_fixed_amount() { - 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_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")); - } - - #[test] - fn validate_add_invoice_rejects_omitted_identity_fields() { - let id = uuid::Uuid::new_v4(); - let requested = sample_small_order(id); - let net = expected_buyer_invoice_sats(21_000, 0.01); - - let mut missing_id = sample_small_order(id); - missing_id.id = None; - missing_id.amount = net; - assert!( - validate_take_sell_add_invoice_reply(&requested, &missing_id, None, Some(0.01)) - .unwrap_err() - .to_string() - .contains("missing order id") - ); - - let mut missing_kind = sample_small_order(id); - missing_kind.kind = None; - missing_kind.amount = net; - assert!( - validate_take_sell_add_invoice_reply(&requested, &missing_kind, None, Some(0.01)) - .unwrap_err() - .to_string() - .contains("missing order kind") - ); - - let mut missing_status = sample_small_order(id); - missing_status.status = None; - missing_status.amount = net; - assert!(validate_take_sell_add_invoice_reply( - &requested, - &missing_status, - None, - Some(0.01) - ) - .unwrap_err() - .to_string() - .contains("missing order status")); - - let mut empty_fiat_code = sample_small_order(id); - empty_fiat_code.fiat_code.clear(); - empty_fiat_code.amount = net; - assert!(validate_take_sell_add_invoice_reply( - &requested, - &empty_fiat_code, - None, - Some(0.01) - ) - .unwrap_err() - .to_string() - .contains("missing fiat code")); - - let mut zero_fiat = sample_small_order(id); - zero_fiat.fiat_amount = 0; - zero_fiat.amount = net; - assert!( - validate_take_sell_add_invoice_reply(&requested, &zero_fiat, None, Some(0.01)) - .unwrap_err() - .to_string() - .contains("fiat amount mismatch") - ); - } }