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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions lib/features/order/providers/exchange_rate_provider.dart
Original file line number Diff line number Diff line change
@@ -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<double?, String>((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,
);
});
145 changes: 124 additions & 21 deletions lib/features/order/screens/add_order_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -57,6 +60,43 @@ 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.
///
/// 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<String> 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<AddOrderScreen> {
final _amountController = TextEditingController();
final _minController = TextEditingController();
Expand Down Expand Up @@ -106,6 +146,51 @@ class _AddOrderScreenState extends ConsumerState<AddOrderScreen> {
}
}

/// [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<String> selectedMethods,
String customMethod,
Expand All @@ -121,12 +206,11 @@ class _AddOrderScreenState extends ConsumerState<AddOrderScreen> {
}

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;
}
}

Expand Down Expand Up @@ -202,19 +286,26 @@ class _AddOrderScreenState extends ConsumerState<AddOrderScreen> {
// 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);
Expand Down Expand Up @@ -289,10 +380,24 @@ class _AddOrderScreenState extends ConsumerState<AddOrderScreen> {
? 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)),
Expand Down Expand Up @@ -412,18 +517,16 @@ class _AddOrderScreenState extends ConsumerState<AddOrderScreen> {
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,
Expand Down Expand Up @@ -524,14 +627,14 @@ class _AddOrderScreenState extends ConsumerState<AddOrderScreen> {
// 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';
}
}
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_es.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_fr.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_it.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading