From 6c00634920d0f951645f701da619713bc5899cd7 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Fri, 28 Aug 2026 23:05:39 -0300 Subject: [PATCH 1/6] feat(rates): add exchange rate fetching from mostro node's kind 30078 event --- rust/src/api/nostr.rs | 96 ++++++++++++++ rust/src/frb_generated.rs | 196 +++++++++++++++++----------- rust/src/mostro/mod.rs | 1 + rust/src/mostro/rates.rs | 260 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 476 insertions(+), 77 deletions(-) create mode 100644 rust/src/mostro/rates.rs diff --git a/rust/src/api/nostr.rs b/rust/src/api/nostr.rs index bd014db2..8c689f5e 100644 --- a/rust/src/api/nostr.rs +++ b/rust/src/api/nostr.rs @@ -220,6 +220,102 @@ pub async fn fetch_mostro_instance_tags( } } +/// Price of one BTC in `fiat_code`, as published by `mostro_pubkey_hex` in its +/// Kind 30078 (`d` = `mostro-rates`) event. +/// +/// Lets the client tell, before submitting, whether a market-price order will +/// land inside the node's sats limits (#337): the daemon prices such an order +/// as `fiat_amount / price * 1E8` from this same aggregate, so this is the +/// number its `OutOfRangeSatsAmount` check will use. +/// +/// Returns `None` — never an error — for every "no usable rate" case: the node +/// publishes no rates event (publishing is optional), the one on the relay has +/// expired, its payload is unusable, or it quotes no such currency. Callers +/// must then submit unchecked and let the daemon decide, which is the +/// fail-open behaviour PR #302 chose for fixed-sats amounts. `Err` is reserved +/// for a client that is not initialised, a malformed pubkey, or a relay query +/// that failed outright. +/// +/// Answers from a per-node cache bounded by the event's own NIP-40 expiration, +/// so the three amount fields of a range order cost one relay query, not three. +pub async fn fetch_exchange_rate( + mostro_pubkey_hex: String, + fiat_code: String, +) -> Result> { + use crate::mostro::rates; + use nostr_sdk::prelude::*; + use std::time::Duration; + + let now = crate::rt::unix_now(); + if let Some(rate) = rates::cached_rate(&mostro_pubkey_hex, &fiat_code, now) { + return Ok(Some(rate)); + } + + let client = pool()?.client(); + + let pubkey = nostr_sdk::PublicKey::from_hex(&mostro_pubkey_hex) + .map_err(|e| anyhow::anyhow!("invalid pubkey hex: {e}"))?; + + let filter = Filter::new() + .kind(Kind::from(rates::RATES_KIND)) + .author(pubkey) + .custom_tag(SingleLetterTag::lowercase(Alphabet::D), rates::RATES_D_TAG) + .limit(1); + + let events = client + .fetch_events(filter, Duration::from_secs(10)) + .await + .map_err(|e| anyhow::anyhow!("fetch_events failed: {e}"))?; + + // Defence in depth, as v1 does: a relay is free to answer with events the + // filter never asked for, and pricing an order off another kind, another + // d-tag or another author's event would be worse than not checking at all. + let event = events + .into_iter() + .filter(|e| { + e.kind == Kind::from(rates::RATES_KIND) + && e.pubkey == pubkey + && tag_value(e, "d").as_deref() == Some(rates::RATES_D_TAG) + }) + .max_by_key(|e| e.created_at); + + let Some(event) = event else { + log::warn!("[rates] node {mostro_pubkey_hex} published no usable kind 30078 event"); + rates::clear(); + return Ok(None); + }; + + let expires_at = rates::expires_at( + event.created_at.as_secs() as i64, + tag_value(&event, "expiration").and_then(|v| v.parse::().ok()), + ); + if now >= expires_at { + // A relay that ignores NIP-40 must not let a zombie price through. + log::warn!("[rates] discarding expired kind 30078 event from {mostro_pubkey_hex}"); + rates::clear(); + return Ok(None); + } + + let Some(parsed) = rates::parse_rates_content(&event.content) else { + log::warn!("[rates] unusable kind 30078 payload from {mostro_pubkey_hex}"); + rates::clear(); + return Ok(None); + }; + + rates::store(&mostro_pubkey_hex, parsed, expires_at); + Ok(rates::cached_rate(&mostro_pubkey_hex, &fiat_code, now)) +} + +/// First value of the single-letter or named tag `name` on `event`. +fn tag_value(event: &nostr_sdk::Event, name: &str) -> Option { + event + .tags + .iter() + .map(|t| t.as_slice()) + .find(|t| t.first().map(String::as_str) == Some(name)) + .and_then(|t| t.get(1).cloned()) +} + /// Fetch everything the active Mostro node advertises about itself from its /// Kind 38385 event and store it globally: the PoW requirement, and (phase C1) /// the escrow mode plus its Cashu parameters. diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 3705e297..c005a654 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -48,7 +48,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 659438006; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1401466058; // Section: executor @@ -1799,6 +1799,47 @@ fn wire__crate__api__identity__export_encrypted_backup_impl( }, ) } +fn wire__crate__api__nostr__fetch_exchange_rate_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "fetch_exchange_rate", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_mostro_pubkey_hex = ::sse_decode(&mut deserializer); + let api_fiat_code = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::api::nostr::fetch_exchange_rate( + api_mostro_pubkey_hex, + api_fiat_code, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__nostr__fetch_mostro_instance_tags_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -6242,201 +6283,202 @@ fn pde_ffi_dispatcher_primary_impl( rust_vec_len, data_len, ), - 36 => wire__crate__api__nostr__fetch_mostro_instance_tags_impl( + 36 => wire__crate__api__nostr__fetch_exchange_rate_impl(port, ptr, rust_vec_len, data_len), + 37 => wire__crate__api__nostr__fetch_mostro_instance_tags_impl( port, ptr, rust_vec_len, data_len, ), - 37 => wire__crate__api__nostr__flush_message_queue_impl(port, ptr, rust_vec_len, data_len), - 38 => wire__crate__api__get_app_version_impl(port, ptr, rust_vec_len, data_len), - 39 => wire__crate__api__messages__get_attachment_status_impl( + 38 => wire__crate__api__nostr__flush_message_queue_impl(port, ptr, rust_vec_len, data_len), + 39 => wire__crate__api__get_app_version_impl(port, ptr, rust_vec_len, data_len), + 40 => wire__crate__api__messages__get_attachment_status_impl( port, ptr, rust_vec_len, data_len, ), - 40 => wire__crate__api__nwc__get_balance_impl(port, ptr, rust_vec_len, data_len), - 41 => wire__crate__api__nostr__get_connection_state_impl(port, ptr, rust_vec_len, data_len), - 42 => wire__crate__api__disputes__get_dispute_impl(port, ptr, rust_vec_len, data_len), - 43 => wire__crate__api__escrow__get_escrow_mode_impl(port, ptr, rust_vec_len, data_len), - 44 => wire__crate__api__identity__get_identity_impl(port, ptr, rust_vec_len, data_len), - 45 => wire__crate__api__messages__get_messages_impl(port, ptr, rust_vec_len, data_len), - 46 => wire__crate__api__settings__get_mostro_pubkey_impl(port, ptr, rust_vec_len, data_len), - 47 => wire__crate__api__identity__get_nym_identity_impl(port, ptr, rust_vec_len, data_len), - 48 => wire__crate__api__orders__get_order_impl(port, ptr, rust_vec_len, data_len), - 49 => wire__crate__api__orders__get_orders_impl(port, ptr, rust_vec_len, data_len), - 50 => { + 41 => wire__crate__api__nwc__get_balance_impl(port, ptr, rust_vec_len, data_len), + 42 => wire__crate__api__nostr__get_connection_state_impl(port, ptr, rust_vec_len, data_len), + 43 => wire__crate__api__disputes__get_dispute_impl(port, ptr, rust_vec_len, data_len), + 44 => wire__crate__api__escrow__get_escrow_mode_impl(port, ptr, rust_vec_len, data_len), + 45 => wire__crate__api__identity__get_identity_impl(port, ptr, rust_vec_len, data_len), + 46 => wire__crate__api__messages__get_messages_impl(port, ptr, rust_vec_len, data_len), + 47 => wire__crate__api__settings__get_mostro_pubkey_impl(port, ptr, rust_vec_len, data_len), + 48 => wire__crate__api__identity__get_nym_identity_impl(port, ptr, rust_vec_len, data_len), + 49 => wire__crate__api__orders__get_order_impl(port, ptr, rust_vec_len, data_len), + 50 => wire__crate__api__orders__get_orders_impl(port, ptr, rust_vec_len, data_len), + 51 => { wire__crate__api__reputation__get_privacy_mode_impl(port, ptr, rust_vec_len, data_len) } - 51 => wire__crate__api__reputation__get_rating_for_trade_impl( + 52 => wire__crate__api__reputation__get_rating_for_trade_impl( port, ptr, rust_vec_len, data_len, ), - 52 => wire__crate__api__nostr__get_relays_impl(port, ptr, rust_vec_len, data_len), - 53 => wire__crate__api__settings__get_settings_impl(port, ptr, rust_vec_len, data_len), - 54 => wire__crate__api__identity__get_trade_key_impl(port, ptr, rust_vec_len, data_len), - 55 => wire__crate__api__orders__get_trade_role_impl(port, ptr, rust_vec_len, data_len), - 56 => wire__crate__api__messages__get_unread_count_impl(port, ptr, rust_vec_len, data_len), - 57 => wire__crate__api__nwc__get_wallet_impl(port, ptr, rust_vec_len, data_len), - 58 => wire__crate__api__disputes__handle_admin_canceled_impl( + 53 => wire__crate__api__nostr__get_relays_impl(port, ptr, rust_vec_len, data_len), + 54 => wire__crate__api__settings__get_settings_impl(port, ptr, rust_vec_len, data_len), + 55 => wire__crate__api__identity__get_trade_key_impl(port, ptr, rust_vec_len, data_len), + 56 => wire__crate__api__orders__get_trade_role_impl(port, ptr, rust_vec_len, data_len), + 57 => wire__crate__api__messages__get_unread_count_impl(port, ptr, rust_vec_len, data_len), + 58 => wire__crate__api__nwc__get_wallet_impl(port, ptr, rust_vec_len, data_len), + 59 => wire__crate__api__disputes__handle_admin_canceled_impl( port, ptr, rust_vec_len, data_len, ), - 59 => { + 60 => { wire__crate__api__disputes__handle_admin_settled_impl(port, ptr, rust_vec_len, data_len) } - 60 => wire__crate__api__disputes__handle_admin_took_dispute_impl( + 61 => wire__crate__api__disputes__handle_admin_took_dispute_impl( port, ptr, rust_vec_len, data_len, ), - 61 => wire__crate__api__reputation__handle_rating_received_impl( + 62 => wire__crate__api__reputation__handle_rating_received_impl( port, ptr, rust_vec_len, data_len, ), - 62 => { + 63 => { wire__crate__api__identity__import_from_mnemonic_impl(port, ptr, rust_vec_len, data_len) } - 63 => wire__crate__api__identity__import_from_nsec_impl(port, ptr, rust_vec_len, data_len), - 64 => wire__crate__api__init_db_impl(port, ptr, rust_vec_len, data_len), - 65 => wire__crate__api__nostr__initialize_impl(port, ptr, rust_vec_len, data_len), - 66 => wire__crate__api__logging__install_log_bridge_impl(port, ptr, rust_vec_len, data_len), - 67 => wire__crate__api__orders__list_trades_impl(port, ptr, rust_vec_len, data_len), - 68 => wire__crate__api__identity__load_identity_from_mnemonic_impl( + 64 => wire__crate__api__identity__import_from_nsec_impl(port, ptr, rust_vec_len, data_len), + 65 => wire__crate__api__init_db_impl(port, ptr, rust_vec_len, data_len), + 66 => wire__crate__api__nostr__initialize_impl(port, ptr, rust_vec_len, data_len), + 67 => wire__crate__api__logging__install_log_bridge_impl(port, ptr, rust_vec_len, data_len), + 68 => wire__crate__api__orders__list_trades_impl(port, ptr, rust_vec_len, data_len), + 69 => wire__crate__api__identity__load_identity_from_mnemonic_impl( port, ptr, rust_vec_len, data_len, ), - 69 => wire__crate__api__nwc__make_invoice_impl(port, ptr, rust_vec_len, data_len), - 70 => wire__crate__api__messages__mark_as_read_impl(port, ptr, rust_vec_len, data_len), - 71 => wire__crate__api__messages__on_attachment_progress_impl( + 70 => wire__crate__api__nwc__make_invoice_impl(port, ptr, rust_vec_len, data_len), + 71 => wire__crate__api__messages__mark_as_read_impl(port, ptr, rust_vec_len, data_len), + 72 => wire__crate__api__messages__on_attachment_progress_impl( port, ptr, rust_vec_len, data_len, ), - 72 => wire__crate__api__bond__on_bond_slashed_impl(port, ptr, rust_vec_len, data_len), - 73 => wire__crate__api__nostr__on_connection_state_changed_impl( + 73 => wire__crate__api__bond__on_bond_slashed_impl(port, ptr, rust_vec_len, data_len), + 74 => wire__crate__api__nostr__on_connection_state_changed_impl( port, ptr, rust_vec_len, data_len, ), - 74 => { + 75 => { wire__crate__api__disputes__on_dispute_updated_impl(port, ptr, rust_vec_len, data_len) } - 75 => { + 76 => { wire__crate__api__escrow__on_escrow_mode_changed_impl(port, ptr, rust_vec_len, data_len) } - 76 => wire__crate__api__logging__on_log_entry_impl(port, ptr, rust_vec_len, data_len), - 77 => wire__crate__api__messages__on_new_message_impl(port, ptr, rust_vec_len, data_len), - 78 => wire__crate__api__orders__on_orders_updated_impl(port, ptr, rust_vec_len, data_len), - 79 => { + 77 => wire__crate__api__logging__on_log_entry_impl(port, ptr, rust_vec_len, data_len), + 78 => wire__crate__api__messages__on_new_message_impl(port, ptr, rust_vec_len, data_len), + 79 => wire__crate__api__orders__on_orders_updated_impl(port, ptr, rust_vec_len, data_len), + 80 => { wire__crate__api__reputation__on_rating_received_impl(port, ptr, rust_vec_len, data_len) } - 80 => { + 81 => { wire__crate__api__nostr__on_relay_status_changed_impl(port, ptr, rust_vec_len, data_len) } - 81 => { + 82 => { wire__crate__api__settings__on_settings_changed_impl(port, ptr, rust_vec_len, data_len) } - 82 => wire__crate__api__identity__on_trade_key_index_changed_impl( + 83 => wire__crate__api__identity__on_trade_key_index_changed_impl( port, ptr, rust_vec_len, data_len, ), - 83 => wire__crate__api__orders__on_trade_updated_impl(port, ptr, rust_vec_len, data_len), - 84 => wire__crate__api__messages__on_unread_count_changed_impl( + 84 => wire__crate__api__orders__on_trade_updated_impl(port, ptr, rust_vec_len, data_len), + 85 => wire__crate__api__messages__on_unread_count_changed_impl( port, ptr, rust_vec_len, data_len, ), - 85 => { + 86 => { wire__crate__api__nwc__on_wallet_status_changed_impl(port, ptr, rust_vec_len, data_len) } - 86 => wire__crate__api__disputes__open_dispute_impl(port, ptr, rust_vec_len, data_len), - 87 => { + 87 => wire__crate__api__disputes__open_dispute_impl(port, ptr, rust_vec_len, data_len), + 88 => { wire__crate__api__orders__order_filters_default_impl(port, ptr, rust_vec_len, data_len) } - 88 => wire__crate__api__nwc__pay_invoice_impl(port, ptr, rust_vec_len, data_len), - 89 => wire__crate__api__logging__recent_logs_impl(port, ptr, rust_vec_len, data_len), - 90 => wire__crate__api__settings__rehydrate_active_mostro_node_impl( + 89 => wire__crate__api__nwc__pay_invoice_impl(port, ptr, rust_vec_len, data_len), + 90 => wire__crate__api__logging__recent_logs_impl(port, ptr, rust_vec_len, data_len), + 91 => wire__crate__api__settings__rehydrate_active_mostro_node_impl( port, ptr, rust_vec_len, data_len, ), - 91 => wire__crate__api__escrow__rehydrate_escrow_overrides_impl( + 92 => wire__crate__api__escrow__rehydrate_escrow_overrides_impl( port, ptr, rust_vec_len, data_len, ), - 92 => wire__crate__api__orders__release_order_impl(port, ptr, rust_vec_len, data_len), - 93 => wire__crate__api__nostr__remove_relay_impl(port, ptr, rust_vec_len, data_len), - 94 => wire__crate__api__orders__restart_orders_subscription_impl( + 93 => wire__crate__api__orders__release_order_impl(port, ptr, rust_vec_len, data_len), + 94 => wire__crate__api__nostr__remove_relay_impl(port, ptr, rust_vec_len, data_len), + 95 => wire__crate__api__orders__restart_orders_subscription_impl( port, ptr, rust_vec_len, data_len, ), - 95 => wire__crate__api__orders__send_fiat_sent_impl(port, ptr, rust_vec_len, data_len), - 96 => wire__crate__api__messages__send_file_impl(port, ptr, rust_vec_len, data_len), - 97 => wire__crate__api__orders__send_invoice_impl(port, ptr, rust_vec_len, data_len), - 98 => wire__crate__api__messages__send_message_impl(port, ptr, rust_vec_len, data_len), - 99 => wire__crate__api__settings__set_active_mostro_node_impl( + 96 => wire__crate__api__orders__send_fiat_sent_impl(port, ptr, rust_vec_len, data_len), + 97 => wire__crate__api__messages__send_file_impl(port, ptr, rust_vec_len, data_len), + 98 => wire__crate__api__orders__send_invoice_impl(port, ptr, rust_vec_len, data_len), + 99 => wire__crate__api__messages__send_message_impl(port, ptr, rust_vec_len, data_len), + 100 => wire__crate__api__settings__set_active_mostro_node_impl( port, ptr, rust_vec_len, data_len, ), - 100 => wire__crate__api__escrow__set_cashu_mint_url_override_impl( + 101 => wire__crate__api__escrow__set_cashu_mint_url_override_impl( port, ptr, rust_vec_len, data_len, ), - 101 => wire__crate__api__settings__set_default_fiat_code_impl( + 102 => wire__crate__api__settings__set_default_fiat_code_impl( port, ptr, rust_vec_len, data_len, ), - 102 => wire__crate__api__settings__set_default_lightning_address_impl( + 103 => wire__crate__api__settings__set_default_lightning_address_impl( port, ptr, rust_vec_len, data_len, ), - 103 => wire__crate__api__escrow__set_escrow_mode_override_impl( + 104 => wire__crate__api__escrow__set_escrow_mode_override_impl( port, ptr, rust_vec_len, data_len, ), - 104 => wire__crate__api__settings__set_language_impl(port, ptr, rust_vec_len, data_len), - 105 => { + 105 => wire__crate__api__settings__set_language_impl(port, ptr, rust_vec_len, data_len), + 106 => { wire__crate__api__settings__set_logging_enabled_impl(port, ptr, rust_vec_len, data_len) } - 106 => { + 107 => { wire__crate__api__reputation__set_privacy_mode_impl(port, ptr, rust_vec_len, data_len) } - 107 => wire__crate__api__settings__set_theme_impl(port, ptr, rust_vec_len, data_len), - 108 => wire__crate__api__disputes__submit_evidence_impl(port, ptr, rust_vec_len, data_len), - 109 => wire__crate__api__reputation__submit_rating_impl(port, ptr, rust_vec_len, data_len), - 110 => wire__crate__api__orders__subscribe_orders_impl(port, ptr, rust_vec_len, data_len), - 111 => wire__crate__api__orders__take_order_impl(port, ptr, rust_vec_len, data_len), + 108 => wire__crate__api__settings__set_theme_impl(port, ptr, rust_vec_len, data_len), + 109 => wire__crate__api__disputes__submit_evidence_impl(port, ptr, rust_vec_len, data_len), + 110 => wire__crate__api__reputation__submit_rating_impl(port, ptr, rust_vec_len, data_len), + 111 => wire__crate__api__orders__subscribe_orders_impl(port, ptr, rust_vec_len, data_len), + 112 => wire__crate__api__orders__take_order_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } diff --git a/rust/src/mostro/mod.rs b/rust/src/mostro/mod.rs index ba6b9319..a2718a89 100644 --- a/rust/src/mostro/mod.rs +++ b/rust/src/mostro/mod.rs @@ -4,6 +4,7 @@ pub mod fsm; pub(crate) mod pending; pub mod pow; pub mod protocol_version; +pub mod rates; pub mod session; pub(crate) mod status; diff --git a/rust/src/mostro/rates.rs b/rust/src/mostro/rates.rs new file mode 100644 index 00000000..5e044417 --- /dev/null +++ b/rust/src/mostro/rates.rs @@ -0,0 +1,260 @@ +//! Bitcoin/fiat exchange rates published by the active Mostro node +//! (kind 30078, NIP-33, `d` tag `mostro-rates`). +//! +//! The rate exists for one reason: a market-price order carries no sats +//! amount, so nothing on this side can tell whether it lands inside the +//! node's `min_order_amount`/`max_order_amount` until the daemon prices it +//! and answers `OutOfRangeSatsAmount` (#337). +//! +//! The node's own event is the right source, not a third-party API. The +//! daemon prices such an order as `fiat_amount / price * 1E8` from the very +//! aggregate it publishes here (`mostro/src/app/order.rs`), so this is the +//! same number its range check will use; any other quote would be a different +//! price, and asking for it would tell a stranger which currency the user is +//! about to trade. +//! +//! Node-scoped like [`crate::mostro::pow`] and [`crate::mostro::escrow_mode`]: +//! a snapshot is only ever served back to the node it was fetched from, so a +//! node switch can never price an order at the previous node's rate. +//! +//! Nothing here blocks anything. A missing, stale or unparseable rate simply +//! yields `None`, and the caller then submits unchecked with the daemon as the +//! backstop — the same fail-open choice PR #302 made for fixed-sats orders. + +use std::collections::HashMap; +use std::sync::RwLock; + +/// Kind of the rates event (NIP-33 addressable). +pub const RATES_KIND: u16 = 30078; + +/// NIP-33 `d` tag identifying it. +pub const RATES_D_TAG: &str = "mostro-rates"; + +/// Lifetime assumed for an event published without a NIP-40 `expiration` tag, +/// and the ceiling clamped onto one that carries an implausibly distant value. +/// +/// It is the daemon's own ceiling: it stamps `min(update_interval * 2, 3600)` +/// seconds (`mostro/src/price/manager.rs`). Clamping costs at most one refetch +/// per hour and stops a misconfigured node from pinning the app to a price +/// that stopped being true long ago. +const MAX_LIFETIME_SECS: i64 = 3600; + +/// One fetched rate table plus the node it came from and the instant it stops +/// being usable. Stored whole so a reader can never mix a currency from one +/// refresh with the expiry of another. +struct Snapshot { + node: String, + rates: HashMap, + expires_at: i64, +} + +/// `None` until the first successful fetch. +static SNAPSHOT: RwLock> = RwLock::new(None); + +/// Read the BTC rate table out of a rates event's content. +/// +/// The payload is Yadio-shaped — `{"BTC": {"USD": 50000.0, ...}}` — and is +/// parsed leniently: a currency whose value is not a usable positive number is +/// dropped rather than failing the whole table, since one bad entry says +/// nothing about the rest. `None` means nothing usable was found at all. +/// +/// Codes are upper-cased so a lookup never misses on capitalisation alone. +pub fn parse_rates_content(content: &str) -> Option> { + let value: serde_json::Value = serde_json::from_str(content).ok()?; + let table = value.get("BTC")?.as_object()?; + + let rates: HashMap = table + .iter() + .filter(|(code, _)| code.as_str() != "BTC") + .filter_map(|(code, price)| { + let price = price.as_f64()?; + (price.is_finite() && price > 0.0).then(|| (code.to_uppercase(), price)) + }) + .collect(); + + (!rates.is_empty()).then_some(rates) +} + +/// When a rates event published at `created_at` stops being usable, from its +/// NIP-40 `expiration` tag when it carries one. See [`MAX_LIFETIME_SECS`] for +/// both the fallback and the clamp. +pub fn expires_at(created_at: i64, expiration_tag: Option) -> i64 { + let ceiling = created_at.saturating_add(MAX_LIFETIME_SECS); + expiration_tag.map_or(ceiling, |tag| tag.min(ceiling)) +} + +/// Record the rates `node` (hex pubkey) published, valid until `expires_at`. +/// +/// A poisoned lock is recovered from rather than propagated, as in +/// `escrow_mode`: this is a cache of what a node said, and refusing to refresh +/// it after an unrelated panic would only serve older prices. +pub fn store(node: &str, rates: HashMap, expires_at: i64) { + let count = rates.len(); + *SNAPSHOT.write().unwrap_or_else(|e| e.into_inner()) = Some(Snapshot { + node: node.to_string(), + rates, + expires_at, + }); + log::info!("[rates] node {node}: cached {count} rates until {expires_at}"); +} + +/// The cached price of one BTC in `fiat_code`, or `None` when there is nothing +/// usable to answer with: no fetch yet, a snapshot belonging to another node, +/// one that has expired by `now`, or a currency this node does not quote. +pub fn cached_rate(node: &str, fiat_code: &str, now: i64) -> Option { + let guard = SNAPSHOT.read().unwrap_or_else(|e| e.into_inner()); + let snapshot = guard.as_ref()?; + if snapshot.node != node || now >= snapshot.expires_at { + return None; + } + snapshot.rates.get(&fiat_code.to_uppercase()).copied() +} + +/// Drop the snapshot. Called when a fetch finds no usable event, so an +/// unreachable or de-configured price source stops answering from the last +/// good one. +pub fn clear() { + *SNAPSHOT.write().unwrap_or_else(|e| e.into_inner()) = None; +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, MutexGuard, PoisonError}; + + /// Serializes the tests that touch the process-global snapshot and drops + /// it afterwards, so none of them leaks a rate into another. + struct Guard(#[allow(dead_code)] MutexGuard<'static, ()>); + + impl Drop for Guard { + fn drop(&mut self) { + clear(); + } + } + + fn lock() -> Guard { + static LOCK: Mutex<()> = Mutex::new(()); + Guard(LOCK.lock().unwrap_or_else(PoisonError::into_inner)) + } + + #[test] + fn a_yadio_shaped_payload_parses() { + let rates = parse_rates_content(r#"{"BTC":{"USD":50000.0,"EUR":45000.5}}"#).unwrap(); + + assert_eq!(rates.get("USD"), Some(&50000.0)); + assert_eq!(rates.get("EUR"), Some(&45000.5)); + } + + #[test] + fn the_btc_self_rate_is_dropped() { + let rates = parse_rates_content(r#"{"BTC":{"BTC":1,"USD":50000.0}}"#).unwrap(); + + assert_eq!(rates.len(), 1); + assert!(!rates.contains_key("BTC")); + } + + #[test] + fn codes_are_upper_cased() { + let rates = parse_rates_content(r#"{"BTC":{"usd":50000.0}}"#).unwrap(); + + assert_eq!(rates.get("USD"), Some(&50000.0)); + } + + #[test] + fn unusable_entries_are_dropped_without_failing_the_table() { + // Zero and negative prices would divide into absurd sats amounts, and + // a string is not a price at all — but USD still is. + let rates = + parse_rates_content(r#"{"BTC":{"ARS":0,"VES":-1,"GBP":"nope","USD":50000.0}}"#) + .unwrap(); + + assert_eq!(rates.len(), 1); + assert_eq!(rates.get("USD"), Some(&50000.0)); + } + + #[test] + fn a_payload_with_nothing_usable_is_none() { + assert!(parse_rates_content(r#"{"BTC":{}}"#).is_none()); + assert!(parse_rates_content(r#"{"BTC":{"USD":0}}"#).is_none()); + } + + #[test] + fn a_payload_that_is_not_a_rate_table_is_none() { + assert!(parse_rates_content("").is_none()); + assert!(parse_rates_content("not json").is_none()); + assert!(parse_rates_content(r#"{"USD":50000.0}"#).is_none()); + assert!(parse_rates_content(r#"{"BTC":"50000"}"#).is_none()); + } + + #[test] + fn an_expiration_tag_bounds_the_snapshot() { + assert_eq!(expires_at(1_000, Some(1_600)), 1_600); + } + + #[test] + fn an_event_without_an_expiration_tag_gets_the_default_lifetime() { + assert_eq!(expires_at(1_000, None), 1_000 + MAX_LIFETIME_SECS); + } + + #[test] + fn an_implausible_expiration_is_clamped_to_the_ceiling() { + // A node claiming its price is good for a year does not get to pin the + // app to it. + assert_eq!( + expires_at(1_000, Some(1_000 + 365 * 24 * 3600)), + 1_000 + MAX_LIFETIME_SECS + ); + } + + fn usd(price: f64) -> HashMap { + HashMap::from([("USD".to_string(), price)]) + } + + #[test] + fn a_stored_rate_is_served_back_to_its_node() { + let _guard = lock(); + store("node-a", usd(50_000.0), 1_000); + + assert_eq!(cached_rate("node-a", "USD", 999), Some(50_000.0)); + assert_eq!(cached_rate("node-a", "usd", 999), Some(50_000.0)); + } + + #[test] + fn another_nodes_snapshot_is_never_served() { + // Same reason as `pow`: after a node switch the store still describes + // the previous node, whose price is not the one this order will be + // quoted at. + let _guard = lock(); + store("node-a", usd(50_000.0), 1_000); + + assert_eq!(cached_rate("node-b", "USD", 999), None); + } + + #[test] + fn an_expired_snapshot_is_not_served() { + let _guard = lock(); + store("node-a", usd(50_000.0), 1_000); + + assert_eq!(cached_rate("node-a", "USD", 1_000), None); + assert_eq!(cached_rate("node-a", "USD", 1_001), None); + } + + #[test] + fn a_currency_the_node_does_not_quote_is_none() { + let _guard = lock(); + store("node-a", usd(50_000.0), 1_000); + + assert_eq!(cached_rate("node-a", "CLP", 999), None); + } + + #[test] + fn nothing_is_served_before_a_fetch_or_after_a_clear() { + let _guard = lock(); + assert_eq!(cached_rate("node-a", "USD", 999), None); + + store("node-a", usd(50_000.0), 1_000); + clear(); + + assert_eq!(cached_rate("node-a", "USD", 999), None); + } +} From f19a7c13c5e9a365ad5cffc2b5e21e383f0fcbcc Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Fri, 28 Aug 2026 23:07:54 -0300 Subject: [PATCH 2/6] feat(#337): validate market-price order amount against node's min/max before submitting --- lib/shared/utils/order_amount_limits.dart | 92 +++++++++++ .../utils/order_amount_limits_test.dart | 148 ++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 lib/shared/utils/order_amount_limits.dart create mode 100644 test/shared/utils/order_amount_limits_test.dart diff --git a/lib/shared/utils/order_amount_limits.dart b/lib/shared/utils/order_amount_limits.dart new file mode 100644 index 00000000..6fd56b48 --- /dev/null +++ b/lib/shared/utils/order_amount_limits.dart @@ -0,0 +1,92 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; + +/// Expresses a Mostro node's sats order limits in the fiat currency the user +/// types in, so a market-price order can be checked before it is submitted +/// (#337). +/// +/// A market-price order carries no sats amount: the daemon derives one from +/// the fiat amount at its own rate and rejects the order with +/// `OutOfRangeSatsAmount` when the result falls outside +/// `min_order_amount`/`max_order_amount`. Everything here mirrors that +/// derivation so the client reaches the same verdict beforehand. + +/// Sats per BTC. +const int _satsPerBtc = 100000000; + +/// The sats amount the daemon will price [fiat] at, given [rate] (the price of +/// one BTC in that fiat). +/// +/// Truncates rather than rounds, because that is what the daemon does: +/// `(fiat_amount / price * 1E8) as i64` (`mostro/src/app/order.rs`). Rounding +/// up would let the client accept an amount one sat below the node's minimum +/// and still see it rejected — the exact surprise this check exists to remove. +int satsFromFiat(double fiat, double rate) => + (fiat / rate * _satsPerBtc).truncate(); + +/// A node's sats limits converted to whole fiat units. +@immutable +class FiatAmountLimits { + const FiatAmountLimits({required this.minFiat, required this.maxFiat}); + + final int minFiat; + final int maxFiat; + + /// Whether the range is worth showing. False when the node's whole valid + /// range collapses below one unit of fiat, leaving no enterable whole + /// number; callers then fall back to the raw sats bounds. + bool get isDisplayable => minFiat >= 1 && maxFiat >= minFiat; +} + +/// Converts the node's sats limits to whole-fiat bounds at [rate]. +/// +/// The minimum rounds up and the maximum rounds down, so every whole number +/// inside the returned range converts back to a sats amount inside the node's +/// real range — a bound shown to the user is never itself rejected. The +/// minimum is floored at 1 because the amount field takes whole numbers only. +FiatAmountLimits fiatAmountLimits({ + required int minSats, + required int maxSats, + required double rate, +}) { + if (rate <= 0) return const FiatAmountLimits(minFiat: 0, maxFiat: 0); + return FiatAmountLimits( + minFiat: max(1, (minSats / _satsPerBtc * rate).ceil()), + maxFiat: (maxSats / _satsPerBtc * rate).floor(), + ); +} + +/// Returns the node's accepted range — in sats, and converted to fiat — when +/// the entered market-price [fiatStr] prices outside it, otherwise null. +/// +/// Pure and testable, like `satsOutOfNodeRange` in `add_order_screen.dart`, +/// its fixed-sats counterpart. Fails open on everything it cannot +/// judge: no rate ([rate] null or non-positive, i.e. the node publishes none), +/// a node advertising only one bound, or an amount that is not a positive +/// number. In those cases the daemon stays the only authority, exactly as it +/// was before this check existed. +({int minSats, int maxSats, FiatAmountLimits limits})? fiatOutOfNodeRange( + String fiatStr, + int? minOrder, + int? maxOrder, + double? rate, +) { + if (minOrder == null || maxOrder == null) return null; + if (rate == null || rate <= 0) return null; + final fiat = double.tryParse(fiatStr.trim()); + if (fiat == null || fiat <= 0) return null; + + final sats = satsFromFiat(fiat, rate); + if (sats >= minOrder && sats <= maxOrder) return null; + + return ( + minSats: minOrder, + maxSats: maxOrder, + limits: fiatAmountLimits( + minSats: minOrder, + maxSats: maxOrder, + rate: rate, + ), + ); +} diff --git a/test/shared/utils/order_amount_limits_test.dart b/test/shared/utils/order_amount_limits_test.dart new file mode 100644 index 00000000..e088062b --- /dev/null +++ b/test/shared/utils/order_amount_limits_test.dart @@ -0,0 +1,148 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro/shared/utils/order_amount_limits.dart'; + +void main() { + group('satsFromFiat (#337)', () { + test('converts at the given rate', () { + expect(satsFromFiat(1, 50000), 2000); + expect(satsFromFiat(100, 50000), 200000); + }); + + test('truncates, as the daemon does', () { + // 1 / 30000 * 1e8 = 3333.33…; the daemon casts to i64, so 3333. + expect(satsFromFiat(1, 30000), 3333); + }); + }); + + group('fiatAmountLimits (#337)', () { + test('converts the node bounds to whole fiat units', () { + final limits = + fiatAmountLimits(minSats: 200000, maxSats: 500000, rate: 50000); + + expect(limits.minFiat, 100); + expect(limits.maxFiat, 250); + expect(limits.isDisplayable, isTrue); + }); + + test('floors the minimum at 1, since the field takes whole numbers', () { + final limits = + fiatAmountLimits(minSats: 100, maxSats: 500000, rate: 50000); + + expect(limits.minFiat, 1); + }); + + test('is not displayable when the range collapses below one fiat unit', () { + // 100–1000 sats is 0.05–0.5 USD at 50k: no whole number fits. + final limits = + fiatAmountLimits(minSats: 100, maxSats: 1000, rate: 50000); + + expect(limits.isDisplayable, isFalse); + }); + + test('is not displayable without a usable rate', () { + expect( + fiatAmountLimits(minSats: 100, maxSats: 500000, rate: 0).isDisplayable, + isFalse, + ); + }); + + /// The acceptance criterion of #337: a bound shown to the user must never + /// be one the daemon rejects. Guaranteed by rounding the minimum up and + /// the maximum down, and checked here against the same conversion the + /// daemon performs. + test('every whole fiat value in the shown range is inside the sats range', + () { + const cases = [ + (min: 100, max: 500000, rate: 50000.0), + (min: 1000, max: 20000000, rate: 12345.67), + (min: 4321, max: 987654, rate: 1000000.0), + (min: 100, max: 500000, rate: 0.37), + ]; + + for (final c in cases) { + final limits = + fiatAmountLimits(minSats: c.min, maxSats: c.max, rate: c.rate); + if (!limits.isDisplayable) continue; + + for (final fiat in [ + limits.minFiat, + limits.minFiat + 1, + (limits.minFiat + limits.maxFiat) ~/ 2, + limits.maxFiat - 1, + limits.maxFiat, + ].where((f) => f >= limits.minFiat && f <= limits.maxFiat)) { + final sats = satsFromFiat(fiat.toDouble(), c.rate); + expect( + sats, + inInclusiveRange(c.min, c.max), + reason: '$fiat fiat at rate ${c.rate} priced $sats sats, outside ' + '${c.min}–${c.max}', + ); + } + } + }); + }); + + group('fiatOutOfNodeRange (#337)', () { + test('returns null when the node advertises no bounds', () { + expect(fiatOutOfNodeRange('100', null, null, 50000), isNull); + }); + + test('returns null when only one bound is advertised', () { + expect(fiatOutOfNodeRange('100', 200000, null, 50000), isNull); + expect(fiatOutOfNodeRange('100', null, 500000, 50000), isNull); + }); + + test('returns null without a usable rate — the daemon still decides', () { + expect(fiatOutOfNodeRange('1', 200000, 500000, null), isNull); + expect(fiatOutOfNodeRange('1', 200000, 500000, 0), isNull); + expect(fiatOutOfNodeRange('1', 200000, 500000, -50000), isNull); + }); + + test('returns null for a non-numeric or non-positive amount', () { + expect(fiatOutOfNodeRange('abc', 200000, 500000, 50000), isNull); + expect(fiatOutOfNodeRange('', 200000, 500000, 50000), isNull); + expect(fiatOutOfNodeRange('0', 200000, 500000, 50000), isNull); + expect(fiatOutOfNodeRange('-10', 200000, 500000, 50000), isNull); + }); + + test('returns null for an amount inside the range', () { + // 100 USD at 50k = 200000 sats, the node's minimum. + expect(fiatOutOfNodeRange('100', 200000, 500000, 50000), isNull); + expect(fiatOutOfNodeRange('250', 200000, 500000, 50000), isNull); + }); + + test('below the minimum returns both the sats and the fiat range', () { + final error = fiatOutOfNodeRange('50', 200000, 500000, 50000); + + expect(error, isNotNull); + expect(error!.minSats, 200000); + expect(error.maxSats, 500000); + expect(error.limits.minFiat, 100); + expect(error.limits.maxFiat, 250); + }); + + test('above the maximum returns the accepted range', () { + final error = fiatOutOfNodeRange('300', 200000, 500000, 50000); + + expect(error, isNotNull); + expect(error!.limits.isDisplayable, isTrue); + }); + + test('reports a collapsed fiat range as not displayable', () { + // The whole 100–1000 sats range is under 1 USD, so the caller must fall + // back to showing sats. + final error = fiatOutOfNodeRange('5', 100, 1000, 50000); + + expect(error, isNotNull); + expect(error!.limits.isDisplayable, isFalse); + expect(error.minSats, 100); + expect(error.maxSats, 1000); + }); + + test('accepts a decimal amount, as the field does', () { + expect(fiatOutOfNodeRange('100.5', 200000, 500000, 50000), isNull); + expect(fiatOutOfNodeRange('0.5', 200000, 500000, 50000), isNotNull); + }); + }); +} From 0f432dc4855c5be8bd992ecb3437383342cf929f Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Fri, 28 Aug 2026 23:17:08 -0300 Subject: [PATCH 3/6] feat(#337): validate market-price order amount against node's min/max before submitting --- .../providers/exchange_rate_provider.dart | 41 +++++++ .../order/screens/add_order_screen.dart | 113 ++++++++++++++++-- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 9 ++ lib/l10n/app_es.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_it.arb | 1 + ...market_amounts_out_of_node_range_test.dart | 71 +++++++++++ 8 files changed, 227 insertions(+), 11 deletions(-) create mode 100644 lib/features/order/providers/exchange_rate_provider.dart create mode 100644 test/features/order/market_amounts_out_of_node_range_test.dart diff --git a/lib/features/order/providers/exchange_rate_provider.dart b/lib/features/order/providers/exchange_rate_provider.dart new file mode 100644 index 00000000..741e2829 --- /dev/null +++ b/lib/features/order/providers/exchange_rate_provider.dart @@ -0,0 +1,41 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:mostro/core/mostro_defaults.dart'; +import 'package:mostro/features/settings/widgets/mostro_node_selector.dart'; +import 'package:mostro/src/rust/api/nostr.dart' as nostr_api; + +/// Price of one BTC in [fiatCode], as published by the active Mostro node in +/// its Kind 30078 (`d` = `mostro-rates`) event. +/// +/// Exists so a market-price order can be checked against the node's sats +/// limits before it is submitted (#337): the daemon prices such an order from +/// this same rate, so it is the number its range check will use. +/// +/// Reads the node pubkey from [mostroPubkeyProvider], like +/// `mostroNodeProvider`, so the rate always belongs to the node the order will +/// be sent to. +/// +/// Resolves to `null` whenever the node has no usable rate to give — it +/// publishes none (publishing is optional), the event has expired, or it +/// quotes no such currency — and an unreachable relay surfaces as an error. +/// Callers must treat both as "not checkable" and submit anyway, leaving the +/// daemon as the authority, which is what PR #302 chose for fixed-sats +/// amounts. +/// +/// `autoDispose` and keyed by currency: switching currency starts a fetch for +/// the new one, which the Rust-side cache usually answers without another +/// relay query. +final exchangeRateProvider = + FutureProvider.autoDispose.family((ref, fiatCode) async { + final code = fiatCode.trim(); + if (code.isEmpty) return null; + + final pubkey = ref.watch(mostroPubkeyProvider); + final resolvedPubkey = + pubkey.trim().isEmpty ? defaultMostroPubkey : pubkey.trim(); + + return nostr_api.fetchExchangeRate( + mostroPubkeyHex: resolvedPubkey, + fiatCode: code, + ); +}); diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index caf3f5e3..e8566cd5 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -10,12 +10,15 @@ import 'package:mostro/core/automation/automation_ids.dart'; import 'package:mostro/core/daemon_errors.dart'; import 'package:mostro/features/order/widgets/currency_section.dart'; import 'package:mostro/features/settings/providers/settings_provider.dart'; +import 'package:mostro/features/about/models/mostro_instance.dart'; import 'package:mostro/features/about/providers/mostro_node_provider.dart'; +import 'package:mostro/features/order/providers/exchange_rate_provider.dart'; import 'package:mostro/features/order/widgets/order_preset_selector.dart'; import 'package:mostro/features/order/widgets/payment_method_section.dart'; import 'package:mostro/features/order/widgets/price_section.dart'; import 'package:mostro/features/trades/providers/trades_providers.dart' show refreshTrades; +import 'package:mostro/shared/utils/order_amount_limits.dart'; import 'package:mostro/src/rust/api/orders.dart' as rust_orders; import 'package:mostro/src/rust/api/types.dart'; @@ -57,6 +60,30 @@ class AddOrderScreen extends ConsumerStatefulWidget { return null; } +/// Returns the node's accepted `(min, max)` sats range, and that range in +/// fiat, when a market-price order's amount prices outside it, otherwise null. +/// +/// Pure and testable, like [satsOutOfNodeRange] above, which is the fixed-sats +/// counterpart. Takes every amount the daemon will price — one for a +/// single-amount order, both ends for a range order — because the daemon +/// prices each of them and rejects the order if any one is out of range +/// (`mostro/src/app/order.rs`). Fails open on anything it cannot judge; see +/// [fiatOutOfNodeRange]. +@visibleForTesting +({int minSats, int maxSats, FiatAmountLimits limits})? + marketAmountsOutOfNodeRange( + List fiatAmounts, + int? minOrder, + int? maxOrder, + double? rate, +) { + for (final amount in fiatAmounts) { + final error = fiatOutOfNodeRange(amount, minOrder, maxOrder, rate); + if (error != null) return error; + } + return null; +} + class _AddOrderScreenState extends ConsumerState { final _amountController = TextEditingController(); final _minController = TextEditingController(); @@ -106,6 +133,51 @@ class _AddOrderScreenState extends ConsumerState { } } + /// [marketAmountsOutOfNodeRange] over whichever amount fields are in play. + ({int minSats, int maxSats, FiatAmountLimits limits})? _fiatRangeError( + MostroInstance? node, + double? rate, + ) => + marketAmountsOutOfNodeRange( + _isRange + ? [_minController.text, _maxController.text] + : [_amountController.text], + node?.minOrderAmount, + node?.maxOrderAmount, + rate, + ); + + /// The out-of-range warning to show under the price card, or null when the + /// entered amount is fine — or cannot be checked at all, in which case the + /// daemon stays the only authority. + String? _rangeWarning({ + required AppLocalizations l10n, + required ({int min, int max})? satsRangeError, + required ({int minSats, int maxSats, FiatAmountLimits limits})? + fiatRangeError, + required String fiatCode, + }) { + if (satsRangeError != null) { + return l10n.orderAmountOutOfRange(satsRangeError.min, satsRangeError.max); + } + if (fiatRangeError == null) return null; + // The sats bounds mean nothing to most users, so a market-price range is + // shown in the currency they typed in. Sats are the fallback for when the + // whole valid range is under one fiat unit, leaving no enterable whole + // number to name. + final limits = fiatRangeError.limits; + return limits.isDisplayable + ? l10n.orderAmountOutOfRangeFiat( + limits.minFiat, + limits.maxFiat, + fiatCode, + ) + : l10n.orderAmountOutOfRange( + fiatRangeError.minSats, + fiatRangeError.maxSats, + ); + } + bool _checkValid( List selectedMethods, String customMethod, @@ -202,19 +274,26 @@ class _AddOrderScreenState extends ConsumerState { // out of the node's sats range, but re-check here so no code path submits // an out-of-range fixed-sats order (#282). final node = ref.read(mostroNodeProvider).valueOrNull; + final fiatCode = ref.read(selectedFiatCodeProvider); final outOfRange = !isMarket && !_isRange && fixedSatsStr.isNotEmpty ? satsOutOfNodeRange( fixedSatsStr, node?.minOrderAmount, node?.maxOrderAmount) : null; + final fiatOutOfRange = isMarket + ? _fiatRangeError( + node, + ref.read(exchangeRateProvider(fiatCode)).valueOrNull, + ) + : null; if (_submitting || !_checkValid(selectedMethods, customMethod, isMarket, fixedSatsStr) || - outOfRange != null) { + outOfRange != null || + fiatOutOfRange != null) { return; } setState(() => _submitting = true); try { - final fiatCode = ref.read(selectedFiatCodeProvider); final isMarket = ref.read(isMarketPriceProvider); final premium = isMarket ? ref.read(premiumValueProvider) : 0.0; final fixedSatsStr = ref.read(fixedSatsProvider); @@ -289,10 +368,24 @@ class _AddOrderScreenState extends ConsumerState { ? satsOutOfNodeRange( fixedSatsStr, node?.minOrderAmount, node?.maxOrderAmount) : null; + // Watched rather than read on submit, so the fetch is already in flight by + // the time an amount is typed. Null while it is — and for good when the + // node publishes no rate — which fails the check open (#337). + final rate = isMarket + ? ref.watch(exchangeRateProvider(fiatCode)).valueOrNull + : null; + final fiatRangeError = isMarket ? _fiatRangeError(node, rate) : null; final isValid = _checkValid(selectedMethods, customMethod, isMarket, fixedSatsStr) && - satsRangeError == null; + satsRangeError == null && + fiatRangeError == null; final l10n = AppLocalizations.of(context); + final rangeWarning = _rangeWarning( + l10n: l10n, + satsRangeError: satsRangeError, + fiatRangeError: fiatRangeError, + fiatCode: fiatCode, + ); return Scaffold( appBar: AppBar(title: Text(l10n.creatingNewOrderTitle)), @@ -412,18 +505,16 @@ class _AddOrderScreenState extends ConsumerState { color: cardBg, child: const PriceSection(), ), - // Out-of-range warning for fixed-sats orders (#282): show the node's - // accepted range so the user can correct it before submitting, - // instead of the daemon rejecting the order after the fact. - if (satsRangeError != null) ...[ + // Out-of-range warning, for fixed-sats (#282) and market-price + // (#337) orders alike: show the node's accepted range so the user can + // correct it before submitting, instead of the daemon rejecting the + // order after the fact. + if (rangeWarning != null) ...[ const SizedBox(height: AppSpacing.sm), Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), child: Text( - l10n.orderAmountOutOfRange( - satsRangeError.min, - satsRangeError.max, - ), + rangeWarning, style: TextStyle( color: colors?.destructiveRed ?? const Color(0xFFD84D4D), fontSize: 13, diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index a7a7bf13..c5063f74 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -602,6 +602,7 @@ "minHint": "Min", "maxHint": "Max", "orderAmountOutOfRange": "Der Betrag muss für diesen Mostro-Knoten zwischen {min} und {max} Sats liegen", + "orderAmountOutOfRangeFiat": "Der Betrag muss für diesen Mostro-Knoten zwischen {min} und {max} {currency} liegen", "fiatAmountHint": "Fiat-Betrag", "enterAmountForPreview": "Gib einen Betrag ein, um eine Live-Vorschau zu sehen.", "previewLabel": "VORSCHAU", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1313b4b5..095efa67 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1291,6 +1291,15 @@ "max": {"type": "int"} } }, + "orderAmountOutOfRangeFiat": "Amount must be between {min} and {max} {currency} for this Mostro node", + "@orderAmountOutOfRangeFiat": { + "description": "Shown when a market-price order amount converts outside the node min/max order amount, with the range expressed in the user's fiat currency", + "placeholders": { + "min": {"type": "int"}, + "max": {"type": "int"}, + "currency": {"type": "String"} + } + }, "fiatAmountHint": "Fiat amount", "@fiatAmountHint": {"description": "Hint for the fiat amount input"}, "enterAmountForPreview": "Enter an amount to see a live preview.", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index a003efbb..ddf7d912 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -602,6 +602,7 @@ "minHint": "Mín", "maxHint": "Máx", "orderAmountOutOfRange": "El monto debe estar entre {min} y {max} sats para este nodo Mostro", + "orderAmountOutOfRangeFiat": "El monto debe estar entre {min} y {max} {currency} para este nodo Mostro", "fiatAmountHint": "Monto fiat", "enterAmountForPreview": "Ingresa un monto para ver una vista previa en vivo.", "previewLabel": "VISTA PREVIA", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index ccaaf02b..f793ea03 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -602,6 +602,7 @@ "minHint": "Min", "maxHint": "Max", "orderAmountOutOfRange": "Le montant doit être compris entre {min} et {max} sats pour ce nœud Mostro", + "orderAmountOutOfRangeFiat": "Le montant doit être compris entre {min} et {max} {currency} pour ce nœud Mostro", "fiatAmountHint": "Montant fiat", "enterAmountForPreview": "Saisissez un montant pour voir un aperçu en direct.", "previewLabel": "APERÇU", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 9baea4e1..d5075fd0 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -602,6 +602,7 @@ "minHint": "Min", "maxHint": "Max", "orderAmountOutOfRange": "L'importo deve essere compreso tra {min} e {max} sats per questo nodo Mostro", + "orderAmountOutOfRangeFiat": "L'importo deve essere compreso tra {min} e {max} {currency} per questo nodo Mostro", "fiatAmountHint": "Importo fiat", "enterAmountForPreview": "Inserisci un importo per vedere un'anteprima in tempo reale.", "previewLabel": "ANTEPRIMA", diff --git a/test/features/order/market_amounts_out_of_node_range_test.dart b/test/features/order/market_amounts_out_of_node_range_test.dart new file mode 100644 index 00000000..612df49a --- /dev/null +++ b/test/features/order/market_amounts_out_of_node_range_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro/features/order/screens/add_order_screen.dart'; + +void main() { + // The node used throughout: 200000–500000 sats, which at 50000 per BTC is + // 100–250 units of fiat. + const minSats = 200000; + const maxSats = 500000; + const rate = 50000.0; + + group('marketAmountsOutOfNodeRange (#337)', () { + test('returns null for a single amount inside the range', () { + expect( + marketAmountsOutOfNodeRange(['150'], minSats, maxSats, rate), + isNull, + ); + }); + + test('returns the range for a single amount outside it', () { + final error = + marketAmountsOutOfNodeRange(['500'], minSats, maxSats, rate); + + expect(error, isNotNull); + expect(error!.minSats, minSats); + expect(error.limits.minFiat, 100); + expect(error.limits.maxFiat, 250); + }); + + test('accepts a range order with both ends inside', () { + expect( + marketAmountsOutOfNodeRange(['100', '250'], minSats, maxSats, rate), + isNull, + ); + }); + + /// The daemon prices every amount of a range order and rejects the order + /// if any one is out of range, so a valid minimum must not carry an + /// invalid maximum past the check. + test('rejects a range order whose maximum is out of range', () { + expect( + marketAmountsOutOfNodeRange(['100', '400'], minSats, maxSats, rate), + isNotNull, + ); + }); + + test('rejects a range order whose minimum is out of range', () { + expect( + marketAmountsOutOfNodeRange(['10', '250'], minSats, maxSats, rate), + isNotNull, + ); + }); + + test('ignores an empty field, so a half-typed range is not flagged', () { + expect( + marketAmountsOutOfNodeRange(['100', ''], minSats, maxSats, rate), + isNull, + ); + }); + + test('fails open without a rate', () { + expect( + marketAmountsOutOfNodeRange(['500'], minSats, maxSats, null), + isNull, + ); + }); + + test('fails open when the node advertises no bounds', () { + expect(marketAmountsOutOfNodeRange(['500'], null, null, rate), isNull); + }); + }); +} From 70c0ab7b494d954cb6b2b7de0154d59ccb51d9a4 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Fri, 28 Aug 2026 23:20:40 -0300 Subject: [PATCH 4/6] feat(#337): document exchange rate fetching and market-price order validation --- .../004-mostro-p2p-client/contracts/nostr.md | 27 +++++++++++++++++++ .../004-mostro-p2p-client/contracts/orders.md | 12 +++++++++ 2 files changed, 39 insertions(+) diff --git a/specs/004-mostro-p2p-client/contracts/nostr.md b/specs/004-mostro-p2p-client/contracts/nostr.md index 6ec03bc7..252bfe2f 100644 --- a/specs/004-mostro-p2p-client/contracts/nostr.md +++ b/specs/004-mostro-p2p-client/contracts/nostr.md @@ -106,6 +106,33 @@ MostroNodeInfo { --- +### fetch_exchange_rate(mostro_pubkey_hex: String, fiat_code: String) → f64? +Price of one BTC in `fiat_code`, as published by that node in its Kind 30078 +(NIP-33, `d` tag `mostro-rates`) event. + +Exists so a market-price order can be checked against the node's sats limits +before it is submitted (#337): the daemon prices such an order as +`fiat_amount / price * 1E8` from the very aggregate it publishes here, so this +is the number its range check will use. The node's own event is the source, not +a third-party API — any other quote would be a different price, and asking for +one would disclose which currency the user is about to trade. + +**Returns**: the rate, or `null` whenever the node has no usable one to give: +it publishes no rates event (publishing is optional for an operator), the event +served by the relay has expired per its NIP-40 `expiration` tag, its payload is +unusable, or it quotes no such currency. Callers MUST treat `null` as "not +checkable" — see `create_order` in `orders.md`. + +**Caching**: The rate table is cached per node — never served back to a +different one — and bounded by the event's own expiration, clamped to one hour. +The amount fields of a range order therefore cost a single relay query. + +**Errors**: `NotInitialized`, `InvalidPublicKey`, or a failed relay query. A +failed query is an error rather than `null`, but callers act on both the same +way. + +--- + ### get_known_mostro_nodes() → Vec Return the list of hardcoded default Mostro nodes bundled with the app. Used by the node selector screen (FR-056). To switch the active node, diff --git a/specs/004-mostro-p2p-client/contracts/orders.md b/specs/004-mostro-p2p-client/contracts/orders.md index a2e29609..97fd35ac 100644 --- a/specs/004-mostro-p2p-client/contracts/orders.md +++ b/specs/004-mostro-p2p-client/contracts/orders.md @@ -63,6 +63,18 @@ NewOrderParams { - If range: `fiat_amount_min` MUST be > 0 and < `fiat_amount_max` - `fiat_code` MUST be valid ISO 4217 - `payment_method` MUST not be empty +- The amount is checked against the node's advertised `min_order_amount` / + `max_order_amount` before anything is sent: directly for a fixed + `amount_sats` (#282), and for a market-price order by converting every fiat + amount the daemon will price — both ends of a range order — at the rate the + node publishes (`fetch_exchange_rate` in `nostr.md`), truncating as the + daemon does (#337) + +**Fail-open**: that range check blocks nothing it cannot judge — no rate, no +advertised bounds, an amount that is not yet a number. The order is submitted +and the daemon stays the authority, answering `OutOfRangeSatsAmount` if it +disagrees. Blocking instead would make market-price orders unusable against +every node that leaves rate publishing off, which the protocol allows. **Side effects**: Sends the new-order message to the Mostro daemon and waits for its confirmation. The order is created only once the daemon confirms it; the public order book is populated exclusively from the daemon's Kind 38383 event (the order is **not** inserted optimistically). On no confirmation within the timeout the order is treated as not created — nothing is persisted to My Trades and nothing is added to the book. From 8b6b7d80e1dd0661370189afb4bb0783e4b67ab2 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Mon, 31 Aug 2026 19:40:46 -0300 Subject: [PATCH 5/6] fix(#337): handle non-finite values in market-price order validation to prevent crashes --- .../order/screens/add_order_screen.dart | 32 ++++++--- lib/shared/utils/order_amount_limits.dart | 68 +++++++++++++++---- .../004-mostro-p2p-client/contracts/orders.md | 13 ++-- ...market_amounts_out_of_node_range_test.dart | 34 ++++++++++ .../utils/order_amount_limits_test.dart | 57 ++++++++++++++++ 5 files changed, 175 insertions(+), 29 deletions(-) diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index e8566cd5..4b2d409f 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -60,6 +60,19 @@ class AddOrderScreen extends ConsumerStatefulWidget { return null; } +/// The amount [text] holds, or null when it is not one the form can submit. +/// +/// `Infinity`, `-Infinity` and `NaN` all parse as doubles and would pass a +/// bare positivity check, only to throw in the sats conversion further down — +/// while the screen is building. Nothing filters the amount fields' input, so +/// a pasted value can be any of them. +@visibleForTesting +double? enteredAmount(String text) { + final value = double.tryParse(text.trim()); + if (value == null || !value.isFinite || value <= 0) return null; + return value; +} + /// Returns the node's accepted `(min, max)` sats range, and that range in /// fiat, when a market-price order's amount prices outside it, otherwise null. /// @@ -193,12 +206,11 @@ class _AddOrderScreenState extends ConsumerState { } if (_isRange) { - final min = double.tryParse(_minController.text); - final max = double.tryParse(_maxController.text); - return min != null && max != null && min > 0 && min < max; + final min = enteredAmount(_minController.text); + final max = enteredAmount(_maxController.text); + return min != null && max != null && min < max; } else { - final amount = double.tryParse(_amountController.text); - return amount != null && amount > 0; + return enteredAmount(_amountController.text) != null; } } @@ -615,14 +627,14 @@ class _AddOrderScreenState extends ConsumerState { // Fiat side, mirroring _checkValid's rules. String? amountStr; if (_isRange) { - final min = double.tryParse(_minController.text); - final max = double.tryParse(_maxController.text); - if (min != null && max != null && min > 0 && min < max) { + final min = enteredAmount(_minController.text); + final max = enteredAmount(_maxController.text); + if (min != null && max != null && min < max) { amountStr = '${_formatNum(min)}–${_formatNum(max)} $fiatCode'; } } else { - final amount = double.tryParse(_amountController.text); - if (amount != null && amount > 0) { + final amount = enteredAmount(_amountController.text); + if (amount != null) { amountStr = '${_formatNum(amount)} $fiatCode'; } } diff --git a/lib/shared/utils/order_amount_limits.dart b/lib/shared/utils/order_amount_limits.dart index 6fd56b48..c5a791df 100644 --- a/lib/shared/utils/order_amount_limits.dart +++ b/lib/shared/utils/order_amount_limits.dart @@ -15,15 +15,50 @@ import 'package:flutter/foundation.dart'; /// Sats per BTC. const int _satsPerBtc = 100000000; +/// The value every conversion below saturates at: every sat that will ever +/// exist. +/// +/// `truncate`, `ceil` and `floor` throw on a non-finite double and are +/// undefined beyond the signed 64-bit range, and a large enough amount or rate +/// overflows the multiplication into infinity on its way there. Saturating +/// keeps such an input on the "out of range" side of every comparison here — +/// the verdict it deserves — instead of crashing the form. +const int _saturationCap = 21000000 * _satsPerBtc; + +/// [value] rounded by [rounder], saturating at [_saturationCap]. +int _saturate(double value, int Function(double) rounder) => + value >= _saturationCap ? _saturationCap : rounder(value); + +/// The fiat amount the daemon will actually price, given the [fiat] the user +/// typed. +/// +/// `new_order` casts every fiat amount to `i64` before it reaches the wire +/// (`rust/src/mostro/actions.rs`), so a decimal is truncated there and the +/// daemon never sees the fraction. Pricing the untruncated value here would +/// accept an amount the daemon then prices lower and rejects — the exact +/// surprise this check exists to remove. +double wireFiatAmount(double fiat) => fiat.truncateToDouble(); + /// The sats amount the daemon will price [fiat] at, given [rate] (the price of /// one BTC in that fiat). /// /// Truncates rather than rounds, because that is what the daemon does: /// `(fiat_amount / price * 1E8) as i64` (`mostro/src/app/order.rs`). Rounding /// up would let the client accept an amount one sat below the node's minimum -/// and still see it rejected — the exact surprise this check exists to remove. -int satsFromFiat(double fiat, double rate) => - (fiat / rate * _satsPerBtc).truncate(); +/// and still see it rejected. Pass [fiat] through [wireFiatAmount] first: this +/// mirrors the daemon's arithmetic, not the wire's own truncation. +/// +/// Returns 0 for a conversion that is not a number, which every caller reads +/// as below any minimum. +int satsFromFiat(double fiat, double rate) { + final sats = fiat / rate * _satsPerBtc; + if (sats.isNaN) return 0; + return _saturate(sats, (v) => v.truncate()); +} + +/// Whether [rate] can price an order: a real, positive price for one BTC. +bool _isUsableRate(double? rate) => + rate != null && rate.isFinite && rate > 0; /// A node's sats limits converted to whole fiat units. @immutable @@ -50,10 +85,12 @@ FiatAmountLimits fiatAmountLimits({ required int maxSats, required double rate, }) { - if (rate <= 0) return const FiatAmountLimits(minFiat: 0, maxFiat: 0); + if (!_isUsableRate(rate)) { + return const FiatAmountLimits(minFiat: 0, maxFiat: 0); + } return FiatAmountLimits( - minFiat: max(1, (minSats / _satsPerBtc * rate).ceil()), - maxFiat: (maxSats / _satsPerBtc * rate).floor(), + minFiat: max(1, _saturate(minSats / _satsPerBtc * rate, (v) => v.ceil())), + maxFiat: _saturate(maxSats / _satsPerBtc * rate, (v) => v.floor()), ); } @@ -62,10 +99,10 @@ FiatAmountLimits fiatAmountLimits({ /// /// Pure and testable, like `satsOutOfNodeRange` in `add_order_screen.dart`, /// its fixed-sats counterpart. Fails open on everything it cannot -/// judge: no rate ([rate] null or non-positive, i.e. the node publishes none), -/// a node advertising only one bound, or an amount that is not a positive -/// number. In those cases the daemon stays the only authority, exactly as it -/// was before this check existed. +/// judge: no rate ([rate] null, non-finite or non-positive, i.e. the node +/// publishes none), a node advertising only one bound, or an amount that is +/// not a finite positive number. In those cases the daemon stays the only +/// authority, exactly as it was before this check existed. ({int minSats, int maxSats, FiatAmountLimits limits})? fiatOutOfNodeRange( String fiatStr, int? minOrder, @@ -73,11 +110,14 @@ FiatAmountLimits fiatAmountLimits({ double? rate, ) { if (minOrder == null || maxOrder == null) return null; - if (rate == null || rate <= 0) return null; + if (!_isUsableRate(rate)) return null; + final usableRate = rate!; final fiat = double.tryParse(fiatStr.trim()); - if (fiat == null || fiat <= 0) return null; + // `Infinity` and `NaN` parse as doubles and would pass a bare positivity + // check; the form rejects them outright, so there is nothing to warn about. + if (fiat == null || !fiat.isFinite || fiat <= 0) return null; - final sats = satsFromFiat(fiat, rate); + final sats = satsFromFiat(wireFiatAmount(fiat), usableRate); if (sats >= minOrder && sats <= maxOrder) return null; return ( @@ -86,7 +126,7 @@ FiatAmountLimits fiatAmountLimits({ limits: fiatAmountLimits( minSats: minOrder, maxSats: maxOrder, - rate: rate, + rate: usableRate, ), ); } diff --git a/specs/004-mostro-p2p-client/contracts/orders.md b/specs/004-mostro-p2p-client/contracts/orders.md index 97fd35ac..c812b2cf 100644 --- a/specs/004-mostro-p2p-client/contracts/orders.md +++ b/specs/004-mostro-p2p-client/contracts/orders.md @@ -68,13 +68,16 @@ NewOrderParams { `amount_sats` (#282), and for a market-price order by converting every fiat amount the daemon will price — both ends of a range order — at the rate the node publishes (`fetch_exchange_rate` in `nostr.md`), truncating as the - daemon does (#337) + daemon does (#337). The fiat amount is first normalised to the whole unit the + wire carries (`new_order` casts it to `i64`), so the check judges the amount + the daemon actually receives rather than the decimal the user typed **Fail-open**: that range check blocks nothing it cannot judge — no rate, no -advertised bounds, an amount that is not yet a number. The order is submitted -and the daemon stays the authority, answering `OutOfRangeSatsAmount` if it -disagrees. Blocking instead would make market-price orders unusable against -every node that leaves rate publishing off, which the protocol allows. +advertised bounds, an amount that is not yet a finite positive number. The +order is submitted and the daemon stays the authority, answering +`OutOfRangeSatsAmount` if it disagrees. Blocking instead would make +market-price orders unusable against every node that leaves rate publishing +off, which the protocol allows. **Side effects**: Sends the new-order message to the Mostro daemon and waits for its confirmation. The order is created only once the daemon confirms it; the public order book is populated exclusively from the daemon's Kind 38383 event (the order is **not** inserted optimistically). On no confirmation within the timeout the order is treated as not created — nothing is persisted to My Trades and nothing is added to the book. diff --git a/test/features/order/market_amounts_out_of_node_range_test.dart b/test/features/order/market_amounts_out_of_node_range_test.dart index 612df49a..61ab40d6 100644 --- a/test/features/order/market_amounts_out_of_node_range_test.dart +++ b/test/features/order/market_amounts_out_of_node_range_test.dart @@ -67,5 +67,39 @@ void main() { test('fails open when the node advertises no bounds', () { expect(marketAmountsOutOfNodeRange(['500'], null, null, rate), isNull); }); + + test('fails open on a non-finite amount, which the form rejects', () { + for (final amount in ['Infinity', '-Infinity', 'NaN']) { + expect( + marketAmountsOutOfNodeRange([amount], minSats, maxSats, rate), + isNull, + reason: '$amount must not reach truncate()', + ); + } + }); + }); + + group('enteredAmount (#337)', () { + test('returns the amount for a submittable value', () { + expect(enteredAmount('150'), 150); + expect(enteredAmount(' 150.5 '), 150.5); + }); + + test('rejects a non-numeric or non-positive value', () { + expect(enteredAmount(''), isNull); + expect(enteredAmount('abc'), isNull); + expect(enteredAmount('0'), isNull); + expect(enteredAmount('-10'), isNull); + }); + + /// Nothing filters the amount fields' input, so these can be pasted in. + /// They parse as doubles and pass a bare positivity check, then throw in + /// the sats conversion while the screen is building. + test('rejects a non-finite value', () { + expect(enteredAmount('Infinity'), isNull); + expect(enteredAmount('-Infinity'), isNull); + expect(enteredAmount('NaN'), isNull); + expect(enteredAmount('1e309'), isNull); + }); }); } diff --git a/test/shared/utils/order_amount_limits_test.dart b/test/shared/utils/order_amount_limits_test.dart index e088062b..bcf44d85 100644 --- a/test/shared/utils/order_amount_limits_test.dart +++ b/test/shared/utils/order_amount_limits_test.dart @@ -12,6 +12,28 @@ void main() { // 1 / 30000 * 1e8 = 3333.33…; the daemon casts to i64, so 3333. expect(satsFromFiat(1, 30000), 3333); }); + + test('saturates instead of overflowing on an absurd amount', () { + // 1e300 / 50000 * 1e8 overflows to infinity, which truncate() cannot + // convert; the capped result still reads as out of any node's range. + expect(satsFromFiat(1e300, 50000), greaterThan(0)); + expect(satsFromFiat(double.infinity, 50000), greaterThan(0)); + }); + + test('reports a conversion that is not a number as 0 sats', () { + expect(satsFromFiat(double.nan, 50000), 0); + }); + }); + + group('wireFiatAmount (#337)', () { + /// `new_order` casts the fiat amount to `i64`, so the daemon prices the + /// whole unit, never the decimal the user typed. + test('truncates to the whole unit the wire carries', () { + expect(wireFiatAmount(1.1), 1); + expect(wireFiatAmount(100.9), 100); + expect(wireFiatAmount(100), 100); + expect(wireFiatAmount(0.9), 0); + }); }); group('fiatAmountLimits (#337)', () { @@ -46,6 +68,17 @@ void main() { ); }); + test('is not displayable for a non-finite rate', () { + for (final rate in [double.infinity, double.negativeInfinity, double.nan]) { + expect( + fiatAmountLimits(minSats: 200000, maxSats: 500000, rate: rate) + .isDisplayable, + isFalse, + reason: 'rate $rate must not reach ceil()/floor()', + ); + } + }); + /// The acceptance criterion of #337: a bound shown to the user must never /// be one the daemon rejects. Guaranteed by rounding the minimum up and /// the maximum down, and checked here against the same conversion the @@ -144,5 +177,29 @@ void main() { expect(fiatOutOfNodeRange('100.5', 200000, 500000, 50000), isNull); expect(fiatOutOfNodeRange('0.5', 200000, 500000, 50000), isNotNull); }); + + /// The decimal never reaches the daemon: `new_order` casts the fiat amount + /// to `i64`. Judging the untruncated value would accept 1.1 as 3666 sats + /// and let the daemon reject the 1 it actually receives, priced at 3333. + test('judges the truncated amount the wire carries, not the decimal', () { + expect(fiatOutOfNodeRange('1.1', 3334, 500000, 30000), isNotNull); + expect(fiatOutOfNodeRange('2.9', 3334, 500000, 30000), isNull); + }); + + test('returns null for a non-finite amount, which the form rejects', () { + for (final amount in ['Infinity', '-Infinity', 'NaN', '1e309']) { + expect( + fiatOutOfNodeRange(amount, 200000, 500000, 50000), + isNull, + reason: '$amount must not reach truncate()', + ); + } + }); + + test('returns null for a non-finite rate', () { + for (final rate in [double.infinity, double.negativeInfinity, double.nan]) { + expect(fiatOutOfNodeRange('100', 200000, 500000, rate), isNull); + } + }); }); } From 222ae4709ec992345c70f405d5a351057c7ecc46 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Mon, 31 Aug 2026 19:44:29 -0300 Subject: [PATCH 6/6] fix(rates): verify signature on kind 30078 events before using exchange rates to prevent relay forgery --- rust/src/api/nostr.rs | 145 ++++++++++++++++-- .../004-mostro-p2p-client/contracts/nostr.md | 8 + 2 files changed, 140 insertions(+), 13 deletions(-) diff --git a/rust/src/api/nostr.rs b/rust/src/api/nostr.rs index 8c689f5e..dbb6942c 100644 --- a/rust/src/api/nostr.rs +++ b/rust/src/api/nostr.rs @@ -267,19 +267,7 @@ pub async fn fetch_exchange_rate( .await .map_err(|e| anyhow::anyhow!("fetch_events failed: {e}"))?; - // Defence in depth, as v1 does: a relay is free to answer with events the - // filter never asked for, and pricing an order off another kind, another - // d-tag or another author's event would be worse than not checking at all. - let event = events - .into_iter() - .filter(|e| { - e.kind == Kind::from(rates::RATES_KIND) - && e.pubkey == pubkey - && tag_value(e, "d").as_deref() == Some(rates::RATES_D_TAG) - }) - .max_by_key(|e| e.created_at); - - let Some(event) = event else { + let Some(event) = select_rates_event(events, &pubkey) else { log::warn!("[rates] node {mostro_pubkey_hex} published no usable kind 30078 event"); rates::clear(); return Ok(None); @@ -306,6 +294,44 @@ pub async fn fetch_exchange_rate( Ok(rates::cached_rate(&mostro_pubkey_hex, &fiat_code, now)) } +/// The newest authentic rates event among `events`, or `None`. +/// +/// Defence in depth, as v1 does: a relay is free to answer with events the +/// filter never asked for, and pricing an order off another kind, another +/// d-tag or another author's event would be worse than not checking at all. +/// +/// The signature check is what makes the author check mean anything. Up to +/// 0.44.7 `nostr-sdk` does not guarantee that a fetched event was verified +/// before it reaches the caller (GHSA-f96q-5f6p-v7cj), and this crate pins +/// 0.44.1, so a relay can hand us an event carrying the node's pubkey that the +/// node never signed. Every field below is attacker-chosen until `verify()` +/// says otherwise, and a forged price would silently move the client's whole +/// range check. Verification runs before the newest-first pick, so a forgery +/// cannot shadow the genuine event by claiming a later `created_at` either. +fn select_rates_event( + events: impl IntoIterator, + pubkey: &nostr_sdk::PublicKey, +) -> Option { + use crate::mostro::rates; + use nostr_sdk::prelude::*; + + events + .into_iter() + .filter(|e| { + e.kind == Kind::from(rates::RATES_KIND) + && e.pubkey == *pubkey + && tag_value(e, "d").as_deref() == Some(rates::RATES_D_TAG) + }) + .filter(|e| match e.verify() { + Ok(()) => true, + Err(err) => { + log::warn!("[rates] discarding unauthenticated kind 30078 event: {err}"); + false + } + }) + .max_by_key(|e| e.created_at) +} + /// First value of the single-letter or named tag `name` on `event`. fn tag_value(event: &nostr_sdk::Event, name: &str) -> Option { event @@ -389,3 +415,96 @@ fn default_relays() -> Vec { pub(crate) fn get_pool() -> Result<&'static Arc> { pool() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::mostro::rates; + use nostr_sdk::prelude::*; + + const RATES: &str = r#"{"BTC":{"USD":50000.0}}"#; + + fn rates_event(keys: &Keys, content: &str, created_at: u64) -> Event { + EventBuilder::new(Kind::from(rates::RATES_KIND), content) + .tag(Tag::parse(["d", rates::RATES_D_TAG]).unwrap()) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(keys) + .unwrap() + } + + /// What a hostile relay can do without the node's key: take a real event + /// and rewrite the price. Everything the field checks look at survives — + /// kind, author, `d` tag — and only the signature gives it away. + fn forge_content(event: &Event, content: &str) -> Event { + let mut json: serde_json::Value = serde_json::from_str(&event.as_json()).unwrap(); + json["content"] = serde_json::Value::String(content.to_string()); + Event::from_json(json.to_string()).unwrap() + } + + #[test] + fn selects_a_signed_rates_event() { + let node = Keys::generate(); + let event = rates_event(&node, RATES, 1000); + + let selected = select_rates_event([event.clone()], &node.public_key()); + + assert_eq!(selected.map(|e| e.id), Some(event.id)); + } + + #[test] + fn selects_the_newest_of_several() { + let node = Keys::generate(); + let old = rates_event(&node, RATES, 1000); + let new = rates_event(&node, r#"{"BTC":{"USD":60000.0}}"#, 2000); + + let selected = select_rates_event([old, new.clone()], &node.public_key()); + + assert_eq!(selected.map(|e| e.id), Some(new.id)); + } + + #[test] + fn rejects_a_forged_rates_event() { + let node = Keys::generate(); + let genuine = rates_event(&node, RATES, 1000); + let forged = forge_content(&genuine, r#"{"BTC":{"USD":1.0}}"#); + + assert_eq!(forged.pubkey, node.public_key()); + assert!(forged.verify().is_err(), "the forgery must not authenticate"); + + assert!(select_rates_event([forged], &node.public_key()).is_none()); + } + + /// A forgery must not be able to bury the real price by claiming a later + /// `created_at`, which is why the signature check runs before the pick. + #[test] + fn a_newer_forgery_does_not_shadow_the_genuine_event() { + let node = Keys::generate(); + let genuine = rates_event(&node, RATES, 1000); + let forged = forge_content(&rates_event(&node, RATES, 2000), r#"{"BTC":{"USD":1.0}}"#); + + let selected = select_rates_event([forged, genuine.clone()], &node.public_key()); + + assert_eq!(selected.map(|e| e.id), Some(genuine.id)); + } + + #[test] + fn rejects_another_author_kind_or_d_tag() { + let node = Keys::generate(); + let other = Keys::generate(); + + let wrong_author = rates_event(&other, RATES, 1000); + assert!(select_rates_event([wrong_author], &node.public_key()).is_none()); + + let wrong_kind = EventBuilder::new(Kind::TextNote, RATES) + .tag(Tag::parse(["d", rates::RATES_D_TAG]).unwrap()) + .sign_with_keys(&node) + .unwrap(); + assert!(select_rates_event([wrong_kind], &node.public_key()).is_none()); + + let wrong_d_tag = EventBuilder::new(Kind::from(rates::RATES_KIND), RATES) + .tag(Tag::parse(["d", "something-else"]).unwrap()) + .sign_with_keys(&node) + .unwrap(); + assert!(select_rates_event([wrong_d_tag], &node.public_key()).is_none()); + } +} diff --git a/specs/004-mostro-p2p-client/contracts/nostr.md b/specs/004-mostro-p2p-client/contracts/nostr.md index 252bfe2f..deb559e0 100644 --- a/specs/004-mostro-p2p-client/contracts/nostr.md +++ b/specs/004-mostro-p2p-client/contracts/nostr.md @@ -123,6 +123,14 @@ served by the relay has expired per its NIP-40 `expiration` tag, its payload is unusable, or it quotes no such currency. Callers MUST treat `null` as "not checkable" — see `create_order` in `orders.md`. +**Authenticity**: An event is only used once its Schnorr signature verifies +against the node's pubkey, and only the newest event that does is considered. +The kind, author and `d` tag checks say nothing on their own — a relay is free +to answer with events the filter never asked for, and `nostr-sdk` does not +guarantee that a fetched event was verified before it reaches the caller +(GHSA-f96q-5f6p-v7cj) — so an unverified event would let a relay set the price +the whole range check is measured against. + **Caching**: The rate table is cached per node — never served back to a different one — and bounded by the event's own expiration, clamped to one hour. The amount fields of a range order therefore cost a single relay query.