diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index 2534541cf..590ae6f8f 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -23,6 +23,56 @@ class Session { NostrKeyPairs? _adminSharedKey; String? disputeId; + /// The order amount in satoshis as it stood when this session committed to + /// the trade, or null when there was no resolved figure to pin. + /// + /// The settlement checks derive what a payment should be from the node's + /// kind-38383 order event and kind-38385 info event, both of which are + /// addressable and can be republished after the fact. Pinning the figures + /// the user actually agreed to holds the check to those terms rather than + /// to whatever the node last said. + /// + /// Null for a market-price or range order, whose sats figure the node only + /// resolves after the take — there is nothing to pin at the moment of + /// commitment, and those orders fall back to the live events. + late final int? pinnedAmountSats; + + /// The node's fee rate when this session committed, on the same terms as + /// [pinnedAmountSats]. + late final double? pinnedFeeRate; + + /// The currency, fiat figure and premium this session committed to. + /// + /// The independent market quote is priced from these three, and all three + /// live in the same addressable kind-38383 event as the sats amount. Read + /// live, they let a node make a shaved settlement quote exactly: resolve + /// fewer sats than the trade was agreed at, then republish the premium so + /// the quote lands on the shaved figure. Pinned, the quote prices what the + /// user accepted. + /// + /// Null where there was nothing to pin — a session written before pinning + /// existed, or an order event that had not arrived at commitment. + late final String? pinnedFiatCode; + late final int? pinnedFiatAmount; + late final double? pinnedPremium; + + /// Whether the terms were pinned when this session committed. + /// + /// Together with the figures above: all of it is written once, in the + /// constructor body, and never moved afterwards. That is what a pin is. + /// + /// Separates a session that pinned whatever there was to pin — possibly + /// nothing, for a market-price order with no resolved sats, or for a + /// commitment made before the info event arrived — from one written before + /// pinning existed at all. Only the second has any business reading a term + /// the node publishes later; for the first, an absent figure means the node + /// could not supply it at the moment of agreement, and letting it supply + /// one afterwards is the move pinning exists to stop. + /// + /// False for every session written by an earlier version, which keeps them + /// behaving as they did. + final bool termsPinned; + /// Transient marker (never persisted): set while a maker-created order is in /// the anti-abuse bond limbo, so the shared pay-bond handler skips persisting /// the still-uncommitted session. Cleared and persisted on confirmation. @@ -38,9 +88,37 @@ class Session { this.parentOrderId, this.role, this.disputeId, + int? pinnedAmountSats, + double? pinnedFeeRate, + String? pinnedFiatCode, + int? pinnedFiatAmount, + double? pinnedPremium, + this.termsPinned = false, Peer? peer, String? adminPubkey, }) { + // Normalized here rather than at each reader: a figure that cannot anchor + // anything is the same as no figure, and the checks that consult these + // fields have to agree on which is which. + this.pinnedAmountSats = + (pinnedAmountSats != null && pinnedAmountSats > 0) + ? pinnedAmountSats + : null; + this.pinnedFeeRate = + (pinnedFeeRate != null && pinnedFeeRate >= 0 && pinnedFeeRate.isFinite) + ? pinnedFeeRate + : null; + this.pinnedFiatCode = + (pinnedFiatCode != null && pinnedFiatCode.isNotEmpty) + ? pinnedFiatCode + : null; + this.pinnedFiatAmount = + (pinnedFiatAmount != null && pinnedFiatAmount > 0) + ? pinnedFiatAmount + : null; + this.pinnedPremium = + (pinnedPremium != null && pinnedPremium.isFinite) ? pinnedPremium : null; + _peer = peer; if (peer != null) { _sharedKey = NostrUtils.computeSharedKey( @@ -64,6 +142,12 @@ class Session { 'peer': peer?.publicKey, 'admin_peer': _adminPubkey, 'dispute_id': disputeId, + 'pinned_amount_sats': pinnedAmountSats, + 'pinned_fee_rate': pinnedFeeRate, + 'pinned_fiat_code': pinnedFiatCode, + 'pinned_fiat_amount': pinnedFiatAmount, + 'pinned_premium': pinnedPremium, + 'terms_pinned': termsPinned, }; factory Session.fromJson(Map json) { @@ -181,6 +265,48 @@ class Session { disputeId = disputeIdValue; } + // Absent in sessions written before the terms were pinned, so a missing + // or unusable value reads as "nothing was pinned" rather than an error: + // those trades fall back to the live events, as they always did. + final pinnedAmountValue = json['pinned_amount_sats']; + int? pinnedAmountSats; + if (pinnedAmountValue is int) { + pinnedAmountSats = pinnedAmountValue; + } else if (pinnedAmountValue is String) { + pinnedAmountSats = int.tryParse(pinnedAmountValue); + } + + final termsPinnedValue = json['terms_pinned']; + final termsPinned = termsPinnedValue is bool + ? termsPinnedValue + : termsPinnedValue is String + ? termsPinnedValue.toLowerCase() == 'true' + : false; + + final pinnedFeeValue = json['pinned_fee_rate']; + double? pinnedFeeRate; + if (pinnedFeeValue is num) { + pinnedFeeRate = pinnedFeeValue.toDouble(); + } else if (pinnedFeeValue is String) { + pinnedFeeRate = double.tryParse(pinnedFeeValue); + } + + final pinnedFiatAmountValue = json['pinned_fiat_amount']; + int? pinnedFiatAmount; + if (pinnedFiatAmountValue is int) { + pinnedFiatAmount = pinnedFiatAmountValue; + } else if (pinnedFiatAmountValue is String) { + pinnedFiatAmount = int.tryParse(pinnedFiatAmountValue); + } + + final pinnedPremiumValue = json['pinned_premium']; + double? pinnedPremium; + if (pinnedPremiumValue is num) { + pinnedPremium = pinnedPremiumValue.toDouble(); + } else if (pinnedPremiumValue is String) { + pinnedPremium = double.tryParse(pinnedPremiumValue); + } + return Session( masterKey: masterKeyValue, tradeKey: tradeKeyValue, @@ -193,6 +319,12 @@ class Session { peer: peer, adminPubkey: adminPubkey, disputeId: disputeId, + pinnedAmountSats: pinnedAmountSats, + pinnedFeeRate: pinnedFeeRate, + pinnedFiatCode: json['pinned_fiat_code']?.toString(), + pinnedFiatAmount: pinnedFiatAmount, + pinnedPremium: pinnedPremium, + termsPinned: termsPinned, ); } catch (e) { throw FormatException('Failed to parse Session from JSON: $e'); diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index d271b0436..5f6df7792 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -12,6 +12,21 @@ const orderEventKind = 38383; const infoEventKind = 38385; const orderFilterDurationHours = 48; +/// Upper bound on the stored order events a relay may return when the book +/// subscription opens. +/// +/// A backstop, not a page size. Kind 38383 is addressable, so a relay that +/// follows NIP-01 already holds one event per order rather than one per status +/// change, and this figure sits well above what any node's 48-hour window +/// carries — the book is not expected to reach it, and a relay that honours it +/// returns the most recent events, which is where an in-flight trade's own +/// order sits. +/// +/// What it bounds is the pathological case: every event that arrives is parsed +/// on the main isolate, so an unbounded backlog is unbounded work between +/// launch and the first thing the user can act on. +const orderFilterLimit = 1000; + class OpenOrdersRepository implements OrderRepository { final NostrService _nostrService; NostrEvent? _mostroInstance; @@ -29,6 +44,10 @@ class OpenOrdersRepository implements OrderRepository { final Map _events = {}; StreamSubscription? _subscription; + /// The node info subscription, kept apart from the order one. See + /// [_subscribeToOrders] for why it does not share a filter. + StreamSubscription? _infoSubscription; + /// Polls for NostrService readiness when the repository is built before /// init() completes, so the order subscription can be opened once Nostr is up. Timer? _initRetryTimer; @@ -48,9 +67,28 @@ class OpenOrdersRepository implements OrderRepository { _emitEvents(); } - /// Subscribes to events matching the given filter. + /// Subscribes to the node's order book and to its info event. + /// + /// The two go out as separate requests, and the info one first. Sharing a + /// filter put the info event behind the entire order backlog: one request, + /// answered in whatever order the relay holds its events, and the book is + /// every order the node touched in [orderFilterDurationHours]. + /// + /// That ordering is not cosmetic. The fee rate the settlement checks are + /// anchored to is read off this event, and it is pinned at the moment the + /// user commits to a trade. A take that lands before the event does pins no + /// rate at all, and the settlement screens then report — for the life of + /// that trade — an amount they could not verify. On a slow link, or via a + /// `mostro:` deep link that opens straight onto the take screen, that is a + /// caution the user's bandwidth decides rather than the node's honesty. + /// + /// Its own request also lets the filter drop `since`. The node republishes + /// its info event on its own schedule, not the order book's, so a node that + /// has been quiet for longer than the order window would otherwise never + /// announce itself at all. void _subscribeToOrders() { _subscription?.cancel(); + _infoSubscription?.cancel(); _initRetryTimer?.cancel(); // The repository can be built before NostrService.init() completes (the @@ -63,30 +101,42 @@ class OpenOrdersRepository implements OrderRepository { return; } + final nodePubkey = _settings.mostroPublicKey; + + _infoSubscription = _nostrService + .subscribeToEvents( + NostrRequest( + filters: [ + NostrFilter( + kinds: [infoEventKind], + authors: [nodePubkey], + limit: 1, + ), + ], + ), + ) + .listen(_handleInfoEvent, onError: (error) { + logger.e('Error in Mostro info subscription: $error'); + }); + final filterTime = DateTime.now().subtract(Duration(hours: orderFilterDurationHours)); - final filter = NostrFilter( - kinds: [orderEventKind, infoEventKind], - since: filterTime, - authors: [_settings.mostroPublicKey], - ); - final request = NostrRequest( - filters: [filter], + filters: [ + NostrFilter( + kinds: [orderEventKind], + since: filterTime, + authors: [nodePubkey], + limit: orderFilterLimit, + ), + ], ); _subscription = _nostrService.subscribeToEvents(request).listen((event) { if (event.type == 'order') { _events[event.orderId!] = event; _eventStreamController.add(_events.values.toList()); - } else if (event.kind == infoEventKind && - event.pubkey == _settings.mostroPublicKey) { - logger.i('Mostro instance info loaded: $event'); - _mostroInstance = event; - if (!_mostroInstanceController.isClosed) { - _mostroInstanceController.add(event); - } } }, onError: (error) { logger.e('Error in order subscription: $error'); @@ -97,6 +147,24 @@ class OpenOrdersRepository implements OrderRepository { _emitEvents(); } + /// Records the node's info event and announces it. + /// + /// The author is checked rather than assumed: a node switch cancels this + /// subscription, but an event already in flight when it does must not be + /// reported as the new node's terms. + void _handleInfoEvent(NostrEvent event) { + if (event.kind != infoEventKind || + event.pubkey != _settings.mostroPublicKey) { + return; + } + + logger.i('Mostro instance info loaded: $event'); + _mostroInstance = event; + if (!_mostroInstanceController.isClosed) { + _mostroInstanceController.add(event); + } + } + /// Polls NostrService until it reports initialized, then opens the order /// subscription. Bounded so a failed init does not leave a timer running /// forever; `reloadData`/`updateSettings` can still re-trigger later. @@ -127,6 +195,7 @@ class OpenOrdersRepository implements OrderRepository { @override void dispose() { _subscription?.cancel(); + _infoSubscription?.cancel(); _initRetryTimer?.cancel(); _eventStreamController.close(); _mostroInstanceController.close(); diff --git a/lib/features/order/notifiers/add_order_notifier.dart b/lib/features/order/notifiers/add_order_notifier.dart index c16d47ccf..d463baf3e 100644 --- a/lib/features/order/notifiers/add_order_notifier.dart +++ b/lib/features/order/notifiers/add_order_notifier.dart @@ -8,6 +8,7 @@ import 'package:mostro_mobile/features/order/providers/order_notifier_provider.d import 'package:mostro_mobile/features/order/models/order_state.dart'; import 'package:mostro_mobile/services/logger_service.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; +import 'package:mostro_mobile/features/order/providers/settlement_anchor_provider.dart'; class AddOrderNotifier extends AbstractMostroNotifier { late final MostroService mostroService; @@ -114,9 +115,23 @@ class AddOrderNotifier extends AbstractMostroNotifier { // reset runs, and if a restore is in progress we block until it releases. await ref.read(sessionLifecycleLockProvider).withSessionLock(() async { final sessionNotifier = ref.read(sessionNotifierProvider.notifier); + // Pin the terms this order is being created on. The maker's own figure + // is the agreement here, so it is the one the settlement is later held + // to rather than whatever the node ends up publishing. A market-price + // or range order carries no sats amount to pin. + // The maker's own figures are the agreement, so they are pinned from + // the order being submitted rather than read back off the event the + // node publishes for it. A range order carries no single fiat figure + // until a taker resolves the band. + final isRange = order.maxAmount != null; session = await sessionNotifier.newSession( requestId: requestId, role: order.kind == OrderType.buy ? Role.buyer : Role.seller, + pinnedAmountSats: order.amount > 0 ? order.amount : null, + pinnedFeeRate: ref.read(nodeFeeRateProvider), + pinnedFiatCode: order.fiatCode, + pinnedFiatAmount: isRange ? null : order.fiatAmount, + pinnedPremium: order.premium.toDouble(), ); // Start 10s timeout cleanup timer for create orders diff --git a/lib/features/order/notifiers/order_notifier.dart b/lib/features/order/notifiers/order_notifier.dart index a082d79d3..08f6b367a 100644 --- a/lib/features/order/notifiers/order_notifier.dart +++ b/lib/features/order/notifiers/order_notifier.dart @@ -7,9 +7,11 @@ import 'package:mostro_mobile/features/order/models/order_state.dart'; import 'package:mostro_mobile/features/notifications/providers/notifications_provider.dart'; import 'package:mostro_mobile/shared/providers.dart'; import 'package:mostro_mobile/features/order/notifiers/abstract_mostro_notifier.dart'; +import 'package:mostro_mobile/features/order/providers/settlement_anchor_provider.dart'; import 'package:mostro_mobile/services/logger_service.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; import 'package:mostro_mobile/shared/utils/order_sync_helpers.dart'; +import 'package:mostro_mobile/shared/utils/pricing_terms.dart'; class OrderNotifier extends AbstractMostroNotifier { late final MostroService mostroService; @@ -148,9 +150,21 @@ class OrderNotifier extends AbstractMostroNotifier { // restore (TOCTOU-safe). See [SessionLifecycleLock]. await ref.read(sessionLifecycleLockProvider).withSessionLock(() async { final sessionNotifier = ref.read(sessionNotifierProvider.notifier); + // Pin the terms being agreed to. Both inputs come from addressable + // events the node can republish, so reading them again later would let + // it move the figure this trade is checked against after the fact. + final pinnedAmountSats = ref.read(publishedOrderAmountProvider(orderId)); + final pinnedFeeRate = ref.read(nodeFeeRateProvider); + final terms = pricingTermsOf(ref.read(eventProvider(orderId))); + session = await sessionNotifier.newSession( orderId: orderId, role: Role.buyer, + pinnedAmountSats: pinnedAmountSats, + pinnedFeeRate: pinnedFeeRate, + pinnedFiatCode: terms?.fiatCode, + pinnedFiatAmount: terms?.fiatAmount, + pinnedPremium: terms?.premium, ); // Drop any stale grace timer/flag from a previous cycle on this order so @@ -174,9 +188,21 @@ class OrderNotifier extends AbstractMostroNotifier { // restore (TOCTOU-safe). See [SessionLifecycleLock]. await ref.read(sessionLifecycleLockProvider).withSessionLock(() async { final sessionNotifier = ref.read(sessionNotifierProvider.notifier); + // Pin the terms being agreed to. Both inputs come from addressable + // events the node can republish, so reading them again later would let + // it move the figure this trade is checked against after the fact. + final pinnedAmountSats = ref.read(publishedOrderAmountProvider(orderId)); + final pinnedFeeRate = ref.read(nodeFeeRateProvider); + final terms = pricingTermsOf(ref.read(eventProvider(orderId))); + session = await sessionNotifier.newSession( orderId: orderId, role: Role.seller, + pinnedAmountSats: pinnedAmountSats, + pinnedFeeRate: pinnedFeeRate, + pinnedFiatCode: terms?.fiatCode, + pinnedFiatAmount: terms?.fiatAmount, + pinnedPremium: terms?.premium, ); // Drop any stale grace timer/flag from a previous cycle on this order so diff --git a/lib/features/order/providers/market_check_provider.dart b/lib/features/order/providers/market_check_provider.dart new file mode 100644 index 000000000..975ead0be --- /dev/null +++ b/lib/features/order/providers/market_check_provider.dart @@ -0,0 +1,146 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/features/order/providers/settlement_anchor_provider.dart'; +import 'package:mostro_mobile/services/exchange_service.dart'; +import 'package:mostro_mobile/services/logger_service.dart'; +import 'package:mostro_mobile/services/yadio_exchange_service.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; +import 'package:mostro_mobile/shared/utils/market_quote.dart'; + +/// How long a fetched rate may be reused before it is asked for again. +/// +/// Bitcoin moves, and a settlement checked against a price from hours ago is +/// not checked against the market. Short enough that a rate cannot outlive +/// the screen it was fetched for by much, long enough that a rebuild does not +/// mean another request. +const Duration kQuoteTtl = Duration(minutes: 2); + +/// A bitcoin price from a source the connected node does not control. +/// +/// Deliberately not [exchangeServiceProvider], which asks the node first: it +/// reads the node's own kind-30078 rates event and only falls back to Yadio if +/// that fails. That is the right order for pricing an order the user is +/// composing, and the wrong one for checking the node's arithmetic — a node +/// that skims a settlement can publish the rate that makes the skim look +/// correct. +final independentExchangeServiceProvider = Provider( + (ref) => YadioExchangeService(), +); + +/// Fiat units per bitcoin for [fiatCode], from the independent source. +/// +/// Null rather than an error when the rate cannot be had: being offline is not +/// evidence against a settlement, and the caller reports it as a check it +/// could not make rather than as a settlement that passed. +/// +/// Disposed with its last listener and refetched on a [kQuoteTtl] timer. Held +/// alive for the process, as a plain family is, the first rate fetched would +/// go on pricing every later trade — a settlement hours later would be +/// compared against a price from the first screen that ever asked. +final independentFiatPerBtcProvider = + FutureProvider.autoDispose.family((ref, fiatCode) async { + if (fiatCode.isEmpty) return null; + + final staleAt = Timer(kQuoteTtl, ref.invalidateSelf); + ref.onDispose(staleAt.cancel); + + try { + final service = ref.watch(independentExchangeServiceProvider); + return await service.getExchangeRate(fiatCode, 'BTC'); + } catch (e) { + logger.w('Independent rate for $fiatCode unavailable: $e'); + return null; + } +}); + +/// Re-prices [orderId] against the independent rate. +/// +/// Only applies where the client holds no figure of its own. A session that +/// pinned the sats amount at commitment is already held to what the user saw +/// on the take screen, and re-pricing it would only second-guess a number +/// they accepted with their eyes open: a fixed-amount order may sit off the +/// market on purpose. What is left is the market-price and range orders, +/// whose sats the node resolved after the commitment. +/// +/// Sessions written before pinning existed are left out. They pinned no sats +/// either, so without this they would all fall through to the check — and a +/// fixed-amount order among them, priced by hand and legitimately far from +/// the market, would draw a caution on a settlement that is perfectly honest. +/// Trades already in flight when the user updates would carry it. Declining +/// to apply a new check to a commitment that predates it is the same call +/// [orderFeeRateProvider] makes for the fee. +/// +/// The quote is priced from the terms the session pinned, not from the event +/// the node currently publishes. Currency, fiat figure and premium all live +/// in the same addressable event as the sats amount, so a node reading them +/// back could resolve a shaved settlement and then republish the premium that +/// makes the shave quote exactly — the check would agree with the skim. +/// +/// The four outcomes stay distinct. "Does not apply" and "could not be made" +/// are different facts about a settlement, and neither is "priced correctly": +/// a commitment made with no order event pinned no currency or premium, so +/// silence there would be a state the node can produce by withholding it. +final marketCheckProvider = + Provider.autoDispose.family((ref, orderId) { + final session = ref.watch(sessionProvider(orderId)); + if (session?.termsPinned != true) return MarketCheckResult.notApplicable; + if (session?.pinnedAmountSats != null) return MarketCheckResult.notApplicable; + + final settledSats = ref.watch(signedOrderAmountProvider(orderId)); + if (settledSats == null) return MarketCheckResult.notApplicable; + + // Currency and premium come from what the session committed to, never from + // the live event. All three inputs sit in the same addressable kind-38383 + // event as the sats amount, so reading them back would let a node make a + // shaved settlement quote exactly right: resolve fewer sats than the trade + // was agreed at, then republish the premium so the quote lands on the + // shaved figure and the gap disappears. + // + // The fiat figure is the exception that has to be read live, and only for a + // range order: the band is not resolved to one figure until a taker settles + // it, so there was nothing to pin at commitment. A node moving it there + // moves a term nobody had agreed to yet. + final fiatCode = session!.pinnedFiatCode; + final premium = session.pinnedPremium; + if (fiatCode == null || fiatCode.isEmpty || premium == null) { + return MarketCheckResult.unavailable; + } + + int? fiatAmount = session.pinnedFiatAmount; + if (fiatAmount == null) { + final event = ref.watch(eventProvider(orderId)); + if (event == null) return MarketCheckResult.notApplicable; + + // A range order still advertising its band has not been resolved to the + // one fiat figure this trade is for, so there is nothing to re-price yet. + final fiat = event.fiatAmount; + if (fiat.isRange() || fiat.minimum <= 0) { + return MarketCheckResult.notApplicable; + } + fiatAmount = fiat.minimum; + } + + // Only a cold fetch holds the flow. The TTL refresh also reports isLoading, + // but Riverpod keeps the previous value alongside it, so treating every + // loading state as "no answer yet" would pull the settlement actions off + // both screens for the length of an HTTP request, every two minutes, in + // front of a user who is mid-payment. + final rate = ref.watch(independentFiatPerBtcProvider(fiatCode)); + if (rate.isLoading && !rate.hasValue) return MarketCheckResult.loading; + + final fiatPerBtc = rate.valueOrNull; + if (fiatPerBtc == null) return MarketCheckResult.unavailable; + + final check = MarketCheck.of( + settledSats: settledSats, + fiatAmount: fiatAmount, + fiatPerBtc: fiatPerBtc, + premium: premium, + ); + if (check == null) return MarketCheckResult.unavailable; + + return MarketCheckResult.checked(check); +}); diff --git a/lib/features/order/providers/settlement_anchor_provider.dart b/lib/features/order/providers/settlement_anchor_provider.dart new file mode 100644 index 000000000..7cbd9252d --- /dev/null +++ b/lib/features/order/providers/settlement_anchor_provider.dart @@ -0,0 +1,236 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; +import 'package:mostro_mobile/features/order/settlement_terms_store.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; +import 'package:mostro_mobile/services/logger_service.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; +import 'package:mostro_mobile/shared/utils/settlement_amounts.dart'; + +/// The order amount the node currently publishes for [orderId], in satoshis. +/// +/// Read off the kind-38383 order event rather than the direct message that +/// asks for the payment. Both come from the node, but only one of them is the +/// order the user chose: the event is addressable and public, and it is what +/// the take screen showed. A market-price order carries no amount until it is +/// taken, and the node republishes the event with the resolved figure as part +/// of that flow, so by the time either side is asked to act the amount is +/// there. +/// +/// Being addressable cuts both ways: the node can republish it again at any +/// point, which is why a settlement is held to [signedOrderAmountProvider] +/// and not to this. +/// +/// Null when no event has arrived, or when it carries no usable amount. +final publishedOrderAmountProvider = + Provider.family((ref, orderId) { + final event = ref.watch(eventProvider(orderId)); + if (event == null) return null; + + final amount = int.tryParse(event.amount ?? ''); + if (amount == null || amount <= 0) return null; + return amount; +}); + +/// The order amount this settlement is held to, in satoshis. +/// +/// The figure pinned when the session committed to the trade, so republishing +/// the order event afterwards cannot move what the client will accept. Falls +/// back to the live event where nothing was pinned: sessions written before +/// the pin existed, and market-price and range orders, whose sats figure the +/// node only resolves after the commitment there was to make. +final signedOrderAmountProvider = Provider.family((ref, orderId) { + final pinned = ref.watch(sessionProvider(orderId))?.pinnedAmountSats; + if (pinned != null) return pinned; + + return ref.watch(publishedOrderAmountProvider(orderId)); +}); + +/// The node's fee rate, and when the node published the event carrying it. +/// +/// Followed rather than sampled: the info event arrives asynchronously after +/// the order subscription is opened, so a screen built first would otherwise +/// hold a null read for the rest of the session and keep falling back to the +/// weaker check. +/// +/// The event is pinned to the node currently selected. Switching instances +/// clears the repository's cached event without emitting the drop, so an +/// unpinned fee rate would go on deriving amounts from the previous node's +/// terms and refuse settlements that are in fact correct. +/// +/// `publishedAt` travels with the rate rather than being read off the event +/// again elsewhere, because it is what separates a term the node supplied +/// after an agreement from one this client was merely late to receive. See +/// [orderFeeRateProvider], which is the only thing that needs the +/// distinction. +/// +/// Null when the info event has not arrived, belongs to another node, or does +/// not carry the tag. The getter parses eagerly and throws on a missing tag, +/// which is fine for the About screen it was written for and not for a +/// payment check. +final nodeFeeTermsProvider = + Provider<({double rate, DateTime? publishedAt})?>((ref) { + final nodePubkey = ref.watch( + settingsProvider.select((settings) => settings.mostroPublicKey), + ); + final info = ref.watch(mostroInfoEventProvider).valueOrNull; + if (info == null || info.pubkey != nodePubkey) return null; + try { + return (rate: info.fee, publishedAt: info.createdAt); + } catch (e) { + logger.w('Node info event carries no usable fee rate: $e'); + return null; + } +}); + +/// The fee rate the node currently advertises. See [nodeFeeTermsProvider]. +/// +/// What a commitment pins, and what a session predating pinning follows. +final nodeFeeRateProvider = Provider( + (ref) => ref.watch(nodeFeeTermsProvider)?.rate, +); + +/// When the trade behind [orderId] committed, from the durable anchor store. +/// +/// Deliberately not `Session.startTime`. A restore rebuilds every session with +/// `DateTime.now()`, so that field records when the app last reconstructed the +/// trade rather than when the user agreed to it — and a term the node +/// published after the agreement but before the restore would then read as one +/// that preceded it, which is the substitution pinning exists to refuse. The +/// anchor is written once, before the commitment is published, and a restore +/// does not touch it. +/// +/// Null for a session that pinned nothing: one written before pinning existed +/// has no instant of agreement to hold anything to. +final commitmentTimeProvider = + Provider.family((ref, orderId) { + final session = ref.watch(sessionProvider(orderId)); + if (session == null || !session.termsPinned) return null; + + return ref + .read(settlementTermsStoreProvider) + .termsFor(session.tradeKey.public) + ?.pinnedAt; +}); + +/// The fee rate this settlement is held to. +/// +/// [nodeFeeRateProvider] tracks whatever the node currently advertises, which +/// it can change under a trade already in flight. This prefers the rate +/// pinned when the session committed, on the same terms as +/// [signedOrderAmountProvider]. +/// +/// Known race, deliberately not papered over. mostrod computes `order.fee` +/// at take time from `Settings::get_mostro().fee` (`take_sell.rs`, +/// `take_buy.rs`, `util.rs`), while the client pins what the kind-38385 info +/// event advertised. If an operator changes the fee and the republished info +/// event has not reached this client before it takes, the two disagree and +/// the seller meets a hard refusal on an honest trade, recoverable only by +/// cancelling. The window is narrow — it needs a fee change and an +/// unpropagated event at the same moment — but pinning makes the +/// disagreement permanent for that trade, and it is the worst failure mode +/// in this flow. +/// +/// Accepting a second figure derived from the live rate would close it, and +/// is not done here: that is exactly the shape of "a term the node supplies +/// after the agreement" that pinning exists to refuse, and it would let a +/// node raise its fee under a trade already committed. If the race shows up +/// in the field, the fix belongs on the daemon side — publishing the fee that +/// applied at take time alongside the order — rather than in a client that +/// guesses which of two rates it is being held to. +final orderFeeRateProvider = Provider.family((ref, orderId) { + final session = ref.watch(sessionProvider(orderId)); + + final pinned = session?.pinnedFeeRate; + if (pinned != null) return pinned; + + // Pinning ran for this session and came up with no rate. Two unrelated + // things put a session here, and only one of them is the node's doing: + // + // - the node published no rate at the moment of agreement and supplied + // one afterwards. That is not a term of the agreement, and adopting it + // is the move pinning exists to stop. + // - the node had published one, signed, before the agreement, and this + // client had not finished receiving it. Nothing was supplied after the + // fact; the client was behind. + // + // Refusing both put the second — an ordinary trade on an honest node, over + // a link slow enough to still be draining the order book — behind a caution + // for the life of the trade. A warning that fires on honest trades is read + // once and furniture by the twentieth, which costs more than it buys the + // one time it fires for the reason it exists. + // + // The event's own created_at separates them, and is covered by the + // signature and the recomputed id, so a node cannot move it without + // signing for it. It does not hold against one willing to backdate; that + // case is not blocked today either, only warned about, and a warning that + // is read beats one that is not. + // + // Skew is not compensated. The two sides come from different clocks — the + // node's for created_at, the device's for the commitment — so a device + // running behind keeps the caution where an honest node published seconds + // before the take. That direction is the safe one, and the race this closes + // is a client draining a backlog, where the event predates the commitment + // by far more than any plausible skew. + // + // None of this can be inferred from a pinned amount being present: a + // market-price order resolves no sats until after the take, so it pins + // none, and inferring from that would leave exactly those orders following + // whatever rate the node publishes next. + if (session?.termsPinned == true) { + final committedAt = ref.watch(commitmentTimeProvider(orderId)); + final terms = ref.watch(nodeFeeTermsProvider); + final publishedAt = terms?.publishedAt; + + // An anchor with no instant behind it, or an event that does not say when + // it was published, is not evidence that it predates anything. + if (committedAt == null || terms == null || publishedAt == null) { + return null; + } + + return publishedAt.isAfter(committedAt) ? null : terms.rate; + } + + return ref.watch(nodeFeeRateProvider); +}); + +/// What the seller's hold invoice for [orderId] should ask for, derived from +/// the signed order amount and the signed fee rate. +/// +/// Null when either input is missing. A caller that gets null has not learned +/// that the payment is wrong — only that it cannot re-derive what it should +/// be, and should fall back to the weaker check it can still make. +final anchoredSellerAmountProvider = + Provider.family((ref, orderId) { + final amountSats = ref.watch(signedOrderAmountProvider(orderId)); + final feeRate = ref.watch(orderFeeRateProvider(orderId)); + if (amountSats == null || feeRate == null) return null; + + return SettlementAmounts.sellerPays( + amountSats: amountSats, + feeRate: feeRate, + ); +}); + +/// What the buyer's payout invoice for [orderId] should ask for, derived from +/// the signed order amount and the signed fee rate. +/// +/// The figure is the order amount less the buyer's half of the fee, so it is +/// never the amount shown on the order — a client comparing against that +/// would refuse every correct payout. +/// +/// Null on the same terms as [anchoredSellerAmountProvider]: the caller has +/// learned nothing about whether the request is right, only that it cannot +/// re-derive what it should be. +final anchoredBuyerAmountProvider = + Provider.family((ref, orderId) { + final amountSats = ref.watch(signedOrderAmountProvider(orderId)); + final feeRate = ref.watch(orderFeeRateProvider(orderId)); + if (amountSats == null || feeRate == null) return null; + + return SettlementAmounts.buyerReceives( + amountSats: amountSats, + feeRate: feeRate, + ); +}); diff --git a/lib/features/order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart index 752be8414..0a8a0d859 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/order/providers/market_check_provider.dart'; +import 'package:mostro_mobile/features/order/providers/settlement_anchor_provider.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/order/screens/payout_invoice_screen.dart'; import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; @@ -13,7 +15,9 @@ import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; import 'package:mostro_mobile/shared/widgets/add_lightning_invoice_widget.dart'; import 'package:mostro_mobile/shared/widgets/nwc_invoice_widget.dart'; import 'package:mostro_mobile/shared/widgets/invoice_header.dart'; +import 'package:mostro_mobile/shared/widgets/invoice_notice.dart'; import 'package:mostro_mobile/shared/widgets/ln_address_confirmation_widget.dart'; +import 'package:mostro_mobile/shared/utils/market_quote.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/shared/utils/snack_bar_helper.dart'; import 'package:mostro_mobile/services/logger_service.dart'; @@ -42,6 +46,16 @@ class _AddLightningInvoiceScreenState /// Whether the user chose to enter the invoice manually (fallback from NWC or LN address). bool _manualMode = false; + /// The market gap the user chose to invoice past, if any. + /// + /// The refusal rests on a third-party rate, which can be stale or simply + /// disagree, so it is not a verdict the screen should be able to impose + /// with no way past it. Held as the quote they agreed to rather than as a + /// flag: the node can republish the order and the rate can refresh while + /// this screen stays mounted, and consent to one pair of figures is not + /// consent to whatever replaces them. + MarketOverride? _marketOverride; + @override void dispose() { invoiceController.dispose(); @@ -91,6 +105,93 @@ class _AddLightningInvoiceScreenState reputation: orderState.peerReputation, ); + // What this order should pay out, re-derived from the terms the node + // signed: the amount in its kind-38383 order event, less the buyer's + // half of the fee rate in its kind-38385 one. The figure above came + // from the message asking for the invoice, and an invoice minted for + // it is what the trade settles at. + // + // Null on either side means a figure has not arrived yet, not that + // the request is wrong, so the screen only refuses on an actual + // disagreement between two amounts it holds. + final expectedSats = ref.watch(anchoredBuyerAmountProvider(orderId)); + final blocked = + amount != null && expectedSats != null && amount != expectedSats; + + // Nothing to derive from means the request goes unchecked. The node + // publishes both inputs and can withhold either, so this is a state + // it can put the screen into rather than only an unlucky race: say so + // instead of letting the absence of a refusal read as a confirmation. + final unverified = amount != null && expectedSats == null; + + // A market-price order's sats were resolved by the node after the + // take, so the check above only establishes that its own figures + // agree. Re-price it against a rate the node does not control. + final market = ref.watch(marketCheckProvider(orderId)); + final check = market.check; + final offMarket = !blocked && (check?.isOffMarket ?? false); + + // A rate still in flight is not a settlement that has passed its + // check. Hold the flows until it lands rather than opening them and + // correcting afterwards. + final marketPending = !blocked && market.isLoading; + + // A check that applies and could not be made is said out loud, on the + // same terms as an amount whose signed terms never arrived. It does + // not refuse: an unreachable third party is not evidence against a + // settlement, and refusing on it would hand anyone who can break that + // request a way to stop honest trades. + final marketUnavailable = !blocked && market.isUnavailable; + + // Adding an invoice is always the buyer's side of the trade. A payout + // below the quote is the direction that shorts them, and the quote is + // the only protection a market-price settlement has, so it is refused + // until they say otherwise. + // + // The late paths — a payout retry, and the window between release and + // payout — never reach here: a settled order returns PayoutInvoiceScreen + // at the top of this build, and that screen cautions instead of + // refusing precisely because it has no way out to offer. + final marketOverridden = + check != null && (_marketOverride?.covers(orderId, check) ?? false); + final marketBlocked = + offMarket && !marketOverridden && check!.isAdverseTo(Role.buyer); + final marketCaution = offMarket && !marketBlocked; + + final headerBlock = (unverified || marketCaution || marketUnavailable) + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + header, + if (unverified) ...[ + const SizedBox(height: 16), + InvoiceNotice.caution( + title: S.of(context)!.invoiceTermsUnverifiedTitle, + body: S.of(context)!.invoiceTermsUnverifiedBody, + ), + ], + if (marketCaution) ...[ + const SizedBox(height: 16), + InvoiceNotice.caution( + title: S.of(context)!.invoiceOffMarketTitle, + body: S.of(context)!.invoiceOffMarketBody( + check!.settledSats.toString(), + check.quotedSats.toString(), + ), + ), + ], + if (marketUnavailable) ...[ + const SizedBox(height: 16), + InvoiceNotice.caution( + title: + S.of(context)!.invoiceMarketRateUnavailableTitle, + body: S.of(context)!.invoiceMarketRateUnavailableBody, + ), + ], + ], + ) + : header; + final nwcState = ref.watch(nwcProvider); final isNwcConnected = nwcState.status == NwcStatus.connected; final showLnAddressConfirmation = @@ -110,27 +211,49 @@ class _AddLightningInvoiceScreenState 16, 16 + MediaQuery.of(context).viewPadding.bottom, ), - child: showLnAddressConfirmation - ? _buildLnAddressConfirmation(header: header) - : showNwcInvoice - ? _buildNwcInvoiceFlow(header: header) - : AddLightningInvoiceWidget( - controller: invoiceController, - onSubmit: () async { - final invoice = invoiceController.text.trim(); - if (invoice.isNotEmpty) { - await _submitInvoice(invoice, amount); - } - }, - onCancel: () async { - await _cancelOrder(); - }, - amount: amount ?? 0, - fiatAmount: fiatAmount, - fiatCode: fiatCode, - orderId: orderIdValue, - header: header, - ), + // Ordered by precedence: a refusal outranks a caution, and both + // outrank whichever invoice flow the user would otherwise get. + // Written out rather than nested as conditionals so adding a gate + // does not re-indent the ones below it. + child: switch (true) { + _ when marketPending => + _buildMarketPendingFlow(header: headerBlock), + _ when marketBlocked => _buildMarketBlockedFlow( + header: headerBlock, + orderId: orderId, + settledSats: check.settledSats, + quotedSats: check.quotedSats, + ), + _ when blocked => _buildBlockedFlow( + header: headerBlock, + requestedSats: amount, + expectedSats: expectedSats, + ), + _ when showLnAddressConfirmation => + _buildLnAddressConfirmation(header: headerBlock), + _ when showNwcInvoice => _buildNwcInvoiceFlow( + header: headerBlock, + amount: amount ?? 0, + orderIdValue: orderIdValue, + ), + _ => AddLightningInvoiceWidget( + controller: invoiceController, + onSubmit: () async { + final invoice = invoiceController.text.trim(); + if (invoice.isNotEmpty) { + await _submitInvoice(invoice, amount); + } + }, + onCancel: () async { + await _cancelOrder(); + }, + amount: amount ?? 0, + fiatAmount: fiatAmount, + fiatCode: fiatCode, + orderId: orderIdValue, + header: headerBlock, + ), + }, ), ); }, @@ -179,9 +302,11 @@ class _AddLightningInvoiceScreenState } } - Widget _buildNwcInvoiceFlow({required InvoiceHeader header}) { - final amount = header.sats; - final orderIdValue = header.orderId; + Widget _buildNwcInvoiceFlow({ + required Widget header, + required int amount, + required String orderIdValue, + }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -203,6 +328,120 @@ class _AddLightningInvoiceScreenState ); } + /// Shown while the independent rate is still in flight. + /// + /// The check is the only protection a market-price settlement has, and it + /// has not come back yet. Opening the invoice flows here and withdrawing + /// them a moment later would be worse than waiting for the answer. + /// + /// Cancel stays reachable. Every other branch on this screen offers it, and + /// a request that never comes back would otherwise leave the user with no + /// way forward and no way out. + Widget _buildMarketPendingFlow({required Widget header}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + header, + const SizedBox(height: 32), + Center( + child: Column( + children: [ + const CircularProgressIndicator(color: AppTheme.mostroGreen), + const SizedBox(height: 16), + Text( + S.of(context)!.invoiceCheckingMarketRate, + style: TextStyle(color: AppTheme.cream1.withValues(alpha: 0.7)), + ), + ], + ), + ), + const Spacer(), + _buildCancelButton(), + ], + ); + } + + /// Shown when the settlement sits off the market rate in the node's favour. + /// + /// A refusal with a way past it: the quote comes from a third party and can + /// be stale or simply disagree, so the screen states the gap and leaves the + /// decision with the user rather than making it for them. + Widget _buildMarketBlockedFlow({ + required Widget header, + required String orderId, + required int settledSats, + required int quotedSats, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + header, + const SizedBox(height: 24), + InvoiceNotice.refusal( + title: S.of(context)!.invoiceOffMarketTitle, + body: S.of(context)!.invoiceOffMarketBlockedBody( + settledSats.toString(), + quotedSats.toString(), + ), + ), + const Spacer(), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () async { + await _cancelOrder(); + }, + style: ElevatedButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.red, + ), + child: Text(S.of(context)!.cancel), + ), + const SizedBox(width: 12), + TextButton( + onPressed: () => setState(() => _marketOverride = MarketOverride( + orderId: orderId, + settledSats: settledSats, + quotedSats: quotedSats, + )), + child: Text(S.of(context)!.invoiceContinueAnyway), + ), + ], + ), + ], + ); + } + + /// Shown instead of every invoice flow when the amount asked for is not the + /// amount this order should pay out. + /// + /// A refusal rather than a warning: an invoice minted here is what the + /// trade settles at, and there is no confirming a figure the signed terms + /// contradict. + Widget _buildBlockedFlow({ + required Widget header, + required int requestedSats, + required int expectedSats, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + header, + const SizedBox(height: 24), + InvoiceNotice.refusal( + title: S.of(context)!.invoiceRequestMismatchTitle, + body: S.of(context)!.invoiceRequestMismatchBody( + requestedSats.toString(), + expectedSats.toString(), + ), + ), + const Spacer(), + _buildCancelButton(), + ], + ); + } + Widget _buildCancelButton() { return Row( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index aeda024f9..76ccb9e01 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -468,7 +468,7 @@ class _AddOrderScreenState extends ConsumerState { return _submitOrder; // Form is valid - allow submission } - void _submitOrder() { + Future _submitOrder() async { // Force-commit any pending text field changes (e.g. premium debounce timer) primaryFocus?.unfocus(); if (_formKey.currentState?.validate() ?? false) { @@ -571,9 +571,13 @@ class _AddOrderScreenState extends ConsumerState { buyerInvoice: buyerInvoice, ); - notifier.submitOrder(order); + // Awaited so a failure to record the order's terms reaches the catch + // below instead of escaping as an unhandled async error. Nothing was + // published when that throws. + await notifier.submitOrder(order); } catch (e) { - if (context.mounted) { + if (!mounted || !context.mounted) return; + { showDialog( context: context, builder: (context) => AlertDialog( diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index 35e41587d..db4cb6227 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -10,9 +10,15 @@ import 'package:mostro_mobile/features/wallet/providers/nwc_provider.dart'; import 'package:mostro_mobile/data/models/enums/role.dart'; import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; import 'package:mostro_mobile/shared/widgets/invoice_header.dart'; +import 'package:mostro_mobile/shared/widgets/invoice_notice.dart'; import 'package:mostro_mobile/shared/widgets/nwc_payment_widget.dart'; import 'package:mostro_mobile/shared/widgets/pay_lightning_invoice_widget.dart'; +import 'package:mostro_mobile/shared/utils/market_quote.dart'; import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/shared/utils/snack_bar_helper.dart'; +import 'package:mostro_mobile/features/order/providers/market_check_provider.dart'; +import 'package:mostro_mobile/features/order/providers/settlement_anchor_provider.dart'; +import 'package:mostro_mobile/shared/utils/invoice_terms.dart'; class PayLightningInvoiceScreen extends ConsumerStatefulWidget { final String orderId; @@ -29,16 +35,112 @@ class _PayLightningInvoiceScreenState /// Whether the user chose to pay manually (fallback from NWC). bool _manualMode = false; + /// The market gap the user chose to pay past, if any. + /// + /// The refusal rests on a third-party rate, which can be stale or simply + /// disagree, so it is not a verdict the screen should be able to impose + /// with no way past it. The way past is a deliberate second action, not a + /// banner over a live pay button. Held as the quote they agreed to rather + /// than as a flag: the node can republish the order and the rate can + /// refresh while this screen stays mounted, and consent to one pair of + /// figures is not consent to whatever replaces them. + MarketOverride? _marketOverride; + + /// Cancels the trade, and only leaves the screen once that has gone + /// through. Navigating first would strand the failure: `cancelOrder` + /// rethrows, the screen is already gone, and the user is told nothing while + /// the trade stays active. It is the only action offered after a refusal, + /// so a silent failure there leaves no way forward at all. + Future _cancelOrder() async { + final orderNotifier = + ref.read(orderNotifierProvider(widget.orderId).notifier); + try { + await orderNotifier.cancelOrder(); + if (mounted) context.go('/'); + } catch (e) { + if (mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + SnackBarHelper.showTopSnackBar( + context, + S.of(context)!.failedToCancelOrder(e.toString()), + ); + }); + } + } + } + @override Widget build(BuildContext context) { final orderState = ref.watch(orderNotifierProvider(widget.orderId)); final lnInvoice = orderState.paymentRequest?.lnInvoice ?? ''; final sats = orderState.order?.amount ?? 0; + + // What this payment should cost, preferably re-derived rather than + // accepted. The node publishes the order amount in its kind-38383 event + // and the fee rate in its kind-38385 one, and computes the hold invoice + // from exactly those two; deriving the same figure holds the message to + // the order the user actually chose, instead of only holding the message + // to itself. + // + // Falling back to the message's own amount when either input is missing + // is deliberate. The events arrive asynchronously and the message can win + // the race, so treating "cannot derive" as "wrong" would refuse correct + // payments on a slow relay. The fallback still reconciles the figure on + // screen against the invoice, which is what a wallet will actually send. + final anchoredSats = ref.watch(anchoredSellerAmountProvider(widget.orderId)); + final expectedSats = anchoredSats ?? orderState.order?.amount; + + final terms = InvoiceTerms.check( + invoice: lnInvoice, + expectedSats: expectedSats, + ); + final blocked = lnInvoice.isNotEmpty && !terms.isPayable; + + // Two ways this payment goes unchecked, and both are said out loud rather + // than passed over: the signed terms never arrived to re-derive from, or + // they arrived with no amount for the invoice to be checked against. + final unverified = lnInvoice.isNotEmpty && + !blocked && + (anchoredSats == null || terms.isUnverifiable); + + // Paying the hold invoice always means the user is the seller, but not + // always the maker: taking a buy order lands here too. Read once, so the + // summary and the gap direction cannot disagree about whose side this is + // — a hardcoded role here would invert the direction silently if this + // screen ever served the other one. + final session = ref.watch(sessionProvider(widget.orderId)); + final userIsSeller = session?.role == null || session!.role == Role.seller; + final userRole = userIsSeller ? Role.seller : Role.buyer; + + // A market-price order's sats were resolved by the node after the take, + // so the checks above only establish that its own figures agree. Re-price + // it against a rate the node does not control. + final market = ref.watch(marketCheckProvider(widget.orderId)); + final check = market.check; + final offMarket = !blocked && (check?.isOffMarket ?? false); + + // A rate still in flight is not a settlement that has passed its check. + // Hold the pay button until it lands rather than exposing it and + // withdrawing it a moment later. + final marketPending = !blocked && market.isLoading; + + // A check that applies and could not be made is said out loud, on the + // same terms as an amount whose signed terms never arrived. It does not + // refuse: an unreachable third party is not evidence against a + // settlement, and refusing on it would hand anyone who can break that + // request a way to stop honest trades. + final marketUnavailable = !blocked && market.isUnavailable; + + // A gap that runs against the user is refused until they say otherwise, + // since the quote is the only protection a market-price settlement has; a + // gap in their favour is worth naming and not worth stopping. + final marketOverridden = check != null && + (_marketOverride?.covers(widget.orderId, check) ?? false); + final marketBlocked = + offMarket && !marketOverridden && check!.isAdverseTo(userRole); + final marketCaution = offMarket && !marketBlocked; final fiatAmount = orderState.order?.fiatAmount.toString() ?? '0'; final fiatCode = orderState.order?.fiatCode ?? ''; - final orderNotifier = - ref.watch(orderNotifierProvider(widget.orderId).notifier); - final nwcState = ref.watch(nwcProvider); final isNwcConnected = nwcState.status == NwcStatus.connected; final showNwcPayment = @@ -46,12 +148,13 @@ class _PayLightningInvoiceScreenState // Trade summary shown by every flow: trade type, who took the order, // amounts, order id and the counterpart reputation (when received). - // Paying the hold invoice always means the user is the seller, but not - // always the maker: taking a buy order lands here too. - final session = ref.watch(sessionProvider(widget.orderId)); final header = InvoiceHeader( - userIsSeller: session?.role == null || session!.role == Role.seller, - sats: sats, + userIsSeller: userIsSeller, + // The summary states what the trade is, so it takes the re-derived + // figure over the one the message asserts. The two agree whenever the + // node is honest, and where they do not the message is the side with + // nothing behind it. + sats: expectedSats ?? sats, fiatAmount: fiatAmount, fiatCode: fiatCode, orderId: widget.orderId, @@ -76,7 +179,115 @@ class _PayLightningInvoiceScreenState child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (showNwcPayment) ...[ + if (unverified) ...[ + InvoiceNotice.caution( + title: S.of(context)!.invoiceTermsUnverifiedTitle, + body: S.of(context)!.invoiceTermsUnverifiedBody, + ), + const SizedBox(height: 16), + ], + if (marketCaution) ...[ + InvoiceNotice.caution( + title: S.of(context)!.invoiceOffMarketTitle, + body: S.of(context)!.invoiceOffMarketBody( + check!.settledSats.toString(), + check.quotedSats.toString(), + ), + ), + const SizedBox(height: 16), + ], + if (marketUnavailable) ...[ + InvoiceNotice.caution( + title: S.of(context)!.invoiceMarketRateUnavailableTitle, + body: S.of(context)!.invoiceMarketRateUnavailableBody, + ), + const SizedBox(height: 16), + ], + if (marketPending) ...[ + header, + const SizedBox(height: 32), + Center( + child: Column( + children: [ + const CircularProgressIndicator( + color: AppTheme.mostroGreen), + const SizedBox(height: 16), + Text( + S.of(context)!.invoiceCheckingMarketRate, + style: TextStyle( + color: AppTheme.cream1.withValues(alpha: 0.7)), + ), + ], + ), + ), + const SizedBox(height: 20), + // Every other branch offers Cancel, and a request that never + // comes back would otherwise leave the user with no way forward + // and no way out. + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: _cancelOrder, + style: ElevatedButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.red, + ), + child: Text(S.of(context)!.cancel), + ).withAutomationId(AutomationIds.payCancel), + ], + ), + ] else if (blocked) ...[ + header, + const SizedBox(height: 24), + _InvoiceTermsNotice( + terms: terms, orderSats: expectedSats ?? sats), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: _cancelOrder, + style: ElevatedButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.red, + ), + child: Text(S.of(context)!.cancel), + ).withAutomationId(AutomationIds.payCancel), + ], + ), + ] else if (marketBlocked) ...[ + header, + const SizedBox(height: 24), + InvoiceNotice.refusal( + title: S.of(context)!.invoiceOffMarketTitle, + body: S.of(context)!.invoiceOffMarketBlockedBody( + check.settledSats.toString(), + check.quotedSats.toString(), + ), + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: _cancelOrder, + style: ElevatedButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.red, + ), + child: Text(S.of(context)!.cancel), + ).withAutomationId(AutomationIds.payCancel), + const SizedBox(width: 12), + TextButton( + onPressed: () => + setState(() => _marketOverride = + MarketOverride.of(widget.orderId, check)), + child: Text(S.of(context)!.invoiceContinueAnyway), + ), + ], + ), + ] else if (showNwcPayment) ...[ // NWC auto-payment flow header, const SizedBox(height: 24), @@ -89,7 +300,11 @@ class _PayLightningInvoiceScreenState label: lnInvoice), NwcPaymentWidget( lnInvoice: lnInvoice, - sats: sats, + // Read back off the invoice rather than the message that + // carried it. The check above has already established the two + // agree; taking it from here keeps the number on screen and + // the number the wallet will send the same value. + sats: terms.amountSats ?? sats, onPaymentSuccess: () { // Payment succeeded — Mostro will update the order state // automatically via the event stream. We just navigate home. @@ -104,10 +319,7 @@ class _PayLightningInvoiceScreenState mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton( - onPressed: () async { - context.go('/'); - await orderNotifier.cancelOrder(); - }, + onPressed: _cancelOrder, style: ElevatedButton.styleFrom( foregroundColor: Colors.white, backgroundColor: Colors.red, @@ -122,12 +334,13 @@ class _PayLightningInvoiceScreenState onSubmit: () async { context.go('/'); }, - onCancel: () async { - context.go('/'); - await orderNotifier.cancelOrder(); - }, + onCancel: _cancelOrder, lnInvoice: lnInvoice, - sats: sats, + // Read back off the invoice, as the NWC branch does. A wallet + // scanning the QR honours the invoice, so the figure printed + // next to it has to be the invoice's own or it is describing + // a different payment than the one being made. + sats: terms.amountSats ?? sats, fiatAmount: fiatAmount, fiatCode: fiatCode, orderId: widget.orderId, @@ -140,3 +353,42 @@ class _PayLightningInvoiceScreenState ); } } + +/// Explains why an invoice will not be paid from this screen. +/// +/// Every case is a refusal rather than a warning: there is no reading of an +/// unreadable invoice, or of one that leaves the amount to the wallet, that +/// makes paying it safe. +class _InvoiceTermsNotice extends StatelessWidget { + final InvoiceTerms terms; + final int orderSats; + + const _InvoiceTermsNotice({required this.terms, required this.orderSats}); + + String _body(BuildContext context) { + final s = S.of(context)!; + switch (terms.problem) { + case InvoiceTermsProblem.amountMismatch: + return s.invoiceTermsMismatchBody( + terms.amountSats?.toString() ?? '', + orderSats.toString(), + ); + case InvoiceTermsProblem.amountMissing: + return s.invoiceAmountMissingBody; + // Never reaches the refusal notice: an order with no amount to check + // against is a caution on the screen, not a payment refused. + case InvoiceTermsProblem.termsUnknown: + case InvoiceTermsProblem.unreadable: + case null: + return s.invoiceUnreadableBody; + } + } + + @override + Widget build(BuildContext context) { + return InvoiceNotice.refusal( + title: S.of(context)!.invoiceNotPayableTitle, + body: _body(context), + ); + } +} diff --git a/lib/features/order/screens/payout_invoice_screen.dart b/lib/features/order/screens/payout_invoice_screen.dart index ca28d009b..0c08665b0 100644 --- a/lib/features/order/screens/payout_invoice_screen.dart +++ b/lib/features/order/screens/payout_invoice_screen.dart @@ -10,12 +10,23 @@ import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/shared/utils/snack_bar_helper.dart'; import 'package:mostro_mobile/shared/widgets/add_lightning_invoice_widget.dart'; import 'package:mostro_mobile/shared/widgets/nwc_invoice_widget.dart'; +import 'package:mostro_mobile/features/order/providers/market_check_provider.dart'; +import 'package:mostro_mobile/features/order/providers/settlement_anchor_provider.dart'; +import 'package:mostro_mobile/shared/widgets/invoice_notice.dart'; /// Invoice screen for collecting the sats of a settled order. /// /// The trade itself is over, so this is not the add-invoice of a take: there is /// no counterpart to introduce, no Lightning address shortcut (it may be what /// broke the payout in the first place) and nothing left to cancel. +/// +/// The settlement checks run here too — an invoice minted here is still what +/// the payout settles at — but every one of them cautions rather than refuses. +/// Nothing left to cancel cuts both ways: the user has already given up their +/// side of the trade, the hold invoice is settled, and collecting is all that +/// remains. A screen that refused to mint would leave them unable to collect +/// at all, with no path back. Naming the gap is the strongest thing that can +/// be done here without stranding an honest trade. class PayoutInvoiceScreen extends ConsumerStatefulWidget { final String orderId; @@ -55,16 +66,78 @@ class _PayoutInvoiceScreenState extends ConsumerState { final fiatCode = order?.fiatCode ?? ''; final orderIdValue = order?.id ?? widget.orderId; + // What the order's signed terms say this payout is worth: the order + // amount less the buyer's half of the fee, re-derived rather than taken + // from the figure the node put in the message. + final expectedSats = ref.watch(anchoredBuyerAmountProvider(widget.orderId)); + final mismatch = + sats > 0 && expectedSats != null && sats != expectedSats; + + // Nothing to re-derive from. The node publishes both inputs and can + // withhold either, so silence here would read as a confirmation. + final unverified = sats > 0 && expectedSats == null; + + // A market-price order's sats were resolved by the node after the take, + // so the check above only establishes that its own figures agree. + final market = ref.watch(marketCheckProvider(widget.orderId)); + final offMarket = market.check?.isOffMarket ?? false; + final marketUnavailable = market.isUnavailable; + final nwcState = ref.watch(nwcProvider); final showNwcInvoice = nwcState.status == NwcStatus.connected && !_manualMode && sats > 0; - final header = _PayoutHeader( - sats: sats, - fiatAmount: fiatAmount, - fiatCode: fiatCode, - orderId: orderIdValue, - ); + final notices = [ + if (mismatch) + InvoiceNotice.caution( + title: S.of(context)!.payoutAmountMismatchTitle, + body: S.of(context)!.payoutAmountMismatchBody( + sats.toString(), + expectedSats.toString(), + ), + ), + if (unverified) + InvoiceNotice.caution( + title: S.of(context)!.invoiceTermsUnverifiedTitle, + body: S.of(context)!.invoiceTermsUnverifiedBody, + ), + if (offMarket) + InvoiceNotice.caution( + title: S.of(context)!.invoiceOffMarketTitle, + body: S.of(context)!.invoiceOffMarketBody( + market.check!.settledSats.toString(), + market.check!.quotedSats.toString(), + ), + ), + if (marketUnavailable) + InvoiceNotice.caution( + title: S.of(context)!.invoiceMarketRateUnavailableTitle, + body: S.of(context)!.invoiceMarketRateUnavailableBody, + ), + ]; + + final header = notices.isEmpty + ? _PayoutHeader( + sats: sats, + fiatAmount: fiatAmount, + fiatCode: fiatCode, + orderId: orderIdValue, + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _PayoutHeader( + sats: sats, + fiatAmount: fiatAmount, + fiatCode: fiatCode, + orderId: orderIdValue, + ), + for (final notice in notices) ...[ + const SizedBox(height: 16), + notice, + ], + ], + ); return Scaffold( backgroundColor: AppTheme.dark1, diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 0c76629f3..16b490625 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -23,6 +23,8 @@ import 'package:mostro_mobile/shared/widgets/custom_card.dart'; import 'package:mostro_mobile/shared/providers/time_provider.dart'; import 'package:mostro_mobile/shared/widgets/dynamic_countdown_widget.dart'; import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/features/order/settlement_terms_store.dart'; +import 'package:mostro_mobile/shared/utils/snack_bar_helper.dart'; class TakeOrderScreen extends ConsumerStatefulWidget { final String orderId; @@ -438,6 +440,12 @@ class _TakeOrderScreenState extends ConsumerState { stackTrace: stackTrace, ); if (!mounted) return; + if (e is SettlementTermsNotDurable && context.mounted) { + SnackBarHelper.showTopSnackBar( + context, + S.of(context)!.orderTermsNotStored, + ); + } setState(() { _isSubmitting = false; }); @@ -476,6 +484,12 @@ class _TakeOrderScreenState extends ConsumerState { stackTrace: stackTrace, ); if (!mounted) return; + if (e is SettlementTermsNotDurable && context.mounted) { + SnackBarHelper.showTopSnackBar( + context, + S.of(context)!.orderTermsNotStored, + ); + } setState(() { _isSubmitting = false; }); diff --git a/lib/features/order/settlement_terms_store.dart b/lib/features/order/settlement_terms_store.dart new file mode 100644 index 000000000..092af5255 --- /dev/null +++ b/lib/features/order/settlement_terms_store.dart @@ -0,0 +1,297 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/services/logger_service.dart'; +import 'package:mostro_mobile/shared/providers/storage_providers.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Raised when a trade's terms could not be recorded durably. +/// +/// The commitment is published as soon as the anchor call returns, so a +/// caller that swallowed this would send a trade whose terms exist only in +/// memory — and a crash, a timeout, a restore or a node switch would then +/// bring that trade back reading whatever the node currently advertises. +/// Failing here costs a retry; failing quietly costs the guarantee. +class SettlementTermsNotDurable implements Exception { + final Object cause; + + const SettlementTermsNotDurable(this.cause); + + @override + String toString() => 'Settlement terms could not be stored: $cause'; +} + +/// The terms a trade committed to, as they stood at the moment of commitment. +/// +/// Every field here is something the node publishes in an addressable event +/// and can republish afterwards. Holding the values the user actually agreed +/// to is what keeps the settlement checks pointed at the trade rather than at +/// whatever the node last said. +class PinnedTerms { + /// The resolved order amount, or null where there was none to pin — a + /// market-price or range order resolves its sats only after the take. + final int? amountSats; + + /// The node's fee rate, or null where the info event had not arrived. + final double? feeRate; + + /// The currency, fiat figure and premium the market quote is priced from. + final String? fiatCode; + final int? fiatAmount; + final double? premium; + + /// When these terms were pinned, so an anchor for a trade long finished can + /// be pruned. + final DateTime pinnedAt; + + const PinnedTerms({ + this.amountSats, + this.feeRate, + this.fiatCode, + this.fiatAmount, + this.premium, + required this.pinnedAt, + }); + + Map toJson() => { + if (amountSats != null) 'amount_sats': amountSats, + if (feeRate != null) 'fee_rate': feeRate, + if (fiatCode != null) 'fiat_code': fiatCode, + if (fiatAmount != null) 'fiat_amount': fiatAmount, + if (premium != null) 'premium': premium, + 'pinned_at': pinnedAt.millisecondsSinceEpoch, + }; + + /// Null for a record that cannot be read back, so a corrupt entry is the + /// same as no entry rather than a half-populated anchor. + static PinnedTerms? fromJson(Object? json) { + if (json is! Map) return null; + + final pinnedAt = json['pinned_at']; + if (pinnedAt is! int) return null; + + final amount = json['amount_sats']; + final fee = json['fee_rate']; + final fiatAmount = json['fiat_amount']; + final premium = json['premium']; + + return PinnedTerms( + amountSats: amount is int && amount > 0 ? amount : null, + feeRate: fee is num && fee >= 0 && fee.toDouble().isFinite + ? fee.toDouble() + : null, + fiatCode: json['fiat_code'] is String && (json['fiat_code'] as String).isNotEmpty + ? json['fiat_code'] as String + : null, + fiatAmount: fiatAmount is int && fiatAmount > 0 ? fiatAmount : null, + premium: premium is num && premium.toDouble().isFinite + ? premium.toDouble() + : null, + pinnedAt: DateTime.fromMillisecondsSinceEpoch(pinnedAt), + ); + } +} + +/// Remembers what each trade committed to, keyed by its trade key. +/// +/// The session row in Sembast already carries these figures, and is not +/// enough on its own. It is written when the daemon acknowledges the trade, +/// which is after the commitment has been published — a crash or a response +/// timeout in between leaves the remote trade standing and the anchors gone. +/// Worse, a restore clears the session store outright and rebuilds every +/// session from the node's own data, so switching nodes and back returned a +/// committed trade to whatever terms the node currently advertises. +/// +/// This store is the part that survives both. It is written before the +/// commitment goes out and lives outside the session store, so +/// `SessionStorage.deleteAll` does not take it, and it is keyed by the trade +/// key because that is the one identifier that spans the whole lifecycle: it +/// exists before the order id does, it is what the commitment is signed with, +/// and restore re-derives it from the key index. +class SettlementTermsStore { + static const String _prefsKey = 'settlement_pinned_terms'; + + /// How long an anchor is kept. Well past any trade's life, short enough + /// that the map does not grow without bound. + static const Duration retention = Duration(days: 90); + + final SharedPreferencesAsync _prefs; + + /// Trade key pubkey -> the terms that trade committed to. Authoritative + /// once [init] has run; the persisted copy mirrors it. + Map _terms = {}; + + bool _initialized = false; + + /// Set when [init] could not read the persisted value at all. + /// + /// Memory then mirrors nothing, and flushing it would serialize an empty + /// map over anchors belonging to trades still in flight — which would send + /// exactly those trades back to whatever the node currently advertises. + /// Writes are refused for the rest of the run instead. + /// + /// A value that was read and could not be parsed is the opposite case: there + /// is nothing behind it to preserve, and refusing to write would leave + /// pinning broken for good. Those are overwritten. + bool _readFailed = false; + + /// Tail of the write chain, so writes land in call order. + Future _writes = Future.value(); + + SettlementTermsStore(this._prefs); + + /// Completes when every write queued so far has finished. [pin] returns only + /// once its own write has landed, so callers that must be durable before + /// publishing simply await it. + Future get pendingWrites => _writes; + + /// Loads the persisted anchors. Must run before the first [termsFor], or a + /// restore would read an empty map and reconstruct committed trades as + /// legacy. + Future init() async { + if (_initialized) return; + + _readFailed = !await _load(); + _initialized = true; + if (!_readFailed) _prune(); + } + + /// Reads the persisted map into memory. + /// + /// False when the value could not be read at all — the one state where + /// writing would destroy anchors rather than update them. A value that was + /// read and cannot be parsed returns true: there is nothing behind it to + /// preserve, and refusing to write would leave pinning broken for good. + Future _load() async { + final String? raw; + try { + raw = await _prefs.getString(_prefsKey); + } catch (e) { + logger.e('Failed to read pinned settlement terms: $e'); + return false; + } + + if (raw != null) { + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + final loaded = {}; + decoded.forEach((key, value) { + if (key is! String) return; + final terms = PinnedTerms.fromJson(value); + if (terms != null) loaded[key] = terms; + }); + _terms = loaded; + } + } catch (e) { + logger.e('Discarding unreadable pinned settlement terms: $e'); + _terms = {}; + } + } + + return true; + } + + /// The terms [tradeKeyPublic] committed to, or null if it never pinned any. + /// + /// Null is what a session written before pinning existed looks like, which + /// is the same thing the caller does with it: leave the trade behaving as it + /// did rather than applying a check it never agreed to. + PinnedTerms? termsFor(String tradeKeyPublic) => _terms[tradeKeyPublic]; + + /// Records what [tradeKeyPublic] is committing to, and returns once the + /// record is durable. + /// + /// Call this before the commitment is published. An anchor already held for + /// the trade key is kept: a retake must not overwrite the terms the first + /// commitment pinned. + Future pin( + String tradeKeyPublic, { + int? amountSats, + double? feeRate, + String? fiatCode, + int? fiatAmount, + double? premium, + DateTime? pinnedAt, + }) async { + if (_terms.containsKey(tradeKeyPublic)) return; + + if (_readFailed) { + // One transient read failure at startup must not lock the app out of + // trading for the rest of the run: with pinning refusing, the user can + // take nothing, create nothing and release nothing until they restart. + // A store that reads now is a store that can be written to. + _readFailed = !await _load(); + if (_readFailed) { + throw const SettlementTermsNotDurable( + 'the persisted map went unread, so writing would erase it', + ); + } + _prune(); + } + + _terms[tradeKeyPublic] = PinnedTerms( + amountSats: amountSats, + feeRate: feeRate, + fiatCode: fiatCode, + fiatAmount: fiatAmount, + premium: premium, + pinnedAt: pinnedAt ?? DateTime.now(), + ); + + try { + await _flush(); + } catch (e) { + // Memory must not keep what the disk refused. Left in place, the entry + // would report this trade as anchored when nothing was recorded, and + // the containsKey guard above would turn every later attempt into a + // no-op that never retries the write. + _terms.remove(tradeKeyPublic); + throw SettlementTermsNotDurable(e); + } + } + + /// Drops the anchor for [tradeKeyPublic]. For a session the user deleted; + /// a finished trade is left to [retention]. + Future forget(String tradeKeyPublic) { + if (_terms.remove(tradeKeyPublic) == null) return Future.value(); + return _flush(); + } + + void _prune() { + final cutoff = DateTime.now().subtract(retention); + final before = _terms.length; + _terms.removeWhere((_, terms) => terms.pinnedAt.isBefore(cutoff)); + if (_terms.length != before) _flush(); + } + + /// Appends the write to the chain and returns when it has run. + /// + /// `SharedPreferencesAsync` keeps no Dart cache and completes concurrent + /// calls in whatever order the platform picks, so without the chain an + /// older snapshot could land after a newer one. + /// + /// The returned future carries the failure to the caller — [pin] has to + /// know, since the commitment goes out on the strength of it. The chain + /// itself absorbs the failure separately so one bad write cannot poison the + /// ordering of every later one. + Future _flush() { + if (_readFailed) { + logger.w('Not flushing settlement anchors: the persisted map went unread'); + return Future.value(); + } + + final snapshot = jsonEncode( + _terms.map((key, value) => MapEntry(key, value.toJson())), + ); + final queued = _writes.then((_) => _prefs.setString(_prefsKey, snapshot)); + _writes = queued.catchError( + (Object e) => logger.e('Failed to persist pinned settlement terms: $e'), + ); + return queued; + } +} + +final settlementTermsStoreProvider = Provider( + (ref) => SettlementTermsStore(ref.read(sharedPreferencesProvider)), +); diff --git a/lib/features/restore/restore_manager.dart b/lib/features/restore/restore_manager.dart index 4e8d1230b..2e481f63e 100644 --- a/lib/features/restore/restore_manager.dart +++ b/lib/features/restore/restore_manager.dart @@ -663,6 +663,15 @@ class RestoreService { ? restoredDispute!.solverPubkey : null; + // The rebuild takes its figures from the node, which is exactly what + // the settlement checks must not be held to. Anything this trade + // pinned at commitment is recovered from the durable anchor store, + // which _clearAll does not touch; without it a committed trade would + // come back as one that predates pinning and follow whatever the node + // advertises now, and a node A -> B -> A round trip would do that to + // every trade in flight. + final anchored = sessionNotifier.anchoredTermsFor(tradeKey.public); + final session = Session( masterKey: _masterKey!, tradeKey: tradeKey, @@ -674,6 +683,12 @@ class RestoreService { peer: peer, adminPubkey: adminPubkey, disputeId: restoredDispute?.disputeId, + pinnedAmountSats: anchored?.amountSats, + pinnedFeeRate: anchored?.feeRate, + pinnedFiatCode: anchored?.fiatCode, + pinnedFiatAmount: anchored?.fiatAmount, + pinnedPremium: anchored?.premium, + termsPinned: anchored != null, ); // Store session diff --git a/lib/features/wallet/providers/nwc_provider.dart b/lib/features/wallet/providers/nwc_provider.dart index 5fc139854..93854f447 100644 --- a/lib/features/wallet/providers/nwc_provider.dart +++ b/lib/features/wallet/providers/nwc_provider.dart @@ -384,6 +384,12 @@ class NwcNotifier extends StateNotifier { /// Throws [NwcNotConnectedException] if no wallet is connected. /// Throws [NwcResponseException] if the wallet returns an error. /// Throws [NwcTimeoutException] if the payment times out. + /// The request carries the invoice alone. NIP-47's `amount` is optional and + /// wallets do not agree on it: some honour it only for a zero-amount + /// invoice and error out when the invoice already encodes a figure, which + /// would break every payment here rather than catch a wrong one. The caller + /// has already reconciled the invoice against the order's signed terms, so + /// the field would add nothing it does not already enforce. Future payInvoice(String invoice) async { if (_client == null || !_client!.isConnected) { throw const NwcNotConnectedException('No wallet connected'); diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 4a55b8241..f16aa6bc5 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1783,5 +1783,24 @@ "type": "int" } } - } + }, + "invoiceNotPayableTitle": "Diese Rechnung kann nicht bezahlt werden", + "invoiceTermsUnverifiedTitle": "Betrag nicht verifiziert", + "invoiceTermsUnverifiedBody": "Die vom Node veröffentlichten Orderbedingungen sind nicht eingetroffen, daher konnte dieser Betrag nicht dagegen geprüft werden. Prüfe ihn selbst, bevor du fortfährst.", + "payoutAmountMismatchTitle": "Betrag passt nicht zur Order", + "payoutAmountMismatchBody": "Diese Rechnung wäre über {requestedAmount} Sats, die Order sollte aber {expectedAmount} auszahlen. Prüfe das, bevor du kassierst.", + "orderTermsNotStored": "Die Orderbedingungen konnten auf diesem Gerät nicht gespeichert werden, daher wurde nichts gesendet. Versuche es erneut.", + "invoiceMarketRateUnavailableTitle": "Marktkurs nicht verfügbar", + "invoiceMarketRateUnavailableBody": "Ein unabhängiger Kurs war nicht erreichbar, daher wurde dieser Betrag mit keinem verglichen. Prüfe ihn selbst, bevor du fortfährst.", + "invoiceCheckingMarketRate": "Marktkurs wird geprüft ...", + "invoiceOffMarketTitle": "Abweichung vom Marktkurs", + "invoiceOffMarketBody": "Diese Order wird mit {settledSats} sats abgerechnet, ein unabhängiger Kurs bewertet sie aber mit etwa {quotedSats}. Das beweist keinen Fehler — Kurse unterscheiden sich und bewegen sich — aber prüfe es, bevor du fortfährst.", + "invoiceOffMarketBlockedBody": "Diese Order wird mit {settledSats} sats abgerechnet, ein unabhängiger Kurs bewertet sie aber mit etwa {quotedSats} — die Differenz begünstigt den Node. Prüfe die Bedingungen, bevor du fortfährst.", + "invoiceContinueAnyway": "Trotzdem fortfahren", + "invoiceTermsMismatchBody": "Die Rechnung fordert {invoiceAmount} Sats, diese Order lautet aber über {orderAmount} Sats. Sie kann hier nicht bezahlt werden.", + "invoiceUnreadableBody": "Diese Rechnung konnte nicht gelesen werden, daher lässt sich nicht bestätigen, was sie bezahlen würde. Sie kann hier nicht bezahlt werden.", + "invoiceAmountMissingBody": "Diese Rechnung legt keinen Betrag fest; die Wallet würde entscheiden, wie viel gesendet wird. Sie kann hier nicht bezahlt werden.", + "invoiceTermsUnknownBody": "Diese Order enthält keinen Betrag, gegen den die Rechnung geprüft werden kann. Sie kann hier nicht bezahlt werden.", + "invoiceRequestMismatchTitle": "Diese Anfrage passt nicht zur Order", + "invoiceRequestMismatchBody": "Sie wurden um eine Rechnung über {requestedAmount} Sats gebeten, diese Order sollte aber {expectedAmount} Sats auszahlen. Es wird keine Rechnung erstellt." } diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 849e9712b..08f72fa00 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1828,5 +1828,67 @@ "type": "int" } } + }, + "invoiceNotPayableTitle": "This invoice cannot be paid", + "invoiceTermsUnverifiedTitle": "Amount not verified", + "invoiceTermsUnverifiedBody": "The order terms the node published have not arrived, so this amount could not be checked against them. Check it yourself before continuing.", + "payoutAmountMismatchTitle": "Amount does not match the order", + "payoutAmountMismatchBody": "This invoice would be for {requestedAmount} sats, but the order should pay out {expectedAmount}. Check it before collecting.", + "@payoutAmountMismatchBody": {"placeholders": {"requestedAmount": {"type": "String"}, "expectedAmount": {"type": "String"}}}, + "orderTermsNotStored": "The order terms could not be saved on this device, so nothing was sent. Try again.", + "invoiceMarketRateUnavailableTitle": "Market rate unavailable", + "invoiceMarketRateUnavailableBody": "An independent rate could not be reached, so this settlement was not compared against one. Check the amount yourself before continuing.", + "invoiceCheckingMarketRate": "Checking the market rate...", + "invoiceOffMarketTitle": "Off the market rate", + "invoiceOffMarketBody": "This order settles at {settledSats} sats, but an independent rate prices it at about {quotedSats}. Nothing is proven wrong — rates differ and move — but check it before continuing.", + "invoiceOffMarketBlockedBody": "This order settles at {settledSats} sats, but an independent rate prices it at about {quotedSats} — a gap in the node's favour. Check the terms before continuing.", + "@invoiceOffMarketBlockedBody": { + "placeholders": { + "settledSats": { + "type": "String" + }, + "quotedSats": { + "type": "String" + } + } + }, + "invoiceContinueAnyway": "Continue anyway", + "@invoiceOffMarketBody": { + "placeholders": { + "settledSats": { + "type": "String" + }, + "quotedSats": { + "type": "String" + } + } + }, + "invoiceTermsMismatchBody": "The invoice asks for {invoiceAmount} sats, but this order is for {orderAmount} sats. It cannot be paid from here.", + "@invoiceTermsMismatchBody": { + "description": "Shown when the invoice amount does not match the order amount", + "placeholders": { + "invoiceAmount": { + "type": "String" + }, + "orderAmount": { + "type": "String" + } + } + }, + "invoiceUnreadableBody": "This invoice could not be read, so there is no way to confirm what it would pay. It cannot be paid from here.", + "invoiceAmountMissingBody": "This invoice sets no amount, which would let the wallet decide how much to send. It cannot be paid from here.", + "invoiceTermsUnknownBody": "This order carries no amount to check the invoice against. It cannot be paid from here.", + "invoiceRequestMismatchTitle": "This request does not match the order", + "invoiceRequestMismatchBody": "You were asked to provide an invoice for {requestedAmount} sats, but this order should pay out {expectedAmount} sats. No invoice will be created.", + "@invoiceRequestMismatchBody": { + "description": "Shown when the amount the node asks the buyer to invoice for does not match the order terms", + "placeholders": { + "requestedAmount": { + "type": "String" + }, + "expectedAmount": { + "type": "String" + } + } } } diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index b5659c649..2a274e334 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1758,5 +1758,24 @@ "type": "int" } } - } + }, + "invoiceNotPayableTitle": "Esta factura no se puede pagar", + "invoiceTermsUnverifiedTitle": "Monto no verificado", + "invoiceTermsUnverifiedBody": "No llegaron los términos de la orden que publica el nodo, así que este monto no pudo verificarse contra ellos. Revísalo antes de continuar.", + "payoutAmountMismatchTitle": "El monto no coincide con la orden", + "payoutAmountMismatchBody": "Esta factura sería por {requestedAmount} sats, pero la orden debería pagar {expectedAmount}. Revísalo antes de cobrar.", + "orderTermsNotStored": "No se pudieron guardar los términos de la orden en este dispositivo, así que no se envió nada. Inténtalo de nuevo.", + "invoiceMarketRateUnavailableTitle": "Tasa de mercado no disponible", + "invoiceMarketRateUnavailableBody": "No se pudo obtener una tasa independiente, así que este monto no se comparó con ninguna. Revísalo antes de continuar.", + "invoiceCheckingMarketRate": "Verificando la tasa de mercado...", + "invoiceOffMarketTitle": "Fuera de la tasa de mercado", + "invoiceOffMarketBody": "Esta orden se liquida en {settledSats} sats, pero una tasa independiente la valúa en unos {quotedSats}. Nada está probado como incorrecto —las tasas difieren y se mueven— pero revísalo antes de continuar.", + "invoiceOffMarketBlockedBody": "Esta orden se liquida en {settledSats} sats, pero una tasa independiente la valúa en unos {quotedSats}: la diferencia favorece al nodo. Revisa los términos antes de continuar.", + "invoiceContinueAnyway": "Continuar igualmente", + "invoiceTermsMismatchBody": "La factura pide {invoiceAmount} sats, pero esta orden es por {orderAmount} sats. No se puede pagar desde aquí.", + "invoiceUnreadableBody": "No se pudo leer esta factura, así que no hay forma de confirmar qué pagaría. No se puede pagar desde aquí.", + "invoiceAmountMissingBody": "Esta factura no fija un monto, lo que dejaría que la billetera decida cuánto enviar. No se puede pagar desde aquí.", + "invoiceTermsUnknownBody": "Esta orden no tiene un monto contra el cual verificar la factura. No se puede pagar desde aquí.", + "invoiceRequestMismatchTitle": "Esta solicitud no coincide con la orden", + "invoiceRequestMismatchBody": "Se te pidió una factura por {requestedAmount} sats, pero esta orden debería pagar {expectedAmount} sats. No se creará ninguna factura." } diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index a12456c7d..e487e3829 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1783,5 +1783,24 @@ "type": "int" } } - } + }, + "invoiceNotPayableTitle": "Cette facture ne peut pas être payée", + "invoiceTermsUnverifiedTitle": "Montant non vérifié", + "invoiceTermsUnverifiedBody": "Les termes de l’ordre publiés par le nœud ne sont pas arrivés, ce montant n’a donc pas pu être vérifié. Vérifiez-le vous-même avant de continuer.", + "payoutAmountMismatchTitle": "Le montant ne correspond pas à l’ordre", + "payoutAmountMismatchBody": "Cette facture serait de {requestedAmount} sats, mais l’ordre devrait verser {expectedAmount}. Vérifiez avant d’encaisser.", + "orderTermsNotStored": "Les termes de l’ordre n’ont pas pu être enregistrés sur cet appareil, rien n’a donc été envoyé. Réessayez.", + "invoiceMarketRateUnavailableTitle": "Taux du marché indisponible", + "invoiceMarketRateUnavailableBody": "Un taux indépendant n’a pas pu être obtenu, ce montant n’a donc été comparé à aucun. Vérifiez-le vous-même avant de continuer.", + "invoiceCheckingMarketRate": "Vérification du taux du marché...", + "invoiceOffMarketTitle": "Écart avec le taux du marché", + "invoiceOffMarketBody": "Cet ordre se règle à {settledSats} sats, mais un taux indépendant l’évalue à environ {quotedSats}. Rien ne prouve une erreur — les taux diffèrent et bougent — mais vérifiez avant de continuer.", + "invoiceOffMarketBlockedBody": "Cet ordre se règle à {settledSats} sats, mais un taux indépendant l’évalue à environ {quotedSats} : l’écart favorise le nœud. Vérifiez les termes avant de continuer.", + "invoiceContinueAnyway": "Continuer quand même", + "invoiceTermsMismatchBody": "La facture demande {invoiceAmount} sats, mais cet ordre porte sur {orderAmount} sats. Elle ne peut pas être payée ici.", + "invoiceUnreadableBody": "Cette facture n’a pas pu être lue, il est donc impossible de confirmer ce qu’elle paierait. Elle ne peut pas être payée ici.", + "invoiceAmountMissingBody": "Cette facture ne fixe aucun montant, ce qui laisserait le portefeuille décider du montant à envoyer. Elle ne peut pas être payée ici.", + "invoiceTermsUnknownBody": "Cet ordre ne comporte aucun montant permettant de vérifier la facture. Elle ne peut pas être payée ici.", + "invoiceRequestMismatchTitle": "Cette demande ne correspond pas à l’ordre", + "invoiceRequestMismatchBody": "Une facture de {requestedAmount} sats vous a été demandée, mais cet ordre devrait verser {expectedAmount} sats. Aucune facture ne sera créée." } diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 2c072da57..ba1cdb405 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1824,5 +1824,24 @@ "type": "int" } } - } + }, + "invoiceNotPayableTitle": "Questa fattura non può essere pagata", + "invoiceTermsUnverifiedTitle": "Importo non verificato", + "invoiceTermsUnverifiedBody": "I termini dell'ordine pubblicati dal nodo non sono arrivati, quindi questo importo non ha potuto essere verificato. Controllalo tu prima di continuare.", + "payoutAmountMismatchTitle": "L'importo non corrisponde all'ordine", + "payoutAmountMismatchBody": "Questa fattura sarebbe di {requestedAmount} sats, ma l'ordine dovrebbe pagare {expectedAmount}. Controlla prima di incassare.", + "orderTermsNotStored": "Non è stato possibile salvare i termini dell'ordine su questo dispositivo, quindi non è stato inviato nulla. Riprova.", + "invoiceMarketRateUnavailableTitle": "Tasso di mercato non disponibile", + "invoiceMarketRateUnavailableBody": "Non è stato possibile ottenere un tasso indipendente, quindi questo importo non è stato confrontato con nessuno. Controllalo tu prima di continuare.", + "invoiceCheckingMarketRate": "Verifica del tasso di mercato...", + "invoiceOffMarketTitle": "Fuori dal tasso di mercato", + "invoiceOffMarketBody": "Questo ordine si regola a {settledSats} sats, ma un tasso indipendente lo valuta a circa {quotedSats}. Nulla è provato come errato — i tassi differiscono e si muovono — ma controlla prima di continuare.", + "invoiceOffMarketBlockedBody": "Questo ordine si regola a {settledSats} sats, ma un tasso indipendente lo valuta a circa {quotedSats}: la differenza favorisce il nodo. Controlla i termini prima di continuare.", + "invoiceContinueAnyway": "Continua comunque", + "invoiceTermsMismatchBody": "La fattura chiede {invoiceAmount} sats, ma questo ordine è di {orderAmount} sats. Non può essere pagata da qui.", + "invoiceUnreadableBody": "Non è stato possibile leggere questa fattura, quindi non c’è modo di confermare cosa pagherebbe. Non può essere pagata da qui.", + "invoiceAmountMissingBody": "Questa fattura non fissa un importo, il che lascerebbe al portafoglio decidere quanto inviare. Non può essere pagata da qui.", + "invoiceTermsUnknownBody": "Questo ordine non ha un importo con cui verificare la fattura. Non può essere pagata da qui.", + "invoiceRequestMismatchTitle": "Questa richiesta non corrisponde all’ordine", + "invoiceRequestMismatchBody": "Ti è stata chiesta una fattura da {requestedAmount} sats, ma questo ordine dovrebbe pagare {expectedAmount} sats. Non verrà creata alcuna fattura." } diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index 63ff0b5f5..76940be54 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -1828,5 +1828,24 @@ "type": "int" } } - } + }, + "invoiceNotPayableTitle": "Esta fatura não pode ser paga", + "invoiceTermsUnverifiedTitle": "Valor não verificado", + "invoiceTermsUnverifiedBody": "Os termos da ordem publicados pelo nó não chegaram, então este valor não pôde ser verificado. Confira você mesmo antes de continuar.", + "payoutAmountMismatchTitle": "O valor não corresponde à ordem", + "payoutAmountMismatchBody": "Esta fatura seria de {requestedAmount} sats, mas a ordem deveria pagar {expectedAmount}. Confira antes de receber.", + "orderTermsNotStored": "Não foi possível salvar os termos da ordem neste dispositivo, então nada foi enviado. Tente novamente.", + "invoiceMarketRateUnavailableTitle": "Taxa de mercado indisponível", + "invoiceMarketRateUnavailableBody": "Não foi possível obter uma taxa independente, então este valor não foi comparado com nenhuma. Confira você mesmo antes de continuar.", + "invoiceCheckingMarketRate": "Verificando a taxa de mercado...", + "invoiceOffMarketTitle": "Fora da taxa de mercado", + "invoiceOffMarketBody": "Esta ordem liquida em {settledSats} sats, mas uma taxa independente a avalia em cerca de {quotedSats}. Nada está provado como errado — as taxas diferem e se movem — mas confira antes de continuar.", + "invoiceOffMarketBlockedBody": "Esta ordem liquida em {settledSats} sats, mas uma taxa independente a avalia em cerca de {quotedSats} — a diferença favorece o nó. Confira os termos antes de continuar.", + "invoiceContinueAnyway": "Continuar mesmo assim", + "invoiceTermsMismatchBody": "A fatura pede {invoiceAmount} sats, mas esta ordem é de {orderAmount} sats. Não pode ser paga aqui.", + "invoiceUnreadableBody": "Não foi possível ler esta fatura, portanto não há como confirmar o que ela pagaria. Não pode ser paga aqui.", + "invoiceAmountMissingBody": "Esta fatura não define um valor, o que deixaria a carteira decidir quanto enviar. Não pode ser paga aqui.", + "invoiceTermsUnknownBody": "Esta ordem não tem um valor contra o qual verificar a fatura. Não pode ser paga aqui.", + "invoiceRequestMismatchTitle": "Esta solicitação não corresponde à ordem", + "invoiceRequestMismatchBody": "Foi pedida uma fatura de {requestedAmount} sats, mas esta ordem deveria pagar {expectedAmount} sats. Nenhuma fatura será criada." } diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 7be83dbcd..f98395ad6 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -323,6 +323,18 @@ class MostroService { keyIndex: nextKeyIndex, parentOrderId: orderId, role: currentSession.role!, + pinnedFeeRate: currentSession.pinnedFeeRate, + // The currency and premium carry over from the parent agreement; the + // remainder's own fiat figure is not known until a taker resolves it, + // which is the market check's territory. + pinnedFiatCode: currentSession.pinnedFiatCode, + pinnedPremium: currentSession.pinnedPremium, + // The parent's instant of agreement, so the child is held to when the + // range order was made rather than to when its remainder was released. + committedAt: sessionNotifier + .anchoredTermsFor(currentSession.tradeKey.public) + ?.pinnedAt, + termsPinned: currentSession.termsPinned, ); logger.i( '[$callerLabel] Prepared child session for $orderId using key index $nextKeyIndex', diff --git a/lib/shared/notifiers/session_notifier.dart b/lib/shared/notifiers/session_notifier.dart index 9a6933786..3d222ffe3 100644 --- a/lib/shared/notifiers/session_notifier.dart +++ b/lib/shared/notifiers/session_notifier.dart @@ -9,6 +9,7 @@ import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; import 'package:mostro_mobile/shared/providers/notifications_history_repository_provider.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; +import 'package:mostro_mobile/features/order/settlement_terms_store.dart'; import 'package:sembast/sembast.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:mostro_mobile/data/models/order.dart'; @@ -173,8 +174,33 @@ class SessionNotifier extends StateNotifier> { _settings = settings.copyWith(); } - Future newSession( - {String? orderId, int? requestId, Role? role}) async { + /// Creates the session for a trade. + /// + /// [pinnedAmountSats] and [pinnedFeeRate] record the terms the user is + /// committing to, so the settlement checks can hold the trade to them even + /// if the node later republishes its order or info event. They are set here + /// rather than assigned afterwards so the pin is already in place when the + /// state is emitted and the anchor providers first read it. + /// + /// An existing session for [orderId] is returned untouched: a retake must + /// not overwrite the terms the first take pinned. + /// + /// Returns only once the pins are durable. The caller publishes the + /// commitment as soon as this comes back, and a crash or a response timeout + /// before the daemon replies would otherwise leave the trade standing + /// remotely with its anchors gone — restore would then rebuild the session + /// as one that predates pinning and go back to reading whatever terms the + /// node currently advertises. + Future newSession({ + String? orderId, + int? requestId, + Role? role, + int? pinnedAmountSats, + double? pinnedFeeRate, + String? pinnedFiatCode, + int? pinnedFiatAmount, + double? pinnedPremium, + }) async { if (orderId != null && state.any((s) => s.orderId == orderId)) { return state.firstWhere((s) => s.orderId == orderId); } @@ -190,18 +216,81 @@ class SessionNotifier extends StateNotifier> { fullPrivacy: _settings.fullPrivacyMode, orderId: orderId, role: role, + pinnedAmountSats: pinnedAmountSats, + pinnedFeeRate: pinnedFeeRate, + pinnedFiatCode: pinnedFiatCode, + pinnedFiatAmount: pinnedFiatAmount, + pinnedPremium: pinnedPremium, + termsPinned: true, ); + // Anchor before the session is registered anywhere. If the terms cannot + // be recorded, this trade must not exist at all: the caller publishes the + // commitment the moment this returns, and a trade published without its + // anchors is the failure the store exists to prevent. Throws rather than + // logging, so the take or create stops here. + await _anchorTerms(session); + if (orderId != null) { _sessions[orderId] = session; } else if (requestId != null) { _requestIdToSession[requestId] = session; } + // A take knows its order id, so the session itself can be written now + // rather than on the daemon's reply. A create has only a request id and + // stays deliberately ephemeral until the order is confirmed. + // + // Best effort, unlike the anchor above: this row is a fast path, not the + // guarantee. Restore rebuilds the session from the node's data and takes + // its terms from the anchor, so losing the row costs a rebuild while + // losing the anchor costs the terms. + if (orderId != null) { + try { + await _storage.putSession(session); + } catch (e) { + logger.e('Failed to persist session $orderId before publish: $e'); + } + } + _emitState(); return session; } + /// Writes what [session] committed to into the durable anchor store, and + /// returns once it has landed. + /// + /// Separate from the session row because a restore clears the session store + /// outright — including sessions already persisted — and rebuilds every + /// session from the node's own data. Keyed by trade key, which spans the + /// whole lifecycle: it exists before the order id, it signs the commitment, + /// and restore re-derives it from the key index. + /// + /// Throws [SettlementTermsNotDurable] rather than logging. Callers publish a + /// commitment on the strength of this returning, so swallowing the failure + /// would leave the anchors in memory only and hand the trade back to the + /// node's current terms after any crash, timeout, restore or node switch. + Future _anchorTerms(Session session) { + return ref.read(settlementTermsStoreProvider).pin( + session.tradeKey.public, + amountSats: session.pinnedAmountSats, + feeRate: session.pinnedFeeRate, + fiatCode: session.pinnedFiatCode, + fiatAmount: session.pinnedFiatAmount, + premium: session.pinnedPremium, + ); + } + + /// What the trade under [tradeKeyPublic] committed to, from the durable + /// anchor store, or null if it pinned nothing. + /// + /// For rebuilding rather than loading a session: the restore path + /// reconstructs from the node's own data, which carries none of this. + /// Without it a committed trade comes back as one that predates pinning and + /// follows whatever the node advertises now. + PinnedTerms? anchoredTermsFor(String tradeKeyPublic) => + ref.read(settlementTermsStoreProvider).termsFor(tradeKeyPublic); + Future saveSession(Session session) async { _sessions[session.orderId!] = session; _requestIdToSession.removeWhere((_, value) => identical(value, session)); @@ -301,6 +390,9 @@ class SessionNotifier extends StateNotifier> { .removeWhere((_, session) => identical(session, removed)); } await _storage.deleteSession(sessionId); + if (removed != null) { + await ref.read(settlementTermsStoreProvider).forget(removed.tradeKey.public); + } _emitState(); } @@ -327,14 +419,63 @@ class SessionNotifier extends StateNotifier> { /// Create and register a child session that will represent the upcoming /// child order generated from a range order release. + /// [pinnedFeeRate] carries the fee the parent order committed to, and + /// [termsPinned] whether it committed to anything at all. The child is the + /// remainder of an order the user already made, so it is held to the terms + /// of that agreement rather than to whatever the node advertises by the + /// time the remainder settles — and where the parent pinned no rate, the + /// child inherits that absence rather than a rate read later, which was + /// never part of the agreement either. Its sats are not pinned: no taker + /// has resolved them yet, which is the market check's territory. + /// + /// [committedAt] is the parent's instant of agreement, not this moment. The + /// remainder is a leg of an order the user already made, so it is held to + /// when that was made — otherwise a rate the node published between the + /// parent's commitment and this release would count as having preceded the + /// child, and the child would adopt a term nobody agreed to. Future createChildOrderSession({ required NostrKeyPairs tradeKey, required int keyIndex, required String parentOrderId, required Role role, + double? pinnedFeeRate, + String? pinnedFiatCode, + double? pinnedPremium, + DateTime? committedAt, + bool termsPinned = false, }) async { final masterKey = ref.read(keyManagerProvider).masterKeyPair!; + // Anchored before the child is built, so the session records what was + // actually stored rather than what was hoped for. + // + // Unlike a take or a create, a failure here does not stop the caller. The + // release that carries NextTrade is what moves money out of escrow, and + // the child is a preparation of the range order's next leg: refusing to + // release because the remainder's anchor could not be written trades the + // important thing for the incidental one, and leaves funds stuck with + // nothing published and nothing to retry. The child falls back to the + // terms a session predating pinning uses. A storage failure is not + // something the node can bring about, so this is not a downgrade it can + // reach for. + var pinned = termsPinned; + if (termsPinned) { + try { + await ref.read(settlementTermsStoreProvider).pin( + tradeKey.public, + feeRate: pinnedFeeRate, + fiatCode: pinnedFiatCode, + premium: pinnedPremium, + pinnedAt: committedAt, + ); + } on SettlementTermsNotDurable catch (e) { + logger.e( + 'Child session for $parentOrderId falls back to live terms: $e', + ); + pinned = false; + } + } + final session = Session( startTime: DateTime.now(), masterKey: masterKey, @@ -343,9 +484,14 @@ class SessionNotifier extends StateNotifier> { fullPrivacy: _settings.fullPrivacyMode, parentOrderId: parentOrderId, role: role, + pinnedFeeRate: pinned ? pinnedFeeRate : null, + pinnedFiatCode: pinned ? pinnedFiatCode : null, + pinnedPremium: pinned ? pinnedPremium : null, + termsPinned: pinned, ); _pendingChildSessions[tradeKey.public] = session; + _emitState(); // Register the child trade key with the push server right away: the child diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 1e59a5176..185bca756 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; +import 'package:mostro_mobile/features/order/settlement_terms_store.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/features/mostro/mostro_nodes_provider.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; @@ -33,6 +34,11 @@ final appInitializerProvider = FutureProvider((ref) async { await mostroNodes.init(); unawaited(mostroNodes.fetchAllNodeMetadata()); + // Before the session manager: a restore can run as soon as sessions load, + // and it reads this store to recover what each trade committed to. An empty + // store at that moment would rebuild committed trades as legacy ones. + await ref.read(settlementTermsStoreProvider).init(); + final sessionManager = ref.read(sessionNotifierProvider.notifier); await sessionManager.init(); diff --git a/lib/shared/providers/order_repository_provider.dart b/lib/shared/providers/order_repository_provider.dart index 7e600755e..2219db871 100644 --- a/lib/shared/providers/order_repository_provider.dart +++ b/lib/shared/providers/order_repository_provider.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:collection/collection.dart'; import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -19,6 +21,36 @@ final orderRepositoryProvider = Provider((ref) { return orderRepo; }); +/// The connected node's kind-38385 info event, followed rather than sampled. +/// +/// The repository keeps the event in a mutable field and also emits it on a +/// stream. Reading the field alone caches whatever had arrived at that moment, +/// which is not enough for a consumer built before the event: the info event +/// lands asynchronously after the subscription is opened, and a fee rate read +/// too early would stay missing for the rest of the session. +/// +/// Null until the event arrives. +final mostroInfoEventProvider = StreamProvider((ref) { + final orderRepository = ref.watch(orderRepositoryProvider); + + // Subscribe before sampling the current value. The repository's stream is a + // broadcast one, so an event fired between the two would be lost, leaving + // the provider serving a snapshot it has already outlived. + final controller = StreamController(); + final subscription = orderRepository.mostroInstanceStream.listen( + controller.add, + onError: controller.addError, + ); + controller.add(orderRepository.mostroInstance); + + ref.onDispose(() { + subscription.cancel(); + controller.close(); + }); + + return controller.stream; +}); + final orderEventsProvider = StreamProvider>((ref) { final orderRepository = ref.read(orderRepositoryProvider); return orderRepository.eventsStream; diff --git a/lib/shared/utils/bolt11.dart b/lib/shared/utils/bolt11.dart new file mode 100644 index 000000000..8ebc46e23 --- /dev/null +++ b/lib/shared/utils/bolt11.dart @@ -0,0 +1,160 @@ +/// The Lightning networks a BOLT-11 invoice can name in its prefix. +enum Bolt11Network { + mainnet('bc'), + testnet('tb'), + signet('tbs'), + regtest('bcrt'), + simnet('sb'); + + const Bolt11Network(this.prefix); + + /// The network part of the `ln` prefix, without the leading `ln`. + final String prefix; +} + +/// What a BOLT-11 invoice states in its human-readable prefix: which network +/// it is for, and how much it asks for. +/// +/// Only the prefix is read. The amount and the network are the whole of it — +/// everything else (payment hash, expiry, routing hints) lives in the bech32 +/// data part behind a tagged-field encoding this deliberately does not touch. +/// Parsing less means less of this app interpreting a string chosen by +/// whoever sent the invoice. +/// +/// The prefix is enough for the question the app actually has to answer +/// before paying: is this invoice for the amount the trade says it is. The +/// amount is not optional there — an invoice that encodes none lets the +/// wallet pick, which is the ambiguity that has to be refused rather than +/// resolved. +class Bolt11Invoice { + /// The network the invoice is drawn on. + final Bolt11Network network; + + /// Millisatoshis the invoice asks for, or null when it encodes no amount + /// and leaves the figure to the payer. + final BigInt? amountMsat; + + const Bolt11Invoice({required this.network, required this.amountMsat}); + + /// Whole satoshis the invoice asks for, truncating any sub-satoshi + /// remainder. Null when the invoice encodes no amount. + /// + /// Compare in [amountMsat] rather than here: a millisatoshi remainder is + /// invisible at this resolution, and two invoices that differ by less than + /// a satoshi would look equal. + int? get amountSats => + amountMsat == null ? null : (amountMsat! ~/ BigInt.from(1000)).toInt(); + + /// Every amount is expressed as a multiple of this many millisatoshis, + /// per the multiplier that follows the figure. Bitcoin's own supply cap + /// bounds anything a real invoice can ask for. + static final BigInt _msatPerBtc = BigInt.from(100000000000); + static final BigInt _maxMsat = BigInt.from(21000000) * _msatPerBtc; + + /// Multipliers BOLT-11 defines, as the fraction of a bitcoin each denotes. + /// `p` is finer than a millisatoshi, so an amount using it has to land on a + /// whole one. + static final Map _multipliers = { + 'm': BigInt.from(100000000), + 'u': BigInt.from(100000), + 'n': BigInt.from(100), + }; + + /// Reads [invoice]'s prefix, or returns null if it is not one this app is + /// willing to act on. + /// + /// Null covers every reason equally on purpose — a malformed prefix, an + /// unknown network, an amount that overflows or is not expressible. The + /// caller has one decision to make and no use for the distinction: an + /// invoice whose terms cannot be read is one that must not be paid. + static Bolt11Invoice? tryParse(String invoice) { + final normalized = invoice.trim().toLowerCase(); + if (!normalized.startsWith('ln')) return null; + + // The bech32 charset has no `1`, so every `1` in the string belongs to the + // prefix except the last, which separates it from the data part. + final separator = normalized.lastIndexOf('1'); + if (separator < 0) return null; + + final prefixBody = normalized.substring(2, separator); + if (prefixBody.isEmpty) return null; + + // The data part is what carries the payment hash and the signature. An + // invoice with none is a prefix, not an invoice. + if (separator == normalized.length - 1) return null; + + final network = _networkOf(prefixBody); + if (network == null) return null; + + final amountPart = prefixBody.substring(network.prefix.length); + if (amountPart.isEmpty) { + return Bolt11Invoice(network: network, amountMsat: null); + } + + final amountMsat = _amountMsatOf(amountPart); + if (amountMsat == null) return null; + + return Bolt11Invoice(network: network, amountMsat: amountMsat); + } + + /// Longest prefix wins: `bc` is a prefix of `bcrt`, and `tb` of `tbs`, so + /// matching in declaration order would read every regtest invoice as + /// mainnet. + static Bolt11Network? _networkOf(String prefixBody) { + Bolt11Network? match; + for (final network in Bolt11Network.values) { + if (!prefixBody.startsWith(network.prefix)) continue; + if (match == null || network.prefix.length > match.prefix.length) { + match = network; + } + } + return match; + } + + /// Parses the figure and its multiplier, in millisatoshis. + /// + /// Held in [BigInt] until the range check: the figure comes from the + /// invoice, so nothing stops it being long enough to wrap a 64-bit int and + /// land on a small, plausible-looking number. + static BigInt? _amountMsatOf(String amountPart) { + final last = amountPart[amountPart.length - 1]; + final hasMultiplier = !_isDigit(last); + + final digits = hasMultiplier + ? amountPart.substring(0, amountPart.length - 1) + : amountPart; + if (digits.isEmpty || !digits.split('').every(_isDigit)) return null; + + // A leading zero is not a valid BOLT-11 amount, and `0` itself would mean + // an invoice asking for nothing. + if (digits.startsWith('0')) return null; + + final value = BigInt.tryParse(digits); + if (value == null || value == BigInt.zero) return null; + + final BigInt msat; + if (!hasMultiplier) { + msat = value * _msatPerBtc; + } else if (last == 'p') { + // A tenth of a millisatoshi each: only whole millisatoshis are payable. + if (value % BigInt.from(10) != BigInt.zero) return null; + msat = value ~/ BigInt.from(10); + } else { + final multiplier = _multipliers[last]; + if (multiplier == null) return null; + msat = value * multiplier; + } + + if (msat <= BigInt.zero || msat > _maxMsat) return null; + return msat; + } + + static bool _isDigit(String c) { + final code = c.codeUnitAt(0); + return code >= 0x30 && code <= 0x39; + } + + @override + String toString() => + 'Bolt11Invoice(network: ${network.name}, amountMsat: $amountMsat)'; +} diff --git a/lib/shared/utils/invoice_terms.dart b/lib/shared/utils/invoice_terms.dart new file mode 100644 index 000000000..151140b22 --- /dev/null +++ b/lib/shared/utils/invoice_terms.dart @@ -0,0 +1,96 @@ +import 'package:mostro_mobile/shared/utils/bolt11.dart'; + +/// Why an invoice cannot be reconciled with the trade it is supposed to +/// settle. +enum InvoiceTermsProblem { + /// The string is not a BOLT-11 invoice this app can read. + unreadable, + + /// The invoice encodes no amount, leaving the figure to the payer's wallet. + amountMissing, + + /// The order carries no amount to check the invoice against. + termsUnknown, + + /// The invoice asks for something other than what the order says. + amountMismatch, +} + +/// The result of holding an invoice against the terms of the order it belongs +/// to. +/// +/// The amount shown on screen and the amount encoded in the invoice arrive by +/// different routes and were never reconciled: the figure came from the +/// order in the message, the payment came from the invoice string in the same +/// message, and nothing compared them. A wallet honours the invoice, so the +/// figure on screen decided nothing. +/// +/// Every failure is refusal, not a warning. There is no reading of an +/// unreadable invoice, or of one that lets the wallet choose the amount, that +/// makes it safe to pay — and the order is the only statement of what this +/// payment is for. +class InvoiceTerms { + /// What the invoice turned out to say, when it could be read at all. + final Bolt11Invoice? invoice; + + /// What stops this invoice from being paid, or null when nothing does. + final InvoiceTermsProblem? problem; + + const InvoiceTerms._({this.invoice, this.problem}); + + /// Whether the invoice may be paid from here. + /// + /// [InvoiceTermsProblem.termsUnknown] is the one problem that qualifies the + /// payment rather than refusing it: nothing about the invoice contradicts + /// the order, there is simply no order figure to check it against. Refusing + /// there would make an absent term stronger evidence than a disagreeing + /// one, and it is the same state the rest of the flow reports as a check it + /// could not make. The caller says so out loud instead. + bool get isPayable => + problem == null || problem == InvoiceTermsProblem.termsUnknown; + + /// Whether the invoice was read but had nothing to be checked against. + bool get isUnverifiable => problem == InvoiceTermsProblem.termsUnknown; + + /// Satoshis the invoice asks for, from the invoice itself rather than from + /// the message that carried it. Null when it could not be read. + int? get amountSats => invoice?.amountSats; + + /// Checks [invoice] against [expectedSats], the amount the order says this + /// payment is for. + static InvoiceTerms check({ + required String invoice, + required int? expectedSats, + }) { + final decoded = Bolt11Invoice.tryParse(invoice); + if (decoded == null) { + return const InvoiceTerms._(problem: InvoiceTermsProblem.unreadable); + } + + final amountMsat = decoded.amountMsat; + if (amountMsat == null) { + return InvoiceTerms._( + invoice: decoded, + problem: InvoiceTermsProblem.amountMissing, + ); + } + + if (expectedSats == null || expectedSats <= 0) { + return InvoiceTerms._( + invoice: decoded, + problem: InvoiceTermsProblem.termsUnknown, + ); + } + + // Compared in millisatoshis: at satoshi resolution two invoices differing + // by less than a satoshi read as equal. + if (amountMsat != BigInt.from(expectedSats) * BigInt.from(1000)) { + return InvoiceTerms._( + invoice: decoded, + problem: InvoiceTermsProblem.amountMismatch, + ); + } + + return InvoiceTerms._(invoice: decoded); + } +} diff --git a/lib/shared/utils/market_quote.dart b/lib/shared/utils/market_quote.dart new file mode 100644 index 000000000..4bdc2423b --- /dev/null +++ b/lib/shared/utils/market_quote.dart @@ -0,0 +1,224 @@ +import 'package:mostro_mobile/data/models/enums/role.dart'; + +/// Re-prices a market-price order from an outside rate, so a settlement the +/// user never saw a figure for is held to something the node did not choose. +/// +/// A fixed-amount order states its sats up front; the take screen shows it and +/// the client pins it. A market-price order states only fiat and a premium, +/// and the node resolves the sats itself at take time — the figure that later +/// appears on the invoice screen is the first the user sees of it, and +/// checking it against the node's own order event only asks the node whether +/// it agrees with itself. +/// +/// The arithmetic mirrors mostrod so an honest quote lands on the same figure: +/// +/// ```rust +/// // util.rs — get_market_quote +/// let mut sats = (*fiat_amount as f64 / price) * 100_000_000_f64; +/// if premium != 0 { +/// sats -= (premium as f64) / 100_f64 * sats; +/// } +/// Ok(sats as i64) +/// ``` +/// +/// A positive premium *lowers* the sats, which is the direction that reads +/// backwards: the premium is what the maker charges over the market, so the +/// counterparty gets fewer sats for the same fiat. +class MarketQuote { + const MarketQuote._(); + + static const int _satsPerBtc = 100000000; + + /// Bitcoin's supply cap, as the ceiling any real quote has to sit under. + static const int _maxSats = 21000000 * _satsPerBtc; + + /// How far a settlement may sit from the quote before it is worth saying so. + /// + /// Wide enough to absorb what honestly moves the two apart — a different + /// rate aggregate, and the minutes between the node pricing the take and + /// the user reaching the settlement screen — and still narrow enough to + /// catch a skim, which has to be worth taking to be worth doing. + static const double tolerance = 0.05; + + /// What [fiatAmount] is worth in satoshis at [fiatPerBtc], after [premium]. + /// + /// Null when an input cannot produce a figure worth comparing against. + static int? satsFor({ + required int fiatAmount, + required double fiatPerBtc, + required double premium, + }) { + if (fiatAmount <= 0) return null; + if (fiatPerBtc <= 0 || !fiatPerBtc.isFinite) return null; + if (!premium.isFinite) return null; + + var sats = (fiatAmount / fiatPerBtc) * _satsPerBtc; + if (premium != 0) { + sats -= (premium / 100.0) * sats; + } + + if (!sats.isFinite || sats < 1 || sats > _maxSats) return null; + // mostrod truncates on the cast to i64; rounding would put the client a + // satoshi off an otherwise exact quote. + return sats.truncate(); + } + + /// How far [settledSats] sits from [quotedSats], as a fraction of the quote. + /// + /// Null when there is no quote to be a fraction of. + static double? deviation({ + required int quotedSats, + required int settledSats, + }) { + if (quotedSats <= 0) return null; + return (settledSats - quotedSats).abs() / quotedSats; + } +} + +/// What re-pricing an order against an outside rate turned up. +class MarketCheck { + /// What the outside rate says the order is worth, in satoshis. + final int quotedSats; + + /// What the order actually settles at, in satoshis. + final int settledSats; + + /// How far the two are apart, as a fraction of [quotedSats]. + final double deviation; + + const MarketCheck({ + required this.quotedSats, + required this.settledSats, + required this.deviation, + }); + + /// Whether the gap is wide enough to put in front of the user. + bool get isOffMarket => deviation > MarketQuote.tolerance; + + /// Whether the order settles for fewer sats than the outside rate says it + /// should. + bool get isBelowMarket => settledSats < quotedSats; + + /// Whether the gap runs against the user holding [role]. + /// + /// Which direction hurts depends on the side. A seller gives up sats for + /// fiat, so a settlement above the quote takes more from them than the + /// trade was for; a buyer receives sats for fiat, so one below the quote + /// hands them less. The other direction is a gap in the user's favour, + /// which is worth mentioning and not worth stopping. + bool isAdverseTo(Role role) => + role == Role.seller ? !isBelowMarket : isBelowMarket; + + /// Re-prices [settledSats] against [fiatPerBtc], or returns null when there + /// is not enough to compare. + static MarketCheck? of({ + required int settledSats, + required int fiatAmount, + required double fiatPerBtc, + required double premium, + }) { + if (settledSats <= 0) return null; + + final quotedSats = MarketQuote.satsFor( + fiatAmount: fiatAmount, + fiatPerBtc: fiatPerBtc, + premium: premium, + ); + if (quotedSats == null) return null; + + final deviation = MarketQuote.deviation( + quotedSats: quotedSats, + settledSats: settledSats, + ); + if (deviation == null) return null; + + return MarketCheck( + quotedSats: quotedSats, + settledSats: settledSats, + deviation: deviation, + ); + } +} + +/// Where the independent market check stands for a settlement. +/// +/// Four outcomes the screens have to tell apart. Collapsing them to one +/// nullable [MarketCheck] made three of them indistinguishable from "no gap", +/// so a rate still in flight, a rate that could not be fetched, and an input +/// the node had emptied all read to the user as a settlement that had passed +/// its check. +enum MarketCheckStatus { + /// There is nothing for an outside rate to second-guess: the settlement is + /// held to a figure the user agreed to, or the order has not resolved to + /// one yet. + notApplicable, + + /// The rate has been asked for and has not come back. + loading, + + /// The check applies but could not be made — no rate, or an input the node + /// publishes that arrived empty or unreadable. + unavailable, + + /// The settlement was re-priced. [MarketCheckResult.check] carries the + /// comparison. + checked, +} + +/// The outcome of [MarketCheckStatus] together with the comparison, if one +/// was made. +class MarketCheckResult { + final MarketCheckStatus status; + + /// The comparison, present only when [status] is + /// [MarketCheckStatus.checked]. + final MarketCheck? check; + + const MarketCheckResult._(this.status) : check = null; + + static const MarketCheckResult notApplicable = + MarketCheckResult._(MarketCheckStatus.notApplicable); + static const MarketCheckResult loading = + MarketCheckResult._(MarketCheckStatus.loading); + static const MarketCheckResult unavailable = + MarketCheckResult._(MarketCheckStatus.unavailable); + + const MarketCheckResult.checked(MarketCheck this.check) + : status = MarketCheckStatus.checked; + + bool get isLoading => status == MarketCheckStatus.loading; + bool get isUnavailable => status == MarketCheckStatus.unavailable; +} + +/// A market gap the user has waved through, tied to the quote they saw. +/// +/// The consent is for a pair of figures, not for the screen. A bare flag +/// stayed true while the node republished the order or the rate refreshed +/// underneath it, so one acknowledgement of a 3% gap went on authorizing a +/// 30% one with nothing but a caution left on screen. Holding the figures +/// means a changed quote is a quote nobody has agreed to yet. +class MarketOverride { + final String orderId; + final int settledSats; + final int quotedSats; + + const MarketOverride({ + required this.orderId, + required this.settledSats, + required this.quotedSats, + }); + + /// The consent a user gives when they continue past [check] on [orderId]. + factory MarketOverride.of(String orderId, MarketCheck check) => + MarketOverride( + orderId: orderId, + settledSats: check.settledSats, + quotedSats: check.quotedSats, + ); + + /// Whether this consent was given for exactly [check] on [orderId]. + bool covers(String orderId, MarketCheck check) => + this.orderId == orderId && + settledSats == check.settledSats && + quotedSats == check.quotedSats; +} diff --git a/lib/shared/utils/pricing_terms.dart b/lib/shared/utils/pricing_terms.dart new file mode 100644 index 000000000..72e1989cd --- /dev/null +++ b/lib/shared/utils/pricing_terms.dart @@ -0,0 +1,47 @@ +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; + +/// The three figures the independent market quote is priced from. +class PricingTerms { + final String fiatCode; + final int fiatAmount; + final double premium; + + const PricingTerms({ + required this.fiatCode, + required this.fiatAmount, + required this.premium, + }); +} + +/// Reads the pricing terms out of a kind-38383 order [event]. +/// +/// Null when the event is missing or does not carry a usable set. A partial +/// pin would be worse than none: the quote would be priced from a mix of what +/// was agreed and what the node says now, which is the thing pinning exists to +/// stop. +/// +/// A range order that has not been resolved to one fiat figure yields null +/// too — there is no single figure to pin until a taker settles the band. +PricingTerms? pricingTermsOf(NostrEvent? event) { + if (event == null) return null; + + final fiatCode = event.currency; + if (fiatCode == null || fiatCode.isEmpty) return null; + + final fiat = event.fiatAmount; + if (fiat.isRange() || fiat.minimum <= 0) return null; + + // An absent premium is zero; one that is present and unreadable is not. + final premiumTag = event.premium; + final premium = (premiumTag == null || premiumTag.trim().isEmpty) + ? 0.0 + : double.tryParse(premiumTag.trim()); + if (premium == null || !premium.isFinite) return null; + + return PricingTerms( + fiatCode: fiatCode, + fiatAmount: fiat.minimum, + premium: premium, + ); +} diff --git a/lib/shared/utils/settlement_amounts.dart b/lib/shared/utils/settlement_amounts.dart new file mode 100644 index 000000000..0613c862e --- /dev/null +++ b/lib/shared/utils/settlement_amounts.dart @@ -0,0 +1,81 @@ +/// Re-derives what a settlement is supposed to cost, from the terms the node +/// signed rather than from the figure it puts in the message. +/// +/// The node computes both sides from one order amount and one fee rate, and +/// publishes both inputs where they can be checked independently: the amount +/// in the kind-38383 order event, the rate in the kind-38385 info event. That +/// makes the figure carried in a direct message something the client can +/// re-derive instead of accept. +/// +/// The arithmetic mirrors mostrod exactly, because a client that rounds +/// differently would reject settlements that are perfectly correct: +/// +/// ```rust +/// // util.rs — get_fee +/// let split_fee = (mostro_settings.fee * amount as f64) / 2.0; +/// split_fee.round() as i64 +/// +/// // util.rs — show_hold_invoice: what the seller pays +/// let new_amount = order.amount + order.fee; +/// +/// // util.rs — set_waiting_invoice_status: what the buyer asks for +/// let buyer_final_amount = order.amount.saturating_sub(order.fee); +/// ``` +class SettlementAmounts { + const SettlementAmounts._(); + + /// Bitcoin's supply cap, in satoshis. + /// + /// Nothing derived here can exceed it and still be a settlement figure, + /// whatever the node published to produce it. The same bound guards the + /// amount parsed out of an invoice. + static const int maxSats = 21000000 * 100000000; + + /// The node's fee on an order of [amountSats], in satoshis. + /// + /// Half the configured rate: each side pays its own half, so neither can + /// derive the other's figure by halving the total. + /// + /// Null when no fee can be derived, which is not the same as a fee of zero + /// — a node that charges nothing is a term the client can check against, + /// and a rate it cannot use is not. Checking `isFinite` on the rate alone + /// does not settle it: the multiplication is where the domain is actually + /// left. A rate of 1e308 is finite and passes every input check, but the + /// product overflows to infinity and `round()` throws on it; a rate a + /// little under that stays finite and `round()` saturates silently at the + /// int64 ceiling, which is worse — the caller would carry the ceiling into + /// a derived amount as though the node had really asked for it. + static int? feeFor({required int amountSats, required double feeRate}) { + if (amountSats <= 0 || amountSats > maxSats) return null; + if (!feeRate.isFinite || feeRate < 0) return null; + if (feeRate == 0) return 0; + + final fee = feeRate * amountSats / 2.0; + if (!fee.isFinite || fee < 0 || fee > maxSats) return null; + return fee.round(); + } + + /// What the seller's hold invoice should ask for: the order amount plus + /// their half of the fee. + /// + /// Null when there is nothing to derive from — an order amount that has not + /// been resolved yet reads as zero, and a client that treated that as a + /// real figure would expect a settlement of exactly the fee. + static int? sellerPays({required int amountSats, required double feeRate}) { + final fee = feeFor(amountSats: amountSats, feeRate: feeRate); + if (fee == null) return null; + + final total = amountSats + fee; + return total <= maxSats ? total : null; + } + + /// What the buyer's payout invoice should ask for: the order amount less + /// their half of the fee. + static int? buyerReceives({required int amountSats, required double feeRate}) { + final fee = feeFor(amountSats: amountSats, feeRate: feeRate); + if (fee == null) return null; + + final net = amountSats - fee; + return net > 0 ? net : null; + } +} diff --git a/lib/shared/widgets/invoice_notice.dart b/lib/shared/widgets/invoice_notice.dart new file mode 100644 index 000000000..960e37d48 --- /dev/null +++ b/lib/shared/widgets/invoice_notice.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; + +/// A boxed notice about the terms of a settlement, shown above the flow it +/// qualifies on the add- and pay-invoice screens. +/// +/// The accent carries the whole of the distinction the user has to make: +/// [InvoiceNotice.refusal] is red and means the screen will not proceed, +/// [InvoiceNotice.caution] is yellow and means it will, with something the +/// app could not confirm on the user's behalf. +class InvoiceNotice extends StatelessWidget { + final Color accent; + final String title; + final String body; + + const InvoiceNotice({ + super.key, + required this.accent, + required this.title, + required this.body, + }); + + /// Something is wrong with the terms and the screen will not act on them. + const InvoiceNotice.refusal({ + Key? key, + required String title, + required String body, + }) : this( + key: key, + accent: AppTheme.statusError, + title: title, + body: body, + ); + + /// Nothing is known to be wrong, but a check the app would normally make + /// could not be made. + const InvoiceNotice.caution({ + Key? key, + required String title, + required String body, + }) : this( + key: key, + accent: AppTheme.statusWarning, + title: title, + body: body, + ); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: accent.withValues(alpha: 0.3)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.warning_amber_rounded, color: accent, size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: accent, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + body, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 13, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/test/data/models/session_pinned_terms_test.dart b/test/data/models/session_pinned_terms_test.dart new file mode 100644 index 000000000..5b242ada8 --- /dev/null +++ b/test/data/models/session_pinned_terms_test.dart @@ -0,0 +1,191 @@ +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models/session.dart'; + +/// The settlement checks are held to the terms pinned when the session +/// committed, so those figures have to survive a restart — and their absence +/// in a session written before they existed has to read as "nothing pinned" +/// rather than as a parse failure that would strand the trade. +void main() { + final keyPair = NostrKeyPairs( + private: + '0000000000000000000000000000000000000000000000000000000000000001', + ); + + Map baseJson() => { + 'master_key': keyPair, + 'trade_key': keyPair, + 'key_index': 0, + 'full_privacy': false, + 'start_time': '2026-06-03T12:00:00.000', + 'order_id': 'order-1', + }; + + Session decode(Map extra) => + Session.fromJson({...baseJson(), ...extra}); + + group('Session pinned terms', () { + test('round-trip through JSON keeps both figures', () { + final session = Session( + masterKey: keyPair, + tradeKey: keyPair, + keyIndex: 0, + fullPrivacy: false, + startTime: DateTime.parse('2026-06-03T12:00:00.000'), + orderId: 'order-1', + pinnedAmountSats: 100000, + pinnedFeeRate: 0.006, + ); + + final restored = Session.fromJson({ + ...session.toJson(), + 'master_key': keyPair, + 'trade_key': keyPair, + }); + + expect(restored.pinnedAmountSats, 100000); + expect(restored.pinnedFeeRate, 0.006); + }); + + test('round-trip through JSON keeps the pricing terms', () { + // The market quote is priced from these three, and all three live in + // the same addressable event as the sats amount. + final session = Session( + masterKey: keyPair, + tradeKey: keyPair, + keyIndex: 0, + fullPrivacy: false, + startTime: DateTime.parse('2026-06-03T12:00:00.000'), + orderId: 'order-1', + pinnedFiatCode: 'USD', + pinnedFiatAmount: 100, + pinnedPremium: 2.5, + ); + + final restored = Session.fromJson({ + ...session.toJson(), + 'master_key': keyPair, + 'trade_key': keyPair, + }); + + expect(restored.pinnedFiatCode, 'USD'); + expect(restored.pinnedFiatAmount, 100); + expect(restored.pinnedPremium, 2.5); + }); + + test('a zero premium is pinned rather than dropped', () { + // Zero is a real term: an order at no premium is priced from the market + // rate exactly. Dropping it would read as "nothing pinned" and put the + // check back on whatever the node publishes next. + final restored = decode({'pinned_premium': 0}); + expect(restored.pinnedPremium, 0); + }); + + test('pricing terms that are not usable figures read as unpinned', () { + final restored = decode({ + 'pinned_fiat_code': '', + 'pinned_fiat_amount': 0, + 'pinned_premium': double.nan, + }); + + expect(restored.pinnedFiatCode, isNull); + expect(restored.pinnedFiatAmount, isNull); + expect(restored.pinnedPremium, isNull); + }); + + test('a session written before the pricing pins existed reads as unpinned', + () { + final restored = decode({'pinned_amount_sats': 100000}); + + expect(restored.pinnedFiatCode, isNull); + expect(restored.pinnedFiatAmount, isNull); + expect(restored.pinnedPremium, isNull); + }); + + test('a session written before the pin existed reads as unpinned', () { + final session = decode(const {}); + + expect(session.pinnedAmountSats, isNull); + expect(session.pinnedFeeRate, isNull); + }); + + test('explicit nulls read as unpinned', () { + final session = decode(const { + 'pinned_amount_sats': null, + 'pinned_fee_rate': null, + }); + + expect(session.pinnedAmountSats, isNull); + expect(session.pinnedFeeRate, isNull); + }); + + test('string-encoded figures are read', () { + final session = decode(const { + 'pinned_amount_sats': '100000', + 'pinned_fee_rate': '0.006', + }); + + expect(session.pinnedAmountSats, 100000); + expect(session.pinnedFeeRate, 0.006); + }); + + test('an integer fee rate is widened rather than dropped', () { + expect(decode(const {'pinned_fee_rate': 0}).pinnedFeeRate, 0.0); + }); + + test('an amount that is not a usable figure reads as unpinned', () { + expect(decode(const {'pinned_amount_sats': 0}).pinnedAmountSats, isNull); + expect(decode(const {'pinned_amount_sats': -1}).pinnedAmountSats, isNull); + expect( + decode(const {'pinned_amount_sats': 'not a number'}).pinnedAmountSats, + isNull, + ); + }); + + test('a fee rate that is not a usable figure reads as unpinned', () { + expect(decode(const {'pinned_fee_rate': -0.1}).pinnedFeeRate, isNull); + expect( + decode({'pinned_fee_rate': double.nan}).pinnedFeeRate, + isNull, + ); + expect( + decode({'pinned_fee_rate': double.infinity}).pinnedFeeRate, + isNull, + ); + expect( + decode(const {'pinned_fee_rate': 'not a number'}).pinnedFeeRate, + isNull, + ); + }); + + test('records that pinning ran, and survives a round trip', () { + final session = Session( + masterKey: keyPair, + tradeKey: keyPair, + keyIndex: 0, + fullPrivacy: false, + startTime: DateTime.parse('2026-06-03T12:00:00.000'), + orderId: 'order-1', + termsPinned: true, + ); + + final restored = Session.fromJson({ + ...session.toJson(), + 'master_key': keyPair, + 'trade_key': keyPair, + }); + + expect(restored.termsPinned, isTrue); + }); + + test('a session written before the marker existed reads as unpinned', () { + expect(decode(const {}).termsPinned, isFalse); + expect(decode(const {'terms_pinned': null}).termsPinned, isFalse); + }); + + test('a string-encoded marker is read', () { + expect(decode(const {'terms_pinned': 'true'}).termsPinned, isTrue); + expect(decode(const {'terms_pinned': 'false'}).termsPinned, isFalse); + }); + }); +} diff --git a/test/data/repositories/open_orders_subscription_test.dart b/test/data/repositories/open_orders_subscription_test.dart new file mode 100644 index 000000000..f4dfa2c6f --- /dev/null +++ b/test/data/repositories/open_orders_subscription_test.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +import 'package:dart_nostr/nostr/model/event/event.dart'; +import 'package:dart_nostr/nostr/model/request/request.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:mostro_mobile/data/repositories/open_orders_repository.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; + +import '../../mocks.mocks.dart'; + +const _nodePubkey = + '0000000000000000000000000000000000000000000000000000000000000001'; + +Settings _settings() => Settings( + relays: const ['wss://relay.example'], + fullPrivacyMode: false, + mostroPublicKey: _nodePubkey, + ); + +void main() { + group('OpenOrdersRepository subscriptions', () { + late MockNostrService nostrService; + late List requests; + late List> controllers; + + setUp(() { + nostrService = MockNostrService(); + requests = []; + controllers = []; + + when(nostrService.isInitialized).thenReturn(true); + when(nostrService.subscribeToEvents(any)).thenAnswer((invocation) { + requests.add(invocation.positionalArguments.first as NostrRequest); + final controller = StreamController.broadcast(); + controllers.add(controller); + return controller.stream; + }); + }); + + tearDown(() { + for (final controller in controllers) { + controller.close(); + } + }); + + test('asks for the node info event on its own request, and first', () { + final repository = OpenOrdersRepository(nostrService, _settings()); + addTearDown(repository.dispose); + + expect(requests, hasLength(2)); + + // The info event is what the settlement checks pin their fee rate from, + // and it is pinned at the moment the user commits. Behind the order + // backlog it can land after the take, which leaves the trade reporting a + // settlement it could not verify for as long as it lives. + final info = requests.first.filters.single; + expect(info.kinds, [infoEventKind]); + expect(info.authors, [_nodePubkey]); + expect(info.limit, 1); + + // No `since`: the node republishes its info on its own schedule, so one + // quiet for longer than the order window would never announce itself. + expect(info.since, isNull); + }); + + test('keeps the order book on a request of its own', () { + final repository = OpenOrdersRepository(nostrService, _settings()); + addTearDown(repository.dispose); + + final orders = requests[1].filters.single; + expect(orders.kinds, [orderEventKind]); + expect(orders.authors, [_nodePubkey]); + expect(orders.since, isNotNull); + expect(orders.limit, orderFilterLimit); + }); + + test('resubscribes both when the node changes', () { + final repository = OpenOrdersRepository(nostrService, _settings()); + addTearDown(repository.dispose); + requests.clear(); + + repository.updateSettings( + _settings().copyWith(mostroPublicKey: 'f' * 64), + ); + + expect(requests, hasLength(2)); + expect(requests.first.filters.single.kinds, [infoEventKind]); + expect(requests.first.filters.single.authors, ['f' * 64]); + expect(requests[1].filters.single.kinds, [orderEventKind]); + expect(requests[1].filters.single.authors, ['f' * 64]); + }); + }); +} diff --git a/test/features/order/providers/market_check_provider_test.dart b/test/features/order/providers/market_check_provider_test.dart new file mode 100644 index 000000000..a59aad75d --- /dev/null +++ b/test/features/order/providers/market_check_provider_test.dart @@ -0,0 +1,379 @@ +import 'dart:async'; + +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/features/order/providers/market_check_provider.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; +import 'package:mostro_mobile/services/exchange_service.dart'; +import 'package:mostro_mobile/shared/utils/market_quote.dart'; + +import '../../../mocks.mocks.dart'; + +const _orderId = 'order-1'; + +final _keyPair = NostrKeyPairs( + private: '0000000000000000000000000000000000000000000000000000000000000001', +); + +/// The kind-38383 order event, resolved to the figures a settlement is priced +/// from: sats, currency, fiat amount and premium. +NostrEvent _orderEvent({ + String amount = '200000', + String currency = 'USD', + List fiat = const ['fa', '100'], + String premium = '0', +}) => + NostrEvent( + id: 'order-event', + kind: 38383, + content: '', + sig: 'sig', + pubkey: 'a' * 64, + createdAt: DateTime.utc(2026), + tags: [ + ['d', _orderId], + ['amt', amount], + ['f', currency], + fiat, + ['premium', premium], + ], + ); + +/// A session that pinned the pricing terms at commitment: $100 at no +/// premium, which the order event below also advertises. +Session _session({ + int? pinnedAmountSats, + bool termsPinned = true, + String? pinnedFiatCode = 'USD', + int? pinnedFiatAmount = 100, + double? pinnedPremium = 0, +}) => + Session( + masterKey: _keyPair, + tradeKey: _keyPair, + keyIndex: 0, + fullPrivacy: false, + startTime: DateTime.utc(2026), + orderId: _orderId, + pinnedAmountSats: pinnedAmountSats, + pinnedFiatCode: pinnedFiatCode, + pinnedFiatAmount: pinnedFiatAmount, + pinnedPremium: pinnedPremium, + termsPinned: termsPinned, + ); + +void main() { + late MockOpenOrdersRepository repository; + late StreamController> orderEvents; + late Session? session; + late double? independentRate; + late ProviderContainer container; + + void build() { + container = ProviderContainer(overrides: [ + orderRepositoryProvider.overrideWithValue(repository), + sessionProvider.overrideWith((ref, id) => id == _orderId ? session : null), + independentFiatPerBtcProvider + .overrideWith((ref, code) async => independentRate), + ]); + addTearDown(container.dispose); + + // Both providers are autoDispose now, so a bare read would build and tear + // down an instance per call and restart the rate request every time. A + // live listener is what a mounted screen is. + container.listen(marketCheckProvider(_orderId), (_, __) {}, + fireImmediately: true); + } + + /// Publishes [event] and lets the provider chain settle on it. + Future publish(NostrEvent event) async { + orderEvents.add([event]); + await container.read(independentFiatPerBtcProvider('USD').future); + await Future.delayed(Duration.zero); + } + + setUp(() { + repository = MockOpenOrdersRepository(); + orderEvents = StreamController>.broadcast(); + when(repository.eventsStream).thenAnswer((_) => orderEvents.stream); + + session = _session(); + // $50,000 per bitcoin: the $100 order above is worth 200,000 sats. + independentRate = 50000; + build(); + + addTearDown(orderEvents.close); + }); + + group('marketCheckProvider', () { + test('says nothing about an order settling at the market rate', () async { + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent()); + + final result = container.read(marketCheckProvider(_orderId)); + expect(result.status, MarketCheckStatus.checked); + expect(result.check!.quotedSats, 200000); + expect(result.check!.isOffMarket, isFalse); + }); + + test('catches the node shaving the settlement', () async { + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent(amount: '180000')); + + final check = container.read(marketCheckProvider(_orderId)).check!; + expect(check.isOffMarket, isTrue); + expect(check.isBelowMarket, isTrue); + expect(check.settledSats, 180000); + }); + + test('accounts for the premium before judging', () async { + session = _session(pinnedPremium: 10); + build(); + + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent(amount: '180000', premium: '10')); + + expect(container.read(marketCheckProvider(_orderId)).check!.isOffMarket, + isFalse); + }); + + test('prices from the pinned premium, not the one republished later', + () async { + // The finding's scenario: the user accepts 100 USD at no premium, worth + // 200000 sats. The node resolves 180000 and republishes premium as 10, + // which would make the shave quote exactly right. + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent(amount: '180000', premium: '10')); + + final check = container.read(marketCheckProvider(_orderId)).check!; + expect(check.quotedSats, 200000); + expect(check.isOffMarket, isTrue); + }); + + test('prices from the pinned currency, not the one republished later', + () async { + // Same move through the currency tag: the quote must not follow it. + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent(amount: '180000', currency: 'EUR')); + + final check = container.read(marketCheckProvider(_orderId)).check!; + expect(check.quotedSats, 200000); + expect(check.isOffMarket, isTrue); + }); + + test('prices from the pinned fiat amount, not the one republished later', + () async { + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent(amount: '180000', fiat: const ['fa', '90'])); + + final check = container.read(marketCheckProvider(_orderId)).check!; + expect(check.quotedSats, 200000); + expect(check.isOffMarket, isTrue); + }); + + test('skips an order whose sats the session pinned', () async { + // A fixed-amount order was shown to the user and pinned at commitment, + // so it is held to that figure and may sit off the market on purpose. + session = _session(pinnedAmountSats: 180000); + build(); + + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent(amount: '180000')); + + expect(container.read(marketCheckProvider(_orderId)).status, + MarketCheckStatus.notApplicable); + }); + + test('skips a session written before pinning existed', () async { + // A session from an earlier build pinned no sats, so without a marker of + // its own it is indistinguishable from a market-price order the node + // resolved. Checking it would put a caution on any fixed-amount trade + // in flight when the user updated, priced by hand and off the market on + // purpose. + session = _session(termsPinned: false); + build(); + + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent(amount: '180000')); + + expect(container.read(marketCheckProvider(_orderId)).status, + MarketCheckStatus.notApplicable); + }); + + test('skips an order with no session of its own', () async { + session = null; + build(); + + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent(amount: '180000')); + + expect(container.read(marketCheckProvider(_orderId)).status, + MarketCheckStatus.notApplicable); + }); + + test('skips a range order that has not been resolved to one figure', + () async { + session = _session(pinnedFiatAmount: null); + build(); + + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent(fiat: const ['fa', '50', '200'])); + + expect(container.read(marketCheckProvider(_orderId)).status, + MarketCheckStatus.notApplicable); + }); + + test('reports a rate that cannot be had as a check it could not make', + () async { + // Not the same as a settlement that passed: the screens say so rather + // than opening the flow on silence. + independentRate = null; + build(); + + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent(amount: '180000')); + + expect(container.read(marketCheckProvider(_orderId)).status, + MarketCheckStatus.unavailable); + }); + + test('reports a commitment that pinned no currency as unmakeable', + () async { + // A take made before the order event arrived pinned no pricing terms. + // Reading them off the event now would let the node supply a term that + // was never agreed, so the check says it could not be made. + session = _session(pinnedFiatCode: null); + build(); + + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent()); + + expect(container.read(marketCheckProvider(_orderId)).status, + MarketCheckStatus.unavailable); + }); + + test('skips before the order event has arrived', () { + expect(container.read(marketCheckProvider(_orderId)).status, + MarketCheckStatus.notApplicable); + }); + + test('reports a commitment that pinned no premium as unmakeable', + () async { + session = _session(pinnedPremium: null); + build(); + + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent()); + + expect(container.read(marketCheckProvider(_orderId)).status, + MarketCheckStatus.unavailable); + }); + + test('falls back to the live band only where none was pinned', () async { + // A range order resolves its fiat figure after the commitment, so there + // was nothing to pin. The rest of the terms still come from the pin. + session = _session(pinnedFiatAmount: null); + build(); + + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent(fiat: const ['fa', '100'])); + + final check = container.read(marketCheckProvider(_orderId)).check!; + expect(check.quotedSats, 200000); + }); + + test('reports a rate still in flight as loading, not as no gap', () async { + // The gap the finding describes: a request that has not come back read + // the same as a settlement priced correctly, so the screens opened + // before the check had an answer. + final rate = Completer(); + container = ProviderContainer(overrides: [ + orderRepositoryProvider.overrideWithValue(repository), + sessionProvider + .overrideWith((ref, id) => id == _orderId ? session : null), + independentFiatPerBtcProvider.overrideWith((ref, code) => rate.future), + ]); + addTearDown(container.dispose); + container.listen(marketCheckProvider(_orderId), (_, __) {}, + fireImmediately: true); + + orderEvents.add([_orderEvent(amount: '180000')]); + await Future.delayed(Duration.zero); + + expect(container.read(marketCheckProvider(_orderId)).status, + MarketCheckStatus.loading); + + rate.complete(50000); + await Future.delayed(Duration.zero); + + final settled = container.read(marketCheckProvider(_orderId)); + expect(settled.status, MarketCheckStatus.checked); + expect(settled.check!.isOffMarket, isTrue); + }); + + test('keeps checking against the cached rate while it refreshes', () async { + // The TTL refresh reports isLoading with the previous value still + // attached. Treating that as "no answer yet" would pull the settlement + // actions off both screens for the length of an HTTP request, every two + // minutes, in front of a user who is mid-payment. + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent(amount: '180000')); + expect(container.read(marketCheckProvider(_orderId)).status, + MarketCheckStatus.checked); + + container.invalidate(independentFiatPerBtcProvider('USD')); + + final refreshing = container.read(marketCheckProvider(_orderId)); + expect(refreshing.status, MarketCheckStatus.checked); + expect(refreshing.check!.isOffMarket, isTrue); + }); + }); + + group('the independent rate', () { + test('is fetched again for a later settlement rather than reused', () async { + // Not autoDispose, the first rate a process ever fetched went on + // pricing every trade after it, however many hours later. + var fetches = 0; + final container = ProviderContainer(overrides: [ + independentExchangeServiceProvider + .overrideWithValue(_CountingExchangeService(() => fetches++)), + ]); + addTearDown(container.dispose); + + final first = container.listen( + independentFiatPerBtcProvider('USD'), + (_, __) {}, + ); + await container.read(independentFiatPerBtcProvider('USD').future); + expect(fetches, 1); + + // The settlement screen goes away, and with it the last listener. + first.close(); + await Future.delayed(Duration.zero); + + container.listen(independentFiatPerBtcProvider('USD'), (_, __) {}); + await container.read(independentFiatPerBtcProvider('USD').future); + expect(fetches, 2); + }); + }); +} + +/// Counts how many times a rate is actually asked for. +class _CountingExchangeService implements ExchangeService { + _CountingExchangeService(this.onFetch); + + final void Function() onFetch; + + @override + Future getExchangeRate(String from, String to) async { + onFetch(); + return 50000; + } + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} diff --git a/test/features/order/providers/settlement_anchor_provider_test.dart b/test/features/order/providers/settlement_anchor_provider_test.dart new file mode 100644 index 000000000..1a1d8268f --- /dev/null +++ b/test/features/order/providers/settlement_anchor_provider_test.dart @@ -0,0 +1,407 @@ +import 'dart:async'; + +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:mostro_mobile/features/order/settlement_terms_store.dart'; +import 'package:mostro_mobile/features/order/providers/settlement_anchor_provider.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/features/settings/settings_notifier.dart'; +import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; + +import '../../../mocks.mocks.dart'; + +final _nodePubkey = 'a' * 64; +final _otherNodePubkey = 'b' * 64; +const _orderId = 'order-1'; + +/// When the trade under test committed, and the two instants either side of +/// it that an info event can carry. +final _commitment = DateTime.utc(2026, 6, 15, 12); +final _beforeCommitment = _commitment.subtract(const Duration(hours: 1)); +final _afterCommitment = _commitment.add(const Duration(hours: 1)); + +/// The kind-38385 info event, carrying only the tags the fee rate is read +/// from. `MostroInstance.fee` parses the `fee` tag eagerly and throws when it +/// is absent, which is what the provider's guard is there to absorb. +/// +/// [createdAt] is load-bearing where the session pinned no rate: it is what +/// separates a rate the node published after the agreement from one this +/// client was merely late to receive. +NostrEvent _infoEvent({String? pubkey, String? fee, DateTime? createdAt}) => + NostrEvent( + id: 'info-event', + kind: 38385, + content: '', + sig: 'sig', + pubkey: pubkey ?? _nodePubkey, + createdAt: createdAt ?? DateTime.utc(2026), + tags: [ + ['d', pubkey ?? _nodePubkey], + if (fee != null) ['fee', fee], + ], + ); + +/// The kind-38383 order event the signed amount is read from. +NostrEvent _orderEvent({String amount = '100000'}) => NostrEvent( + id: 'order-event', + kind: 38383, + content: '', + sig: 'sig', + pubkey: _nodePubkey, + createdAt: DateTime.utc(2026), + tags: [ + ['d', _orderId], + ['amt', amount], + ], + ); + +final _keyPair = NostrKeyPairs( + private: '0000000000000000000000000000000000000000000000000000000000000001', +); + +/// The session for [_orderId], carrying whatever it pinned when it committed. +/// +/// [termsPinned] defaults to true: every session this build creates has been +/// through pinning, whether or not there was anything to pin. Pass false to +/// stand for one written before pinning existed. +Session _session({ + int? pinnedAmountSats, + double? pinnedFeeRate, + bool termsPinned = true, +}) => + Session( + masterKey: _keyPair, + tradeKey: _keyPair, + keyIndex: 0, + fullPrivacy: false, + startTime: DateTime.utc(2026), + orderId: _orderId, + pinnedAmountSats: pinnedAmountSats, + pinnedFeeRate: pinnedFeeRate, + termsPinned: termsPinned, + ); + +/// Settings that can be pointed at another node without touching storage. +class _StubSettingsNotifier extends SettingsNotifier { + _StubSettingsNotifier(String mostroPublicKey) + : super(MockSharedPreferencesAsync()) { + state = Settings( + relays: const [], + fullPrivacyMode: false, + mostroPublicKey: mostroPublicKey, + defaultFiatCode: 'USD', + selectedLanguage: null, + ); + } + + void useNode(String mostroPublicKey) { + state = state.copyWith(mostroPublicKey: mostroPublicKey); + } +} + +void main() { + late MockOpenOrdersRepository repository; + late StreamController infoEvents; + late StreamController> orderEvents; + late _StubSettingsNotifier settings; + late Session? session; + late SettlementTermsStore termsStore; + late ProviderContainer container; + + /// An anchor store holding nothing, so a test that does not care about the + /// commitment instant behaves as one whose trade never recorded it. + Future emptyStore() async { + final prefs = MockSharedPreferencesAsync(); + when(prefs.getString(any)).thenAnswer((_) async => null); + when(prefs.setString(any, any)).thenAnswer((_) async {}); + + final store = SettlementTermsStore(prefs); + await store.init(); + return store; + } + + /// Rebuilds the container so a session set by a test is in place before the + /// providers first read it. + void build() { + container = ProviderContainer(overrides: [ + orderRepositoryProvider.overrideWithValue(repository), + settingsProvider.overrideWith((ref) => settings), + sessionProvider.overrideWith((ref, id) => id == _orderId ? session : null), + settlementTermsStoreProvider.overrideWithValue(termsStore), + ]); + addTearDown(container.dispose); + } + + /// Anchors the trade under test at [at], the way a take does before it + /// publishes, and rebuilds the container around the result. + Future commitAt(DateTime at) async { + termsStore = await emptyStore(); + await termsStore.pin(_keyPair.public, pinnedAt: at); + build(); + } + + /// Lets the stream deliveries behind the providers settle. + Future flush() => Future.delayed(Duration.zero); + + setUp(() async { + repository = MockOpenOrdersRepository(); + infoEvents = StreamController.broadcast(); + orderEvents = StreamController>.broadcast(); + when(repository.mostroInstance).thenReturn(null); + when(repository.mostroInstanceStream).thenAnswer((_) => infoEvents.stream); + when(repository.eventsStream).thenAnswer((_) => orderEvents.stream); + + settings = _StubSettingsNotifier(_nodePubkey); + session = null; + termsStore = await emptyStore(); + build(); + + addTearDown(infoEvents.close); + addTearDown(orderEvents.close); + }); + + group('nodeFeeRateProvider', () { + test('reports the fee once a late info event arrives', () async { + expect(container.read(nodeFeeRateProvider), isNull); + + infoEvents.add(_infoEvent(fee: '0.006')); + await flush(); + + expect(container.read(nodeFeeRateProvider), 0.006); + }); + + test('reports the fee of an event that arrived before the first read', + () async { + when(repository.mostroInstance).thenReturn(_infoEvent(fee: '0.006')); + + container.read(nodeFeeRateProvider); + await flush(); + + expect(container.read(nodeFeeRateProvider), 0.006); + }); + + test('stays null when the info event carries no fee tag', () async { + container.read(nodeFeeRateProvider); + infoEvents.add(_infoEvent()); + await flush(); + + expect(container.read(nodeFeeRateProvider), isNull); + }); + + test('drops the previous node fee when the selected node changes', + () async { + container.read(nodeFeeRateProvider); + infoEvents.add(_infoEvent(fee: '0.006')); + await flush(); + expect(container.read(nodeFeeRateProvider), 0.006); + + settings.useNode(_otherNodePubkey); + expect(container.read(nodeFeeRateProvider), isNull); + + infoEvents.add(_infoEvent(pubkey: _otherNodePubkey, fee: '0.002')); + await flush(); + expect(container.read(nodeFeeRateProvider), 0.002); + }); + }); + + group('anchored settlement amounts', () { + test('resolve once the order and info events have both arrived', () async { + expect(container.read(anchoredSellerAmountProvider(_orderId)), isNull); + expect(container.read(anchoredBuyerAmountProvider(_orderId)), isNull); + + orderEvents.add([_orderEvent()]); + infoEvents.add(_infoEvent(fee: '0.006')); + await flush(); + + // 100000 sats, each side paying half of the 0.6% fee: 300 sats. + expect(container.read(anchoredSellerAmountProvider(_orderId)), 100300); + expect(container.read(anchoredBuyerAmountProvider(_orderId)), 99700); + }); + + test('stay null while only the order event has arrived', () async { + expect(container.read(anchoredSellerAmountProvider(_orderId)), isNull); + + orderEvents.add([_orderEvent()]); + await flush(); + + expect(container.read(anchoredSellerAmountProvider(_orderId)), isNull); + expect(container.read(anchoredBuyerAmountProvider(_orderId)), isNull); + }); + }); + + group('terms pinned at commitment', () { + test('holds the settlement to the pinned amount, not the republished one', + () async { + session = _session(pinnedAmountSats: 100000, pinnedFeeRate: 0.006); + build(); + + container.read(anchoredSellerAmountProvider(_orderId)); + // The node republishes its order event asking for five times as much. + orderEvents.add([_orderEvent(amount: '500000')]); + infoEvents.add(_infoEvent(fee: '0.006')); + await flush(); + + expect(container.read(anchoredSellerAmountProvider(_orderId)), 100300); + expect(container.read(anchoredBuyerAmountProvider(_orderId)), 99700); + }); + + test('a fee published after the commitment does not become a term of it', + () async { + // The node had published no rate when the user agreed, and signs one + // afterwards. Adopting it would let it choose the term after the fact. + session = _session(pinnedAmountSats: 100000); + await commitAt(_commitment); + + container.read(anchoredSellerAmountProvider(_orderId)); + orderEvents.add([_orderEvent()]); + // A rate more than sixteen times the usual, signed after the agreement. + infoEvents.add(_infoEvent(fee: '0.1', createdAt: _afterCommitment)); + await flush(); + + expect(container.read(orderFeeRateProvider(_orderId)), isNull); + expect(container.read(anchoredSellerAmountProvider(_orderId)), isNull); + expect(container.read(anchoredBuyerAmountProvider(_orderId)), isNull); + }); + + test('holds the settlement to the pinned fee rate', () async { + session = _session(pinnedFeeRate: 0.006); + build(); + + container.read(anchoredSellerAmountProvider(_orderId)); + orderEvents.add([_orderEvent()]); + // The node now advertises a fee rate more than sixteen times higher. + infoEvents.add(_infoEvent(fee: '0.1')); + await flush(); + + expect(container.read(orderFeeRateProvider(_orderId)), 0.006); + expect(container.read(anchoredSellerAmountProvider(_orderId)), 100300); + }); + + test('resolves from the live events for a session written before the pin', + () async { + session = _session(termsPinned: false); + build(); + + container.read(anchoredSellerAmountProvider(_orderId)); + orderEvents.add([_orderEvent()]); + infoEvents.add(_infoEvent(fee: '0.006')); + await flush(); + + // A market-price or range order pins no sats figure, so it stays on the + // live events and depends on the market check instead. + expect(container.read(anchoredSellerAmountProvider(_orderId)), 100300); + }); + + test('ignores a pinned amount that is not a usable figure', () async { + // Normalized to "nothing pinned" on the way in, so the trade falls back + // to the live events rather than being held to a figure that anchors + // nothing. + session = _session(pinnedAmountSats: 0, termsPinned: false); + expect(session!.pinnedAmountSats, isNull); + build(); + + container.read(anchoredSellerAmountProvider(_orderId)); + orderEvents.add([_orderEvent()]); + infoEvents.add(_infoEvent(fee: '0.006')); + await flush(); + + expect(container.read(anchoredSellerAmountProvider(_orderId)), 100300); + }); + + test('a market-price take stays unknown against a later rate', () async { + // Nothing to pin on either side: the sats are unresolved until a taker + // fixes them, and the node had published no rate. One signed afterwards + // must not fill the absence in. + session = _session(); + await commitAt(_commitment); + + container.read(anchoredSellerAmountProvider(_orderId)); + orderEvents.add([_orderEvent()]); + infoEvents.add(_infoEvent(fee: '0.1', createdAt: _afterCommitment)); + await flush(); + + expect(container.read(orderFeeRateProvider(_orderId)), isNull); + expect(container.read(anchoredSellerAmountProvider(_orderId)), isNull); + }); + + test('a range remainder inherits the absence its parent committed to', + () async { + // The child session carries the parent's pinned rate, which is none, + // and is anchored at the parent's instant of agreement rather than at + // the release. A rate signed between the two is not a term of either. + session = _session(pinnedFeeRate: null); + await commitAt(_commitment); + + container.read(anchoredSellerAmountProvider(_orderId)); + orderEvents.add([_orderEvent()]); + infoEvents.add(_infoEvent(fee: '0.05', createdAt: _afterCommitment)); + await flush(); + + expect(container.read(orderFeeRateProvider(_orderId)), isNull); + }); + }); + + group('a rate the client was late to receive', () { + test('is adopted when the node signed it before the commitment', () async { + // The honest race: the rate was public and signed before the user + // agreed, and this client was still draining the order book. Nothing + // was supplied after the fact, so there is nothing to caution about. + session = _session(pinnedAmountSats: 100000); + await commitAt(_commitment); + + container.read(anchoredSellerAmountProvider(_orderId)); + orderEvents.add([_orderEvent()]); + infoEvents.add(_infoEvent(fee: '0.006', createdAt: _beforeCommitment)); + await flush(); + + expect(container.read(orderFeeRateProvider(_orderId)), 0.006); + expect(container.read(anchoredSellerAmountProvider(_orderId)), 100300); + }); + + test('is refused when the node signed it after the commitment', () async { + session = _session(pinnedAmountSats: 100000); + await commitAt(_commitment); + + container.read(anchoredSellerAmountProvider(_orderId)); + orderEvents.add([_orderEvent()]); + infoEvents.add(_infoEvent(fee: '0.006', createdAt: _afterCommitment)); + await flush(); + + expect(container.read(orderFeeRateProvider(_orderId)), isNull); + }); + + test('is refused when the trade recorded no instant of agreement', + () async { + // Pinning ran but left no anchor to compare against, so there is no + // evidence the rate preceded anything. An absence is not a licence. + session = _session(pinnedAmountSats: 100000); + build(); + + container.read(anchoredSellerAmountProvider(_orderId)); + orderEvents.add([_orderEvent()]); + infoEvents.add(_infoEvent(fee: '0.006', createdAt: _beforeCommitment)); + await flush(); + + expect(container.read(orderFeeRateProvider(_orderId)), isNull); + }); + + test('is not consulted at all once a rate was pinned', () async { + // The commitment recorded a rate, so the event's age is beside the + // point: an older one must not displace what the user agreed to. + session = _session(pinnedAmountSats: 100000, pinnedFeeRate: 0.006); + await commitAt(_commitment); + + container.read(anchoredSellerAmountProvider(_orderId)); + orderEvents.add([_orderEvent()]); + infoEvents.add(_infoEvent(fee: '0.1', createdAt: _beforeCommitment)); + await flush(); + + expect(container.read(orderFeeRateProvider(_orderId)), 0.006); + }); + }); +} diff --git a/test/features/order/screens/add_lightning_invoice_screen_test.dart b/test/features/order/screens/add_lightning_invoice_screen_test.dart new file mode 100644 index 000000000..ac8d37f0f --- /dev/null +++ b/test/features/order/screens/add_lightning_invoice_screen_test.dart @@ -0,0 +1,367 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as mostro; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/enums/status.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/features/order/models/order_state.dart'; +import 'package:mostro_mobile/features/order/notifiers/order_notifier.dart'; +import 'package:mostro_mobile/features/order/providers/market_check_provider.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/features/order/providers/settlement_anchor_provider.dart'; +import 'package:mostro_mobile/features/order/screens/add_lightning_invoice_screen.dart'; +import 'package:mostro_mobile/features/wallet/providers/nwc_provider.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; +import 'package:mostro_mobile/shared/widgets/add_lightning_invoice_widget.dart'; +import 'package:mostro_mobile/shared/utils/market_quote.dart'; + +const _orderId = 'order-1'; + +/// Feeds [marketCheckProvider] so a test can move the quote under a screen +/// that is already mounted, the way a node republish or a rate refresh does. +final _marketSource = StateProvider( + (ref) => MarketCheckResult.notApplicable); + +/// Holds a fixed [OrderState] without any of the notifier's real machinery. +class _StubOrderNotifier extends StateNotifier + implements OrderNotifier { + _StubOrderNotifier(super.state); + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +/// Reports a disconnected wallet, so the screen takes the manual flow. +class _StubNwcNotifier extends StateNotifier implements NwcNotifier { + _StubNwcNotifier() : super(const NwcState()); + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +/// The node's `add-invoice` message, asking the buyer for [requestedSats]. +MostroMessage? _request(int? requestedSats) { + if (requestedSats == null) return null; + + return MostroMessage( + action: mostro.Action.addInvoice, + id: _orderId, + payload: Order( + id: _orderId, + kind: OrderType.sell, + status: Status.waitingBuyerInvoice, + amount: requestedSats, + fiatCode: 'USD', + fiatAmount: 100, + paymentMethod: 'bank', + premium: 0, + ), + ); +} + +Future pumpAddScreen( + WidgetTester tester, { + required int? requestedSats, + int? anchoredSats, + MarketCheck? market, + MarketCheckResult? marketResult, +}) async { + final state = OrderState( + status: Status.waitingBuyerInvoice, + action: mostro.Action.addInvoice, + order: _request(requestedSats)?.getPayload(), + ); + + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const AddLightningInvoiceScreen(orderId: _orderId), + ), + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + mostroOrderStreamProvider + .overrideWith((ref, id) => Stream.value(_request(requestedSats))), + orderNotifierProvider + .overrideWith((ref, id) => _StubOrderNotifier(state)), + nwcProvider.overrideWith((ref) => _StubNwcNotifier()), + sessionProvider.overrideWith((ref, id) => null), + anchoredBuyerAmountProvider.overrideWith((ref, id) => anchoredSats), + _marketSource.overrideWith((ref) => + marketResult ?? + (market == null + ? MarketCheckResult.notApplicable + : MarketCheckResult.checked(market))), + marketCheckProvider.overrideWith((ref, id) => ref.watch(_marketSource)), + ], + child: MaterialApp.router( + routerConfig: router, + localizationsDelegates: S.localizationsDelegates, + supportedLocales: S.supportedLocales, + ), + ), + ); + await tester.pump(); + await tester.pump(); +} + +S _s(WidgetTester tester) => S.of(tester.element(find.byType(Scaffold)))!; + +/// Replaces the quote on the mounted screen and lets it rebuild. +Future moveMarketTo(WidgetTester tester, MarketCheck market) => + moveMarketResultTo(tester, MarketCheckResult.checked(market)); + +/// Replaces the whole check outcome on the mounted screen and lets it rebuild. +Future moveMarketResultTo( + WidgetTester tester, MarketCheckResult result) async { + final container = ProviderScope.containerOf( + tester.element(find.byType(AddLightningInvoiceScreen)), + ); + container.read(_marketSource.notifier).state = result; + await tester.pump(); +} + +void main() { + group('AddLightningInvoiceScreen gating', () { + testWidgets('refuses a request that disagrees with the signed terms', + (tester) async { + // The sibling finding's scenario: the trade is for 99,700 after fees and + // the node asks the buyer to invoice for 90,000. + await pumpAddScreen(tester, requestedSats: 90000, anchoredSats: 99700); + + expect(find.text(_s(tester).invoiceRequestMismatchTitle), findsOneWidget); + expect(find.byType(AddLightningInvoiceWidget), findsNothing); + }); + + testWidgets('accepts a request that matches the signed terms', + (tester) async { + await pumpAddScreen(tester, requestedSats: 99700, anchoredSats: 99700); + + expect(find.text(_s(tester).invoiceRequestMismatchTitle), findsNothing); + expect(find.byType(AddLightningInvoiceWidget), findsOneWidget); + }); + + testWidgets('a pending request is not a mismatch', (tester) async { + // No message has arrived yet, so there is no requested figure. Reading + // that absence as a disagreement would strand the screen on a refusal + // whose only action is Cancel. + await pumpAddScreen(tester, requestedSats: null, anchoredSats: 99700); + + expect(find.text(_s(tester).invoiceRequestMismatchTitle), findsNothing); + }); + + testWidgets('cautions when the signed terms could not be derived', + (tester) async { + await pumpAddScreen(tester, requestedSats: 99700, anchoredSats: null); + + expect(find.text(_s(tester).invoiceTermsUnverifiedTitle), findsOneWidget); + expect(find.text(_s(tester).invoiceRequestMismatchTitle), findsNothing); + }); + + testWidgets('says nothing extra when the terms were derived', + (tester) async { + await pumpAddScreen(tester, requestedSats: 99700, anchoredSats: 99700); + + expect(find.text(_s(tester).invoiceTermsUnverifiedTitle), findsNothing); + expect(find.text(_s(tester).invoiceOffMarketTitle), findsNothing); + }); + + testWidgets('cautions when the gap runs in the buyer\'s favour', + (tester) async { + // The buyer receives more sats than the market prices the fiat at. + await pumpAddScreen( + tester, + requestedSats: 99700, + anchoredSats: 99700, + market: const MarketCheck( + quotedSats: 50000, + settledSats: 100000, + deviation: 1.0, + ), + ); + + final s = _s(tester); + expect(find.text(s.invoiceOffMarketTitle), findsOneWidget); + expect(find.text(s.invoiceContinueAnyway), findsNothing); + expect(find.byType(AddLightningInvoiceWidget), findsOneWidget); + }); + + testWidgets('refuses when the gap runs against the buyer', (tester) async { + // The payout is half what the market prices the fiat at — the shave the + // sibling finding describes. + await pumpAddScreen( + tester, + requestedSats: 99700, + anchoredSats: 99700, + market: const MarketCheck( + quotedSats: 200000, + settledSats: 100000, + deviation: 0.5, + ), + ); + + final s = _s(tester); + expect(find.text(s.invoiceOffMarketTitle), findsOneWidget); + expect(find.byType(AddLightningInvoiceWidget), findsNothing); + expect(find.text(s.invoiceContinueAnyway), findsOneWidget); + }); + + testWidgets('lets the buyer invoice a refused payout deliberately', + (tester) async { + await pumpAddScreen( + tester, + requestedSats: 99700, + anchoredSats: 99700, + market: const MarketCheck( + quotedSats: 200000, + settledSats: 100000, + deviation: 0.5, + ), + ); + + await tester.tap(find.text(_s(tester).invoiceContinueAnyway)); + await tester.pumpAndSettle(); + + final s = _s(tester); + expect(find.byType(AddLightningInvoiceWidget), findsOneWidget); + expect(find.text(s.invoiceOffMarketTitle), findsOneWidget); + }); + + testWidgets('holds the invoice flow while the rate is still in flight', + (tester) async { + await pumpAddScreen( + tester, + requestedSats: 99700, + anchoredSats: 99700, + marketResult: MarketCheckResult.loading, + ); + + expect(find.byType(AddLightningInvoiceWidget), findsNothing); + expect(find.text(_s(tester).invoiceCheckingMarketRate), findsOneWidget); + + await moveMarketResultTo( + tester, + const MarketCheckResult.checked(MarketCheck( + quotedSats: 100100, + settledSats: 100000, + deviation: 0.001, + )), + ); + + expect(find.byType(AddLightningInvoiceWidget), findsOneWidget); + }); + + testWidgets('keeps cancel reachable while the rate is in flight', + (tester) async { + // Every other branch offers it. A request that never comes back would + // otherwise leave the user with no way forward and no way out. + await pumpAddScreen( + tester, + requestedSats: 99700, + anchoredSats: 99700, + marketResult: MarketCheckResult.loading, + ); + + expect(find.text(_s(tester).cancel), findsOneWidget); + }); + + testWidgets('says so when the rate could not be had, without refusing', + (tester) async { + await pumpAddScreen( + tester, + requestedSats: 99700, + anchoredSats: 99700, + marketResult: MarketCheckResult.unavailable, + ); + + final s = _s(tester); + expect(find.text(s.invoiceMarketRateUnavailableTitle), findsOneWidget); + expect(find.byType(AddLightningInvoiceWidget), findsOneWidget); + }); + + testWidgets('does not carry an override onto a quote that has changed', + (tester) async { + // The user accepts a payout of 100000 against a 200000 quote. The node + // then republishes and the payout halves again. A route-wide flag stayed + // true through that and left invoice creation open under a caution. + await pumpAddScreen( + tester, + requestedSats: 99700, + anchoredSats: 99700, + market: const MarketCheck( + quotedSats: 200000, + settledSats: 100000, + deviation: 0.5, + ), + ); + + await tester.tap(find.text(_s(tester).invoiceContinueAnyway)); + await tester.pumpAndSettle(); + expect(find.byType(AddLightningInvoiceWidget), findsOneWidget); + + await moveMarketTo( + tester, + const MarketCheck( + quotedSats: 200000, + settledSats: 50000, + deviation: 0.75, + ), + ); + + final s = _s(tester); + expect(find.byType(AddLightningInvoiceWidget), findsNothing); + expect(find.text(s.invoiceOffMarketTitle), findsOneWidget); + expect(find.text(s.invoiceContinueAnyway), findsOneWidget); + }); + + testWidgets('keeps the override while the quote it was given for holds', + (tester) async { + const accepted = MarketCheck( + quotedSats: 200000, + settledSats: 100000, + deviation: 0.5, + ); + await pumpAddScreen( + tester, + requestedSats: 99700, + anchoredSats: 99700, + market: accepted, + ); + + await tester.tap(find.text(_s(tester).invoiceContinueAnyway)); + await tester.pumpAndSettle(); + + // A rebuild carrying the same figures is the same quote, so the user is + // not asked again. + await moveMarketTo(tester, accepted); + + expect(find.byType(AddLightningInvoiceWidget), findsOneWidget); + }); + + testWidgets('stays quiet when the settlement is on the market rate', + (tester) async { + await pumpAddScreen( + tester, + requestedSats: 99700, + anchoredSats: 99700, + market: const MarketCheck( + quotedSats: 100100, + settledSats: 100000, + deviation: 0.001, + ), + ); + + expect(find.text(_s(tester).invoiceOffMarketTitle), findsNothing); + }); + }); +} diff --git a/test/features/order/screens/pay_lightning_invoice_screen_test.dart b/test/features/order/screens/pay_lightning_invoice_screen_test.dart new file mode 100644 index 000000000..ade54033e --- /dev/null +++ b/test/features/order/screens/pay_lightning_invoice_screen_test.dart @@ -0,0 +1,501 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as mostro; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/enums/status.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/payment_request.dart'; +import 'package:mostro_mobile/features/order/models/order_state.dart'; +import 'package:mostro_mobile/features/order/notifiers/order_notifier.dart'; +import 'package:mostro_mobile/features/order/providers/market_check_provider.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/features/order/providers/settlement_anchor_provider.dart'; +import 'package:mostro_mobile/features/order/screens/pay_lightning_invoice_screen.dart'; +import 'package:mostro_mobile/features/wallet/providers/nwc_provider.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; +import 'package:mostro_mobile/shared/widgets/pay_lightning_invoice_widget.dart'; +import 'package:mostro_mobile/shared/utils/market_quote.dart'; + +const _orderId = 'order-1'; + +/// Feeds [marketCheckProvider] so a test can move the quote under a screen +/// that is already mounted, the way a node republish or a rate refresh does. +final _marketSource = StateProvider( + (ref) => MarketCheckResult.notApplicable); + +/// A data part long enough to look real; only the prefix is read. +const _data = 'pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfq'; + +/// [sats] satoshis expressed the way an invoice does, in nano-bitcoin. +String invoiceFor(int sats) => 'lnbc${sats * 10}n1$_data'; + +/// Holds a fixed [OrderState] without any of the notifier's real machinery, +/// which reaches for sessions, storage and a live subscription. +class _StubOrderNotifier extends StateNotifier + implements OrderNotifier { + _StubOrderNotifier(super.state, {this.cancelFails = false}); + + final bool cancelFails; + + @override + Future cancelOrder() async { + if (cancelFails) throw Exception('relay unreachable'); + } + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +/// Reports a disconnected wallet, so the screen takes the manual flow. +class _StubNwcNotifier extends StateNotifier implements NwcNotifier { + _StubNwcNotifier() : super(const NwcState()); + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +Future pumpPayScreen( + WidgetTester tester, { + required String lnInvoice, + required int messageSats, + int? anchoredSats, + MarketCheck? market, + MarketCheckResult? marketResult, + bool cancelFails = false, +}) async { + final state = OrderState( + status: Status.waitingPayment, + action: mostro.Action.payInvoice, + order: Order( + id: _orderId, + kind: OrderType.sell, + status: Status.waitingPayment, + amount: messageSats, + fiatCode: 'USD', + fiatAmount: 100, + paymentMethod: 'bank', + premium: 0, + ), + paymentRequest: PaymentRequest(lnInvoice: lnInvoice), + ); + + final router = GoRouter( + initialLocation: '/pay', + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const Scaffold(body: Text('home')), + ), + GoRoute( + path: '/pay', + builder: (_, __) => const PayLightningInvoiceScreen(orderId: _orderId), + ), + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + orderNotifierProvider + .overrideWith( + (ref, id) => _StubOrderNotifier(state, cancelFails: cancelFails)), + nwcProvider.overrideWith((ref) => _StubNwcNotifier()), + sessionProvider.overrideWith((ref, id) => null), + anchoredSellerAmountProvider.overrideWith((ref, id) => anchoredSats), + _marketSource.overrideWith((ref) => + marketResult ?? + (market == null + ? MarketCheckResult.notApplicable + : MarketCheckResult.checked(market))), + marketCheckProvider.overrideWith((ref, id) => ref.watch(_marketSource)), + ], + child: MaterialApp.router( + routerConfig: router, + localizationsDelegates: S.localizationsDelegates, + supportedLocales: S.supportedLocales, + ), + ), + ); + await tester.pump(); +} + +S _s(WidgetTester tester) => + S.of(tester.element(find.byType(PayLightningInvoiceScreen)))!; + +/// Replaces the quote on the mounted screen and lets it rebuild. +Future moveMarketTo(WidgetTester tester, MarketCheck market) => + moveMarketResultTo(tester, MarketCheckResult.checked(market)); + +/// Replaces the whole check outcome on the mounted screen and lets it rebuild. +Future moveMarketResultTo( + WidgetTester tester, MarketCheckResult result) async { + final container = ProviderScope.containerOf( + tester.element(find.byType(PayLightningInvoiceScreen)), + ); + container.read(_marketSource.notifier).state = result; + await tester.pump(); +} + +/// The terms refusal box, whichever case produced it. +Finder refusalNotice(WidgetTester tester) => + find.text(_s(tester).invoiceNotPayableTitle); + +void main() { + group('PayLightningInvoiceScreen gating', () { + testWidgets('refuses an invoice that asks for more than the order says', + (tester) async { + // The finding's scenario: the message shows 50,000 and carries an + // invoice for 500,000. + await pumpPayScreen( + tester, + lnInvoice: invoiceFor(500000), + messageSats: 50000, + anchoredSats: 50000, + ); + + expect(refusalNotice(tester), findsOneWidget); + expect(find.byType(PayLightningInvoiceWidget), findsNothing); + }); + + testWidgets('pays an invoice that agrees with the signed terms', + (tester) async { + await pumpPayScreen( + tester, + lnInvoice: invoiceFor(50300), + messageSats: 50300, + anchoredSats: 50300, + ); + + expect(refusalNotice(tester), findsNothing); + }); + + testWidgets('refuses an invoice that sets no amount', (tester) async { + await pumpPayScreen( + tester, + lnInvoice: 'lnbc1$_data', + messageSats: 50000, + anchoredSats: 50000, + ); + + expect(refusalNotice(tester), findsOneWidget); + }); + + testWidgets('refuses an invoice it cannot read', (tester) async { + await pumpPayScreen( + tester, + lnInvoice: 'not-an-invoice', + messageSats: 50000, + anchoredSats: 50000, + ); + + expect(refusalNotice(tester), findsOneWidget); + }); + + testWidgets('shows the amount the invoice asks for, not the message figure', + (tester) async { + // The invoice agrees with the signed terms, so nothing is blocked. The + // message's own figure disagrees with both, and a wallet scanning the + // QR code would pay the invoice — so that is the figure the screen has + // to print beside it. + await pumpPayScreen( + tester, + lnInvoice: invoiceFor(50300), + messageSats: 99999, + anchoredSats: 50300, + ); + + expect(find.textContaining('50300'), findsWidgets); + expect(find.textContaining('99999'), findsNothing); + }); + + testWidgets('cautions when the signed terms could not be derived', + (tester) async { + await pumpPayScreen( + tester, + lnInvoice: invoiceFor(50000), + messageSats: 50000, + anchoredSats: null, + ); + + final s = _s(tester); + expect(find.text(s.invoiceTermsUnverifiedTitle), findsOneWidget); + expect(refusalNotice(tester), findsNothing); + }); + + testWidgets('says nothing extra when the terms were derived', + (tester) async { + await pumpPayScreen( + tester, + lnInvoice: invoiceFor(50300), + messageSats: 50300, + anchoredSats: 50300, + ); + + final s = _s(tester); + expect(find.text(s.invoiceTermsUnverifiedTitle), findsNothing); + expect(find.text(s.invoiceOffMarketTitle), findsNothing); + }); + + testWidgets('cautions when the gap runs in the seller\'s favour', + (tester) async { + // The seller gives up fewer sats than the market says the fiat is + // worth. Worth naming, not worth stopping. + await pumpPayScreen( + tester, + lnInvoice: invoiceFor(50000), + messageSats: 50000, + anchoredSats: 50000, + market: const MarketCheck( + quotedSats: 100000, + settledSats: 50000, + deviation: 0.5, + ), + ); + + final s = _s(tester); + expect(find.text(s.invoiceOffMarketTitle), findsOneWidget); + expect(find.text(s.invoiceContinueAnyway), findsNothing); + expect(find.byType(PayLightningInvoiceWidget), findsOneWidget); + }); + + testWidgets('refuses when the gap runs against the seller', (tester) async { + // The seller would give up twice the sats the market prices the fiat + // at, which is the direction a skim takes. + await pumpPayScreen( + tester, + lnInvoice: invoiceFor(100000), + messageSats: 100000, + anchoredSats: 100000, + market: const MarketCheck( + quotedSats: 50000, + settledSats: 100000, + deviation: 1.0, + ), + ); + + final s = _s(tester); + expect(find.text(s.invoiceOffMarketTitle), findsOneWidget); + expect(find.byType(PayLightningInvoiceWidget), findsNothing); + expect(find.text(s.invoiceContinueAnyway), findsOneWidget); + expect(find.text(s.cancel), findsOneWidget); + }); + + testWidgets('lets the seller pay a refused settlement deliberately', + (tester) async { + await pumpPayScreen( + tester, + lnInvoice: invoiceFor(100000), + messageSats: 100000, + anchoredSats: 100000, + market: const MarketCheck( + quotedSats: 50000, + settledSats: 100000, + deviation: 1.0, + ), + ); + + await tester.tap(find.text(_s(tester).invoiceContinueAnyway)); + await tester.pumpAndSettle(); + + final s = _s(tester); + expect(find.byType(PayLightningInvoiceWidget), findsOneWidget); + // The gap does not stop being true once it has been accepted. + expect(find.text(s.invoiceOffMarketTitle), findsOneWidget); + }); + + testWidgets('cautions rather than refusing when the order carries no amount', + (tester) async { + // Nothing about the invoice contradicts the order; there is simply no + // order figure to check it against. Refusing there would make an absent + // term stronger evidence than a disagreeing one. + await pumpPayScreen( + tester, + lnInvoice: invoiceFor(100000), + messageSats: 0, + anchoredSats: null, + ); + + final s = _s(tester); + expect(refusalNotice(tester), findsNothing); + expect(find.text(s.invoiceTermsUnverifiedTitle), findsOneWidget); + expect(find.byType(PayLightningInvoiceWidget), findsOneWidget); + }); + + testWidgets('holds the pay button while the rate is still in flight', + (tester) async { + await pumpPayScreen( + tester, + lnInvoice: invoiceFor(100000), + messageSats: 100000, + anchoredSats: 100000, + marketResult: MarketCheckResult.loading, + ); + + expect(find.byType(PayLightningInvoiceWidget), findsNothing); + expect(find.text(_s(tester).invoiceCheckingMarketRate), findsOneWidget); + + // The answer lands and the flow opens on it. + await moveMarketResultTo( + tester, + const MarketCheckResult.checked(MarketCheck( + quotedSats: 100100, + settledSats: 100000, + deviation: 0.001, + )), + ); + + expect(find.byType(PayLightningInvoiceWidget), findsOneWidget); + }); + + testWidgets('keeps cancel reachable while the rate is in flight', + (tester) async { + // Every other branch offers it. A request that never comes back would + // otherwise leave the user with no way forward and no way out. + await pumpPayScreen( + tester, + lnInvoice: invoiceFor(100000), + messageSats: 100000, + anchoredSats: 100000, + marketResult: MarketCheckResult.loading, + ); + + expect(find.text(_s(tester).cancel), findsOneWidget); + }); + + testWidgets('says so when the rate could not be had, without refusing', + (tester) async { + // An unreachable third party is not evidence against a settlement, so + // it is named rather than used to stop the trade. + await pumpPayScreen( + tester, + lnInvoice: invoiceFor(100000), + messageSats: 100000, + anchoredSats: 100000, + marketResult: MarketCheckResult.unavailable, + ); + + final s = _s(tester); + expect(find.text(s.invoiceMarketRateUnavailableTitle), findsOneWidget); + expect(find.byType(PayLightningInvoiceWidget), findsOneWidget); + }); + + testWidgets('does not carry an override onto a quote that has changed', + (tester) async { + // The user accepts a gap of 100000 against a 50000 quote. The node then + // republishes and the settlement doubles again. A route-wide flag stayed + // true through that and left the pay button live under a caution. + await pumpPayScreen( + tester, + lnInvoice: invoiceFor(100000), + messageSats: 100000, + anchoredSats: 100000, + market: const MarketCheck( + quotedSats: 50000, + settledSats: 100000, + deviation: 1.0, + ), + ); + + await tester.tap(find.text(_s(tester).invoiceContinueAnyway)); + await tester.pumpAndSettle(); + expect(find.byType(PayLightningInvoiceWidget), findsOneWidget); + + await moveMarketTo( + tester, + const MarketCheck( + quotedSats: 50000, + settledSats: 200000, + deviation: 3.0, + ), + ); + + final s = _s(tester); + expect(find.byType(PayLightningInvoiceWidget), findsNothing); + expect(find.text(s.invoiceOffMarketTitle), findsOneWidget); + expect(find.text(s.invoiceContinueAnyway), findsOneWidget); + }); + + testWidgets('keeps the override while the quote it was given for holds', + (tester) async { + const accepted = MarketCheck( + quotedSats: 50000, + settledSats: 100000, + deviation: 1.0, + ); + await pumpPayScreen( + tester, + lnInvoice: invoiceFor(100000), + messageSats: 100000, + anchoredSats: 100000, + market: accepted, + ); + + await tester.tap(find.text(_s(tester).invoiceContinueAnyway)); + await tester.pumpAndSettle(); + + // A rebuild carrying the same figures is the same quote, so the user is + // not asked again. + await moveMarketTo(tester, accepted); + + expect(find.byType(PayLightningInvoiceWidget), findsOneWidget); + }); + + testWidgets('stays quiet when the settlement is on the market rate', + (tester) async { + await pumpPayScreen( + tester, + lnInvoice: invoiceFor(50000), + messageSats: 50000, + anchoredSats: 50000, + market: const MarketCheck( + quotedSats: 50100, + settledSats: 50000, + deviation: 0.002, + ), + ); + + final s = _s(tester); + expect(find.text(s.invoiceOffMarketTitle), findsNothing); + }); + + testWidgets('leaves the screen once cancellation goes through', + (tester) async { + await pumpPayScreen( + tester, + lnInvoice: invoiceFor(500000), + messageSats: 50000, + anchoredSats: 50000, + ); + + await tester.tap(find.text(_s(tester).cancel)); + await tester.pumpAndSettle(); + + expect(find.byType(PayLightningInvoiceScreen), findsNothing); + expect(find.text('home'), findsOneWidget); + }); + + testWidgets('keeps the screen and reports a cancellation that failed', + (tester) async { + // Cancel is the only action offered after a refusal. Navigating away + // before the cancellation lands would hide the failure and leave the + // trade active with nothing left to act on. + await pumpPayScreen( + tester, + lnInvoice: invoiceFor(500000), + messageSats: 50000, + anchoredSats: 50000, + cancelFails: true, + ); + + await tester.tap(find.text(_s(tester).cancel)); + await tester.pump(); + await tester.pump(); + + expect(find.byType(PayLightningInvoiceScreen), findsOneWidget); + expect(find.textContaining('relay unreachable'), findsOneWidget); + }); + }); +} diff --git a/test/features/order/screens/payout_invoice_screen_test.dart b/test/features/order/screens/payout_invoice_screen_test.dart new file mode 100644 index 000000000..e6d288580 --- /dev/null +++ b/test/features/order/screens/payout_invoice_screen_test.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/enums/status.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/features/order/models/order_state.dart'; +import 'package:mostro_mobile/features/order/notifiers/order_notifier.dart'; +import 'package:mostro_mobile/features/order/providers/market_check_provider.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/features/order/providers/settlement_anchor_provider.dart'; +import 'package:mostro_mobile/features/order/screens/payout_invoice_screen.dart'; +import 'package:mostro_mobile/features/wallet/providers/nwc_provider.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/shared/utils/market_quote.dart'; +import 'package:mostro_mobile/shared/widgets/add_lightning_invoice_widget.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as mostro; + +const _orderId = 'order-1'; + +/// Holds a fixed [OrderState] without the notifier's real machinery. +class _StubOrderNotifier extends StateNotifier + implements OrderNotifier { + _StubOrderNotifier(super.state); + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +/// Reports a disconnected wallet, so the screen takes the manual flow. +class _StubNwcNotifier extends StateNotifier implements NwcNotifier { + _StubNwcNotifier() : super(const NwcState()); + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +Order _order(int sats) => Order( + id: _orderId, + kind: OrderType.buy, + status: Status.settledHoldInvoice, + amount: sats, + fiatCode: 'USD', + fiatAmount: 100, + paymentMethod: 'cash', + premium: 0, + ); + +Future pumpPayoutScreen( + WidgetTester tester, { + required int payoutSats, + int? anchoredSats, + MarketCheckResult marketResult = MarketCheckResult.notApplicable, +}) async { + final order = _order(payoutSats); + final state = OrderState( + status: Status.settledHoldInvoice, + action: mostro.Action.addInvoice, + order: order, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + orderNotifierProvider + .overrideWith((ref, id) => _StubOrderNotifier(state)), + nwcProvider.overrideWith((ref) => _StubNwcNotifier()), + anchoredBuyerAmountProvider.overrideWith((ref, id) => anchoredSats), + marketCheckProvider.overrideWith((ref, id) => marketResult), + ], + child: MaterialApp( + localizationsDelegates: S.localizationsDelegates, + supportedLocales: S.supportedLocales, + home: PayoutInvoiceScreen(orderId: _orderId, order: order), + ), + ), + ); + await tester.pump(); +} + +S _s(WidgetTester tester) => + S.of(tester.element(find.byType(PayoutInvoiceScreen)))!; + +void main() { + group('PayoutInvoiceScreen settlement checks', () { + testWidgets('says nothing when the payout matches the signed terms', + (tester) async { + await pumpPayoutScreen(tester, payoutSats: 99700, anchoredSats: 99700); + + final s = _s(tester); + expect(find.text(s.payoutAmountMismatchTitle), findsNothing); + expect(find.text(s.invoiceTermsUnverifiedTitle), findsNothing); + expect(find.byType(AddLightningInvoiceWidget), findsOneWidget); + }); + + testWidgets('names a payout that disagrees with the signed terms', + (tester) async { + await pumpPayoutScreen(tester, payoutSats: 90000, anchoredSats: 99700); + + expect(find.text(_s(tester).payoutAmountMismatchTitle), findsOneWidget); + }); + + testWidgets('still lets the user collect when the amounts disagree', + (tester) async { + // The hold invoice is already settled and there is nothing to cancel. + // A refusal here would leave the user unable to collect at all, which + // is worse than collecting an amount they were shown to be wrong. + await pumpPayoutScreen(tester, payoutSats: 90000, anchoredSats: 99700); + + expect(find.byType(AddLightningInvoiceWidget), findsOneWidget); + }); + + testWidgets('cautions when the signed terms could not be derived', + (tester) async { + await pumpPayoutScreen(tester, payoutSats: 99700, anchoredSats: null); + + final s = _s(tester); + expect(find.text(s.invoiceTermsUnverifiedTitle), findsOneWidget); + expect(find.byType(AddLightningInvoiceWidget), findsOneWidget); + }); + + testWidgets('names an off-market payout without stopping it', + (tester) async { + await pumpPayoutScreen( + tester, + payoutSats: 99700, + anchoredSats: 99700, + marketResult: const MarketCheckResult.checked(MarketCheck( + quotedSats: 200000, + settledSats: 100000, + deviation: 0.5, + )), + ); + + final s = _s(tester); + expect(find.text(s.invoiceOffMarketTitle), findsOneWidget); + expect(find.text(s.invoiceContinueAnyway), findsNothing); + expect(find.byType(AddLightningInvoiceWidget), findsOneWidget); + }); + + testWidgets('says so when the market rate could not be had', + (tester) async { + await pumpPayoutScreen( + tester, + payoutSats: 99700, + anchoredSats: 99700, + marketResult: MarketCheckResult.unavailable, + ); + + expect(find.text(_s(tester).invoiceMarketRateUnavailableTitle), + findsOneWidget); + }); + }); +} diff --git a/test/features/order/settlement_terms_store_test.dart b/test/features/order/settlement_terms_store_test.dart new file mode 100644 index 000000000..1b15fc478 --- /dev/null +++ b/test/features/order/settlement_terms_store_test.dart @@ -0,0 +1,391 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/features/order/settlement_terms_store.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +/// A store backed by whatever the platform channel mock holds, so a second +/// instance reads what the first one wrote — which is the whole point of the +/// anchors surviving a process that ended mid-commitment. +SettlementTermsStore _store() => SettlementTermsStore(SharedPreferencesAsync()); + +final _tradeKey = 'a' * 64; +final _otherKey = 'b' * 64; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + group('SettlementTermsStore', () { + test('hands back what a trade committed to', () async { + final store = _store(); + await store.init(); + + await store.pin( + _tradeKey, + amountSats: 100000, + feeRate: 0.006, + fiatCode: 'USD', + fiatAmount: 100, + premium: 2.5, + ); + + final terms = store.termsFor(_tradeKey)!; + expect(terms.amountSats, 100000); + expect(terms.feeRate, 0.006); + expect(terms.fiatCode, 'USD'); + expect(terms.fiatAmount, 100); + expect(terms.premium, 2.5); + }); + + test('knows nothing about a trade that never pinned', () async { + final store = _store(); + await store.init(); + + expect(store.termsFor(_otherKey), isNull); + }); + + test('survives a process that ended before the daemon replied', () async { + // The finding's first scenario. The commitment is published right after + // pin() returns; if the app dies there, the trade stands remotely and + // these are the only anchors left. + final before = _store(); + await before.init(); + await before.pin(_tradeKey, amountSats: 100000, feeRate: 0.006); + await before.pendingWrites; + + final after = _store(); + await after.init(); + + expect(after.termsFor(_tradeKey)!.amountSats, 100000); + expect(after.termsFor(_tradeKey)!.feeRate, 0.006); + }); + + test('outlives a node switch and the session wipe it performs', () async { + // Node A -> B -> A. The restore clears the session store outright and + // rebuilds every session from the node's data, so this store is what + // keeps a committed trade from coming back as a legacy one. + final onNodeA = _store(); + await onNodeA.init(); + await onNodeA.pin(_tradeKey, amountSats: 100000, feeRate: 0.006); + await onNodeA.pendingWrites; + + // Switching to B and back rebuilds the store from disk twice; nothing + // in that path is allowed to clear it. + final onNodeB = _store(); + await onNodeB.init(); + expect(onNodeB.termsFor(_tradeKey), isNotNull); + + final backOnA = _store(); + await backOnA.init(); + expect(backOnA.termsFor(_tradeKey)!.amountSats, 100000); + }); + + test('keeps the terms the first commitment pinned', () async { + // A retake must not move the anchor: the trade was agreed once. + final store = _store(); + await store.init(); + + await store.pin(_tradeKey, amountSats: 100000, feeRate: 0.006); + await store.pin(_tradeKey, amountSats: 999999, feeRate: 0.9); + + expect(store.termsFor(_tradeKey)!.amountSats, 100000); + expect(store.termsFor(_tradeKey)!.feeRate, 0.006); + }); + + test('records that pinning ran even where there was nothing to pin', + () async { + // A market-price take before the info event arrives pins neither + // figure. The record still has to exist, or the trade is + // indistinguishable from one that predates pinning and goes back to + // reading whatever the node publishes next. + final store = _store(); + await store.init(); + + await store.pin(_tradeKey); + + final terms = store.termsFor(_tradeKey); + expect(terms, isNotNull); + expect(terms!.amountSats, isNull); + expect(terms.feeRate, isNull); + }); + + test('keeps the instant of agreement across a reopen', () async { + // Not only a pruning clock any more: where a commitment pinned no fee + // rate, this is what a later info event is measured against to tell a + // term supplied after the agreement from one the client was late to + // receive. A range remainder is anchored at its parent's instant, so it + // has to survive storage rather than being recomputed on read. + final store = _store(); + await store.init(); + + final committedAt = DateTime.fromMillisecondsSinceEpoch(1781000000000); + await store.pin(_tradeKey, pinnedAt: committedAt); + await store.pendingWrites; + + final reopened = _store(); + await reopened.init(); + + expect(reopened.termsFor(_tradeKey)!.pinnedAt, committedAt); + }); + + test('drops an anchor for a session the user deleted', () async { + final store = _store(); + await store.init(); + await store.pin(_tradeKey, amountSats: 100000); + + await store.forget(_tradeKey); + await store.pendingWrites; + + final reopened = _store(); + await reopened.init(); + expect(reopened.termsFor(_tradeKey), isNull); + }); + + test('prunes an anchor older than the retention window', () async { + final store = _store(); + await store.init(); + await store.pin( + _tradeKey, + amountSats: 100000, + pinnedAt: DateTime.now() + .subtract(SettlementTermsStore.retention * 2), + ); + await store.pin(_otherKey, amountSats: 50000); + await store.pendingWrites; + + final reopened = _store(); + await reopened.init(); + + expect(reopened.termsFor(_tradeKey), isNull); + expect(reopened.termsFor(_otherKey), isNotNull); + }); + + test('fails rather than reporting a write that did not land', () async { + // The commitment goes out on the strength of pin() returning. A write + // that failed and returned normally would publish a trade whose terms + // exist only in memory. + final failing = _FailingWritePrefs(); + final store = SettlementTermsStore(failing); + await store.init(); + + await expectLater( + store.pin(_tradeKey, amountSats: 100000), + throwsA(isA()), + ); + }); + + test('does not keep in memory what the disk refused', () async { + // Left in place, the entry would report the trade as anchored and turn + // every later attempt into a no-op that never retries the write. + final failing = _FailingWritePrefs(); + final store = SettlementTermsStore(failing); + await store.init(); + + await expectLater( + store.pin(_tradeKey, amountSats: 100000), + throwsA(isA()), + ); + expect(store.termsFor(_tradeKey), isNull); + + // A retry once storage recovers actually writes. + failing.failWrites = false; + await store.pin(_tradeKey, amountSats: 100000); + expect(store.termsFor(_tradeKey)!.amountSats, 100000); + }); + + test('one failed write does not poison the writes that follow', () async { + final failing = _FailingWritePrefs(); + final store = SettlementTermsStore(failing); + await store.init(); + + await expectLater( + store.pin(_tradeKey, amountSats: 100000), + throwsA(isA()), + ); + + failing.failWrites = false; + await store.pin(_otherKey, amountSats: 50000); + expect(store.termsFor(_otherKey), isNotNull); + }); + + test('refuses to pin while the map is still unreadable', () async { + // Writing here would erase anchors that were never read. + final store = SettlementTermsStore(_ThrowingReadPrefs()); + await store.init(); + + await expectLater( + store.pin(_tradeKey, amountSats: 100000), + throwsA(isA()), + ); + }); + + test('retries the read rather than refusing for the rest of the run', + () async { + // A transient failure at startup used to latch: the user could then + // take nothing, create nothing and release nothing until they restarted + // the app. + final prefs = _FlakyReadPrefs(); + final store = SettlementTermsStore(prefs); + await store.init(); + + await expectLater( + store.pin(_tradeKey, amountSats: 100000), + throwsA(isA()), + ); + + prefs.failReads = false; + await store.pin(_tradeKey, amountSats: 100000); + + expect(store.termsFor(_tradeKey)!.amountSats, 100000); + }); + + test('picks up what was on disk when the read finally works', () async { + // The retry is a real read, so anchors written before the failure come + // back rather than being overwritten by an empty map. + final seeded = _store(); + await seeded.init(); + await seeded.pin(_otherKey, amountSats: 50000); + await seeded.pendingWrites; + + final prefs = _FlakyReadPrefs(); + prefs.seed(await _readRaw()); + final store = SettlementTermsStore(prefs); + await store.init(); + + await expectLater( + store.pin(_tradeKey, amountSats: 100000), + throwsA(isA()), + ); + + prefs.failReads = false; + await store.pin(_tradeKey, amountSats: 100000); + + expect(store.termsFor(_otherKey), isNotNull); + expect(store.termsFor(_tradeKey), isNotNull); + }); + + test('will not write over anchors it never managed to read', () async { + // The read failing says nothing about the contents: the persisted map + // may be perfectly good. Serializing memory over it would erase the + // anchors of every other trade in flight, and those trades would go + // back to whatever the node currently advertises. + final seeded = _store(); + await seeded.init(); + await seeded.pin(_otherKey, amountSats: 50000); + await seeded.pendingWrites; + + final unreadable = _ThrowingReadPrefs(); + final blind = SettlementTermsStore(unreadable); + await blind.init(); + + await expectLater( + blind.pin(_tradeKey, amountSats: 100000), + throwsA(isA()), + ); + await blind.pendingWrites; + + expect(unreadable.writes, isEmpty); + }); + + test('treats an unreadable record as no record at all', () async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'settlement_pinned_terms': 'not json', + }); + + final store = _store(); + await store.init(); + + expect(store.termsFor(_tradeKey), isNull); + }); + + test('overwrites a stored value it could read but not parse', () async { + // The opposite of the case above: there is nothing behind a corrupt + // value to preserve, so refusing to write would leave pinning broken + // for good. + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'settlement_pinned_terms': 'not json', + }); + + final store = _store(); + await store.init(); + await store.pin(_tradeKey, amountSats: 100000); + await store.pendingWrites; + + final reopened = _store(); + await reopened.init(); + expect(reopened.termsFor(_tradeKey)!.amountSats, 100000); + }); + }); +} + +/// Writes fail until [failWrites] is cleared. Reads come back empty. +/// Reads what it has been seeded with, and returns a copy of the store's raw +/// value so a flaky-read double can start from real data. +Future _readRaw() => + SharedPreferencesAsync().getString('settlement_pinned_terms'); + +/// Reads throw until [failReads] is cleared; writes always land. +// ignore: must_be_immutable +class _FlakyReadPrefs implements SharedPreferencesAsync { + bool failReads = true; + String? _stored; + + void seed(String? value) => _stored = value; + + @override + Future getString(String key, {Object? options}) async { + if (failReads) throw StateError('platform unavailable'); + return _stored; + } + + @override + Future setString(String key, String value, {Object? options}) async { + _stored = value; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +// ignore: must_be_immutable +class _FailingWritePrefs implements SharedPreferencesAsync { + bool failWrites = true; + String? _stored; + + @override + Future getString(String key, {Object? options}) async => _stored; + + @override + Future setString(String key, String value, {Object? options}) async { + if (failWrites) throw StateError('disk full'); + _stored = value; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Reads throw; writes are recorded so a test can assert none happened. +class _ThrowingReadPrefs implements SharedPreferencesAsync { + final List writes = []; + + @override + Future getString(String key, {Object? options}) async { + throw StateError('platform unavailable'); + } + + @override + Future setString(String key, String value, {Object? options}) async { + writes.add(key); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/mocks.dart b/test/mocks.dart index 941eeeb8c..353596b42 100644 --- a/test/mocks.dart +++ b/test/mocks.dart @@ -95,8 +95,16 @@ class MockSessionNotifier extends SessionNotifier { List get sessions => _mockSessions; @override - Future newSession( - {String? orderId, int? requestId, Role? role}) async { + Future newSession({ + String? orderId, + int? requestId, + Role? role, + int? pinnedAmountSats, + double? pinnedFeeRate, + String? pinnedFiatCode, + int? pinnedFiatAmount, + double? pinnedPremium, + }) async { final mockSession = Session( // Dummy private keys for testing purposes only masterKey: NostrKeyPairs( @@ -108,6 +116,14 @@ class MockSessionNotifier extends SessionNotifier { keyIndex: 0, fullPrivacy: false, startTime: DateTime.now(), + // Pins go through the constructor: they are written once and never + // moved, which is what the real notifier does too. + pinnedAmountSats: pinnedAmountSats, + pinnedFeeRate: pinnedFeeRate, + pinnedFiatCode: pinnedFiatCode, + pinnedFiatAmount: pinnedFiatAmount, + pinnedPremium: pinnedPremium, + termsPinned: true, ); mockSession.orderId = orderId; mockSession.role = role; diff --git a/test/notifiers/add_order_notifier_test.dart b/test/notifiers/add_order_notifier_test.dart index 8f0c28b7c..fae7e53e3 100644 --- a/test/notifiers/add_order_notifier_test.dart +++ b/test/notifiers/add_order_notifier_test.dart @@ -48,6 +48,11 @@ void main() { // Create test settings final testSettings = MockSettings(); + // The settlement anchor pins the node's fee rate at order creation, and + // reads the selected node to make sure the info event belongs to it. + when(testSettings.mostroPublicKey).thenReturn( + '9d9d0455a96871f2dc4289b8312429db2e925f167b37c77bf7b28014be235980', + ); mockSessionNotifier = MockSessionNotifier(ref, mockKeyManager, mockSessionStorage, testSettings); diff --git a/test/shared/notifiers/session_anchor_durability_test.dart b/test/shared/notifiers/session_anchor_durability_test.dart new file mode 100644 index 000000000..0eb42628b --- /dev/null +++ b/test/shared/notifiers/session_anchor_durability_test.dart @@ -0,0 +1,186 @@ +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:mostro_mobile/data/models/enums/role.dart'; +import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; +import 'package:mostro_mobile/features/order/settlement_terms_store.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../mocks.mocks.dart'; + +/// A trade is published the moment newSession returns, so "the anchors are +/// durable before the commitment goes out" has to mean the call fails when +/// they are not. A best-effort write here puts a live remote trade behind +/// terms that exist only in memory. +void main() { + final keyPair = NostrKeyPairs( + private: + '0000000000000000000000000000000000000000000000000000000000000001', + ); + + late MockKeyManager keyManager; + late MockSessionStorage storage; + late _FailingWritePrefs prefs; + late ProviderContainer container; + late SessionNotifier notifier; + + setUp(() async { + keyManager = MockKeyManager(); + storage = MockSessionStorage(); + prefs = _FailingWritePrefs(); + + when(keyManager.masterKeyPair).thenReturn(keyPair); + when(keyManager.getCurrentKeyIndex()).thenAnswer((_) async => 1); + when(keyManager.deriveTradeKey()).thenAnswer((_) async => keyPair); + when(storage.putSession(any)).thenAnswer((_) async {}); + + container = ProviderContainer(overrides: [ + keyManagerProvider.overrideWith((ref) => keyManager), + settlementTermsStoreProvider + .overrideWithValue(SettlementTermsStore(prefs)), + ]); + addTearDown(container.dispose); + await container.read(settlementTermsStoreProvider).init(); + + // Built through a provider so it gets a real Ref, which is what it reads + // the anchor store through. + notifier = container.read( + Provider( + (ref) => SessionNotifier( + ref, + storage, + Settings( + relays: const [], + fullPrivacyMode: false, + mostroPublicKey: 'a' * 64, + defaultFiatCode: 'USD', + selectedLanguage: 'en', + ), + ), + ), + ); + }); + + group('newSession when the anchors cannot be stored', () { + test('throws instead of handing back a session to publish', () async { + await expectLater( + notifier.newSession( + orderId: 'order-1', + role: Role.buyer, + pinnedAmountSats: 100000, + pinnedFeeRate: 0.006, + ), + throwsA(isA()), + ); + }); + + test('registers no session for the trade it refused', () async { + await expectLater( + notifier.newSession(orderId: 'order-1', role: Role.buyer), + throwsA(isA()), + ); + + expect(notifier.getSessionByOrderId('order-1'), isNull); + expect(notifier.sessions, isEmpty); + verifyNever(storage.putSession(any)); + }); + + test('goes through once storage recovers', () async { + prefs.failWrites = false; + + final session = await notifier.newSession( + orderId: 'order-1', + role: Role.buyer, + pinnedAmountSats: 100000, + ); + + expect(session.termsPinned, isTrue); + expect(notifier.getSessionByOrderId('order-1'), isNotNull); + verify(storage.putSession(any)).called(1); + }); + }); + + group('createChildOrderSession when the anchors cannot be stored', () { + test('still prepares the child, so the release is not blocked', () async { + // The release carrying NextTrade is what moves money out of escrow. + // Refusing it because the remainder's anchor could not be written + // trades the important thing for the incidental one, and leaves funds + // stuck with nothing published and nothing to retry. + final session = await notifier.createChildOrderSession( + tradeKey: keyPair, + keyIndex: 2, + parentOrderId: 'order-1', + role: Role.seller, + pinnedFeeRate: 0.006, + pinnedFiatCode: 'USD', + pinnedPremium: 0, + termsPinned: true, + ); + + expect(notifier.getSessionByTradeKey(keyPair.public), isNotNull); + expect(session.parentOrderId, 'order-1'); + }); + + test('marks the child legacy rather than claiming terms it did not store', + () async { + // Left as termsPinned with the figures in place, the remainder would + // report anchors that survive nothing. + final session = await notifier.createChildOrderSession( + tradeKey: keyPair, + keyIndex: 2, + parentOrderId: 'order-1', + role: Role.seller, + pinnedFeeRate: 0.006, + pinnedFiatCode: 'USD', + pinnedPremium: 0, + termsPinned: true, + ); + + expect(session.termsPinned, isFalse); + expect(session.pinnedFeeRate, isNull); + expect(session.pinnedFiatCode, isNull); + expect(session.pinnedPremium, isNull); + }); + + test('keeps the parent terms when the anchor does land', () async { + prefs.failWrites = false; + + final session = await notifier.createChildOrderSession( + tradeKey: keyPair, + keyIndex: 2, + parentOrderId: 'order-1', + role: Role.seller, + pinnedFeeRate: 0.006, + pinnedFiatCode: 'USD', + pinnedPremium: 0, + termsPinned: true, + ); + + expect(session.termsPinned, isTrue); + expect(session.pinnedFeeRate, 0.006); + expect(session.pinnedFiatCode, 'USD'); + }); + }); +} + +/// Writes fail until [failWrites] is cleared. +// ignore: must_be_immutable +class _FailingWritePrefs implements SharedPreferencesAsync { + bool failWrites = true; + String? _stored; + + @override + Future getString(String key, {Object? options}) async => _stored; + + @override + Future setString(String key, String value, {Object? options}) async { + if (failWrites) throw StateError('disk full'); + _stored = value; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/shared/utils/bolt11_test.dart b/test/shared/utils/bolt11_test.dart new file mode 100644 index 000000000..3229cbe40 --- /dev/null +++ b/test/shared/utils/bolt11_test.dart @@ -0,0 +1,181 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/shared/utils/bolt11.dart'; + +/// A data part long enough to look real. Nothing here reads it — the prefix +/// is the whole subject — but `tryParse` refuses a prefix with no data behind +/// it, so every case needs one. +const _data = 'pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfq'; + +BigInt _msat(String value) => BigInt.parse(value); + +void main() { + group('Bolt11Invoice.tryParse amounts', () { + // The multiplier table, each against the figure the spec gives for it. + test('reads a milli-bitcoin amount', () { + final parsed = Bolt11Invoice.tryParse('lnbc2500m1$_data'); + + expect(parsed!.amountMsat, _msat('250000000000')); + expect(parsed.amountSats, 250000000); + }); + + test('reads a micro-bitcoin amount', () { + final parsed = Bolt11Invoice.tryParse('lnbc2500u1$_data'); + + // 2500 × 10⁻⁶ BTC = 250 000 sats. + expect(parsed!.amountMsat, _msat('250000000')); + expect(parsed.amountSats, 250000); + }); + + test('reads a nano-bitcoin amount', () { + final parsed = Bolt11Invoice.tryParse('lnbc20n1$_data'); + + expect(parsed!.amountMsat, _msat('2000')); + expect(parsed.amountSats, 2); + }); + + test('reads a pico-bitcoin amount', () { + // Ten pico-bitcoin is one millisatoshi. + final parsed = Bolt11Invoice.tryParse('lnbc9678785340p1$_data'); + + expect(parsed!.amountMsat, _msat('967878534')); + }); + + test('reads an amount with no multiplier as whole bitcoin', () { + final parsed = Bolt11Invoice.tryParse('lnbc11$_data'); + + expect(parsed!.amountMsat, _msat('100000000000')); + expect(parsed.amountSats, 100000000); + }); + + test('reads an invoice that encodes no amount', () { + final parsed = Bolt11Invoice.tryParse('lnbc1$_data'); + + expect(parsed, isNotNull); + expect(parsed!.amountMsat, isNull); + expect(parsed.amountSats, isNull); + expect(parsed.network, Bolt11Network.mainnet); + }); + + test('truncates a sub-satoshi remainder in amountSats only', () { + // 1 pico-bitcoin steps are finer than a satoshi. + final parsed = Bolt11Invoice.tryParse('lnbc10010p1$_data'); + + expect(parsed!.amountMsat, _msat('1001')); + expect(parsed.amountSats, 1); + }); + }); + + group('Bolt11Invoice.tryParse networks', () { + test('reads each network prefix', () { + expect(Bolt11Invoice.tryParse('lnbc2500u1$_data')!.network, + Bolt11Network.mainnet); + expect(Bolt11Invoice.tryParse('lntb2500u1$_data')!.network, + Bolt11Network.testnet); + expect(Bolt11Invoice.tryParse('lnsb2500u1$_data')!.network, + Bolt11Network.simnet); + }); + + // `bc` is a prefix of `bcrt` and `tb` of `tbs`. Matching in declaration + // order would read every regtest invoice as a mainnet one, and the amount + // would be parsed out of `rt2500u`. + test('prefers the longest matching prefix', () { + final regtest = Bolt11Invoice.tryParse('lnbcrt2500u1$_data'); + expect(regtest!.network, Bolt11Network.regtest); + expect(regtest.amountMsat, _msat('250000000')); + + final signet = Bolt11Invoice.tryParse('lntbs2500u1$_data'); + expect(signet!.network, Bolt11Network.signet); + expect(signet.amountMsat, _msat('250000000')); + }); + + test('refuses a network it does not know', () { + expect(Bolt11Invoice.tryParse('lnxyz2500u1$_data'), isNull); + }); + }); + + group('Bolt11Invoice.tryParse rejections', () { + test('refuses a string that is not an invoice', () { + expect(Bolt11Invoice.tryParse(''), isNull); + expect(Bolt11Invoice.tryParse('not-an-invoice'), isNull); + expect(Bolt11Invoice.tryParse('bc2500u1$_data'), isNull); + }); + + test('refuses a prefix with no data part behind it', () { + expect(Bolt11Invoice.tryParse('lnbc2500u1'), isNull); + expect(Bolt11Invoice.tryParse('lnbc2500u'), isNull); + }); + + test('refuses an empty prefix body', () { + expect(Bolt11Invoice.tryParse('ln1$_data'), isNull); + }); + + test('refuses a multiplier that is not one of m, u, n, p', () { + expect(Bolt11Invoice.tryParse('lnbc2500k1$_data'), isNull); + expect(Bolt11Invoice.tryParse('lnbc2500x1$_data'), isNull); + }); + + test('refuses a multiplier with no figure in front of it', () { + expect(Bolt11Invoice.tryParse('lnbcu1$_data'), isNull); + }); + + test('refuses an amount of zero', () { + expect(Bolt11Invoice.tryParse('lnbc0u1$_data'), isNull); + }); + + test('refuses a leading zero', () { + // Otherwise `0250u` and `250u` are the same invoice with two spellings. + expect(Bolt11Invoice.tryParse('lnbc0250u1$_data'), isNull); + }); + + test('refuses a pico amount finer than a millisatoshi', () { + // 1p is a tenth of a millisatoshi; nothing can pay it. + expect(Bolt11Invoice.tryParse('lnbc1p1$_data'), isNull); + expect(Bolt11Invoice.tryParse('lnbc15p1$_data'), isNull); + }); + + // The figure comes from whoever wrote the invoice. Held in an int it + // could wrap and land on a small, plausible-looking amount, which is + // exactly the confusion the caller is trying to close. + test('refuses an amount beyond what bitcoin can express', () { + // The whole supply is expressible, one satoshi more is not. + expect( + Bolt11Invoice.tryParse('lnbc210000001$_data')!.amountMsat, + _msat('2100000000000000000'), + ); + expect(Bolt11Invoice.tryParse('lnbc210000011$_data'), isNull); + expect( + Bolt11Invoice.tryParse('lnbc99999999999999999999999999991$_data'), + isNull, + ); + }); + + test('refuses a figure that is not digits', () { + expect(Bolt11Invoice.tryParse('lnbc2a00u1$_data'), isNull); + expect(Bolt11Invoice.tryParse('lnbc-250u1$_data'), isNull); + }); + }); + + group('Bolt11Invoice.tryParse normalization', () { + test('reads an all-uppercase invoice', () { + // BOLT-11 allows the uppercase spelling for QR efficiency. + final parsed = Bolt11Invoice.tryParse('LNBC2500U1${_data.toUpperCase()}'); + + expect(parsed!.network, Bolt11Network.mainnet); + expect(parsed.amountMsat, _msat('250000000')); + }); + + test('ignores surrounding whitespace', () { + final parsed = Bolt11Invoice.tryParse(' lnbc2500u1$_data\n'); + + expect(parsed!.amountMsat, _msat('250000000')); + }); + + test('takes the last 1 as the separator', () { + // The figure can contain a 1, and the bech32 charset cannot, so the + // separator is always the last one in the string. + final parsed = Bolt11Invoice.tryParse('lnbc1500u1$_data'); + + expect(parsed!.amountMsat, _msat('150000000')); + }); + }); +} diff --git a/test/shared/utils/invoice_terms_test.dart b/test/shared/utils/invoice_terms_test.dart new file mode 100644 index 000000000..94b2fd584 --- /dev/null +++ b/test/shared/utils/invoice_terms_test.dart @@ -0,0 +1,131 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/shared/utils/invoice_terms.dart'; + +/// A data part long enough to look real. Nothing reads it — the check is +/// about the amount in the prefix — but a prefix with nothing behind it is +/// not an invoice. +const _data = 'pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfq'; + +/// `sats` satoshis expressed the way an invoice does, in nano-bitcoin: one +/// satoshi is ten nano-bitcoin. +String invoiceFor(int sats) => 'lnbc${sats * 10}n1$_data'; + +void main() { + group('InvoiceTerms.check', () { + test('accepts an invoice for exactly the order amount', () { + final terms = InvoiceTerms.check( + invoice: invoiceFor(50000), + expectedSats: 50000, + ); + + expect(terms.isPayable, isTrue); + expect(terms.problem, isNull); + expect(terms.amountSats, 50000); + }); + + // The finding's scenario: the message shows one figure and carries an + // invoice for another, and the wallet honours the invoice. + test('refuses an invoice that asks for more than the order says', () { + final terms = InvoiceTerms.check( + invoice: invoiceFor(500000), + expectedSats: 50000, + ); + + expect(terms.isPayable, isFalse); + expect(terms.problem, InvoiceTermsProblem.amountMismatch); + // What it would really have sent is still readable, for the screen to + // show alongside what the order said. + expect(terms.amountSats, 500000); + }); + + test('refuses an invoice that asks for less than the order says', () { + final terms = InvoiceTerms.check( + invoice: invoiceFor(10000), + expectedSats: 50000, + ); + + expect(terms.problem, InvoiceTermsProblem.amountMismatch); + }); + + test('refuses a difference smaller than one satoshi', () { + // 50000 sats is 500000000 pico-bitcoin; one millisatoshi more is a + // different invoice that reads identically at satoshi resolution. + final terms = InvoiceTerms.check( + invoice: 'lnbc500000010p1$_data', + expectedSats: 50000, + ); + + expect(terms.problem, InvoiceTermsProblem.amountMismatch); + expect(terms.amountSats, 50000); + }); + + test('refuses an invoice that sets no amount', () { + // The wallet would choose how much to send. + final terms = InvoiceTerms.check( + invoice: 'lnbc1$_data', + expectedSats: 50000, + ); + + expect(terms.problem, InvoiceTermsProblem.amountMissing); + expect(terms.amountSats, isNull); + }); + + test('refuses an invoice it cannot read', () { + final terms = InvoiceTerms.check( + invoice: 'not-an-invoice', + expectedSats: 50000, + ); + + expect(terms.problem, InvoiceTermsProblem.unreadable); + expect(terms.invoice, isNull); + }); + + test('refuses an empty invoice', () { + final terms = InvoiceTerms.check(invoice: '', expectedSats: 50000); + + expect(terms.problem, InvoiceTermsProblem.unreadable); + }); + + test('reports, without refusing, an order with no amount to check against', + () { + // Nothing about the invoice contradicts the order, so this qualifies + // the payment rather than stopping it: refusing would make an absent + // term stronger evidence than a disagreeing one. + for (final expected in [null, 0, -1]) { + final terms = InvoiceTerms.check( + invoice: invoiceFor(50000), + expectedSats: expected, + ); + + expect(terms.problem, InvoiceTermsProblem.termsUnknown, + reason: 'expectedSats: $expected'); + expect(terms.isUnverifiable, isTrue, reason: 'expectedSats: $expected'); + expect(terms.isPayable, isTrue, reason: 'expectedSats: $expected'); + } + }); + + test('refuses every problem that contradicts the order', () { + // The distinction the screen rests on: these are disagreements, not + // absences. + expect( + InvoiceTerms.check(invoice: '', expectedSats: 50000).isPayable, + isFalse, + ); + expect( + InvoiceTerms.check(invoice: invoiceFor(90000), expectedSats: 50000) + .isPayable, + isFalse, + ); + }); + + test('reads the amount off the invoice, not off the order', () { + final terms = InvoiceTerms.check( + invoice: invoiceFor(250000), + expectedSats: 250000, + ); + + expect(terms.amountSats, 250000); + expect(terms.invoice, isNotNull); + }); + }); +} diff --git a/test/shared/utils/market_quote_test.dart b/test/shared/utils/market_quote_test.dart new file mode 100644 index 000000000..82a7f5bb5 --- /dev/null +++ b/test/shared/utils/market_quote_test.dart @@ -0,0 +1,226 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models/enums/role.dart'; +import 'package:mostro_mobile/shared/utils/market_quote.dart'; + +/// A market-price order states no sats until the node resolves them, so the +/// figure on the settlement screen is the first the user sees of it. These +/// lock the re-pricing to mostrod's own arithmetic: a client that quoted +/// differently would flag every honest trade. +void main() { + group('MarketQuote.satsFor', () { + test('converts fiat at the given rate', () { + // $100 at $50,000/BTC is 0.002 BTC. + expect( + MarketQuote.satsFor( + fiatAmount: 100, + fiatPerBtc: 50000, + premium: 0, + ), + 200000, + ); + }); + + test('a positive premium lowers the sats', () { + // The maker charges 5% over the market, so the same fiat buys less. + expect( + MarketQuote.satsFor( + fiatAmount: 100, + fiatPerBtc: 50000, + premium: 5, + ), + 190000, + ); + }); + + test('a negative premium raises the sats', () { + expect( + MarketQuote.satsFor( + fiatAmount: 100, + fiatPerBtc: 50000, + premium: -5, + ), + 210000, + ); + }); + + test('truncates rather than rounds, as mostrod does on the cast', () { + // 1 / 3 BTC-ish: the exact figure carries a fraction of a satoshi. + final sats = MarketQuote.satsFor( + fiatAmount: 1, + fiatPerBtc: 30000.7, + premium: 0, + ); + final exact = (1 / 30000.7) * 100000000; + + expect(sats, exact.truncate()); + expect(sats, lessThan(exact)); + }); + + test('is null when there is nothing to price', () { + expect( + MarketQuote.satsFor(fiatAmount: 0, fiatPerBtc: 50000, premium: 0), + isNull, + ); + expect( + MarketQuote.satsFor(fiatAmount: -1, fiatPerBtc: 50000, premium: 0), + isNull, + ); + }); + + test('is null for a rate that cannot price anything', () { + for (final rate in [0.0, -1.0, double.nan, double.infinity]) { + expect( + MarketQuote.satsFor(fiatAmount: 100, fiatPerBtc: rate, premium: 0), + isNull, + reason: 'rate $rate', + ); + } + }); + + test('is null for a premium that is not a number', () { + expect( + MarketQuote.satsFor( + fiatAmount: 100, + fiatPerBtc: 50000, + premium: double.nan, + ), + isNull, + ); + }); + + test('is null when the premium would consume the whole quote', () { + expect( + MarketQuote.satsFor( + fiatAmount: 100, + fiatPerBtc: 50000, + premium: 100, + ), + isNull, + ); + }); + + test('is null beyond what bitcoin can express', () { + // A rate near zero makes any fiat amount worth more than every bitcoin. + expect( + MarketQuote.satsFor( + fiatAmount: 100, + fiatPerBtc: 0.0000001, + premium: 0, + ), + isNull, + ); + }); + }); + + group('MarketQuote.deviation', () { + test('is the gap as a fraction of the quote', () { + expect( + MarketQuote.deviation(quotedSats: 200000, settledSats: 180000), + closeTo(0.1, 1e-9), + ); + }); + + test('has no direction', () { + expect( + MarketQuote.deviation(quotedSats: 200000, settledSats: 220000), + closeTo(0.1, 1e-9), + ); + }); + + test('is null with no quote to be a fraction of', () { + expect( + MarketQuote.deviation(quotedSats: 0, settledSats: 200000), + isNull, + ); + }); + }); + + group('MarketCheck', () { + MarketCheck? check({required int settledSats, double premium = 0}) => + MarketCheck.of( + settledSats: settledSats, + fiatAmount: 100, + fiatPerBtc: 50000, + premium: premium, + ); + + test('an exact quote is on the market', () { + final result = check(settledSats: 200000)!; + + expect(result.quotedSats, 200000); + expect(result.deviation, 0); + expect(result.isOffMarket, isFalse); + }); + + test('a move within tolerance is not worth raising', () { + // 2% — inside what a different aggregate and a few minutes explain. + expect(check(settledSats: 196000)!.isOffMarket, isFalse); + }); + + test('the shave the finding describes is caught', () { + // The node tells the buyer to invoice for 10% less than the trade. + final result = check(settledSats: 180000)!; + + expect(result.isOffMarket, isTrue); + expect(result.isBelowMarket, isTrue); + }); + + test('an overcharge is caught too', () { + final result = check(settledSats: 260000)!; + + expect(result.isOffMarket, isTrue); + expect(result.isBelowMarket, isFalse); + }); + + test('the premium is accounted for before judging the gap', () { + // A 10% premium legitimately puts the settlement 10% under the raw + // market quote; without accounting for it this would read as a skim. + final result = MarketCheck.of( + settledSats: 180000, + fiatAmount: 100, + fiatPerBtc: 50000, + premium: 10, + )!; + + expect(result.quotedSats, 180000); + expect(result.isOffMarket, isFalse); + }); + + test('is null when there is nothing to compare', () { + expect(check(settledSats: 0), isNull); + expect( + MarketCheck.of( + settledSats: 200000, + fiatAmount: 0, + fiatPerBtc: 50000, + premium: 0, + ), + isNull, + ); + }); + }); + + group('MarketCheck.isAdverseTo', () { + // Which direction hurts depends on the side of the trade. + const below = MarketCheck( + quotedSats: 200000, + settledSats: 180000, + deviation: 0.1, + ); + const above = MarketCheck( + quotedSats: 200000, + settledSats: 220000, + deviation: 0.1, + ); + + test('a payout under the quote shorts the buyer', () { + expect(below.isAdverseTo(Role.buyer), isTrue); + expect(below.isAdverseTo(Role.seller), isFalse); + }); + + test('a settlement over the quote takes more from the seller', () { + expect(above.isAdverseTo(Role.seller), isTrue); + expect(above.isAdverseTo(Role.buyer), isFalse); + }); + }); +} diff --git a/test/shared/utils/settlement_amounts_test.dart b/test/shared/utils/settlement_amounts_test.dart new file mode 100644 index 000000000..e62ebfa33 --- /dev/null +++ b/test/shared/utils/settlement_amounts_test.dart @@ -0,0 +1,218 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/shared/utils/settlement_amounts.dart'; + +void main() { + // mostrod's default rate. Each side pays half of it, so the fee on a + // 100 000 sat order is 0.006 × 100000 / 2 = 300 sats per side. + const feeRate = 0.006; + + group('SettlementAmounts.feeFor', () { + test('takes half the configured rate', () { + expect( + SettlementAmounts.feeFor(amountSats: 100000, feeRate: feeRate), 300); + }); + + test('rounds half away from zero, as mostrod does', () { + // 0.001 × 1000 / 2 = 0.5 + expect(SettlementAmounts.feeFor(amountSats: 1000, feeRate: 0.001), 1); + // 0.001 × 3000 / 2 = 1.5 + expect(SettlementAmounts.feeFor(amountSats: 3000, feeRate: 0.001), 2); + // 0.001 × 2000 / 2 = 1.0 + expect(SettlementAmounts.feeFor(amountSats: 2000, feeRate: 0.001), 1); + }); + + test('is zero when the node charges nothing', () { + // Distinct from null: a rate of zero is a term the settlement can be + // checked against, an unusable rate is not. + expect(SettlementAmounts.feeFor(amountSats: 100000, feeRate: 0), 0); + }); + + test('is null when there is no amount to charge on', () { + expect(SettlementAmounts.feeFor(amountSats: 0, feeRate: feeRate), isNull); + expect( + SettlementAmounts.feeFor(amountSats: -1, feeRate: feeRate), isNull); + }); + + test('is null for a rate that is not a number', () { + expect( + SettlementAmounts.feeFor(amountSats: 100000, feeRate: double.nan), + isNull, + ); + expect( + SettlementAmounts.feeFor(amountSats: 100000, feeRate: double.infinity), + isNull, + ); + expect( + SettlementAmounts.feeFor(amountSats: 100000, feeRate: -0.006), + isNull, + ); + }); + + test('is null for a finite rate that overflows the multiplication', () { + // 1e308 is finite and clears every check on the rate itself, but the + // product is infinity and rounding it throws. + expect( + SettlementAmounts.feeFor(amountSats: 200000, feeRate: 1e308), + isNull, + ); + }); + + test('is null for a finite rate that would saturate the result', () { + // The quieter half of the same bug: this product stays finite, so + // round() does not throw — it pins to the int64 ceiling and hands back + // a figure the node never asked for. + expect( + SettlementAmounts.feeFor(amountSats: 200000, feeRate: 1e30), + isNull, + ); + }); + + test('is null for an amount beyond the supply cap', () { + expect( + SettlementAmounts.feeFor( + amountSats: SettlementAmounts.maxSats + 1, feeRate: feeRate), + isNull, + ); + }); + }); + + group('SettlementAmounts.sellerPays', () { + // The seller covers the order plus their half of the fee, which is why + // the hold invoice is never simply the order amount. + test('adds the seller half of the fee to the order amount', () { + expect( + SettlementAmounts.sellerPays(amountSats: 100000, feeRate: feeRate), + 100300, + ); + }); + + test('is the order amount when the node charges nothing', () { + expect( + SettlementAmounts.sellerPays(amountSats: 100000, feeRate: 0), + 100000, + ); + }); + + test('is null when the order amount is not resolved yet', () { + // A market-price order reads zero until it is taken. Treating that as a + // real figure would expect a settlement of exactly the fee. + expect(SettlementAmounts.sellerPays(amountSats: 0, feeRate: feeRate), + isNull); + expect(SettlementAmounts.sellerPays(amountSats: -5, feeRate: feeRate), + isNull); + }); + + test('is null for a rate that is not a number', () { + expect( + SettlementAmounts.sellerPays(amountSats: 100000, feeRate: double.nan), + isNull, + ); + }); + + test('is null rather than crashing on an extreme finite rate', () { + // The settlement screen reads this on build, so an unguarded rate here + // took the screen down instead of reporting a check it could not make. + expect( + SettlementAmounts.sellerPays(amountSats: 200000, feeRate: 1e308), + isNull, + ); + expect( + SettlementAmounts.sellerPays(amountSats: 200000, feeRate: 1e30), + isNull, + ); + }); + }); + + group('SettlementAmounts.buyerReceives', () { + test('subtracts the buyer half of the fee from the order amount', () { + expect( + SettlementAmounts.buyerReceives(amountSats: 100000, feeRate: feeRate), + 99700, + ); + }); + + test('is the order amount when the node charges nothing', () { + expect( + SettlementAmounts.buyerReceives(amountSats: 100000, feeRate: 0), + 100000, + ); + }); + + test('is null when the order amount is not resolved yet', () { + expect(SettlementAmounts.buyerReceives(amountSats: 0, feeRate: feeRate), + isNull); + }); + + test('is null when the fee would consume the whole order', () { + // Nothing is left to invoice for, so there is no figure to check + // against rather than a figure of zero. + expect( + SettlementAmounts.buyerReceives(amountSats: 10, feeRate: 2.0), + isNull, + ); + }); + + test('is null rather than crashing on an extreme finite rate', () { + expect( + SettlementAmounts.buyerReceives(amountSats: 200000, feeRate: 1e308), + isNull, + ); + expect( + SettlementAmounts.buyerReceives(amountSats: 200000, feeRate: 1e30), + isNull, + ); + }); + }); + + group('the two sides against one order', () { + test('differ by the whole fee, half on each side', () { + const amount = 250000; + final seller = + SettlementAmounts.sellerPays(amountSats: amount, feeRate: feeRate)!; + final buyer = SettlementAmounts.buyerReceives( + amountSats: amount, feeRate: feeRate)!; + final half = + SettlementAmounts.feeFor(amountSats: amount, feeRate: feeRate)!; + + expect(seller - buyer, half * 2); + expect(seller - amount, half); + expect(amount - buyer, half); + }); + }); + + // The two figures a client is asked to act on, against the same order. + // Comparing either side against the order amount itself would refuse every + // correct settlement, which is why the derivation exists at all. + group('what the finding describes', () { + test('a payout request for the order amount is not what the order pays', + () { + const amount = 100000; + final expected = + SettlementAmounts.buyerReceives(amountSats: amount, feeRate: feeRate); + + expect(expected, isNot(amount)); + expect(expected, 99700); + }); + + test('a hold invoice for the order amount is not what the seller owes', () { + const amount = 100000; + final expected = + SettlementAmounts.sellerPays(amountSats: amount, feeRate: feeRate); + + expect(expected, isNot(amount)); + expect(expected, 100300); + }); + + test('an order skimmed by a tenth is nowhere near either figure', () { + // The finding's scenario: the trade is for 100000, the request says + // 90000, and the difference is kept. + const amount = 100000; + const skimmed = 90000; + final expected = SettlementAmounts.buyerReceives( + amountSats: amount, feeRate: feeRate)!; + + expect(skimmed, isNot(expected)); + expect(expected - skimmed, 9700); + }); + }); +}