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/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/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); + } +} 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. 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); + }); + }); +} 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); + }); + }); +}