From ce714a47edc1924d9738bc2547a979ad2a865e32 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 20 Aug 2026 21:01:47 -0300 Subject: [PATCH 01/31] feat: add BOLT-11 invoice prefix parser for amount validation --- lib/shared/utils/bolt11.dart | 160 +++++++++++++++++++++++++ test/shared/utils/bolt11_test.dart | 181 +++++++++++++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 lib/shared/utils/bolt11.dart create mode 100644 test/shared/utils/bolt11_test.dart diff --git a/lib/shared/utils/bolt11.dart b/lib/shared/utils/bolt11.dart new file mode 100644 index 00000000..8ebc46e2 --- /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/test/shared/utils/bolt11_test.dart b/test/shared/utils/bolt11_test.dart new file mode 100644 index 00000000..3229cbe4 --- /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')); + }); + }); +} From 971dcc33c6c9be96a34bafbeb674ddc726234c79 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 20 Aug 2026 21:08:21 -0300 Subject: [PATCH 02/31] fix(invoice): block payment when invoice terms disagree with the order --- .../screens/pay_lightning_invoice_screen.dart | 118 +++++++++++++++++- .../wallet/providers/nwc_provider.dart | 11 +- lib/l10n/intl_de.arb | 7 +- lib/l10n/intl_en.arb | 18 ++- lib/l10n/intl_es.arb | 7 +- lib/l10n/intl_fr.arb | 7 +- lib/l10n/intl_it.arb | 7 +- lib/l10n/intl_pt.arb | 7 +- lib/shared/utils/invoice_terms.dart | 84 +++++++++++++ lib/shared/widgets/nwc_payment_widget.dart | 5 +- test/shared/utils/invoice_terms_test.dart | 111 ++++++++++++++++ 11 files changed, 371 insertions(+), 11 deletions(-) create mode 100644 lib/shared/utils/invoice_terms.dart create mode 100644 test/shared/utils/invoice_terms_test.dart diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index 35e41587..9d357ba9 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -13,6 +13,7 @@ import 'package:mostro_mobile/shared/widgets/invoice_header.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/generated/l10n.dart'; +import 'package:mostro_mobile/shared/utils/invoice_terms.dart'; class PayLightningInvoiceScreen extends ConsumerStatefulWidget { final String orderId; @@ -34,6 +35,16 @@ class _PayLightningInvoiceScreenState final orderState = ref.watch(orderNotifierProvider(widget.orderId)); final lnInvoice = orderState.paymentRequest?.lnInvoice ?? ''; final sats = orderState.order?.amount ?? 0; + + // The figure and the invoice reach this screen by different routes and + // used to be rendered side by side without ever being reconciled. A + // wallet honours the invoice, so until they agree the figure states + // nothing about what a tap would send. + final terms = InvoiceTerms.check( + invoice: lnInvoice, + expectedSats: orderState.order?.amount, + ); + final blocked = lnInvoice.isNotEmpty && !terms.isPayable; final fiatAmount = orderState.order?.fiatAmount.toString() ?? '0'; final fiatCode = orderState.order?.fiatCode ?? ''; final orderNotifier = @@ -76,7 +87,28 @@ class _PayLightningInvoiceScreenState child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (showNwcPayment) ...[ + if (blocked) ...[ + header, + const SizedBox(height: 24), + _InvoiceTermsNotice(terms: terms, orderSats: sats), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () async { + context.go('/'); + await orderNotifier.cancelOrder(); + }, + style: ElevatedButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.red, + ), + child: Text(S.of(context)!.cancel), + ).withAutomationId(AutomationIds.payCancel), + ], + ), + ] else if (showNwcPayment) ...[ // NWC auto-payment flow header, const SizedBox(height: 24), @@ -89,7 +121,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. @@ -140,3 +176,81 @@ 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; + case InvoiceTermsProblem.termsUnknown: + return s.invoiceTermsUnknownBody; + case InvoiceTermsProblem.unreadable: + case null: + return s.invoiceUnreadableBody; + } + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.statusError.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppTheme.statusError.withValues(alpha: 0.3), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.warning_amber_rounded, + color: AppTheme.statusError, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + S.of(context)!.invoiceTermsMismatchTitle, + style: const TextStyle( + color: AppTheme.statusError, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + _body(context), + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 13, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/wallet/providers/nwc_provider.dart b/lib/features/wallet/providers/nwc_provider.dart index 5fc13985..6883d101 100644 --- a/lib/features/wallet/providers/nwc_provider.dart +++ b/lib/features/wallet/providers/nwc_provider.dart @@ -384,13 +384,20 @@ 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. - Future payInvoice(String invoice) async { + /// [expectedAmountMsats] is sent as NIP-47's optional `amount`, so a wallet + /// that honours it refuses anything the invoice asks for beyond what the + /// caller intended. Belt and braces: the caller has already reconciled the + /// invoice against the order, and not every wallet enforces the field. + Future payInvoice( + String invoice, { + int? expectedAmountMsats, + }) async { if (_client == null || !_client!.isConnected) { throw const NwcNotConnectedException('No wallet connected'); } final result = await _client!.payInvoice( - PayInvoiceParams(invoice: invoice), + PayInvoiceParams(invoice: invoice, amount: expectedAmountMsats), ); state = state.copyWith( diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 146e8d6b..e23b0ffb 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1767,5 +1767,10 @@ "type": "int" } } - } + }, + "invoiceTermsMismatchTitle": "Diese Rechnung passt nicht zur Order", + "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." } diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index f3f7c3d2..2b393f91 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1812,5 +1812,21 @@ "type": "int" } } - } + }, + "invoiceTermsMismatchTitle": "This invoice does not match the order", + "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." } diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 3ff880d7..81914e29 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1742,5 +1742,10 @@ "type": "int" } } - } + }, + "invoiceTermsMismatchTitle": "Esta factura no coincide con la orden", + "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í." } diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 8397ac81..7f7d9feb 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1767,5 +1767,10 @@ "type": "int" } } - } + }, + "invoiceTermsMismatchTitle": "Cette facture ne correspond pas à l’ordre", + "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." } diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 6d36f343..740d5721 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1808,5 +1808,10 @@ "type": "int" } } - } + }, + "invoiceTermsMismatchTitle": "Questa fattura non corrisponde all’ordine", + "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." } diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index af08de7e..45c18675 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -1812,5 +1812,10 @@ "type": "int" } } - } + }, + "invoiceTermsMismatchTitle": "Esta fatura não corresponde à ordem", + "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." } diff --git a/lib/shared/utils/invoice_terms.dart b/lib/shared/utils/invoice_terms.dart new file mode 100644 index 00000000..c1398e1b --- /dev/null +++ b/lib/shared/utils/invoice_terms.dart @@ -0,0 +1,84 @@ +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}); + + bool get isPayable => problem == null; + + /// 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/widgets/nwc_payment_widget.dart b/lib/shared/widgets/nwc_payment_widget.dart index af534ba9..20a4a6b7 100644 --- a/lib/shared/widgets/nwc_payment_widget.dart +++ b/lib/shared/widgets/nwc_payment_widget.dart @@ -92,7 +92,10 @@ class _NwcPaymentWidgetState extends ConsumerState { try { logger.i('NWC: Paying invoice (${widget.sats} sats)...'); - final result = await nwcNotifier.payInvoice(widget.lnInvoice); + final result = await nwcNotifier.payInvoice( + widget.lnInvoice, + expectedAmountMsats: widget.sats * 1000, + ); if (!mounted) return; diff --git a/test/shared/utils/invoice_terms_test.dart b/test/shared/utils/invoice_terms_test.dart new file mode 100644 index 00000000..dccaaf40 --- /dev/null +++ b/test/shared/utils/invoice_terms_test.dart @@ -0,0 +1,111 @@ +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('refuses when the order carries no amount to check against', () { + for (final expected in [null, 0, -1]) { + final terms = InvoiceTerms.check( + invoice: invoiceFor(50000), + expectedSats: expected, + ); + + expect(terms.problem, InvoiceTermsProblem.termsUnknown, + reason: 'expectedSats: $expected'); + } + }); + + 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); + }); + }); +} From d89cc1fe2712b699923380d5ce7d2dc3bfdff3c7 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 20 Aug 2026 21:40:33 -0300 Subject: [PATCH 03/31] feat(invoice): anchor seller hold invoice to signed order amount and fee rate --- .../providers/settlement_anchor_provider.dart | 60 +++++++++ .../screens/pay_lightning_invoice_screen.dart | 26 +++- lib/shared/utils/settlement_amounts.dart | 54 ++++++++ .../shared/utils/settlement_amounts_test.dart | 122 ++++++++++++++++++ 4 files changed, 256 insertions(+), 6 deletions(-) create mode 100644 lib/features/order/providers/settlement_anchor_provider.dart create mode 100644 lib/shared/utils/settlement_amounts.dart create mode 100644 test/shared/utils/settlement_amounts_test.dart 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 00000000..3905a9fe --- /dev/null +++ b/lib/features/order/providers/settlement_anchor_provider.dart @@ -0,0 +1,60 @@ +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/services/logger_service.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/utils/settlement_amounts.dart'; + +/// The order amount the node has published 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. +/// +/// Null when no event has arrived, or when it carries no usable amount. +final signedOrderAmountProvider = 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 node's fee rate, from its kind-38385 info event. +/// +/// Null when the info event has not arrived 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 nodeFeeRateProvider = Provider((ref) { + final info = ref.read(orderRepositoryProvider).mostroInstance; + if (info == null) return null; + try { + return info.fee; + } catch (e) { + logger.w('Node info event carries no usable fee rate: $e'); + return null; + } +}); + +/// 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(nodeFeeRateProvider); + if (amountSats == null || feeRate == null) return null; + + return SettlementAmounts.sellerPays( + amountSats: amountSats, + feeRate: feeRate, + ); +}); diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index 9d357ba9..47868952 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -13,6 +13,7 @@ import 'package:mostro_mobile/shared/widgets/invoice_header.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/generated/l10n.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 { @@ -36,13 +37,25 @@ class _PayLightningInvoiceScreenState final lnInvoice = orderState.paymentRequest?.lnInvoice ?? ''; final sats = orderState.order?.amount ?? 0; - // The figure and the invoice reach this screen by different routes and - // used to be rendered side by side without ever being reconciled. A - // wallet honours the invoice, so until they agree the figure states - // nothing about what a tap would send. + // 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 expectedSats = + ref.watch(anchoredSellerAmountProvider(widget.orderId)) ?? + orderState.order?.amount; + final terms = InvoiceTerms.check( invoice: lnInvoice, - expectedSats: orderState.order?.amount, + expectedSats: expectedSats, ); final blocked = lnInvoice.isNotEmpty && !terms.isPayable; final fiatAmount = orderState.order?.fiatAmount.toString() ?? '0'; @@ -90,7 +103,8 @@ class _PayLightningInvoiceScreenState if (blocked) ...[ header, const SizedBox(height: 24), - _InvoiceTermsNotice(terms: terms, orderSats: sats), + _InvoiceTermsNotice( + terms: terms, orderSats: expectedSats ?? sats), const SizedBox(height: 20), Row( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/shared/utils/settlement_amounts.dart b/lib/shared/utils/settlement_amounts.dart new file mode 100644 index 00000000..7bc77d8c --- /dev/null +++ b/lib/shared/utils/settlement_amounts.dart @@ -0,0 +1,54 @@ +/// 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._(); + + /// 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. + static int feeFor({required int amountSats, required double feeRate}) { + if (amountSats <= 0 || feeRate <= 0 || !feeRate.isFinite) return 0; + return (feeRate * amountSats / 2.0).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}) { + if (amountSats <= 0 || feeRate < 0 || !feeRate.isFinite) return null; + return amountSats + feeFor(amountSats: amountSats, feeRate: feeRate); + } + + /// 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}) { + if (amountSats <= 0 || feeRate < 0 || !feeRate.isFinite) return null; + final net = amountSats - feeFor(amountSats: amountSats, feeRate: feeRate); + return net > 0 ? net : null; + } +} diff --git a/test/shared/utils/settlement_amounts_test.dart b/test/shared/utils/settlement_amounts_test.dart new file mode 100644 index 00000000..9e5972e4 --- /dev/null +++ b/test/shared/utils/settlement_amounts_test.dart @@ -0,0 +1,122 @@ +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 there is no rate or no amount', () { + expect(SettlementAmounts.feeFor(amountSats: 100000, feeRate: 0), 0); + expect(SettlementAmounts.feeFor(amountSats: 0, feeRate: feeRate), 0); + expect(SettlementAmounts.feeFor(amountSats: -1, feeRate: feeRate), 0); + }); + + test('is zero for a rate that is not a number', () { + expect( + SettlementAmounts.feeFor(amountSats: 100000, feeRate: double.nan), + 0, + ); + expect( + SettlementAmounts.feeFor(amountSats: 100000, feeRate: double.infinity), + 0, + ); + }); + }); + + 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, + ); + }); + }); + + 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, + ); + }); + }); + + 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); + }); + }); +} From 656cef085a3afe19524f655f7294408191214c04 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 20 Aug 2026 22:05:02 -0300 Subject: [PATCH 04/31] feat(invoice): block buyer payout when invoice request disagrees with the order --- .../providers/settlement_anchor_provider.dart | 22 +++ .../screens/add_lightning_invoice_screen.dart | 129 +++++++++++++++--- lib/l10n/intl_de.arb | 4 +- lib/l10n/intl_en.arb | 15 +- lib/l10n/intl_es.arb | 4 +- lib/l10n/intl_fr.arb | 4 +- lib/l10n/intl_it.arb | 4 +- lib/l10n/intl_pt.arb | 4 +- .../shared/utils/settlement_amounts_test.dart | 43 +++++- 9 files changed, 198 insertions(+), 31 deletions(-) diff --git a/lib/features/order/providers/settlement_anchor_provider.dart b/lib/features/order/providers/settlement_anchor_provider.dart index 3905a9fe..e76384d2 100644 --- a/lib/features/order/providers/settlement_anchor_provider.dart +++ b/lib/features/order/providers/settlement_anchor_provider.dart @@ -58,3 +58,25 @@ final anchoredSellerAmountProvider = 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(nodeFeeRateProvider); + 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 6e4b98a0..cc11a6d6 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -2,6 +2,7 @@ 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/settlement_anchor_provider.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/features/wallet/providers/nwc_provider.dart'; @@ -80,6 +81,17 @@ 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 means the events have not both arrived, not that the request + // is wrong, so the screen only refuses on an actual disagreement. + final expectedSats = ref.watch(anchoredBuyerAmountProvider(orderId)); + final blocked = expectedSats != null && amount != expectedSats; + final nwcState = ref.watch(nwcProvider); final isNwcConnected = nwcState.status == NwcStatus.connected; final showLnAddressConfirmation = @@ -99,27 +111,33 @@ 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, - ), + child: blocked + ? _buildBlockedFlow( + header: header, + requestedSats: amount ?? 0, + expectedSats: expectedSats, + ) + : 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, + ), ), ); }, @@ -192,6 +210,75 @@ class _AddLightningInvoiceScreenState ); } + /// 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), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.statusError.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppTheme.statusError.withValues(alpha: 0.3), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.warning_amber_rounded, + color: AppTheme.statusError, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + S.of(context)!.invoiceRequestMismatchTitle, + style: const TextStyle( + color: AppTheme.statusError, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + S.of(context)!.invoiceRequestMismatchBody( + requestedSats.toString(), + expectedSats.toString(), + ), + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 13, + ), + ), + ], + ), + ), + ], + ), + ), + const Spacer(), + _buildCancelButton(), + ], + ); + } + Widget _buildCancelButton() { return Row( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index e23b0ffb..295f495b 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1772,5 +1772,7 @@ "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." + "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 2b393f91..c3e98e1f 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1828,5 +1828,18 @@ }, "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." + "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 81914e29..85e64715 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1747,5 +1747,7 @@ "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í." + "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 7f7d9feb..caaa0bfc 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1772,5 +1772,7 @@ "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." + "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 740d5721..a8b220e2 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1813,5 +1813,7 @@ "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." + "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 45c18675..e3b6a907 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -1817,5 +1817,7 @@ "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." + "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/test/shared/utils/settlement_amounts_test.dart b/test/shared/utils/settlement_amounts_test.dart index 9e5972e4..d3caf38b 100644 --- a/test/shared/utils/settlement_amounts_test.dart +++ b/test/shared/utils/settlement_amounts_test.dart @@ -8,8 +8,8 @@ void main() { group('SettlementAmounts.feeFor', () { test('takes half the configured rate', () { - expect(SettlementAmounts.feeFor(amountSats: 100000, feeRate: feeRate), - 300); + expect( + SettlementAmounts.feeFor(amountSats: 100000, feeRate: feeRate), 300); }); test('rounds half away from zero, as mostrod does', () { @@ -67,8 +67,7 @@ void main() { test('is null for a rate that is not a number', () { expect( - SettlementAmounts.sellerPays( - amountSats: 100000, feeRate: double.nan), + SettlementAmounts.sellerPays(amountSats: 100000, feeRate: double.nan), isNull, ); }); @@ -119,4 +118,40 @@ void main() { 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); + }); + }); } From e7d5490e3a4cbde5c0ad2f0454c5795fdc0cda4a Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Mon, 24 Aug 2026 14:42:03 -0300 Subject: [PATCH 05/31] fix(invoice): follow info event stream to prevent stale fee rate reads --- .../providers/settlement_anchor_provider.dart | 25 ++- .../screens/add_lightning_invoice_screen.dart | 10 +- .../screens/pay_lightning_invoice_screen.dart | 2 +- lib/l10n/intl_de.arb | 2 +- lib/l10n/intl_en.arb | 2 +- lib/l10n/intl_es.arb | 2 +- lib/l10n/intl_fr.arb | 2 +- lib/l10n/intl_it.arb | 2 +- lib/l10n/intl_pt.arb | 2 +- .../providers/order_repository_provider.dart | 32 ++++ .../settlement_anchor_provider_test.dart | 164 ++++++++++++++++++ 11 files changed, 229 insertions(+), 16 deletions(-) create mode 100644 test/features/order/providers/settlement_anchor_provider_test.dart diff --git a/lib/features/order/providers/settlement_anchor_provider.dart b/lib/features/order/providers/settlement_anchor_provider.dart index e76384d2..23782fa1 100644 --- a/lib/features/order/providers/settlement_anchor_provider.dart +++ b/lib/features/order/providers/settlement_anchor_provider.dart @@ -1,6 +1,7 @@ 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/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/utils/settlement_amounts.dart'; @@ -27,12 +28,26 @@ final signedOrderAmountProvider = Provider.family((ref, orderId) { /// The node's fee rate, from its kind-38385 info event. /// -/// Null when the info event has not arrived 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. +/// 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. +/// +/// 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 nodeFeeRateProvider = Provider((ref) { - final info = ref.read(orderRepositoryProvider).mostroInstance; - if (info == null) return null; + 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 info.fee; } catch (e) { diff --git a/lib/features/order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart index cc11a6d6..f759d848 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -87,10 +87,12 @@ class _AddLightningInvoiceScreenState // from the message asking for the invoice, and an invoice minted for // it is what the trade settles at. // - // Null means the events have not both arrived, not that the request - // is wrong, so the screen only refuses on an actual disagreement. + // 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 = expectedSats != null && amount != expectedSats; + final blocked = + amount != null && expectedSats != null && amount != expectedSats; final nwcState = ref.watch(nwcProvider); final isNwcConnected = nwcState.status == NwcStatus.connected; @@ -114,7 +116,7 @@ class _AddLightningInvoiceScreenState child: blocked ? _buildBlockedFlow( header: header, - requestedSats: amount ?? 0, + requestedSats: amount, expectedSats: expectedSats, ) : showLnAddressConfirmation diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index 47868952..703c3b12 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -245,7 +245,7 @@ class _InvoiceTermsNotice extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - S.of(context)!.invoiceTermsMismatchTitle, + S.of(context)!.invoiceNotPayableTitle, style: const TextStyle( color: AppTheme.statusError, fontSize: 15, diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 295f495b..e317a9d9 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1768,7 +1768,7 @@ } } }, - "invoiceTermsMismatchTitle": "Diese Rechnung passt nicht zur Order", + "invoiceNotPayableTitle": "Diese Rechnung kann nicht bezahlt werden", "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.", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index c3e98e1f..b1095d95 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1813,7 +1813,7 @@ } } }, - "invoiceTermsMismatchTitle": "This invoice does not match the order", + "invoiceNotPayableTitle": "This invoice cannot be paid", "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", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 85e64715..849585e1 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1743,7 +1743,7 @@ } } }, - "invoiceTermsMismatchTitle": "Esta factura no coincide con la orden", + "invoiceNotPayableTitle": "Esta factura no se puede pagar", "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í.", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index caaa0bfc..2a127ed6 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1768,7 +1768,7 @@ } } }, - "invoiceTermsMismatchTitle": "Cette facture ne correspond pas à l’ordre", + "invoiceNotPayableTitle": "Cette facture ne peut pas être payée", "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.", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index a8b220e2..e3a76396 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1809,7 +1809,7 @@ } } }, - "invoiceTermsMismatchTitle": "Questa fattura non corrisponde all’ordine", + "invoiceNotPayableTitle": "Questa fattura non può essere pagata", "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.", diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index e3b6a907..6ebcc605 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -1813,7 +1813,7 @@ } } }, - "invoiceTermsMismatchTitle": "Esta fatura não corresponde à ordem", + "invoiceNotPayableTitle": "Esta fatura não pode ser paga", "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.", diff --git a/lib/shared/providers/order_repository_provider.dart b/lib/shared/providers/order_repository_provider.dart index 7e600755..2219db87 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/test/features/order/providers/settlement_anchor_provider_test.dart b/test/features/order/providers/settlement_anchor_provider_test.dart new file mode 100644 index 00000000..2bbe0076 --- /dev/null +++ b/test/features/order/providers/settlement_anchor_provider_test.dart @@ -0,0 +1,164 @@ +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/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/features/settings/settings_provider.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; + +import '../../../mocks.mocks.dart'; + +final _nodePubkey = 'a' * 64; +final _otherNodePubkey = 'b' * 64; +const _orderId = 'order-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. +NostrEvent _infoEvent({String? pubkey, String? fee}) => NostrEvent( + id: 'info-event', + kind: 38385, + content: '', + sig: 'sig', + pubkey: pubkey ?? _nodePubkey, + 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], + ], + ); + +/// 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 ProviderContainer container; + + /// Lets the stream deliveries behind the providers settle. + Future flush() => Future.delayed(Duration.zero); + + setUp(() { + 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); + container = ProviderContainer(overrides: [ + orderRepositoryProvider.overrideWithValue(repository), + settingsProvider.overrideWith((ref) => settings), + ]); + + addTearDown(container.dispose); + 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); + }); + }); +} From fb5a9c436e0057bfe771d27d851a41ba848425e0 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Mon, 24 Aug 2026 15:37:02 -0300 Subject: [PATCH 06/31] fix(invoice): show the invoice amount in the manual payment flow The NWC branch already read the settlement figure back off the decoded invoice, but the manual QR flow and the trade summary above it still printed what the node's message asserted. A wallet scanning the QR code honours the invoice, so a node could state one amount on screen while the code beside it paid another. The manual flow now prints the invoice's own amount, and the summary the amount re-derived from the signed terms. --- .../order/screens/pay_lightning_invoice_screen.dart | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index 703c3b12..c7c64002 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -75,7 +75,11 @@ class _PayLightningInvoiceScreenState final session = ref.watch(sessionProvider(widget.orderId)); final header = InvoiceHeader( userIsSeller: session?.role == null || session!.role == Role.seller, - sats: sats, + // 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, @@ -177,7 +181,11 @@ class _PayLightningInvoiceScreenState await orderNotifier.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, From a54fd45d792ea5ecdb008d1ca982e0884e3ffc22 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Mon, 24 Aug 2026 15:38:19 -0300 Subject: [PATCH 07/31] feat(invoice): say when a settlement could not be checked Both invoice screens only refuse on an actual disagreement, and fall through silently when there is nothing to re-derive the expected amount from. The node publishes both inputs and can withhold either, so that is a state it can put the screen into rather than only an unlucky race, and the absence of a refusal reads to the user as a confirmation. Both screens now show a caution when the check could not be made: on the add screen when no amount could be derived, on the pay screen when it fell back to reconciling the message against itself. Extracts the notice box the two screens had each copied into a shared InvoiceNotice, which distinguishes a refusal from a caution by colour. --- .../screens/add_lightning_invoice_screen.dart | 90 ++++++++----------- .../screens/pay_lightning_invoice_screen.dart | 61 ++++--------- lib/l10n/intl_de.arb | 2 + lib/l10n/intl_en.arb | 2 + lib/l10n/intl_es.arb | 2 + lib/l10n/intl_fr.arb | 2 + lib/l10n/intl_it.arb | 2 + lib/l10n/intl_pt.arb | 2 + lib/shared/widgets/invoice_notice.dart | 89 ++++++++++++++++++ 9 files changed, 154 insertions(+), 98 deletions(-) create mode 100644 lib/shared/widgets/invoice_notice.dart diff --git a/lib/features/order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart index f759d848..b18dc2a6 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -13,6 +13,7 @@ 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/generated/l10n.dart'; import 'package:mostro_mobile/shared/utils/snack_bar_helper.dart'; @@ -94,6 +95,25 @@ class _AddLightningInvoiceScreenState 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; + final headerBlock = unverified + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + header, + const SizedBox(height: 16), + InvoiceNotice.caution( + title: S.of(context)!.invoiceTermsUnverifiedTitle, + body: S.of(context)!.invoiceTermsUnverifiedBody, + ), + ], + ) + : header; + final nwcState = ref.watch(nwcProvider); final isNwcConnected = nwcState.status == NwcStatus.connected; final showLnAddressConfirmation = @@ -115,14 +135,18 @@ class _AddLightningInvoiceScreenState ), child: blocked ? _buildBlockedFlow( - header: header, + header: headerBlock, requestedSats: amount, expectedSats: expectedSats, ) : showLnAddressConfirmation - ? _buildLnAddressConfirmation(header: header) + ? _buildLnAddressConfirmation(header: headerBlock) : showNwcInvoice - ? _buildNwcInvoiceFlow(header: header) + ? _buildNwcInvoiceFlow( + header: headerBlock, + amount: amount ?? 0, + orderIdValue: orderIdValue, + ) : AddLightningInvoiceWidget( controller: invoiceController, onSubmit: () async { @@ -138,7 +162,7 @@ class _AddLightningInvoiceScreenState fiatAmount: fiatAmount, fiatCode: fiatCode, orderId: orderIdValue, - header: header, + header: headerBlock, ), ), ); @@ -188,9 +212,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: [ @@ -228,52 +254,12 @@ class _AddLightningInvoiceScreenState children: [ header, const SizedBox(height: 24), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppTheme.statusError.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppTheme.statusError.withValues(alpha: 0.3), - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon( - Icons.warning_amber_rounded, - color: AppTheme.statusError, - size: 20, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - S.of(context)!.invoiceRequestMismatchTitle, - style: const TextStyle( - color: AppTheme.statusError, - fontSize: 15, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 6), - Text( - S.of(context)!.invoiceRequestMismatchBody( - requestedSats.toString(), - expectedSats.toString(), - ), - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 13, - ), - ), - ], - ), + InvoiceNotice.refusal( + title: S.of(context)!.invoiceRequestMismatchTitle, + body: S.of(context)!.invoiceRequestMismatchBody( + requestedSats.toString(), + expectedSats.toString(), ), - ], - ), ), const Spacer(), _buildCancelButton(), diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index c7c64002..05f00473 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -10,6 +10,7 @@ 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/generated/l10n.dart'; @@ -49,15 +50,16 @@ class _PayLightningInvoiceScreenState // 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 expectedSats = - ref.watch(anchoredSellerAmountProvider(widget.orderId)) ?? - orderState.order?.amount; + 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; + final unverified = + lnInvoice.isNotEmpty && !blocked && anchoredSats == null; final fiatAmount = orderState.order?.fiatAmount.toString() ?? '0'; final fiatCode = orderState.order?.fiatCode ?? ''; final orderNotifier = @@ -104,6 +106,13 @@ class _PayLightningInvoiceScreenState child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (unverified) ...[ + InvoiceNotice.caution( + title: S.of(context)!.invoiceTermsUnverifiedTitle, + body: S.of(context)!.invoiceTermsUnverifiedBody, + ), + const SizedBox(height: 16), + ], if (blocked) ...[ header, const SizedBox(height: 24), @@ -230,49 +239,9 @@ class _InvoiceTermsNotice extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppTheme.statusError.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppTheme.statusError.withValues(alpha: 0.3), - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon( - Icons.warning_amber_rounded, - color: AppTheme.statusError, - size: 20, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - S.of(context)!.invoiceNotPayableTitle, - style: const TextStyle( - color: AppTheme.statusError, - fontSize: 15, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 6), - Text( - _body(context), - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 13, - ), - ), - ], - ), - ), - ], - ), + return InvoiceNotice.refusal( + title: S.of(context)!.invoiceNotPayableTitle, + body: _body(context), ); } } diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index e317a9d9..707349e1 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1769,6 +1769,8 @@ } }, "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.", "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.", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index b1095d95..64e5d662 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1814,6 +1814,8 @@ } }, "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.", "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", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 849585e1..5dccec54 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1744,6 +1744,8 @@ } }, "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. Revisalo vos antes de continuar.", "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í.", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 2a127ed6..1761b067 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1769,6 +1769,8 @@ } }, "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.", "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.", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index e3a76396..986f7896 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1810,6 +1810,8 @@ } }, "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.", "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.", diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index 6ebcc605..73f6617a 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -1814,6 +1814,8 @@ } }, "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.", "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.", diff --git a/lib/shared/widgets/invoice_notice.dart b/lib/shared/widgets/invoice_notice.dart new file mode 100644 index 00000000..960e37d4 --- /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, + ), + ), + ], + ), + ), + ], + ), + ); + } +} From 883c69a52e5f340c6e8b0086840335bf39770631 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Mon, 24 Aug 2026 15:39:09 -0300 Subject: [PATCH 08/31] feat(invoice): hold a settlement to the terms pinned at commitment The settlement checks derive what a payment should be from the node's kind-38383 order event and its kind-38385 info event. Both are addressable: the node can republish either after a trade is under way and move the figure the client will accept, so the check was anchored to whatever the node last said rather than to what the user agreed to. Session now records the order amount and fee rate as they stood when it committed - at the take for a taker, at submit for a maker, where the maker's own figure is the agreement. The anchor providers prefer those and fall back to the live events where nothing was pinned: sessions written before this existed, and market-price and range orders, whose sats the node only resolves after the commitment there was to make. The pinned figures are optional on the way in, so a session written by an earlier version reads as unpinned instead of failing to parse. --- lib/data/models/session.dart | 50 +++++++++ .../order/notifiers/add_order_notifier.dart | 7 ++ .../order/notifiers/order_notifier.dart | 17 +++ .../providers/settlement_anchor_provider.dart | 41 ++++++- lib/shared/notifiers/session_notifier.dart | 21 +++- .../models/session_pinned_terms_test.dart | 106 ++++++++++++++++++ .../settlement_anchor_provider_test.dart | 94 +++++++++++++++- test/mocks.dart | 11 +- test/notifiers/add_order_notifier_test.dart | 5 + 9 files changed, 339 insertions(+), 13 deletions(-) create mode 100644 test/data/models/session_pinned_terms_test.dart diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index 2534541c..0c7bfd7c 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -23,6 +23,24 @@ 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. + int? pinnedAmountSats; + + /// The node's fee rate when this session committed, on the same terms as + /// [pinnedAmountSats]. + double? pinnedFeeRate; + /// 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,6 +56,8 @@ class Session { this.parentOrderId, this.role, this.disputeId, + this.pinnedAmountSats, + this.pinnedFeeRate, Peer? peer, String? adminPubkey, }) { @@ -64,6 +84,8 @@ class Session { 'peer': peer?.publicKey, 'admin_peer': _adminPubkey, 'dispute_id': disputeId, + 'pinned_amount_sats': pinnedAmountSats, + 'pinned_fee_rate': pinnedFeeRate, }; factory Session.fromJson(Map json) { @@ -181,6 +203,32 @@ 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); + } + if (pinnedAmountSats != null && pinnedAmountSats <= 0) { + pinnedAmountSats = null; + } + + final pinnedFeeValue = json['pinned_fee_rate']; + double? pinnedFeeRate; + if (pinnedFeeValue is num) { + pinnedFeeRate = pinnedFeeValue.toDouble(); + } else if (pinnedFeeValue is String) { + pinnedFeeRate = double.tryParse(pinnedFeeValue); + } + if (pinnedFeeRate != null && + (pinnedFeeRate < 0 || !pinnedFeeRate.isFinite)) { + pinnedFeeRate = null; + } + return Session( masterKey: masterKeyValue, tradeKey: tradeKeyValue, @@ -193,6 +241,8 @@ class Session { peer: peer, adminPubkey: adminPubkey, disputeId: disputeId, + pinnedAmountSats: pinnedAmountSats, + pinnedFeeRate: pinnedFeeRate, ); } catch (e) { throw FormatException('Failed to parse Session from JSON: $e'); diff --git a/lib/features/order/notifiers/add_order_notifier.dart b/lib/features/order/notifiers/add_order_notifier.dart index c16d47cc..acb31e02 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,15 @@ 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. 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), ); // 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 e3046533..27434623 100644 --- a/lib/features/order/notifiers/order_notifier.dart +++ b/lib/features/order/notifiers/order_notifier.dart @@ -6,6 +6,7 @@ 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'; @@ -91,9 +92,17 @@ 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); + session = await sessionNotifier.newSession( orderId: orderId, role: Role.buyer, + pinnedAmountSats: pinnedAmountSats, + pinnedFeeRate: pinnedFeeRate, ); // Drop any stale grace timer/flag from a previous cycle on this order so @@ -117,9 +126,17 @@ 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); + session = await sessionNotifier.newSession( orderId: orderId, role: Role.seller, + pinnedAmountSats: pinnedAmountSats, + pinnedFeeRate: pinnedFeeRate, ); // Drop any stale grace timer/flag from a previous cycle on this order so diff --git a/lib/features/order/providers/settlement_anchor_provider.dart b/lib/features/order/providers/settlement_anchor_provider.dart index 23782fa1..759a8218 100644 --- a/lib/features/order/providers/settlement_anchor_provider.dart +++ b/lib/features/order/providers/settlement_anchor_provider.dart @@ -4,9 +4,10 @@ import 'package:mostro_mobile/features/mostro/mostro_instance.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 has published for [orderId], in satoshis. +/// 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 @@ -16,8 +17,13 @@ import 'package:mostro_mobile/shared/utils/settlement_amounts.dart'; /// 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 signedOrderAmountProvider = Provider.family((ref, orderId) { +final publishedOrderAmountProvider = + Provider.family((ref, orderId) { final event = ref.watch(eventProvider(orderId)); if (event == null) return null; @@ -26,6 +32,20 @@ final signedOrderAmountProvider = Provider.family((ref, orderId) { 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 && pinned > 0) return pinned; + + return ref.watch(publishedOrderAmountProvider(orderId)); +}); + /// The node's fee rate, from its kind-38385 info event. /// /// Followed rather than sampled: the info event arrives asynchronously after @@ -56,6 +76,19 @@ final nodeFeeRateProvider = Provider((ref) { } }); +/// 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]. +final orderFeeRateProvider = Provider.family((ref, orderId) { + final pinned = ref.watch(sessionProvider(orderId))?.pinnedFeeRate; + if (pinned != null) return pinned; + + 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. /// @@ -65,7 +98,7 @@ final nodeFeeRateProvider = Provider((ref) { final anchoredSellerAmountProvider = Provider.family((ref, orderId) { final amountSats = ref.watch(signedOrderAmountProvider(orderId)); - final feeRate = ref.watch(nodeFeeRateProvider); + final feeRate = ref.watch(orderFeeRateProvider(orderId)); if (amountSats == null || feeRate == null) return null; return SettlementAmounts.sellerPays( @@ -87,7 +120,7 @@ final anchoredSellerAmountProvider = final anchoredBuyerAmountProvider = Provider.family((ref, orderId) { final amountSats = ref.watch(signedOrderAmountProvider(orderId)); - final feeRate = ref.watch(nodeFeeRateProvider); + final feeRate = ref.watch(orderFeeRateProvider(orderId)); if (amountSats == null || feeRate == null) return null; return SettlementAmounts.buyerReceives( diff --git a/lib/shared/notifiers/session_notifier.dart b/lib/shared/notifiers/session_notifier.dart index 9a693378..0ab43999 100644 --- a/lib/shared/notifiers/session_notifier.dart +++ b/lib/shared/notifiers/session_notifier.dart @@ -173,8 +173,23 @@ 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. + Future newSession({ + String? orderId, + int? requestId, + Role? role, + int? pinnedAmountSats, + double? pinnedFeeRate, + }) async { if (orderId != null && state.any((s) => s.orderId == orderId)) { return state.firstWhere((s) => s.orderId == orderId); } @@ -190,6 +205,8 @@ class SessionNotifier extends StateNotifier> { fullPrivacy: _settings.fullPrivacyMode, orderId: orderId, role: role, + pinnedAmountSats: pinnedAmountSats, + pinnedFeeRate: pinnedFeeRate, ); if (orderId != null) { 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 00000000..19c70353 --- /dev/null +++ b/test/data/models/session_pinned_terms_test.dart @@ -0,0 +1,106 @@ +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('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, + ); + }); + }); +} diff --git a/test/features/order/providers/settlement_anchor_provider_test.dart b/test/features/order/providers/settlement_anchor_provider_test.dart index 2bbe0076..cca256f1 100644 --- a/test/features/order/providers/settlement_anchor_provider_test.dart +++ b/test/features/order/providers/settlement_anchor_provider_test.dart @@ -7,8 +7,10 @@ import 'package:mockito/mockito.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'; @@ -46,6 +48,22 @@ NostrEvent _orderEvent({String amount = '100000'}) => NostrEvent( ], ); +final _keyPair = NostrKeyPairs( + private: '0000000000000000000000000000000000000000000000000000000000000001', +); + +/// The session for [_orderId], carrying whatever it pinned when it committed. +Session _session({int? pinnedAmountSats, double? pinnedFeeRate}) => Session( + masterKey: _keyPair, + tradeKey: _keyPair, + keyIndex: 0, + fullPrivacy: false, + startTime: DateTime.utc(2026), + orderId: _orderId, + pinnedAmountSats: pinnedAmountSats, + pinnedFeeRate: pinnedFeeRate, + ); + /// Settings that can be pointed at another node without touching storage. class _StubSettingsNotifier extends SettingsNotifier { _StubSettingsNotifier(String mostroPublicKey) @@ -69,8 +87,20 @@ void main() { late StreamController infoEvents; late StreamController> orderEvents; late _StubSettingsNotifier settings; + late Session? session; late ProviderContainer container; + /// 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), + ]); + addTearDown(container.dispose); + } + /// Lets the stream deliveries behind the providers settle. Future flush() => Future.delayed(Duration.zero); @@ -83,12 +113,9 @@ void main() { when(repository.eventsStream).thenAnswer((_) => orderEvents.stream); settings = _StubSettingsNotifier(_nodePubkey); - container = ProviderContainer(overrides: [ - orderRepositoryProvider.overrideWithValue(repository), - settingsProvider.overrideWith((ref) => settings), - ]); + session = null; + build(); - addTearDown(container.dispose); addTearDown(infoEvents.close); addTearDown(orderEvents.close); }); @@ -161,4 +188,61 @@ void main() { 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); + 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('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 when nothing was pinned', () async { + session = _session(); + 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 { + session = _session(pinnedAmountSats: 0); + build(); + + container.read(anchoredSellerAmountProvider(_orderId)); + orderEvents.add([_orderEvent()]); + infoEvents.add(_infoEvent(fee: '0.006')); + await flush(); + + expect(container.read(anchoredSellerAmountProvider(_orderId)), 100300); + }); + }); } diff --git a/test/mocks.dart b/test/mocks.dart index 941eeeb8..c4d45641 100644 --- a/test/mocks.dart +++ b/test/mocks.dart @@ -95,8 +95,13 @@ 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, + }) async { final mockSession = Session( // Dummy private keys for testing purposes only masterKey: NostrKeyPairs( @@ -111,6 +116,8 @@ class MockSessionNotifier extends SessionNotifier { ); mockSession.orderId = orderId; mockSession.role = role; + mockSession.pinnedAmountSats = pinnedAmountSats; + mockSession.pinnedFeeRate = pinnedFeeRate; return mockSession; } } diff --git a/test/notifiers/add_order_notifier_test.dart b/test/notifiers/add_order_notifier_test.dart index 8f0c28b7..fae7e53e 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); From 431aeb673cdc6f2c4d140e9d9eeeb9afa9b7dacc Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Mon, 24 Aug 2026 15:40:26 -0300 Subject: [PATCH 09/31] feat(invoice): re-price market orders against an independent rate A fixed-amount order states its sats up front, so the take screen shows them and the client pins them. A market-price order states only fiat and a premium, and the node resolves the sats itself after the take: the figure on the settlement 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. Both screens now re-price such an order and caution when the settlement sits more than 5% from the quote. The arithmetic mirrors mostrod's get_market_quote, premium included, so an honest quote lands on the same figure. The rate deliberately does not come from exchangeServiceProvider, which reads the node's own kind-30078 rates event first and only falls back to Yadio: a node that skims a settlement can publish the rate that makes the skim look correct. This asks Yadio directly. It cautions rather than refuses. A third-party rate can be stale or the market can have moved, which is not grounds to stop a trade. The check is skipped where the session pinned a figure of its own, since a fixed-amount order may sit off the market on purpose. --- .../providers/market_check_provider.dart | 81 +++++++ .../screens/add_lightning_invoice_screen.dart | 32 ++- .../screens/pay_lightning_invoice_screen.dart | 17 ++ lib/l10n/intl_de.arb | 2 + lib/l10n/intl_en.arb | 12 ++ lib/l10n/intl_es.arb | 2 + lib/l10n/intl_fr.arb | 2 + lib/l10n/intl_it.arb | 2 + lib/l10n/intl_pt.arb | 2 + lib/shared/utils/market_quote.dart | 129 +++++++++++ .../providers/market_check_provider_test.dart | 153 +++++++++++++ test/shared/utils/market_quote_test.dart | 201 ++++++++++++++++++ 12 files changed, 629 insertions(+), 6 deletions(-) create mode 100644 lib/features/order/providers/market_check_provider.dart create mode 100644 lib/shared/utils/market_quote.dart create mode 100644 test/features/order/providers/market_check_provider_test.dart create mode 100644 test/shared/utils/market_quote_test.dart 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 00000000..43cad75f --- /dev/null +++ b/lib/features/order/providers/market_check_provider.dart @@ -0,0 +1,81 @@ +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'; + +/// 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: this check is a +/// second opinion, and being offline is not evidence against a settlement. +final independentFiatPerBtcProvider = + FutureProvider.family((ref, fiatCode) async { + if (fiatCode.isEmpty) return null; + + 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, or null when the check +/// does not apply or cannot be made. +/// +/// Only runs 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, and sessions from +/// before the pin existed. +final marketCheckProvider = + Provider.family((ref, orderId) { + final session = ref.watch(sessionProvider(orderId)); + if (session?.pinnedAmountSats != null) return null; + + final settledSats = ref.watch(signedOrderAmountProvider(orderId)); + if (settledSats == null) return null; + + final event = ref.watch(eventProvider(orderId)); + if (event == null) return null; + + final fiatCode = event.currency; + if (fiatCode == null || fiatCode.isEmpty) return null; + + // 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 null; + + final premium = double.tryParse(event.premium ?? '') ?? 0.0; + + final fiatPerBtc = + ref.watch(independentFiatPerBtcProvider(fiatCode)).valueOrNull; + if (fiatPerBtc == null) return null; + + return MarketCheck.of( + settledSats: settledSats, + fiatAmount: fiat.minimum, + fiatPerBtc: fiatPerBtc, + premium: premium, + ); +}); diff --git a/lib/features/order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart index b18dc2a6..679b85ad 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -2,6 +2,7 @@ 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/widgets/order_app_bar.dart'; @@ -100,16 +101,35 @@ class _AddLightningInvoiceScreenState // 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; - final headerBlock = unverified + + // 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 offMarket = !blocked && (market?.isOffMarket ?? false); + + final headerBlock = (unverified || offMarket) ? Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ header, - const SizedBox(height: 16), - InvoiceNotice.caution( - title: S.of(context)!.invoiceTermsUnverifiedTitle, - body: S.of(context)!.invoiceTermsUnverifiedBody, - ), + if (unverified) ...[ + const SizedBox(height: 16), + InvoiceNotice.caution( + title: S.of(context)!.invoiceTermsUnverifiedTitle, + body: S.of(context)!.invoiceTermsUnverifiedBody, + ), + ], + if (offMarket) ...[ + const SizedBox(height: 16), + InvoiceNotice.caution( + title: S.of(context)!.invoiceOffMarketTitle, + body: S.of(context)!.invoiceOffMarketBody( + market!.settledSats.toString(), + market.quotedSats.toString(), + ), + ), + ], ], ) : header; diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index 05f00473..28cebe3d 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -14,6 +14,7 @@ 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/generated/l10n.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'; @@ -60,6 +61,12 @@ class _PayLightningInvoiceScreenState final blocked = lnInvoice.isNotEmpty && !terms.isPayable; final unverified = lnInvoice.isNotEmpty && !blocked && anchoredSats == null; + + // 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 offMarket = !blocked && (market?.isOffMarket ?? false); final fiatAmount = orderState.order?.fiatAmount.toString() ?? '0'; final fiatCode = orderState.order?.fiatCode ?? ''; final orderNotifier = @@ -113,6 +120,16 @@ class _PayLightningInvoiceScreenState ), const SizedBox(height: 16), ], + if (offMarket) ...[ + InvoiceNotice.caution( + title: S.of(context)!.invoiceOffMarketTitle, + body: S.of(context)!.invoiceOffMarketBody( + market!.settledSats.toString(), + market.quotedSats.toString(), + ), + ), + const SizedBox(height: 16), + ], if (blocked) ...[ header, const SizedBox(height: 24), diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 707349e1..66a91593 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1771,6 +1771,8 @@ "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.", + "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.", "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.", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 64e5d662..6d3265ed 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1816,6 +1816,18 @@ "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.", + "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.", + "@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", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 5dccec54..2dce1040 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1746,6 +1746,8 @@ "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. Revisalo vos antes de continuar.", + "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 revisalo antes de continuar.", "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í.", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 1761b067..744dc0e0 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1771,6 +1771,8 @@ "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.", + "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.", "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.", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 986f7896..621a7be6 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1812,6 +1812,8 @@ "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.", + "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.", "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.", diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index 73f6617a..b937c8ef 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -1816,6 +1816,8 @@ "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.", + "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.", "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.", diff --git a/lib/shared/utils/market_quote.dart b/lib/shared/utils/market_quote.dart new file mode 100644 index 00000000..727c7a7f --- /dev/null +++ b/lib/shared/utils/market_quote.dart @@ -0,0 +1,129 @@ +/// 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 — the direction a skim takes. + bool get isBelowMarket => settledSats < quotedSats; + + /// 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, + ); + } +} 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 00000000..5d481e46 --- /dev/null +++ b/test/features/order/providers/market_check_provider_test.dart @@ -0,0 +1,153 @@ +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 '../../../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], + ], + ); + +Session _session({int? pinnedAmountSats}) => Session( + masterKey: _keyPair, + tradeKey: _keyPair, + keyIndex: 0, + fullPrivacy: false, + startTime: DateTime.utc(2026), + orderId: _orderId, + pinnedAmountSats: pinnedAmountSats, + ); + +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); + } + + /// 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 check = container.read(marketCheckProvider(_orderId))!; + expect(check.quotedSats, 200000); + expect(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))!; + expect(check.isOffMarket, isTrue); + expect(check.isBelowMarket, isTrue); + expect(check.settledSats, 180000); + }); + + test('accounts for the premium before judging', () async { + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent(amount: '180000', premium: '10')); + + expect(container.read(marketCheckProvider(_orderId))!.isOffMarket, + isFalse); + }); + + 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)), isNull); + }); + + test('skips a range order that has not been resolved to one figure', + () async { + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent(fiat: const ['fa', '50', '200'])); + + expect(container.read(marketCheckProvider(_orderId)), isNull); + }); + + test('skips when the independent rate cannot be had', () async { + independentRate = null; + build(); + + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent(amount: '180000')); + + expect(container.read(marketCheckProvider(_orderId)), isNull); + }); + + test('skips before the order event has arrived', () { + expect(container.read(marketCheckProvider(_orderId)), isNull); + }); + }); +} diff --git a/test/shared/utils/market_quote_test.dart b/test/shared/utils/market_quote_test.dart new file mode 100644 index 00000000..1a65ebe4 --- /dev/null +++ b/test/shared/utils/market_quote_test.dart @@ -0,0 +1,201 @@ +import 'package:flutter_test/flutter_test.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, + ); + }); + }); +} From 0c35d7c2118233b9b3186992f25242504c292a3f Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Mon, 24 Aug 2026 15:41:19 -0300 Subject: [PATCH 10/31] test(invoice): cover the settlement gating on both invoice screens The utilities behind the checks were covered, but nothing exercised the screens that act on them, where the decision of what to block, what to caution about and which figure to print actually lives. Covers on both screens: a refusal when the amounts disagree, an unreadable invoice and one that sets no amount, a pending request read as unknown rather than as a mismatch, and the two cautions. On the pay screen it also pins down that the manual flow prints the invoice's own amount and never the message's. --- .../add_lightning_invoice_screen_test.dart | 186 ++++++++++++++ .../pay_lightning_invoice_screen_test.dart | 238 ++++++++++++++++++ 2 files changed, 424 insertions(+) create mode 100644 test/features/order/screens/add_lightning_invoice_screen_test.dart create mode 100644 test/features/order/screens/pay_lightning_invoice_screen_test.dart 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 00000000..328e127f --- /dev/null +++ b/test/features/order/screens/add_lightning_invoice_screen_test.dart @@ -0,0 +1,186 @@ +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'; + +/// 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, +}) 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), + marketCheckProvider.overrideWith((ref, id) => market), + ], + 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)))!; + +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 order settles off the market rate', + (tester) async { + await pumpAddScreen( + tester, + requestedSats: 99700, + anchoredSats: 99700, + market: const MarketCheck( + quotedSats: 200000, + settledSats: 100000, + deviation: 0.5, + ), + ); + + expect(find.text(_s(tester).invoiceOffMarketTitle), 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 00000000..1f74375c --- /dev/null +++ b/test/features/order/screens/pay_lightning_invoice_screen_test.dart @@ -0,0 +1,238 @@ +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'; + +/// 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); + + @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, +}) 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: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const PayLightningInvoiceScreen(orderId: _orderId), + ), + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + orderNotifierProvider + .overrideWith((ref, id) => _StubOrderNotifier(state)), + nwcProvider.overrideWith((ref) => _StubNwcNotifier()), + sessionProvider.overrideWith((ref, id) => null), + anchoredSellerAmountProvider.overrideWith((ref, id) => anchoredSats), + marketCheckProvider.overrideWith((ref, id) => market), + ], + child: MaterialApp.router( + routerConfig: router, + localizationsDelegates: S.localizationsDelegates, + supportedLocales: S.supportedLocales, + ), + ), + ); + await tester.pump(); +} + +/// The refusal box, whichever case produced it. +Finder refusalNotice(WidgetTester tester) => find.text( + S.of(tester.element(find.byType(Scaffold)))!.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.of(tester.element(find.byType(Scaffold)))!; + 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.of(tester.element(find.byType(Scaffold)))!; + expect(find.text(s.invoiceTermsUnverifiedTitle), findsNothing); + expect(find.text(s.invoiceOffMarketTitle), findsNothing); + }); + + testWidgets('cautions when the order settles off the market rate', + (tester) async { + await pumpPayScreen( + tester, + lnInvoice: invoiceFor(50000), + messageSats: 50000, + anchoredSats: 50000, + market: const MarketCheck( + quotedSats: 100000, + settledSats: 50000, + deviation: 0.5, + ), + ); + + final s = S.of(tester.element(find.byType(Scaffold)))!; + expect(find.text(s.invoiceOffMarketTitle), 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.of(tester.element(find.byType(Scaffold)))!; + expect(find.text(s.invoiceOffMarketTitle), findsNothing); + }); + }); +} From 3ae7036637f34b3353a74debde1406095091ccc7 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Mon, 24 Aug 2026 16:11:36 -0300 Subject: [PATCH 11/31] fix(invoice): keep a fee unavailable at commitment from becoming a term A take that lands before the kind-38385 event pins an order amount and no fee rate. orderFeeRateProvider then fell back to the live rate, so a node that republished a higher fee had it accepted as the committed term and the inflated settlement passed as verified - the exact move pinning exists to prevent. A session holding an amount but no rate is one where pinning ran and found nothing, which is distinguishable from a session written before pinning existed, where both are absent. Treat the rate as unknown there: the anchor is not derived at all and the screen says the check could not be made, rather than presenting the node's later figure as confirmed. Session now normalizes both pinned fields on the way in, so a figure that cannot anchor anything reads as absent whichever path built the session, and the two checks that consult them agree on which is which. --- lib/data/models/session.dart | 23 +++++++++++------- .../providers/settlement_anchor_provider.dart | 13 ++++++++-- .../settlement_anchor_provider_test.dart | 24 ++++++++++++++++++- 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index 0c7bfd7c..7e9ddceb 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -56,11 +56,23 @@ class Session { this.parentOrderId, this.role, this.disputeId, - this.pinnedAmountSats, - this.pinnedFeeRate, + int? pinnedAmountSats, + double? pinnedFeeRate, 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; + _peer = peer; if (peer != null) { _sharedKey = NostrUtils.computeSharedKey( @@ -213,9 +225,6 @@ class Session { } else if (pinnedAmountValue is String) { pinnedAmountSats = int.tryParse(pinnedAmountValue); } - if (pinnedAmountSats != null && pinnedAmountSats <= 0) { - pinnedAmountSats = null; - } final pinnedFeeValue = json['pinned_fee_rate']; double? pinnedFeeRate; @@ -224,10 +233,6 @@ class Session { } else if (pinnedFeeValue is String) { pinnedFeeRate = double.tryParse(pinnedFeeValue); } - if (pinnedFeeRate != null && - (pinnedFeeRate < 0 || !pinnedFeeRate.isFinite)) { - pinnedFeeRate = null; - } return Session( masterKey: masterKeyValue, diff --git a/lib/features/order/providers/settlement_anchor_provider.dart b/lib/features/order/providers/settlement_anchor_provider.dart index 759a8218..aa8be402 100644 --- a/lib/features/order/providers/settlement_anchor_provider.dart +++ b/lib/features/order/providers/settlement_anchor_provider.dart @@ -41,7 +41,7 @@ final publishedOrderAmountProvider = /// 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 && pinned > 0) return pinned; + if (pinned != null) return pinned; return ref.watch(publishedOrderAmountProvider(orderId)); }); @@ -83,9 +83,18 @@ final nodeFeeRateProvider = Provider((ref) { /// pinned when the session committed, on the same terms as /// [signedOrderAmountProvider]. final orderFeeRateProvider = Provider.family((ref, orderId) { - final pinned = ref.watch(sessionProvider(orderId))?.pinnedFeeRate; + final session = ref.watch(sessionProvider(orderId)); + + final pinned = session?.pinnedFeeRate; if (pinned != null) return pinned; + // A session holding an amount but no rate is one that committed while the + // info event was still missing: pinning ran, and there was nothing to pin. + // Reading the live rate here would let the node publish the term after the + // fact, which is the whole of what pinning exists to prevent. Report it + // unknown instead and let the screen say the check could not be made. + if (session?.pinnedAmountSats != null) return null; + return ref.watch(nodeFeeRateProvider); }); diff --git a/test/features/order/providers/settlement_anchor_provider_test.dart b/test/features/order/providers/settlement_anchor_provider_test.dart index cca256f1..c0d01550 100644 --- a/test/features/order/providers/settlement_anchor_provider_test.dart +++ b/test/features/order/providers/settlement_anchor_provider_test.dart @@ -192,7 +192,7 @@ void main() { group('terms pinned at commitment', () { test('holds the settlement to the pinned amount, not the republished one', () async { - session = _session(pinnedAmountSats: 100000); + session = _session(pinnedAmountSats: 100000, pinnedFeeRate: 0.006); build(); container.read(anchoredSellerAmountProvider(_orderId)); @@ -205,6 +205,24 @@ void main() { expect(container.read(anchoredBuyerAmountProvider(_orderId)), 99700); }); + test('a fee missing at commitment does not become a term afterwards', + () async { + // The take landed before the info event, so there was an amount to pin + // and no rate. Reading the rate later would let the node choose it. + session = _session(pinnedAmountSats: 100000); + build(); + + container.read(anchoredSellerAmountProvider(_orderId)); + orderEvents.add([_orderEvent()]); + // The node now publishes a rate more than sixteen times the usual. + infoEvents.add(_infoEvent(fee: '0.1')); + 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(); @@ -234,7 +252,11 @@ void main() { }); 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); + expect(session!.pinnedAmountSats, isNull); build(); container.read(anchoredSellerAmountProvider(_orderId)); From dfedb47f7a2acf261e51083cc6a04765400cbd24 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Mon, 24 Aug 2026 16:11:49 -0300 Subject: [PATCH 12/31] fix(invoice): pin the fee rate on range remainder sessions A child session created for the remainder of a range order never traverses the take path, so it pinned nothing and its settlement was checked against whatever the node advertised by the time the remainder was paid. It now inherits the fee its parent committed to. The child is the remainder of an order the user already made, so that agreement is what it should be held to rather than a rate published later. Its sats are still unpinned, since no taker has resolved them yet, which is what the market check covers. --- lib/services/mostro_service.dart | 3 +++ lib/shared/notifiers/session_notifier.dart | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 7be83dbc..b359dcb1 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -15,6 +15,7 @@ import 'package:mostro_mobile/features/order/providers/order_notifier_provider.d import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; +import 'package:mostro_mobile/features/order/providers/settlement_anchor_provider.dart'; class MostroService { final Ref ref; @@ -323,6 +324,8 @@ class MostroService { keyIndex: nextKeyIndex, parentOrderId: orderId, role: currentSession.role!, + pinnedFeeRate: + currentSession.pinnedFeeRate ?? ref.read(nodeFeeRateProvider), ); 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 0ab43999..a219d6d1 100644 --- a/lib/shared/notifiers/session_notifier.dart +++ b/lib/shared/notifiers/session_notifier.dart @@ -344,11 +344,17 @@ 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. 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. Its sats are not pinned: no taker has + /// resolved them yet, which is the market check's territory. Future createChildOrderSession({ required NostrKeyPairs tradeKey, required int keyIndex, required String parentOrderId, required Role role, + double? pinnedFeeRate, }) async { final masterKey = ref.read(keyManagerProvider).masterKeyPair!; @@ -360,6 +366,7 @@ class SessionNotifier extends StateNotifier> { fullPrivacy: _settings.fullPrivacyMode, parentOrderId: parentOrderId, role: role, + pinnedFeeRate: pinnedFeeRate, ); _pendingChildSessions[tradeKey.public] = session; From c2414f37ed6f8364d16c182325d04e79f3c1935d Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Mon, 24 Aug 2026 16:13:49 -0300 Subject: [PATCH 13/31] fix(invoice): await cancellation before leaving the payment screen The cancel buttons navigated home and then awaited cancelOrder, which rethrows. A relay or server failure therefore threw after the screen was gone: the user was told nothing and the trade stayed active. On the terms refusal that button is the only action offered, so the failure left nothing to act on at all. Cancellation is now awaited first, the screen is kept on failure, and the error is surfaced - the pattern the add-invoice screen already used. All three call sites on the screen share one method rather than repeating it. --- .../screens/pay_lightning_invoice_screen.dart | 42 +++++++---- .../pay_lightning_invoice_screen_test.dart | 74 ++++++++++++++++--- 2 files changed, 90 insertions(+), 26 deletions(-) diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index 28cebe3d..335845cf 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -14,6 +14,7 @@ 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/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'; @@ -33,6 +34,29 @@ class _PayLightningInvoiceScreenState /// Whether the user chose to pay manually (fallback from NWC). bool _manualMode = false; + /// 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)); @@ -69,9 +93,6 @@ class _PayLightningInvoiceScreenState final offMarket = !blocked && (market?.isOffMarket ?? false); 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 = @@ -140,10 +161,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, @@ -184,10 +202,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, @@ -202,10 +217,7 @@ class _PayLightningInvoiceScreenState onSubmit: () async { context.go('/'); }, - onCancel: () async { - context.go('/'); - await orderNotifier.cancelOrder(); - }, + onCancel: _cancelOrder, lnInvoice: lnInvoice, // Read back off the invoice, as the NWC branch does. A wallet // scanning the QR honours the invoice, so the figure printed diff --git a/test/features/order/screens/pay_lightning_invoice_screen_test.dart b/test/features/order/screens/pay_lightning_invoice_screen_test.dart index 1f74375c..781eb161 100644 --- a/test/features/order/screens/pay_lightning_invoice_screen_test.dart +++ b/test/features/order/screens/pay_lightning_invoice_screen_test.dart @@ -31,7 +31,14 @@ String invoiceFor(int sats) => 'lnbc${sats * 10}n1$_data'; /// which reaches for sessions, storage and a live subscription. class _StubOrderNotifier extends StateNotifier implements OrderNotifier { - _StubOrderNotifier(super.state); + _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; @@ -51,6 +58,7 @@ Future pumpPayScreen( required int messageSats, int? anchoredSats, MarketCheck? market, + bool cancelFails = false, }) async { final state = OrderState( status: Status.waitingPayment, @@ -69,10 +77,14 @@ Future pumpPayScreen( ); final router = GoRouter( - initialLocation: '/', + initialLocation: '/pay', routes: [ GoRoute( path: '/', + builder: (_, __) => const Scaffold(body: Text('home')), + ), + GoRoute( + path: '/pay', builder: (_, __) => const PayLightningInvoiceScreen(orderId: _orderId), ), ], @@ -82,7 +94,8 @@ Future pumpPayScreen( ProviderScope( overrides: [ orderNotifierProvider - .overrideWith((ref, id) => _StubOrderNotifier(state)), + .overrideWith( + (ref, id) => _StubOrderNotifier(state, cancelFails: cancelFails)), nwcProvider.overrideWith((ref) => _StubNwcNotifier()), sessionProvider.overrideWith((ref, id) => null), anchoredSellerAmountProvider.overrideWith((ref, id) => anchoredSats), @@ -98,10 +111,12 @@ Future pumpPayScreen( await tester.pump(); } -/// The refusal box, whichever case produced it. -Finder refusalNotice(WidgetTester tester) => find.text( - S.of(tester.element(find.byType(Scaffold)))!.invoiceNotPayableTitle, - ); +S _s(WidgetTester tester) => + S.of(tester.element(find.byType(PayLightningInvoiceScreen)))!; + +/// The terms refusal box, whichever case produced it. +Finder refusalNotice(WidgetTester tester) => + find.text(_s(tester).invoiceNotPayableTitle); void main() { group('PayLightningInvoiceScreen gating', () { @@ -180,7 +195,7 @@ void main() { anchoredSats: null, ); - final s = S.of(tester.element(find.byType(Scaffold)))!; + final s = _s(tester); expect(find.text(s.invoiceTermsUnverifiedTitle), findsOneWidget); expect(refusalNotice(tester), findsNothing); }); @@ -194,7 +209,7 @@ void main() { anchoredSats: 50300, ); - final s = S.of(tester.element(find.byType(Scaffold)))!; + final s = _s(tester); expect(find.text(s.invoiceTermsUnverifiedTitle), findsNothing); expect(find.text(s.invoiceOffMarketTitle), findsNothing); }); @@ -213,7 +228,7 @@ void main() { ), ); - final s = S.of(tester.element(find.byType(Scaffold)))!; + final s = _s(tester); expect(find.text(s.invoiceOffMarketTitle), findsOneWidget); }); @@ -231,8 +246,45 @@ void main() { ), ); - final s = S.of(tester.element(find.byType(Scaffold)))!; + 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); + }); }); } From 315e5813be88343fd9750246f90ec37e0351d8b3 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Mon, 24 Aug 2026 16:14:40 -0300 Subject: [PATCH 14/31] feat(invoice): refuse a settlement that runs off market against the user The market check named a materially off-market settlement and left both the QR and the NWC paths live underneath it. NWC then sent that same amount as NIP-47 `amount`, so the wallet enforced the figure the screen had just flagged. For market-price and range orders the independent quote is the only protection there is, and a banner over a working pay button is not protection. An off-market gap now refuses the flow, but only in the direction that runs against the user: a seller giving up more sats than the fiat is worth, a buyer receiving fewer. The other direction stays a caution, since a gap in the user's favour is worth naming and not worth stopping. The refusal keeps 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 instead of making it for them; the caution stays visible afterwards, because the gap does not stop being true once it has been accepted. --- .../screens/add_lightning_invoice_screen.dart | 99 ++++++++++++++++--- .../screens/pay_lightning_invoice_screen.dart | 49 ++++++++- lib/l10n/intl_de.arb | 2 + lib/l10n/intl_en.arb | 12 +++ lib/l10n/intl_es.arb | 2 + lib/l10n/intl_fr.arb | 2 + lib/l10n/intl_it.arb | 2 + lib/l10n/intl_pt.arb | 2 + lib/shared/utils/market_quote.dart | 14 ++- .../add_lightning_invoice_screen_test.dart | 49 ++++++++- .../pay_lightning_invoice_screen_test.dart | 51 +++++++++- test/shared/utils/market_quote_test.dart | 25 +++++ 12 files changed, 289 insertions(+), 20 deletions(-) diff --git a/lib/features/order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart index 679b85ad..cddb3e21 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -44,6 +44,13 @@ class _AddLightningInvoiceScreenState /// Whether the user chose to enter the invoice manually (fallback from NWC or LN address). bool _manualMode = false; + /// Whether the user chose to invoice for a payout the market check refused. + /// + /// 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. + bool _marketOverridden = false; + @override void dispose() { invoiceController.dispose(); @@ -108,7 +115,16 @@ class _AddLightningInvoiceScreenState final market = ref.watch(marketCheckProvider(orderId)); final offMarket = !blocked && (market?.isOffMarket ?? false); - final headerBlock = (unverified || offMarket) + // 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. + final marketBlocked = offMarket && + !_marketOverridden && + market!.isAdverseTo(Role.buyer); + final marketCaution = offMarket && !marketBlocked; + + final headerBlock = (unverified || marketCaution) ? Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -120,7 +136,7 @@ class _AddLightningInvoiceScreenState body: S.of(context)!.invoiceTermsUnverifiedBody, ), ], - if (offMarket) ...[ + if (marketCaution) ...[ const SizedBox(height: 16), InvoiceNotice.caution( title: S.of(context)!.invoiceOffMarketTitle, @@ -153,21 +169,27 @@ class _AddLightningInvoiceScreenState 16, 16 + MediaQuery.of(context).viewPadding.bottom, ), - child: blocked - ? _buildBlockedFlow( + child: marketBlocked + ? _buildMarketBlockedFlow( header: headerBlock, - requestedSats: amount, - expectedSats: expectedSats, + settledSats: market.settledSats, + quotedSats: market.quotedSats, ) - : showLnAddressConfirmation - ? _buildLnAddressConfirmation(header: headerBlock) - : showNwcInvoice - ? _buildNwcInvoiceFlow( - header: headerBlock, - amount: amount ?? 0, - orderIdValue: orderIdValue, - ) - : AddLightningInvoiceWidget( + : blocked + ? _buildBlockedFlow( + header: headerBlock, + requestedSats: amount, + expectedSats: expectedSats, + ) + : showLnAddressConfirmation + ? _buildLnAddressConfirmation(header: headerBlock) + : showNwcInvoice + ? _buildNwcInvoiceFlow( + header: headerBlock, + amount: amount ?? 0, + orderIdValue: orderIdValue, + ) + : AddLightningInvoiceWidget( controller: invoiceController, onSubmit: () async { final invoice = invoiceController.text.trim(); @@ -264,6 +286,53 @@ class _AddLightningInvoiceScreenState /// 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. + /// 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 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(() => _marketOverridden = true), + child: Text(S.of(context)!.invoiceContinueAnyway), + ), + ], + ), + ], + ); + } + Widget _buildBlockedFlow({ required Widget header, required int requestedSats, diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index 335845cf..bf17cfa5 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -34,6 +34,14 @@ class _PayLightningInvoiceScreenState /// Whether the user chose to pay manually (fallback from NWC). bool _manualMode = false; + /// Whether the user chose to pay a settlement the market check refused. + /// + /// 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. + bool _marketOverridden = false; + /// 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 @@ -91,6 +99,15 @@ class _PayLightningInvoiceScreenState // it against a rate the node does not control. final market = ref.watch(marketCheckProvider(widget.orderId)); final offMarket = !blocked && (market?.isOffMarket ?? false); + + // Paying the hold invoice is always the seller's side of the trade. A gap + // that runs against them 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 marketBlocked = offMarket && + !_marketOverridden && + market!.isAdverseTo(Role.seller); + final marketCaution = offMarket && !marketBlocked; final fiatAmount = orderState.order?.fiatAmount.toString() ?? '0'; final fiatCode = orderState.order?.fiatCode ?? ''; final nwcState = ref.watch(nwcProvider); @@ -141,7 +158,7 @@ class _PayLightningInvoiceScreenState ), const SizedBox(height: 16), ], - if (offMarket) ...[ + if (marketCaution) ...[ InvoiceNotice.caution( title: S.of(context)!.invoiceOffMarketTitle, body: S.of(context)!.invoiceOffMarketBody( @@ -170,6 +187,36 @@ class _PayLightningInvoiceScreenState ).withAutomationId(AutomationIds.payCancel), ], ), + ] else if (marketBlocked) ...[ + header, + const SizedBox(height: 24), + InvoiceNotice.refusal( + title: S.of(context)!.invoiceOffMarketTitle, + body: S.of(context)!.invoiceOffMarketBlockedBody( + market.settledSats.toString(), + market.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(() => _marketOverridden = true), + child: Text(S.of(context)!.invoiceContinueAnyway), + ), + ], + ), ] else if (showNwcPayment) ...[ // NWC auto-payment flow header, diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 66a91593..36800910 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1773,6 +1773,8 @@ "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.", "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.", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 6d3265ed..6616ae20 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1818,6 +1818,18 @@ "invoiceTermsUnverifiedBody": "The order terms the node published have not arrived, so this amount could not be checked against them. Check it yourself before continuing.", "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": { diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 2dce1040..1b5cbb05 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1748,6 +1748,8 @@ "invoiceTermsUnverifiedBody": "No llegaron los términos de la orden que publica el nodo, así que este monto no pudo verificarse contra ellos. Revisalo vos antes de continuar.", "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 revisalo 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. Revisá 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í.", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 744dc0e0..53e178db 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1773,6 +1773,8 @@ "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.", "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.", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 621a7be6..57066a18 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1814,6 +1814,8 @@ "invoiceTermsUnverifiedBody": "I termini dell'ordine pubblicati dal nodo non sono arrivati, quindi questo importo non ha potuto essere verificato. Controllalo tu prima di continuare.", "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.", diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index b937c8ef..cc50ba2e 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -1818,6 +1818,8 @@ "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.", "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.", diff --git a/lib/shared/utils/market_quote.dart b/lib/shared/utils/market_quote.dart index 727c7a7f..79912bcf 100644 --- a/lib/shared/utils/market_quote.dart +++ b/lib/shared/utils/market_quote.dart @@ -1,3 +1,5 @@ +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. /// @@ -94,9 +96,19 @@ class MarketCheck { bool get isOffMarket => deviation > MarketQuote.tolerance; /// Whether the order settles for fewer sats than the outside rate says it - /// should — the direction a skim takes. + /// 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({ diff --git a/test/features/order/screens/add_lightning_invoice_screen_test.dart b/test/features/order/screens/add_lightning_invoice_screen_test.dart index 328e127f..98f578cd 100644 --- a/test/features/order/screens/add_lightning_invoice_screen_test.dart +++ b/test/features/order/screens/add_lightning_invoice_screen_test.dart @@ -151,8 +151,29 @@ void main() { expect(find.text(_s(tester).invoiceOffMarketTitle), findsNothing); }); - testWidgets('cautions when the order settles off the market rate', + 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, @@ -164,7 +185,31 @@ void main() { ), ); - expect(find.text(_s(tester).invoiceOffMarketTitle), findsOneWidget); + 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('stays quiet when the settlement is on the market rate', diff --git a/test/features/order/screens/pay_lightning_invoice_screen_test.dart b/test/features/order/screens/pay_lightning_invoice_screen_test.dart index 781eb161..e773d1cd 100644 --- a/test/features/order/screens/pay_lightning_invoice_screen_test.dart +++ b/test/features/order/screens/pay_lightning_invoice_screen_test.dart @@ -214,8 +214,10 @@ void main() { expect(find.text(s.invoiceOffMarketTitle), findsNothing); }); - testWidgets('cautions when the order settles off the market rate', + 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), @@ -230,6 +232,53 @@ void main() { 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('stays quiet when the settlement is on the market rate', diff --git a/test/shared/utils/market_quote_test.dart b/test/shared/utils/market_quote_test.dart index 1a65ebe4..82a7f5bb 100644 --- a/test/shared/utils/market_quote_test.dart +++ b/test/shared/utils/market_quote_test.dart @@ -1,4 +1,5 @@ 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 @@ -198,4 +199,28 @@ void main() { ); }); }); + + 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); + }); + }); } From 1f7a87730f52230fa324f138cfd211b51881845a Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Mon, 24 Aug 2026 16:36:28 -0300 Subject: [PATCH 15/31] fix(invoice): record that pinning ran instead of inferring it The rule that keeps an unavailable fee from becoming a term read a pinned amount as proof that pinning had happened. That proxy fails for exactly the orders it matters most for: a market-price order resolves no sats until after the take, so it pins none, and both fields end up absent - indistinguishable from a session written before pinning existed. Those orders went on following whatever rate the node published next. Session now records whether pinning ran. Where it did and produced no rate, the node had none to offer at the moment of agreement, and one published afterwards is not a term of it. Sessions written by an earlier version read the marker as false and keep the behaviour they had. This also closes the range remainder path, which took the parent's rate where it had one and read a live rate where it did not. The child now inherits the absence too: a rate read at release was no more agreed to than one read at settlement. --- lib/data/models/session.dart | 24 +++++++++ .../providers/settlement_anchor_provider.dart | 17 +++--- lib/services/mostro_service.dart | 5 +- lib/shared/notifiers/session_notifier.dart | 16 ++++-- .../models/session_pinned_terms_test.dart | 30 +++++++++++ .../settlement_anchor_provider_test.dart | 52 +++++++++++++++++-- 6 files changed, 126 insertions(+), 18 deletions(-) diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index 7e9ddceb..03c7d689 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -41,6 +41,20 @@ class Session { /// [pinnedAmountSats]. double? pinnedFeeRate; + /// Whether the terms were pinned when this session committed. + /// + /// 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. @@ -58,6 +72,7 @@ class Session { this.disputeId, int? pinnedAmountSats, double? pinnedFeeRate, + this.termsPinned = false, Peer? peer, String? adminPubkey, }) { @@ -98,6 +113,7 @@ class Session { 'dispute_id': disputeId, 'pinned_amount_sats': pinnedAmountSats, 'pinned_fee_rate': pinnedFeeRate, + 'terms_pinned': termsPinned, }; factory Session.fromJson(Map json) { @@ -226,6 +242,13 @@ class Session { 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) { @@ -248,6 +271,7 @@ class Session { disputeId: disputeId, pinnedAmountSats: pinnedAmountSats, pinnedFeeRate: pinnedFeeRate, + termsPinned: termsPinned, ); } catch (e) { throw FormatException('Failed to parse Session from JSON: $e'); diff --git a/lib/features/order/providers/settlement_anchor_provider.dart b/lib/features/order/providers/settlement_anchor_provider.dart index aa8be402..8622bbe8 100644 --- a/lib/features/order/providers/settlement_anchor_provider.dart +++ b/lib/features/order/providers/settlement_anchor_provider.dart @@ -88,12 +88,17 @@ final orderFeeRateProvider = Provider.family((ref, orderId) { final pinned = session?.pinnedFeeRate; if (pinned != null) return pinned; - // A session holding an amount but no rate is one that committed while the - // info event was still missing: pinning ran, and there was nothing to pin. - // Reading the live rate here would let the node publish the term after the - // fact, which is the whole of what pinning exists to prevent. Report it - // unknown instead and let the screen say the check could not be made. - if (session?.pinnedAmountSats != null) return null; + // Pinning ran for this session and came up with no rate, so the node had + // none to offer at the moment of agreement. Reading the live rate here + // would let it supply the term afterwards, which is the whole of what + // pinning exists to prevent. Report it unknown instead and let the screen + // say the check could not be made. + // + // This cannot 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) return null; return ref.watch(nodeFeeRateProvider); }); diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index b359dcb1..dd64463f 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -15,7 +15,6 @@ import 'package:mostro_mobile/features/order/providers/order_notifier_provider.d import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; -import 'package:mostro_mobile/features/order/providers/settlement_anchor_provider.dart'; class MostroService { final Ref ref; @@ -324,8 +323,8 @@ class MostroService { keyIndex: nextKeyIndex, parentOrderId: orderId, role: currentSession.role!, - pinnedFeeRate: - currentSession.pinnedFeeRate ?? ref.read(nodeFeeRateProvider), + pinnedFeeRate: currentSession.pinnedFeeRate, + 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 a219d6d1..6b40a56d 100644 --- a/lib/shared/notifiers/session_notifier.dart +++ b/lib/shared/notifiers/session_notifier.dart @@ -207,6 +207,7 @@ class SessionNotifier extends StateNotifier> { role: role, pinnedAmountSats: pinnedAmountSats, pinnedFeeRate: pinnedFeeRate, + termsPinned: true, ); if (orderId != null) { @@ -344,17 +345,21 @@ 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. 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. Its sats are not pinned: no taker has - /// resolved them yet, which is the market check's territory. + /// [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. Future createChildOrderSession({ required NostrKeyPairs tradeKey, required int keyIndex, required String parentOrderId, required Role role, double? pinnedFeeRate, + bool termsPinned = false, }) async { final masterKey = ref.read(keyManagerProvider).masterKeyPair!; @@ -367,6 +372,7 @@ class SessionNotifier extends StateNotifier> { parentOrderId: parentOrderId, role: role, pinnedFeeRate: pinnedFeeRate, + termsPinned: termsPinned, ); _pendingChildSessions[tradeKey.public] = session; diff --git a/test/data/models/session_pinned_terms_test.dart b/test/data/models/session_pinned_terms_test.dart index 19c70353..6358a9de 100644 --- a/test/data/models/session_pinned_terms_test.dart +++ b/test/data/models/session_pinned_terms_test.dart @@ -102,5 +102,35 @@ void main() { 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/features/order/providers/settlement_anchor_provider_test.dart b/test/features/order/providers/settlement_anchor_provider_test.dart index c0d01550..546e7124 100644 --- a/test/features/order/providers/settlement_anchor_provider_test.dart +++ b/test/features/order/providers/settlement_anchor_provider_test.dart @@ -53,7 +53,16 @@ final _keyPair = NostrKeyPairs( ); /// The session for [_orderId], carrying whatever it pinned when it committed. -Session _session({int? pinnedAmountSats, double? pinnedFeeRate}) => Session( +/// +/// [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, @@ -62,6 +71,7 @@ Session _session({int? pinnedAmountSats, double? pinnedFeeRate}) => Session( orderId: _orderId, pinnedAmountSats: pinnedAmountSats, pinnedFeeRate: pinnedFeeRate, + termsPinned: termsPinned, ); /// Settings that can be pointed at another node without touching storage. @@ -237,8 +247,9 @@ void main() { expect(container.read(anchoredSellerAmountProvider(_orderId)), 100300); }); - test('resolves from the live events when nothing was pinned', () async { - session = _session(); + test('resolves from the live events for a session written before the pin', + () async { + session = _session(termsPinned: false); build(); container.read(anchoredSellerAmountProvider(_orderId)); @@ -255,7 +266,7 @@ void main() { // 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); + session = _session(pinnedAmountSats: 0, termsPinned: false); expect(session!.pinnedAmountSats, isNull); build(); @@ -266,5 +277,38 @@ void main() { expect(container.read(anchoredSellerAmountProvider(_orderId)), 100300); }); + + test('a market-price take with no rate at commitment stays unknown', + () async { + // Nothing to pin on either side: the sats are unresolved until a taker + // fixes them, and the info event had not arrived. The absent rate must + // not be filled in from a later event. + session = _session(); + build(); + + container.read(anchoredSellerAmountProvider(_orderId)); + orderEvents.add([_orderEvent()]); + infoEvents.add(_infoEvent(fee: '0.1')); + 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. + // Reading one now would promote an event published after the agreement + // into a term of it. + session = _session(pinnedFeeRate: null); + build(); + + container.read(anchoredSellerAmountProvider(_orderId)); + orderEvents.add([_orderEvent()]); + infoEvents.add(_infoEvent(fee: '0.05')); + await flush(); + + expect(container.read(orderFeeRateProvider(_orderId)), isNull); + }); }); } From aa5819688f2c88859d536f4f8b8de687d5e34f90 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Mon, 24 Aug 2026 16:36:28 -0300 Subject: [PATCH 16/31] fix(invoice): do not quote an unreadable premium as zero A premium tag that is present and unparsable fell back to zero, pricing the order as if the maker charged nothing. Since an off-market gap now refuses the flow, that misprices an honest trade by exactly the premium and turns it into a refusal. An absent or empty premium is still zero; one that cannot be read means the order cannot be priced, so the check is skipped rather than answered wrongly. --- .../order/providers/market_check_provider.dart | 9 ++++++++- .../providers/market_check_provider_test.dart | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/features/order/providers/market_check_provider.dart b/lib/features/order/providers/market_check_provider.dart index 43cad75f..c74cc2fb 100644 --- a/lib/features/order/providers/market_check_provider.dart +++ b/lib/features/order/providers/market_check_provider.dart @@ -66,7 +66,14 @@ final marketCheckProvider = final fiat = event.fiatAmount; if (fiat.isRange() || fiat.minimum <= 0) return null; - final premium = double.tryParse(event.premium ?? '') ?? 0.0; + // An absent premium is zero; one that is present and unreadable is not. + // Quoting it as zero would misprice the order by exactly the premium and + // turn an honest trade into a refusal. + final premiumTag = event.premium; + final premium = (premiumTag == null || premiumTag.trim().isEmpty) + ? 0.0 + : double.tryParse(premiumTag.trim()); + if (premium == null) return null; final fiatPerBtc = ref.watch(independentFiatPerBtcProvider(fiatCode)).valueOrNull; diff --git a/test/features/order/providers/market_check_provider_test.dart b/test/features/order/providers/market_check_provider_test.dart index 5d481e46..4f0c8316 100644 --- a/test/features/order/providers/market_check_provider_test.dart +++ b/test/features/order/providers/market_check_provider_test.dart @@ -149,5 +149,22 @@ void main() { test('skips before the order event has arrived', () { expect(container.read(marketCheckProvider(_orderId)), isNull); }); + + test('skips an order whose premium cannot be read', () async { + // Quoting an unreadable premium as zero would misprice the order by + // exactly the premium and turn an honest trade into a refusal. + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent(amount: '180000', premium: 'not-a-number')); + + expect(container.read(marketCheckProvider(_orderId)), isNull); + }); + + test('treats an absent premium as zero', () async { + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent(premium: '')); + + final check = container.read(marketCheckProvider(_orderId))!; + expect(check.quotedSats, 200000); + }); }); } From d4a33234acdbfb0abb1616fe69d11b4ced5b0224 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Tue, 25 Aug 2026 11:50:36 -0300 Subject: [PATCH 17/31] fix(invoice): stop sending an amount NIP-47 wallets may refuse The optional `amount` field is not handled uniformly. Some wallets apply it only to a zero-amount invoice and return an error when the invoice already encodes a figure, which would break the NWC path on every honest trade rather than catch a wrong one. It also protects nothing that is not already covered: InvoiceTerms.check has rejected an unparseable invoice, an amountless one, and one whose amount disagrees with the signed terms before the request is built. The reasoning is kept on payInvoice so the field is not reintroduced as a free improvement. --- lib/features/wallet/providers/nwc_provider.dart | 17 ++++++++--------- lib/shared/widgets/nwc_payment_widget.dart | 5 +---- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/lib/features/wallet/providers/nwc_provider.dart b/lib/features/wallet/providers/nwc_provider.dart index 6883d101..93854f44 100644 --- a/lib/features/wallet/providers/nwc_provider.dart +++ b/lib/features/wallet/providers/nwc_provider.dart @@ -384,20 +384,19 @@ 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. - /// [expectedAmountMsats] is sent as NIP-47's optional `amount`, so a wallet - /// that honours it refuses anything the invoice asks for beyond what the - /// caller intended. Belt and braces: the caller has already reconciled the - /// invoice against the order, and not every wallet enforces the field. - Future payInvoice( - String invoice, { - int? expectedAmountMsats, - }) async { + /// 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'); } final result = await _client!.payInvoice( - PayInvoiceParams(invoice: invoice, amount: expectedAmountMsats), + PayInvoiceParams(invoice: invoice), ); state = state.copyWith( diff --git a/lib/shared/widgets/nwc_payment_widget.dart b/lib/shared/widgets/nwc_payment_widget.dart index 20a4a6b7..af534ba9 100644 --- a/lib/shared/widgets/nwc_payment_widget.dart +++ b/lib/shared/widgets/nwc_payment_widget.dart @@ -92,10 +92,7 @@ class _NwcPaymentWidgetState extends ConsumerState { try { logger.i('NWC: Paying invoice (${widget.sats} sats)...'); - final result = await nwcNotifier.payInvoice( - widget.lnInvoice, - expectedAmountMsats: widget.sats * 1000, - ); + final result = await nwcNotifier.payInvoice(widget.lnInvoice); if (!mounted) return; From 65fe829918e8deb4efa1196aebf177ad6f3e93e4 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Tue, 25 Aug 2026 11:50:43 -0300 Subject: [PATCH 18/31] fix(invoice): leave pre-pinning sessions out of the market check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session written by an earlier build pins no sats, so it was indistinguishable from a market-price order the node resolved after the take and fell straight through into the check. A fixed-amount order can sit legitimately far from the market — the maker set the price by hand — so trades already in flight when the user updated would have drawn a caution on a perfectly honest settlement. Gate on termsPinned instead, which records that pinning ran rather than inferring it from a figure being present. This is the call orderFeeRateProvider already makes for the fee rate. Reading it as `!= true` also covers an absent session, which was falling through the same way. --- .../providers/market_check_provider.dart | 12 ++++++-- .../providers/market_check_provider_test.dart | 28 ++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/lib/features/order/providers/market_check_provider.dart b/lib/features/order/providers/market_check_provider.dart index c74cc2fb..3d549cfd 100644 --- a/lib/features/order/providers/market_check_provider.dart +++ b/lib/features/order/providers/market_check_provider.dart @@ -45,11 +45,19 @@ final independentFiatPerBtcProvider = /// 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, and sessions from -/// before the pin existed. +/// 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. final marketCheckProvider = Provider.family((ref, orderId) { final session = ref.watch(sessionProvider(orderId)); + if (session?.termsPinned != true) return null; if (session?.pinnedAmountSats != null) return null; final settledSats = ref.watch(signedOrderAmountProvider(orderId)); diff --git a/test/features/order/providers/market_check_provider_test.dart b/test/features/order/providers/market_check_provider_test.dart index 4f0c8316..4b0785f2 100644 --- a/test/features/order/providers/market_check_provider_test.dart +++ b/test/features/order/providers/market_check_provider_test.dart @@ -41,7 +41,7 @@ NostrEvent _orderEvent({ ], ); -Session _session({int? pinnedAmountSats}) => Session( +Session _session({int? pinnedAmountSats, bool termsPinned = true}) => Session( masterKey: _keyPair, tradeKey: _keyPair, keyIndex: 0, @@ -49,6 +49,7 @@ Session _session({int? pinnedAmountSats}) => Session( startTime: DateTime.utc(2026), orderId: _orderId, pinnedAmountSats: pinnedAmountSats, + termsPinned: termsPinned, ); void main() { @@ -128,6 +129,31 @@ void main() { expect(container.read(marketCheckProvider(_orderId)), isNull); }); + 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)), isNull); + }); + + 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)), isNull); + }); + test('skips a range order that has not been resolved to one figure', () async { container.read(marketCheckProvider(_orderId)); From 40270fdb3b5bd8d9b716c16a1decfbb9824dcc63 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Tue, 25 Aug 2026 11:58:26 -0300 Subject: [PATCH 19/31] fix(invoice): refuse a fee rate that leaves the arithmetic's domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feeRate.isFinite was checked on the input and nowhere else, but the domain is actually left during the multiplication. A signed rate of 1e308 clears every input check; feeRate * amountSats / 2 is then infinity and round() throws UnsupportedError on a settlement screen the user can reach. The quieter half is worse. A rate around 1e30 keeps the product finite, so round() does not throw — it saturates at the int64 ceiling and hands back 9223372036854775807 as though the node had asked for it, which then propagates into the derived amount. Guard the transformed value, and bound both the input amount and the result by the supply cap: nothing above it is a settlement figure. feeFor now returns int? so a fee that cannot be derived is distinct from a fee of zero, which is a real term a node can charge. Both derived amounts propagate the null, and the screens already treat that as a check they could not make. --- lib/shared/utils/settlement_amounts.dart | 41 ++++++++-- .../shared/utils/settlement_amounts_test.dart | 75 +++++++++++++++++-- 2 files changed, 102 insertions(+), 14 deletions(-) diff --git a/lib/shared/utils/settlement_amounts.dart b/lib/shared/utils/settlement_amounts.dart index 7bc77d8c..0613c862 100644 --- a/lib/shared/utils/settlement_amounts.dart +++ b/lib/shared/utils/settlement_amounts.dart @@ -24,13 +24,35 @@ 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. - static int feeFor({required int amountSats, required double feeRate}) { - if (amountSats <= 0 || feeRate <= 0 || !feeRate.isFinite) return 0; - return (feeRate * amountSats / 2.0).round(); + /// + /// 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 @@ -40,15 +62,20 @@ class SettlementAmounts { /// 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}) { - if (amountSats <= 0 || feeRate < 0 || !feeRate.isFinite) return null; - return amountSats + feeFor(amountSats: amountSats, feeRate: 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}) { - if (amountSats <= 0 || feeRate < 0 || !feeRate.isFinite) return null; - final net = amountSats - feeFor(amountSats: amountSats, feeRate: 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/test/shared/utils/settlement_amounts_test.dart b/test/shared/utils/settlement_amounts_test.dart index d3caf38b..e62ebfa3 100644 --- a/test/shared/utils/settlement_amounts_test.dart +++ b/test/shared/utils/settlement_amounts_test.dart @@ -21,20 +21,57 @@ void main() { expect(SettlementAmounts.feeFor(amountSats: 2000, feeRate: 0.001), 1); }); - test('is zero when there is no rate or no amount', () { + 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); - expect(SettlementAmounts.feeFor(amountSats: 0, feeRate: feeRate), 0); - expect(SettlementAmounts.feeFor(amountSats: -1, feeRate: feeRate), 0); }); - test('is zero for a rate that is not a number', () { + 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), - 0, + isNull, ); expect( SettlementAmounts.feeFor(amountSats: 100000, feeRate: double.infinity), - 0, + 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, ); }); }); @@ -71,6 +108,19 @@ void main() { 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', () { @@ -101,6 +151,17 @@ void main() { 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', () { @@ -111,7 +172,7 @@ void main() { final buyer = SettlementAmounts.buyerReceives( amountSats: amount, feeRate: feeRate)!; final half = - SettlementAmounts.feeFor(amountSats: amount, feeRate: feeRate); + SettlementAmounts.feeFor(amountSats: amount, feeRate: feeRate)!; expect(seller - buyer, half * 2); expect(seller - amount, half); From 8b5dac2101a68d2ea70f798747dd4937e327d569 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Tue, 25 Aug 2026 12:02:00 -0300 Subject: [PATCH 20/31] fix(invoice): bind the market override to the quote it was given for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _marketOverridden was a route-wide flag. Once the user continued past one gap it stayed true for the life of the screen, so a later node republish or rate refresh could move both the settled and the quoted figure while invoice creation, or the pay button, stayed live under nothing but a caution. One acknowledgement of a 3% gap went on authorizing a 30% one. Hold the approved quote instead — order id, settled sats, quoted sats — and treat a check that does not match it as one nobody has agreed to yet. The refusal comes back and asks again; a rebuild carrying the same figures does not. MarketOverride sits next to MarketCheck so both screens share one definition of what was consented to. Tests move the quote under a mounted screen in each flow. --- .../screens/add_lightning_invoice_screen.dart | 25 ++++-- .../screens/pay_lightning_invoice_screen.dart | 20 +++-- lib/shared/utils/market_quote.dart | 33 ++++++++ .../add_lightning_invoice_screen_test.dart | 75 +++++++++++++++++- .../pay_lightning_invoice_screen_test.dart | 77 ++++++++++++++++++- 5 files changed, 214 insertions(+), 16 deletions(-) diff --git a/lib/features/order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart index cddb3e21..dd5dd4f3 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -16,6 +16,7 @@ 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'; @@ -44,12 +45,15 @@ class _AddLightningInvoiceScreenState /// Whether the user chose to enter the invoice manually (fallback from NWC or LN address). bool _manualMode = false; - /// Whether the user chose to invoice for a payout the market check refused. + /// 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. - bool _marketOverridden = false; + /// 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() { @@ -119,9 +123,10 @@ class _AddLightningInvoiceScreenState // 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. - final marketBlocked = offMarket && - !_marketOverridden && - market!.isAdverseTo(Role.buyer); + final marketOverridden = + market != null && (_marketOverride?.covers(orderId, market) ?? false); + final marketBlocked = + offMarket && !marketOverridden && market!.isAdverseTo(Role.buyer); final marketCaution = offMarket && !marketBlocked; final headerBlock = (unverified || marketCaution) @@ -172,6 +177,7 @@ class _AddLightningInvoiceScreenState child: marketBlocked ? _buildMarketBlockedFlow( header: headerBlock, + orderId: orderId, settledSats: market.settledSats, quotedSats: market.quotedSats, ) @@ -293,6 +299,7 @@ class _AddLightningInvoiceScreenState /// decision with the user rather than making it for them. Widget _buildMarketBlockedFlow({ required Widget header, + required String orderId, required int settledSats, required int quotedSats, }) { @@ -324,7 +331,11 @@ class _AddLightningInvoiceScreenState ), const SizedBox(width: 12), TextButton( - onPressed: () => setState(() => _marketOverridden = true), + onPressed: () => setState(() => _marketOverride = MarketOverride( + orderId: orderId, + settledSats: settledSats, + quotedSats: quotedSats, + )), child: Text(S.of(context)!.invoiceContinueAnyway), ), ], diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index bf17cfa5..4b8c31a1 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -13,6 +13,7 @@ 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'; @@ -34,13 +35,16 @@ class _PayLightningInvoiceScreenState /// Whether the user chose to pay manually (fallback from NWC). bool _manualMode = false; - /// Whether the user chose to pay a settlement the market check refused. + /// 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. - bool _marketOverridden = false; + /// 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` @@ -104,9 +108,10 @@ class _PayLightningInvoiceScreenState // that runs against them 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 marketBlocked = offMarket && - !_marketOverridden && - market!.isAdverseTo(Role.seller); + final marketOverridden = market != null && + (_marketOverride?.covers(widget.orderId, market) ?? false); + final marketBlocked = + offMarket && !marketOverridden && market!.isAdverseTo(Role.seller); final marketCaution = offMarket && !marketBlocked; final fiatAmount = orderState.order?.fiatAmount.toString() ?? '0'; final fiatCode = orderState.order?.fiatCode ?? ''; @@ -212,7 +217,8 @@ class _PayLightningInvoiceScreenState const SizedBox(width: 12), TextButton( onPressed: () => - setState(() => _marketOverridden = true), + setState(() => _marketOverride = + MarketOverride.of(widget.orderId, market)), child: Text(S.of(context)!.invoiceContinueAnyway), ), ], diff --git a/lib/shared/utils/market_quote.dart b/lib/shared/utils/market_quote.dart index 79912bcf..aea20471 100644 --- a/lib/shared/utils/market_quote.dart +++ b/lib/shared/utils/market_quote.dart @@ -139,3 +139,36 @@ class MarketCheck { ); } } + +/// 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/test/features/order/screens/add_lightning_invoice_screen_test.dart b/test/features/order/screens/add_lightning_invoice_screen_test.dart index 98f578cd..40227e31 100644 --- a/test/features/order/screens/add_lightning_invoice_screen_test.dart +++ b/test/features/order/screens/add_lightning_invoice_screen_test.dart @@ -22,6 +22,10 @@ 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) => null); + /// Holds a fixed [OrderState] without any of the notifier's real machinery. class _StubOrderNotifier extends StateNotifier implements OrderNotifier { @@ -91,7 +95,8 @@ Future pumpAddScreen( nwcProvider.overrideWith((ref) => _StubNwcNotifier()), sessionProvider.overrideWith((ref, id) => null), anchoredBuyerAmountProvider.overrideWith((ref, id) => anchoredSats), - marketCheckProvider.overrideWith((ref, id) => market), + _marketSource.overrideWith((ref) => market), + marketCheckProvider.overrideWith((ref, id) => ref.watch(_marketSource)), ], child: MaterialApp.router( routerConfig: router, @@ -106,6 +111,15 @@ Future pumpAddScreen( 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) async { + final container = ProviderScope.containerOf( + tester.element(find.byType(AddLightningInvoiceScreen)), + ); + container.read(_marketSource.notifier).state = market; + await tester.pump(); +} + void main() { group('AddLightningInvoiceScreen gating', () { testWidgets('refuses a request that disagrees with the signed terms', @@ -212,6 +226,65 @@ void main() { expect(find.text(s.invoiceOffMarketTitle), 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( diff --git a/test/features/order/screens/pay_lightning_invoice_screen_test.dart b/test/features/order/screens/pay_lightning_invoice_screen_test.dart index e773d1cd..3849527e 100644 --- a/test/features/order/screens/pay_lightning_invoice_screen_test.dart +++ b/test/features/order/screens/pay_lightning_invoice_screen_test.dart @@ -21,6 +21,10 @@ 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) => null); + /// A data part long enough to look real; only the prefix is read. const _data = 'pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfq'; @@ -99,7 +103,8 @@ Future pumpPayScreen( nwcProvider.overrideWith((ref) => _StubNwcNotifier()), sessionProvider.overrideWith((ref, id) => null), anchoredSellerAmountProvider.overrideWith((ref, id) => anchoredSats), - marketCheckProvider.overrideWith((ref, id) => market), + _marketSource.overrideWith((ref) => market), + marketCheckProvider.overrideWith((ref, id) => ref.watch(_marketSource)), ], child: MaterialApp.router( routerConfig: router, @@ -114,6 +119,15 @@ Future pumpPayScreen( 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) async { + final container = ProviderScope.containerOf( + tester.element(find.byType(PayLightningInvoiceScreen)), + ); + container.read(_marketSource.notifier).state = market; + await tester.pump(); +} + /// The terms refusal box, whichever case produced it. Finder refusalNotice(WidgetTester tester) => find.text(_s(tester).invoiceNotPayableTitle); @@ -281,6 +295,67 @@ void main() { expect(find.text(s.invoiceOffMarketTitle), 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( From 6f550ddf6b95b219251ffd731595726ea7e570d5 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Tue, 25 Aug 2026 12:10:03 -0300 Subject: [PATCH 21/31] fix(invoice): tell the market check's four outcomes apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit valueOrNull collapsed a rate still in flight and a rate that could not be fetched into the same null the screens read as "no gap". Both flows opened their settlement actions before the check had an answer, and an input the node had emptied — a blank currency tag, an unreadable premium — read to the user as a settlement that had passed. Model the outcomes instead: notApplicable, loading, unavailable, checked. Loading holds the flow behind a progress state rather than opening it and withdrawing it a moment later. Unavailable is said out loud as a caution and 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. That is the rule the fee path already follows. The rate provider was also a plain family, so the first rate a process ever fetched went on pricing every later trade. Both providers are autoDispose now, with a two-minute TTL while a screen stays mounted, so a settlement is checked against a price fetched for it. Three new keys across all six locales. --- .../providers/market_check_provider.dart | 73 +++++++--- .../screens/add_lightning_invoice_screen.dart | 68 +++++++-- .../screens/pay_lightning_invoice_screen.dart | 57 ++++++-- lib/l10n/intl_de.arb | 3 + lib/l10n/intl_en.arb | 3 + lib/l10n/intl_es.arb | 3 + lib/l10n/intl_fr.arb | 3 + lib/l10n/intl_it.arb | 3 + lib/l10n/intl_pt.arb | 3 + lib/shared/utils/market_quote.dart | 50 +++++++ .../providers/market_check_provider_test.dart | 134 ++++++++++++++++-- .../add_lightning_invoice_screen_test.dart | 57 +++++++- .../pay_lightning_invoice_screen_test.dart | 62 +++++++- 13 files changed, 457 insertions(+), 62 deletions(-) diff --git a/lib/features/order/providers/market_check_provider.dart b/lib/features/order/providers/market_check_provider.dart index 3d549cfd..dd5c126b 100644 --- a/lib/features/order/providers/market_check_provider.dart +++ b/lib/features/order/providers/market_check_provider.dart @@ -1,3 +1,5 @@ +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'; @@ -8,6 +10,14 @@ 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 @@ -22,12 +32,21 @@ final independentExchangeServiceProvider = Provider( /// Fiat units per bitcoin for [fiatCode], from the independent source. /// -/// Null rather than an error when the rate cannot be had: this check is a -/// second opinion, and being offline is not evidence against a settlement. +/// 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.family((ref, fiatCode) async { + 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'); @@ -37,10 +56,9 @@ final independentFiatPerBtcProvider = } }); -/// Re-prices [orderId] against the independent rate, or null when the check -/// does not apply or cannot be made. +/// Re-prices [orderId] against the independent rate. /// -/// Only runs where the client holds no figure of its own. A session that +/// 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 @@ -54,25 +72,35 @@ final independentFiatPerBtcProvider = /// 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 four outcomes stay distinct. "Does not apply" and "could not be made" +/// are different facts about a settlement, and neither is "priced correctly": +/// a node can empty the currency tag or make the premium unreadable, so +/// silence on those would be a state it can put the screen into. final marketCheckProvider = - Provider.family((ref, orderId) { + Provider.autoDispose.family((ref, orderId) { final session = ref.watch(sessionProvider(orderId)); - if (session?.termsPinned != true) return null; - if (session?.pinnedAmountSats != null) return null; + if (session?.termsPinned != true) return MarketCheckResult.notApplicable; + if (session?.pinnedAmountSats != null) return MarketCheckResult.notApplicable; final settledSats = ref.watch(signedOrderAmountProvider(orderId)); - if (settledSats == null) return null; + if (settledSats == null) return MarketCheckResult.notApplicable; final event = ref.watch(eventProvider(orderId)); - if (event == null) return null; - - final fiatCode = event.currency; - if (fiatCode == null || fiatCode.isEmpty) return null; + 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 null; + if (fiat.isRange() || fiat.minimum <= 0) { + return MarketCheckResult.notApplicable; + } + + // The rest of the inputs are ones the node publishes and can withhold. An + // empty currency tag or an unreadable premium is a check that could not be + // made, not one that came back clean. + final fiatCode = event.currency; + if (fiatCode == null || fiatCode.isEmpty) return MarketCheckResult.unavailable; // An absent premium is zero; one that is present and unreadable is not. // Quoting it as zero would misprice the order by exactly the premium and @@ -81,16 +109,21 @@ final marketCheckProvider = final premium = (premiumTag == null || premiumTag.trim().isEmpty) ? 0.0 : double.tryParse(premiumTag.trim()); - if (premium == null) return null; + if (premium == null) return MarketCheckResult.unavailable; + + final rate = ref.watch(independentFiatPerBtcProvider(fiatCode)); + if (rate.isLoading) return MarketCheckResult.loading; - final fiatPerBtc = - ref.watch(independentFiatPerBtcProvider(fiatCode)).valueOrNull; - if (fiatPerBtc == null) return null; + final fiatPerBtc = rate.valueOrNull; + if (fiatPerBtc == null) return MarketCheckResult.unavailable; - return MarketCheck.of( + final check = MarketCheck.of( settledSats: settledSats, fiatAmount: fiat.minimum, fiatPerBtc: fiatPerBtc, premium: premium, ); + if (check == null) return MarketCheckResult.unavailable; + + return MarketCheckResult.checked(check); }); diff --git a/lib/features/order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart index dd5dd4f3..4705edf5 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -117,19 +117,32 @@ class _AddLightningInvoiceScreenState // 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 offMarket = !blocked && (market?.isOffMarket ?? false); + 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. final marketOverridden = - market != null && (_marketOverride?.covers(orderId, market) ?? false); + check != null && (_marketOverride?.covers(orderId, check) ?? false); final marketBlocked = - offMarket && !marketOverridden && market!.isAdverseTo(Role.buyer); + offMarket && !marketOverridden && check!.isAdverseTo(Role.buyer); final marketCaution = offMarket && !marketBlocked; - final headerBlock = (unverified || marketCaution) + final headerBlock = (unverified || marketCaution || marketUnavailable) ? Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -146,11 +159,19 @@ class _AddLightningInvoiceScreenState InvoiceNotice.caution( title: S.of(context)!.invoiceOffMarketTitle, body: S.of(context)!.invoiceOffMarketBody( - market!.settledSats.toString(), - market.quotedSats.toString(), + 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; @@ -174,12 +195,14 @@ class _AddLightningInvoiceScreenState 16, 16 + MediaQuery.of(context).viewPadding.bottom, ), - child: marketBlocked + child: marketPending + ? _buildMarketPendingFlow(header: headerBlock) + : marketBlocked ? _buildMarketBlockedFlow( header: headerBlock, orderId: orderId, - settledSats: market.settledSats, - quotedSats: market.quotedSats, + settledSats: check.settledSats, + quotedSats: check.quotedSats, ) : blocked ? _buildBlockedFlow( @@ -297,6 +320,33 @@ class _AddLightningInvoiceScreenState /// 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. + /// 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. + 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)), + ), + ], + ), + ), + ], + ); + } + Widget _buildMarketBlockedFlow({ required Widget header, required String orderId, diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index 4b8c31a1..b246911b 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -102,16 +102,29 @@ class _PayLightningInvoiceScreenState // 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 offMarket = !blocked && (market?.isOffMarket ?? false); + 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; // Paying the hold invoice is always the seller's side of the trade. A gap // that runs against them 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 = market != null && - (_marketOverride?.covers(widget.orderId, market) ?? false); + final marketOverridden = check != null && + (_marketOverride?.covers(widget.orderId, check) ?? false); final marketBlocked = - offMarket && !marketOverridden && market!.isAdverseTo(Role.seller); + offMarket && !marketOverridden && check!.isAdverseTo(Role.seller); final marketCaution = offMarket && !marketBlocked; final fiatAmount = orderState.order?.fiatAmount.toString() ?? '0'; final fiatCode = orderState.order?.fiatCode ?? ''; @@ -167,13 +180,37 @@ class _PayLightningInvoiceScreenState InvoiceNotice.caution( title: S.of(context)!.invoiceOffMarketTitle, body: S.of(context)!.invoiceOffMarketBody( - market!.settledSats.toString(), - market.quotedSats.toString(), + check!.settledSats.toString(), + check.quotedSats.toString(), ), ), const SizedBox(height: 16), ], - if (blocked) ...[ + 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)), + ), + ], + ), + ), + ] else if (blocked) ...[ header, const SizedBox(height: 24), _InvoiceTermsNotice( @@ -198,8 +235,8 @@ class _PayLightningInvoiceScreenState InvoiceNotice.refusal( title: S.of(context)!.invoiceOffMarketTitle, body: S.of(context)!.invoiceOffMarketBlockedBody( - market.settledSats.toString(), - market.quotedSats.toString(), + check.settledSats.toString(), + check.quotedSats.toString(), ), ), const SizedBox(height: 20), @@ -218,7 +255,7 @@ class _PayLightningInvoiceScreenState TextButton( onPressed: () => setState(() => _marketOverride = - MarketOverride.of(widget.orderId, market)), + MarketOverride.of(widget.orderId, check)), child: Text(S.of(context)!.invoiceContinueAnyway), ), ], diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 36800910..564291b0 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1771,6 +1771,9 @@ "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.", + "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.", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 6616ae20..024813a9 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1816,6 +1816,9 @@ "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.", + "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.", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 1b5cbb05..2a3f39fb 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1746,6 +1746,9 @@ "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. Revisalo vos antes de continuar.", + "invoiceMarketRateUnavailableTitle": "Tasa de mercado no disponible", + "invoiceMarketRateUnavailableBody": "No se pudo obtener una tasa independiente, así que este monto no se comparó con ninguna. Revisalo vos 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 revisalo 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. Revisá los términos antes de continuar.", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 53e178db..39397879 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1771,6 +1771,9 @@ "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.", + "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.", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 57066a18..a802a712 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1812,6 +1812,9 @@ "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.", + "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.", diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index cc50ba2e..e7065c55 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -1816,6 +1816,9 @@ "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.", + "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.", diff --git a/lib/shared/utils/market_quote.dart b/lib/shared/utils/market_quote.dart index aea20471..4bdc2423 100644 --- a/lib/shared/utils/market_quote.dart +++ b/lib/shared/utils/market_quote.dart @@ -140,6 +140,56 @@ class MarketCheck { } } +/// 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 diff --git a/test/features/order/providers/market_check_provider_test.dart b/test/features/order/providers/market_check_provider_test.dart index 4b0785f2..ebdac827 100644 --- a/test/features/order/providers/market_check_provider_test.dart +++ b/test/features/order/providers/market_check_provider_test.dart @@ -8,6 +8,8 @@ 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'; @@ -67,6 +69,12 @@ void main() { .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. @@ -94,16 +102,17 @@ void main() { container.read(marketCheckProvider(_orderId)); await publish(_orderEvent()); - final check = container.read(marketCheckProvider(_orderId))!; - expect(check.quotedSats, 200000); - expect(check.isOffMarket, isFalse); + 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))!; + final check = container.read(marketCheckProvider(_orderId)).check!; expect(check.isOffMarket, isTrue); expect(check.isBelowMarket, isTrue); expect(check.settledSats, 180000); @@ -113,7 +122,7 @@ void main() { container.read(marketCheckProvider(_orderId)); await publish(_orderEvent(amount: '180000', premium: '10')); - expect(container.read(marketCheckProvider(_orderId))!.isOffMarket, + expect(container.read(marketCheckProvider(_orderId)).check!.isOffMarket, isFalse); }); @@ -126,7 +135,8 @@ void main() { container.read(marketCheckProvider(_orderId)); await publish(_orderEvent(amount: '180000')); - expect(container.read(marketCheckProvider(_orderId)), isNull); + expect(container.read(marketCheckProvider(_orderId)).status, + MarketCheckStatus.notApplicable); }); test('skips a session written before pinning existed', () async { @@ -141,7 +151,8 @@ void main() { container.read(marketCheckProvider(_orderId)); await publish(_orderEvent(amount: '180000')); - expect(container.read(marketCheckProvider(_orderId)), isNull); + expect(container.read(marketCheckProvider(_orderId)).status, + MarketCheckStatus.notApplicable); }); test('skips an order with no session of its own', () async { @@ -151,7 +162,8 @@ void main() { container.read(marketCheckProvider(_orderId)); await publish(_orderEvent(amount: '180000')); - expect(container.read(marketCheckProvider(_orderId)), isNull); + expect(container.read(marketCheckProvider(_orderId)).status, + MarketCheckStatus.notApplicable); }); test('skips a range order that has not been resolved to one figure', @@ -159,38 +171,130 @@ void main() { container.read(marketCheckProvider(_orderId)); await publish(_orderEvent(fiat: const ['fa', '50', '200'])); - expect(container.read(marketCheckProvider(_orderId)), isNull); + expect(container.read(marketCheckProvider(_orderId)).status, + MarketCheckStatus.notApplicable); }); - test('skips when the independent rate cannot be had', () async { + 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)), isNull); + expect(container.read(marketCheckProvider(_orderId)).status, + MarketCheckStatus.unavailable); + }); + + test('reports an empty currency tag as a check it could not make', + () async { + // The node publishes this tag and can empty it, so silence here would + // be a state it can put the screen into. + container.read(marketCheckProvider(_orderId)); + await publish(_orderEvent(currency: '')); + + expect(container.read(marketCheckProvider(_orderId)).status, + MarketCheckStatus.unavailable); }); test('skips before the order event has arrived', () { - expect(container.read(marketCheckProvider(_orderId)), isNull); + expect(container.read(marketCheckProvider(_orderId)).status, + MarketCheckStatus.notApplicable); }); - test('skips an order whose premium cannot be read', () async { + test('reports an unreadable premium as a check it could not make', + () async { // Quoting an unreadable premium as zero would misprice the order by // exactly the premium and turn an honest trade into a refusal. container.read(marketCheckProvider(_orderId)); await publish(_orderEvent(amount: '180000', premium: 'not-a-number')); - expect(container.read(marketCheckProvider(_orderId)), isNull); + expect(container.read(marketCheckProvider(_orderId)).status, + MarketCheckStatus.unavailable); }); test('treats an absent premium as zero', () async { container.read(marketCheckProvider(_orderId)); await publish(_orderEvent(premium: '')); - final check = container.read(marketCheckProvider(_orderId))!; + 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); + }); }); + + 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/screens/add_lightning_invoice_screen_test.dart b/test/features/order/screens/add_lightning_invoice_screen_test.dart index 40227e31..b831e1df 100644 --- a/test/features/order/screens/add_lightning_invoice_screen_test.dart +++ b/test/features/order/screens/add_lightning_invoice_screen_test.dart @@ -24,7 +24,8 @@ 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) => null); +final _marketSource = StateProvider( + (ref) => MarketCheckResult.notApplicable); /// Holds a fixed [OrderState] without any of the notifier's real machinery. class _StubOrderNotifier extends StateNotifier @@ -68,6 +69,7 @@ Future pumpAddScreen( required int? requestedSats, int? anchoredSats, MarketCheck? market, + MarketCheckResult? marketResult, }) async { final state = OrderState( status: Status.waitingBuyerInvoice, @@ -95,7 +97,11 @@ Future pumpAddScreen( nwcProvider.overrideWith((ref) => _StubNwcNotifier()), sessionProvider.overrideWith((ref, id) => null), anchoredBuyerAmountProvider.overrideWith((ref, id) => anchoredSats), - _marketSource.overrideWith((ref) => market), + _marketSource.overrideWith((ref) => + marketResult ?? + (market == null + ? MarketCheckResult.notApplicable + : MarketCheckResult.checked(market))), marketCheckProvider.overrideWith((ref, id) => ref.watch(_marketSource)), ], child: MaterialApp.router( @@ -112,11 +118,16 @@ Future pumpAddScreen( 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) async { +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 = market; + container.read(_marketSource.notifier).state = result; await tester.pump(); } @@ -226,6 +237,44 @@ void main() { 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('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 diff --git a/test/features/order/screens/pay_lightning_invoice_screen_test.dart b/test/features/order/screens/pay_lightning_invoice_screen_test.dart index 3849527e..4514a066 100644 --- a/test/features/order/screens/pay_lightning_invoice_screen_test.dart +++ b/test/features/order/screens/pay_lightning_invoice_screen_test.dart @@ -23,7 +23,8 @@ 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) => null); +final _marketSource = StateProvider( + (ref) => MarketCheckResult.notApplicable); /// A data part long enough to look real; only the prefix is read. const _data = 'pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfq'; @@ -62,6 +63,7 @@ Future pumpPayScreen( required int messageSats, int? anchoredSats, MarketCheck? market, + MarketCheckResult? marketResult, bool cancelFails = false, }) async { final state = OrderState( @@ -103,7 +105,11 @@ Future pumpPayScreen( nwcProvider.overrideWith((ref) => _StubNwcNotifier()), sessionProvider.overrideWith((ref, id) => null), anchoredSellerAmountProvider.overrideWith((ref, id) => anchoredSats), - _marketSource.overrideWith((ref) => market), + _marketSource.overrideWith((ref) => + marketResult ?? + (market == null + ? MarketCheckResult.notApplicable + : MarketCheckResult.checked(market))), marketCheckProvider.overrideWith((ref, id) => ref.watch(_marketSource)), ], child: MaterialApp.router( @@ -120,11 +126,16 @@ 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) async { +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 = market; + container.read(_marketSource.notifier).state = result; await tester.pump(); } @@ -295,6 +306,49 @@ void main() { expect(find.text(s.invoiceOffMarketTitle), 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('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 From 51eb490fc67c3697d647511b576b8e1703d7cd62 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Tue, 25 Aug 2026 12:21:24 -0300 Subject: [PATCH 22/31] fix(invoice): durably pin every term the settlement is checked against MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two holes, one fix, because they share the same plumbing. The pins were memory-only at the commitment boundary. newSession() published them to state and the caller sends the commitment as soon as it returns, so a crash or a response timeout before the daemon replied left the trade standing remotely with its anchors gone. Restore was worse: _clearAll wipes the session store outright, including already-persisted sessions, and rebuilds each one from the node's own data — so a node A -> B -> A round trip returned every trade in flight to whatever terms the node currently advertises. And the market check was still reading its pricing inputs live. Currency, fiat amount and premium sit in the same addressable kind-38383 event as the sats amount, so a node could resolve a shaved settlement and then republish the premium that makes the shave quote exactly right: the check would agree with the skim. The user accepts 100 USD at 0% worth 200,000 sats, the node settles 180,000 and republishes premium as 10, and the gap disappears. So: persist the session before publish where a take already knows its order id, and add SettlementTermsStore as the durable anchor for the rest. It lives outside the session store, so deleteAll does not take it, and it is keyed by trade key — the one identifier that spans the lifecycle: it exists before the order id, it signs the commitment, and restore re-derives it from the key index. Restore rebuilds sessions from it rather than as legacy ones. A create stays deliberately ephemeral until confirmed, but its anchor is durable either way. Session gains the three pricing terms, pinned at commitment from what the user accepted, and the quote is priced from those. The one input still read live is a range order's fiat figure, which no commitment could have pinned: the band is not resolved until a taker settles it. An anchor is written once — a retake must not move terms already agreed. A record with nothing in it still counts, or a commitment made before the info event arrived would be indistinguishable from one that predates pinning. Anchors are pruned after 90 days. --- lib/data/models/session.dart | 50 ++++ .../order/notifiers/add_order_notifier.dart | 8 + .../order/notifiers/order_notifier.dart | 9 + .../providers/market_check_provider.dart | 62 +++-- .../order/settlement_terms_store.dart | 217 ++++++++++++++++++ lib/features/restore/restore_manager.dart | 15 ++ lib/services/mostro_service.dart | 5 + lib/shared/notifiers/session_notifier.dart | 74 ++++++ lib/shared/providers/app_init_provider.dart | 6 + lib/shared/utils/pricing_terms.dart | 47 ++++ .../models/session_pinned_terms_test.dart | 55 +++++ .../providers/market_check_provider_test.dart | 84 ++++++- .../order/settlement_terms_store_test.dart | 161 +++++++++++++ test/mocks.dart | 3 + 14 files changed, 760 insertions(+), 36 deletions(-) create mode 100644 lib/features/order/settlement_terms_store.dart create mode 100644 lib/shared/utils/pricing_terms.dart create mode 100644 test/features/order/settlement_terms_store_test.dart diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index 03c7d689..c6af95c4 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -41,6 +41,21 @@ class Session { /// [pinnedAmountSats]. 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. + String? pinnedFiatCode; + int? pinnedFiatAmount; + double? pinnedPremium; + /// Whether the terms were pinned when this session committed. /// /// Separates a session that pinned whatever there was to pin — possibly @@ -72,6 +87,9 @@ class Session { this.disputeId, int? pinnedAmountSats, double? pinnedFeeRate, + String? pinnedFiatCode, + int? pinnedFiatAmount, + double? pinnedPremium, this.termsPinned = false, Peer? peer, String? adminPubkey, @@ -87,6 +105,16 @@ class Session { (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) { @@ -113,6 +141,9 @@ class Session { '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, }; @@ -257,6 +288,22 @@ class Session { 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, @@ -271,6 +318,9 @@ class Session { disputeId: disputeId, pinnedAmountSats: pinnedAmountSats, pinnedFeeRate: pinnedFeeRate, + pinnedFiatCode: json['pinned_fiat_code']?.toString(), + pinnedFiatAmount: pinnedFiatAmount, + pinnedPremium: pinnedPremium, termsPinned: termsPinned, ); } catch (e) { diff --git a/lib/features/order/notifiers/add_order_notifier.dart b/lib/features/order/notifiers/add_order_notifier.dart index acb31e02..d463baf3 100644 --- a/lib/features/order/notifiers/add_order_notifier.dart +++ b/lib/features/order/notifiers/add_order_notifier.dart @@ -119,11 +119,19 @@ class AddOrderNotifier extends AbstractMostroNotifier { // 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 27434623..5f4c2a50 100644 --- a/lib/features/order/notifiers/order_notifier.dart +++ b/lib/features/order/notifiers/order_notifier.dart @@ -9,6 +9,7 @@ import 'package:mostro_mobile/features/order/notifiers/abstract_mostro_notifier. 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/pricing_terms.dart'; class OrderNotifier extends AbstractMostroNotifier { late final MostroService mostroService; @@ -97,12 +98,16 @@ class OrderNotifier extends AbstractMostroNotifier { // 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 @@ -131,12 +136,16 @@ class OrderNotifier extends AbstractMostroNotifier { // 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 index dd5c126b..3cdd3e05 100644 --- a/lib/features/order/providers/market_check_provider.dart +++ b/lib/features/order/providers/market_check_provider.dart @@ -73,10 +73,16 @@ final independentFiatPerBtcProvider = /// 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 node can empty the currency tag or make the premium unreadable, so -/// silence on those would be a state it can put the screen into. +/// 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)); @@ -86,30 +92,36 @@ final marketCheckProvider = final settledSats = ref.watch(signedOrderAmountProvider(orderId)); if (settledSats == null) return MarketCheckResult.notApplicable; - 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; + // 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; } - // The rest of the inputs are ones the node publishes and can withhold. An - // empty currency tag or an unreadable premium is a check that could not be - // made, not one that came back clean. - final fiatCode = event.currency; - if (fiatCode == null || fiatCode.isEmpty) return MarketCheckResult.unavailable; - - // An absent premium is zero; one that is present and unreadable is not. - // Quoting it as zero would misprice the order by exactly the premium and - // turn an honest trade into a refusal. - final premiumTag = event.premium; - final premium = (premiumTag == null || premiumTag.trim().isEmpty) - ? 0.0 - : double.tryParse(premiumTag.trim()); - if (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; + } final rate = ref.watch(independentFiatPerBtcProvider(fiatCode)); if (rate.isLoading) return MarketCheckResult.loading; @@ -119,7 +131,7 @@ final marketCheckProvider = final check = MarketCheck.of( settledSats: settledSats, - fiatAmount: fiat.minimum, + fiatAmount: fiatAmount, fiatPerBtc: fiatPerBtc, premium: premium, ); diff --git a/lib/features/order/settlement_terms_store.dart b/lib/features/order/settlement_terms_store.dart new file mode 100644 index 00000000..efb5a76b --- /dev/null +++ b/lib/features/order/settlement_terms_store.dart @@ -0,0 +1,217 @@ +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'; + +/// 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; + + /// 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; + try { + final raw = await _prefs.getString(_prefsKey); + if (raw != null) { + 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('Failed to load pinned settlement terms: $e'); + _terms = {}; + } + _initialized = true; + _prune(); + } + + /// 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, + }) { + if (_terms.containsKey(tradeKeyPublic)) return Future.value(); + + _terms[tradeKeyPublic] = PinnedTerms( + amountSats: amountSats, + feeRate: feeRate, + fiatCode: fiatCode, + fiatAmount: fiatAmount, + premium: premium, + pinnedAt: pinnedAt ?? DateTime.now(), + ); + return _flush(); + } + + /// 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. `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. Failures are logged and swallowed: one bad write must not poison + /// every later one, and memory stays authoritative for this run. + Future _flush() { + final snapshot = jsonEncode( + _terms.map((key, value) => MapEntry(key, value.toJson())), + ); + final queued = _writes + .then((_) => _prefs.setString(_prefsKey, snapshot)) + .catchError( + (Object e) => logger.e('Failed to persist pinned settlement terms: $e'), + ); + _writes = queued; + 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 4e8d1230..2e481f63 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/services/mostro_service.dart b/lib/services/mostro_service.dart index dd64463f..8bc7e96c 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -324,6 +324,11 @@ class MostroService { 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, termsPinned: currentSession.termsPinned, ); logger.i( diff --git a/lib/shared/notifiers/session_notifier.dart b/lib/shared/notifiers/session_notifier.dart index 6b40a56d..abf80877 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'; @@ -183,12 +184,22 @@ class SessionNotifier extends StateNotifier> { /// /// 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); @@ -207,6 +218,9 @@ class SessionNotifier extends StateNotifier> { role: role, pinnedAmountSats: pinnedAmountSats, pinnedFeeRate: pinnedFeeRate, + pinnedFiatCode: pinnedFiatCode, + pinnedFiatAmount: pinnedFiatAmount, + pinnedPremium: pinnedPremium, termsPinned: true, ); @@ -216,10 +230,58 @@ class SessionNotifier extends StateNotifier> { _requestIdToSession[requestId] = session; } + await _anchorTerms(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; its anchor + // is durable either way, keyed by the trade key it will be confirmed + // under. + 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. + Future _anchorTerms(Session session) async { + try { + await ref.read(settlementTermsStoreProvider).pin( + session.tradeKey.public, + amountSats: session.pinnedAmountSats, + feeRate: session.pinnedFeeRate, + fiatCode: session.pinnedFiatCode, + fiatAmount: session.pinnedFiatAmount, + premium: session.pinnedPremium, + ); + } catch (e) { + logger.e('Failed to anchor pinned terms for a new session: $e'); + } + } + + /// 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)); @@ -319,6 +381,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(); } @@ -359,6 +424,8 @@ class SessionNotifier extends StateNotifier> { required String parentOrderId, required Role role, double? pinnedFeeRate, + String? pinnedFiatCode, + double? pinnedPremium, bool termsPinned = false, }) async { final masterKey = ref.read(keyManagerProvider).masterKeyPair!; @@ -372,10 +439,17 @@ class SessionNotifier extends StateNotifier> { parentOrderId: parentOrderId, role: role, pinnedFeeRate: pinnedFeeRate, + pinnedFiatCode: pinnedFiatCode, + pinnedPremium: pinnedPremium, termsPinned: termsPinned, ); _pendingChildSessions[tradeKey.public] = session; + + // The child has no order id until mostrod delivers one, so the anchor is + // the only durable record of what the parent committed to until then. + if (termsPinned) await _anchorTerms(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 1e59a517..185bca75 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/utils/pricing_terms.dart b/lib/shared/utils/pricing_terms.dart new file mode 100644 index 00000000..72e1989c --- /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/test/data/models/session_pinned_terms_test.dart b/test/data/models/session_pinned_terms_test.dart index 6358a9de..5b242ada 100644 --- a/test/data/models/session_pinned_terms_test.dart +++ b/test/data/models/session_pinned_terms_test.dart @@ -47,6 +47,61 @@ void main() { 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 {}); diff --git a/test/features/order/providers/market_check_provider_test.dart b/test/features/order/providers/market_check_provider_test.dart index ebdac827..3492f271 100644 --- a/test/features/order/providers/market_check_provider_test.dart +++ b/test/features/order/providers/market_check_provider_test.dart @@ -43,7 +43,16 @@ NostrEvent _orderEvent({ ], ); -Session _session({int? pinnedAmountSats, bool termsPinned = true}) => Session( +/// 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, @@ -51,6 +60,9 @@ Session _session({int? pinnedAmountSats, bool termsPinned = true}) => Session( startTime: DateTime.utc(2026), orderId: _orderId, pinnedAmountSats: pinnedAmountSats, + pinnedFiatCode: pinnedFiatCode, + pinnedFiatAmount: pinnedFiatAmount, + pinnedPremium: pinnedPremium, termsPinned: termsPinned, ); @@ -119,6 +131,9 @@ void main() { }); test('accounts for the premium before judging', () async { + session = _session(pinnedPremium: 10); + build(); + container.read(marketCheckProvider(_orderId)); await publish(_orderEvent(amount: '180000', premium: '10')); @@ -126,6 +141,40 @@ void main() { 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. @@ -168,6 +217,9 @@ void main() { 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'])); @@ -189,12 +241,16 @@ void main() { MarketCheckStatus.unavailable); }); - test('reports an empty currency tag as a check it could not make', + test('reports a commitment that pinned no currency as unmakeable', () async { - // The node publishes this tag and can empty it, so silence here would - // be a state it can put the screen into. + // 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(currency: '')); + await publish(_orderEvent()); expect(container.read(marketCheckProvider(_orderId)).status, MarketCheckStatus.unavailable); @@ -205,20 +261,26 @@ void main() { MarketCheckStatus.notApplicable); }); - test('reports an unreadable premium as a check it could not make', + test('reports a commitment that pinned no premium as unmakeable', () async { - // Quoting an unreadable premium as zero would misprice the order by - // exactly the premium and turn an honest trade into a refusal. + session = _session(pinnedPremium: null); + build(); + container.read(marketCheckProvider(_orderId)); - await publish(_orderEvent(amount: '180000', premium: 'not-a-number')); + await publish(_orderEvent()); expect(container.read(marketCheckProvider(_orderId)).status, MarketCheckStatus.unavailable); }); - test('treats an absent premium as zero', () async { + 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(premium: '')); + await publish(_orderEvent(fiat: const ['fa', '100'])); final check = container.read(marketCheckProvider(_orderId)).check!; expect(check.quotedSats, 200000); 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 00000000..4910c787 --- /dev/null +++ b/test/features/order/settlement_terms_store_test.dart @@ -0,0 +1,161 @@ +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('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('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); + }); + }); +} diff --git a/test/mocks.dart b/test/mocks.dart index c4d45641..ef014635 100644 --- a/test/mocks.dart +++ b/test/mocks.dart @@ -101,6 +101,9 @@ class MockSessionNotifier extends SessionNotifier { Role? role, int? pinnedAmountSats, double? pinnedFeeRate, + String? pinnedFiatCode, + int? pinnedFiatAmount, + double? pinnedPremium, }) async { final mockSession = Session( // Dummy private keys for testing purposes only From 4e013191ebbcd2036aa3c03451ebbb0778e870d8 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Tue, 25 Aug 2026 13:55:51 -0300 Subject: [PATCH 23/31] fix(invoice): stop refusing settlements the check has nothing against Three places where a refusal outran its evidence. An order carrying no amount to check the invoice against was refused outright, which made an absent term stronger evidence than a disagreeing one and was the single place contradicting the rule the rest of the flow follows. It cautions now, on the same terms as signed terms that never arrived. The gap direction was hardcoded to Role.seller two lines below a header that consults the session role. Only sellers reach this screen today, so it was not a live bug, but the two disagreeing inside one build would invert the direction silently and downgrade an adverse gap to a caution. Both read the session now. And the re-prompt after a failed payout arrives hours after the order was priced, where bitcoin moves further than the tolerance on its own. The node caused none of that, and refusing strands the user on the path where the only way out is cancelling a half-settled trade. The gap is still named; it no longer blocks. The fee-rate race between what the client pins and what mostrod computes at take time is documented rather than closed: accepting a second figure from the live rate is the shape of a term supplied after the agreement, which pinning exists to refuse. The fix for it belongs on the daemon. Also: the misfiled doc comments on the invoice-flow builders, the ternary chain that grew a level every time a gate was added, and the pins made late final now that they are written once. That last one caught a mock still assigning them after construction. --- lib/data/models/session.dart | 13 +- .../providers/settlement_anchor_provider.dart | 19 +++ .../screens/add_lightning_invoice_screen.dart | 119 ++++++++++-------- .../screens/pay_lightning_invoice_screen.dart | 35 ++++-- lib/shared/utils/invoice_terms.dart | 14 ++- .../add_lightning_invoice_screen_test.dart | 30 +++++ .../pay_lightning_invoice_screen_test.dart | 18 +++ test/mocks.dart | 10 +- test/shared/utils/invoice_terms_test.dart | 22 +++- 9 files changed, 206 insertions(+), 74 deletions(-) diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index c6af95c4..590ae6f8 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -35,11 +35,11 @@ class Session { /// 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. - int? pinnedAmountSats; + late final int? pinnedAmountSats; /// The node's fee rate when this session committed, on the same terms as /// [pinnedAmountSats]. - double? pinnedFeeRate; + late final double? pinnedFeeRate; /// The currency, fiat figure and premium this session committed to. /// @@ -52,12 +52,15 @@ class Session { /// /// Null where there was nothing to pin — a session written before pinning /// existed, or an order event that had not arrived at commitment. - String? pinnedFiatCode; - int? pinnedFiatAmount; - double? pinnedPremium; + 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 diff --git a/lib/features/order/providers/settlement_anchor_provider.dart b/lib/features/order/providers/settlement_anchor_provider.dart index 8622bbe8..0b0a2fdb 100644 --- a/lib/features/order/providers/settlement_anchor_provider.dart +++ b/lib/features/order/providers/settlement_anchor_provider.dart @@ -82,6 +82,25 @@ final nodeFeeRateProvider = Provider((ref) { /// 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)); diff --git a/lib/features/order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart index 4705edf5..5d81e32d 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -132,14 +132,24 @@ class _AddLightningInvoiceScreenState // request a way to stop honest trades. final marketUnavailable = !blocked && market.isUnavailable; + // A re-prompt after a failed payout can arrive hours after the order + // was priced, and bitcoin moves further than the tolerance in that + // time. The gap is then real and the node did nothing to cause it, so + // refusing here would strand the user on the most fragile path in the + // flow — the one where the trade is already half-settled and the only + // way out of a refusal is to cancel. The gap is still named. + final isPayoutRetry = orderState.paymentFailed != null; + // 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. final marketOverridden = check != null && (_marketOverride?.covers(orderId, check) ?? false); - final marketBlocked = - offMarket && !marketOverridden && check!.isAdverseTo(Role.buyer); + final marketBlocked = offMarket && + !marketOverridden && + !isPayoutRetry && + check!.isAdverseTo(Role.buyer); final marketCaution = offMarket && !marketBlocked; final headerBlock = (unverified || marketCaution || marketUnavailable) @@ -195,46 +205,49 @@ class _AddLightningInvoiceScreenState 16, 16 + MediaQuery.of(context).viewPadding.bottom, ), - child: marketPending - ? _buildMarketPendingFlow(header: headerBlock) - : marketBlocked - ? _buildMarketBlockedFlow( - header: headerBlock, - orderId: orderId, - settledSats: check.settledSats, - quotedSats: check.quotedSats, - ) - : blocked - ? _buildBlockedFlow( - header: headerBlock, - requestedSats: amount, - expectedSats: expectedSats, - ) - : showLnAddressConfirmation - ? _buildLnAddressConfirmation(header: headerBlock) - : 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, - ), + // 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, + ), + }, ), ); }, @@ -309,17 +322,6 @@ class _AddLightningInvoiceScreenState ); } - /// 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. - /// 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. /// Shown while the independent rate is still in flight. /// /// The check is the only protection a market-price settlement has, and it @@ -347,6 +349,11 @@ class _AddLightningInvoiceScreenState ); } + /// 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, @@ -394,6 +401,12 @@ class _AddLightningInvoiceScreenState ); } + /// 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, diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index b246911b..7577cab7 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -95,8 +95,22 @@ class _PayLightningInvoiceScreenState expectedSats: expectedSats, ); final blocked = lnInvoice.isNotEmpty && !terms.isPayable; - final unverified = - lnInvoice.isNotEmpty && !blocked && anchoredSats == null; + + // 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 @@ -117,14 +131,13 @@ class _PayLightningInvoiceScreenState // request a way to stop honest trades. final marketUnavailable = !blocked && market.isUnavailable; - // Paying the hold invoice is always the seller's side of the trade. A gap - // that runs against them 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. + // 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(Role.seller); + offMarket && !marketOverridden && check!.isAdverseTo(userRole); final marketCaution = offMarket && !marketBlocked; final fiatAmount = orderState.order?.fiatAmount.toString() ?? '0'; final fiatCode = orderState.order?.fiatCode ?? ''; @@ -135,11 +148,8 @@ 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, + 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 @@ -348,8 +358,9 @@ class _InvoiceTermsNotice extends StatelessWidget { ); 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: - return s.invoiceTermsUnknownBody; case InvoiceTermsProblem.unreadable: case null: return s.invoiceUnreadableBody; diff --git a/lib/shared/utils/invoice_terms.dart b/lib/shared/utils/invoice_terms.dart index c1398e1b..151140b2 100644 --- a/lib/shared/utils/invoice_terms.dart +++ b/lib/shared/utils/invoice_terms.dart @@ -38,7 +38,19 @@ class InvoiceTerms { const InvoiceTerms._({this.invoice, this.problem}); - bool get isPayable => problem == null; + /// 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. diff --git a/test/features/order/screens/add_lightning_invoice_screen_test.dart b/test/features/order/screens/add_lightning_invoice_screen_test.dart index b831e1df..108cde11 100644 --- a/test/features/order/screens/add_lightning_invoice_screen_test.dart +++ b/test/features/order/screens/add_lightning_invoice_screen_test.dart @@ -7,6 +7,7 @@ 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/data/models/payment_failed.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'; @@ -70,11 +71,16 @@ Future pumpAddScreen( int? anchoredSats, MarketCheck? market, MarketCheckResult? marketResult, + bool payoutFailed = false, }) async { final state = OrderState( status: Status.waitingBuyerInvoice, action: mostro.Action.addInvoice, order: _request(requestedSats)?.getPayload(), + // Retained from the earlier payment-failed message, which is what marks + // this prompt as a retry rather than the first one. + paymentFailed: + payoutFailed ? PaymentFailed(paymentAttempts: 1, paymentRetriesInterval: 60) : null, ); final router = GoRouter( @@ -237,6 +243,30 @@ void main() { expect(find.text(s.invoiceOffMarketTitle), findsOneWidget); }); + testWidgets('cautions instead of refusing on a payout retry', + (tester) async { + // The re-prompt after a failed payout arrives hours after the order was + // priced, and bitcoin moves further than the tolerance in that time. + // Refusing there strands the user on the path where the only way out is + // to cancel a half-settled trade. + await pumpAddScreen( + tester, + requestedSats: 99700, + anchoredSats: 99700, + payoutFailed: true, + market: const 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('holds the invoice flow while the rate is still in flight', (tester) async { await pumpAddScreen( diff --git a/test/features/order/screens/pay_lightning_invoice_screen_test.dart b/test/features/order/screens/pay_lightning_invoice_screen_test.dart index 4514a066..fa7f9060 100644 --- a/test/features/order/screens/pay_lightning_invoice_screen_test.dart +++ b/test/features/order/screens/pay_lightning_invoice_screen_test.dart @@ -306,6 +306,24 @@ void main() { 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( diff --git a/test/mocks.dart b/test/mocks.dart index ef014635..353596b4 100644 --- a/test/mocks.dart +++ b/test/mocks.dart @@ -116,11 +116,17 @@ 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; - mockSession.pinnedAmountSats = pinnedAmountSats; - mockSession.pinnedFeeRate = pinnedFeeRate; return mockSession; } } diff --git a/test/shared/utils/invoice_terms_test.dart b/test/shared/utils/invoice_terms_test.dart index dccaaf40..94b2fd58 100644 --- a/test/shared/utils/invoice_terms_test.dart +++ b/test/shared/utils/invoice_terms_test.dart @@ -86,7 +86,11 @@ void main() { expect(terms.problem, InvoiceTermsProblem.unreadable); }); - test('refuses when the order carries no amount to check against', () { + 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), @@ -95,9 +99,25 @@ void main() { 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), From f54d219076304cef867bbef1795b1bd7aa61f692 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Tue, 25 Aug 2026 14:03:45 -0300 Subject: [PATCH 24/31] fix(invoice): keep the market check from flapping, and its anchors intact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three from the review of the previous push, all confirmed against the code first. The TTL refresh reports isLoading with the previous value still attached, and the check treated every loading state as "no answer yet". That pulled 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. Only a cold fetch holds the flow now. The pending state offered no action at all, where every other branch on both screens offers Cancel. A request that never came back left the user with no way forward and no way out. And a failed read of the anchor store discarded every anchor on the next write: memory mirrored nothing, and flushing it serialized an empty map over trades still in flight, which would send exactly those back to whatever the node advertises. A read failure now refuses writes for the rest of the run. A value that was read and could not be parsed is the opposite case and still gets overwritten — there is nothing behind it to preserve, and refusing would leave pinning broken for good. --- .../providers/market_check_provider.dart | 7 ++- .../screens/add_lightning_invoice_screen.dart | 6 ++ .../screens/pay_lightning_invoice_screen.dart | 17 ++++++ .../order/settlement_terms_store.dart | 38 +++++++++++-- .../providers/market_check_provider_test.dart | 17 ++++++ .../add_lightning_invoice_screen_test.dart | 14 +++++ .../pay_lightning_invoice_screen_test.dart | 15 +++++ .../order/settlement_terms_store_test.dart | 56 +++++++++++++++++++ 8 files changed, 164 insertions(+), 6 deletions(-) diff --git a/lib/features/order/providers/market_check_provider.dart b/lib/features/order/providers/market_check_provider.dart index 3cdd3e05..975ead0b 100644 --- a/lib/features/order/providers/market_check_provider.dart +++ b/lib/features/order/providers/market_check_provider.dart @@ -123,8 +123,13 @@ final marketCheckProvider = 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) return MarketCheckResult.loading; + if (rate.isLoading && !rate.hasValue) return MarketCheckResult.loading; final fiatPerBtc = rate.valueOrNull; if (fiatPerBtc == null) return MarketCheckResult.unavailable; diff --git a/lib/features/order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart index 5d81e32d..814126f0 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -327,6 +327,10 @@ class _AddLightningInvoiceScreenState /// 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, @@ -345,6 +349,8 @@ class _AddLightningInvoiceScreenState ], ), ), + const Spacer(), + _buildCancelButton(), ], ); } diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index 7577cab7..db4cb622 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -220,6 +220,23 @@ class _PayLightningInvoiceScreenState ], ), ), + 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), diff --git a/lib/features/order/settlement_terms_store.dart b/lib/features/order/settlement_terms_store.dart index efb5a76b..ad3d7e9e 100644 --- a/lib/features/order/settlement_terms_store.dart +++ b/lib/features/order/settlement_terms_store.dart @@ -107,6 +107,18 @@ class SettlementTermsStore { 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(); @@ -122,9 +134,19 @@ class SettlementTermsStore { /// legacy. Future init() async { if (_initialized) return; + + final String? raw; try { - final raw = await _prefs.getString(_prefsKey); - if (raw != null) { + raw = await _prefs.getString(_prefsKey); + } catch (e) { + logger.e('Failed to read pinned settlement terms: $e'); + _readFailed = true; + _initialized = true; + return; + } + + if (raw != null) { + try { final decoded = jsonDecode(raw); if (decoded is Map) { final loaded = {}; @@ -135,11 +157,12 @@ class SettlementTermsStore { }); _terms = loaded; } + } catch (e) { + logger.e('Discarding unreadable pinned settlement terms: $e'); + _terms = {}; } - } catch (e) { - logger.e('Failed to load pinned settlement terms: $e'); - _terms = {}; } + _initialized = true; _prune(); } @@ -199,6 +222,11 @@ class SettlementTermsStore { /// one. Failures are logged and swallowed: one bad write must not poison /// every later one, and memory stays authoritative for this run. 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())), ); diff --git a/test/features/order/providers/market_check_provider_test.dart b/test/features/order/providers/market_check_provider_test.dart index 3492f271..a59aad75 100644 --- a/test/features/order/providers/market_check_provider_test.dart +++ b/test/features/order/providers/market_check_provider_test.dart @@ -314,6 +314,23 @@ void main() { 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', () { diff --git a/test/features/order/screens/add_lightning_invoice_screen_test.dart b/test/features/order/screens/add_lightning_invoice_screen_test.dart index 108cde11..cd5dbb9e 100644 --- a/test/features/order/screens/add_lightning_invoice_screen_test.dart +++ b/test/features/order/screens/add_lightning_invoice_screen_test.dart @@ -291,6 +291,20 @@ void main() { 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( diff --git a/test/features/order/screens/pay_lightning_invoice_screen_test.dart b/test/features/order/screens/pay_lightning_invoice_screen_test.dart index fa7f9060..ade54033 100644 --- a/test/features/order/screens/pay_lightning_invoice_screen_test.dart +++ b/test/features/order/screens/pay_lightning_invoice_screen_test.dart @@ -350,6 +350,21 @@ void main() { 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 diff --git a/test/features/order/settlement_terms_store_test.dart b/test/features/order/settlement_terms_store_test.dart index 4910c787..1cecb897 100644 --- a/test/features/order/settlement_terms_store_test.dart +++ b/test/features/order/settlement_terms_store_test.dart @@ -146,6 +146,25 @@ void main() { expect(reopened.termsFor(_otherKey), 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 blind.pin(_tradeKey, amountSats: 100000); + await blind.pendingWrites; + + expect(unreadable.writes, isEmpty); + }); + test('treats an unreadable record as no record at all', () async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.withData({ @@ -157,5 +176,42 @@ void main() { 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); + }); }); } + +/// 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); +} From bb1904b17c4104b5d4fbbc93f401b8e64bfe0724 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Tue, 25 Aug 2026 14:36:09 -0300 Subject: [PATCH 25/31] fix(invoice): fail closed when the settlement terms cannot be stored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Durable before publish was a claim the code did not keep. _flush swallowed the setString failure into a log and returned normally, pin returned that future unchanged, and _anchorTerms caught anything left. newSession then carried on and its caller published the commitment, so a transient storage failure put a live remote trade behind anchors that existed only in memory — and any crash, timeout, restore or node switch brought it back as termsPinned == false, reading whatever the node advertises. That is the invariant this whole change exists to establish. Worse, the in-memory entry was inserted before the write and left there when it failed, so the containsKey guard turned every later attempt into a no-op that never retried. Now: _flush carries the failure to its caller while the write chain absorbs it separately, so one bad write still cannot reorder the ones after it. pin removes the entry it could not persist and throws SettlementTermsNotDurable. _anchorTerms propagates. newSession anchors before registering the session anywhere, so a refused trade leaves nothing behind, and createChildOrderSession does the same before the release carrying NextTrade goes out. The session row stays best effort, deliberately: it is a fast path, and restore rebuilds it from the node's data using the anchor. Losing the row costs a rebuild; losing the anchor costs the terms. Take and create surface the failure instead of dropping it — the create path was not even awaiting submitOrder, so the error would have escaped as an unhandled async error. One new key across all six locales. --- .../order/screens/add_order_screen.dart | 10 +- .../order/screens/take_order_screen.dart | 14 ++ .../order/settlement_terms_store.dart | 63 ++++++-- lib/l10n/intl_de.arb | 1 + lib/l10n/intl_en.arb | 1 + lib/l10n/intl_es.arb | 1 + lib/l10n/intl_fr.arb | 1 + lib/l10n/intl_it.arb | 1 + lib/l10n/intl_pt.arb | 1 + lib/shared/notifiers/session_notifier.dart | 55 ++++--- .../order/settlement_terms_store_test.dart | 84 +++++++++- .../session_anchor_durability_test.dart | 143 ++++++++++++++++++ 12 files changed, 335 insertions(+), 40 deletions(-) create mode 100644 test/shared/notifiers/session_anchor_durability_test.dart diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index aeda024f..76ccb9e0 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/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 0c76629f..16b49062 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 index ad3d7e9e..cd477c50 100644 --- a/lib/features/order/settlement_terms_store.dart +++ b/lib/features/order/settlement_terms_store.dart @@ -5,6 +5,22 @@ 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 @@ -188,8 +204,14 @@ class SettlementTermsStore { int? fiatAmount, double? premium, DateTime? pinnedAt, - }) { - if (_terms.containsKey(tradeKeyPublic)) return Future.value(); + }) async { + if (_terms.containsKey(tradeKeyPublic)) return; + + if (_readFailed) { + throw const SettlementTermsNotDurable( + 'the persisted map went unread, so writing would erase it', + ); + } _terms[tradeKeyPublic] = PinnedTerms( amountSats: amountSats, @@ -199,7 +221,17 @@ class SettlementTermsStore { premium: premium, pinnedAt: pinnedAt ?? DateTime.now(), ); - return _flush(); + + 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; @@ -216,11 +248,16 @@ class SettlementTermsStore { if (_terms.length != before) _flush(); } - /// Appends the write to the chain. `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. Failures are logged and swallowed: one bad write must not poison - /// every later one, and memory stays authoritative for this run. + /// 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'); @@ -230,12 +267,10 @@ class SettlementTermsStore { final snapshot = jsonEncode( _terms.map((key, value) => MapEntry(key, value.toJson())), ); - final queued = _writes - .then((_) => _prefs.setString(_prefsKey, snapshot)) - .catchError( - (Object e) => logger.e('Failed to persist pinned settlement terms: $e'), - ); - _writes = queued; + final queued = _writes.then((_) => _prefs.setString(_prefsKey, snapshot)); + _writes = queued.catchError( + (Object e) => logger.e('Failed to persist pinned settlement terms: $e'), + ); return queued; } } diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 564291b0..d2cc190c 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1771,6 +1771,7 @@ "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.", + "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 ...", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 024813a9..b9ab354b 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1816,6 +1816,7 @@ "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.", + "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...", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 2a3f39fb..5e519ade 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1746,6 +1746,7 @@ "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. Revisalo vos antes de continuar.", + "orderTermsNotStored": "No se pudieron guardar los términos de la orden en este dispositivo, así que no se envió nada. Intentá 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. Revisalo vos antes de continuar.", "invoiceCheckingMarketRate": "Verificando la tasa de mercado...", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 39397879..0f780011 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1771,6 +1771,7 @@ "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.", + "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é...", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index a802a712..9ac7e882 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1812,6 +1812,7 @@ "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.", + "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...", diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index e7065c55..964775f8 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -1816,6 +1816,7 @@ "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.", + "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...", diff --git a/lib/shared/notifiers/session_notifier.dart b/lib/shared/notifiers/session_notifier.dart index abf80877..94ffbdac 100644 --- a/lib/shared/notifiers/session_notifier.dart +++ b/lib/shared/notifiers/session_notifier.dart @@ -224,19 +224,27 @@ class SessionNotifier extends StateNotifier> { 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; } - await _anchorTerms(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; its anchor - // is durable either way, keyed by the trade key it will be confirmed - // under. + // 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); @@ -257,19 +265,20 @@ class SessionNotifier extends StateNotifier> { /// 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. - Future _anchorTerms(Session session) async { - try { - await ref.read(settlementTermsStoreProvider).pin( - session.tradeKey.public, - amountSats: session.pinnedAmountSats, - feeRate: session.pinnedFeeRate, - fiatCode: session.pinnedFiatCode, - fiatAmount: session.pinnedFiatAmount, - premium: session.pinnedPremium, - ); - } catch (e) { - logger.e('Failed to anchor pinned terms for a new session: $e'); - } + /// + /// 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 @@ -444,12 +453,14 @@ class SessionNotifier extends StateNotifier> { termsPinned: termsPinned, ); - _pendingChildSessions[tradeKey.public] = session; - - // The child has no order id until mostrod delivers one, so the anchor is - // the only durable record of what the parent committed to until then. + // Before the child is registered, on the same terms as a take: the + // release message carrying NextTrade goes out once this returns, and a + // remainder published without its anchor settles on whatever the node + // advertises by the time a taker resolves it. if (termsPinned) await _anchorTerms(session); + _pendingChildSessions[tradeKey.public] = session; + _emitState(); // Register the child trade key with the push server right away: the child diff --git a/test/features/order/settlement_terms_store_test.dart b/test/features/order/settlement_terms_store_test.dart index 1cecb897..86544fee 100644 --- a/test/features/order/settlement_terms_store_test.dart +++ b/test/features/order/settlement_terms_store_test.dart @@ -146,6 +146,65 @@ void main() { 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 at all when the map went unread', () 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('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 @@ -159,7 +218,11 @@ void main() { final unreadable = _ThrowingReadPrefs(); final blind = SettlementTermsStore(unreadable); await blind.init(); - await blind.pin(_tradeKey, amountSats: 100000); + + await expectLater( + blind.pin(_tradeKey, amountSats: 100000), + throwsA(isA()), + ); await blind.pendingWrites; expect(unreadable.writes, isEmpty); @@ -198,6 +261,25 @@ void main() { }); } +/// Writes fail until [failWrites] is cleared. Reads come back empty. +// 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 = []; 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 00000000..20f00a53 --- /dev/null +++ b/test/shared/notifiers/session_anchor_durability_test.dart @@ -0,0 +1,143 @@ +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('throws, so the release carrying NextTrade is never published', + () async { + await expectLater( + notifier.createChildOrderSession( + tradeKey: keyPair, + keyIndex: 2, + parentOrderId: 'order-1', + role: Role.seller, + pinnedFeeRate: 0.006, + termsPinned: true, + ), + throwsA(isA()), + ); + + expect(notifier.getSessionByTradeKey(keyPair.public), isNull); + }); + }); +} + +/// 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); +} From 2e1a3c16233079901263b32e2488e18dfbbd5dcd Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Tue, 25 Aug 2026 18:53:18 -0300 Subject: [PATCH 26/31] fix(invoice): check the payout invoice against the order's signed terms too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main's PayoutInvoiceScreen returns from the top of the add-invoice build whenever the order is settled, so both late paths — the retry after a failed payout, and the window between release and payout — skipped every check in this branch. An invoice minted there is still what the payout settles at. The checks run there now, and every one of them cautions rather than refuses. "Nothing left to cancel" cuts both ways: the user has already given up their side, 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, which is worse than collecting an amount they were shown to be wrong. Naming the gap is the strongest thing that can be done there without stranding an honest trade. That also relocates the payout-retry exemption rather than dropping it. It was written for exactly this case and lived in the add screen, where the early return made it unreachable — and the test covering it passed only because the harness built a state combination the router no longer produces. Both are gone; the behaviour is where the path actually runs. Two new keys across all six locales. --- .../screens/add_lightning_invoice_screen.dart | 19 +-- .../order/screens/payout_invoice_screen.dart | 85 +++++++++- lib/l10n/intl_de.arb | 2 + lib/l10n/intl_en.arb | 3 + lib/l10n/intl_es.arb | 2 + lib/l10n/intl_fr.arb | 2 + lib/l10n/intl_it.arb | 2 + lib/l10n/intl_pt.arb | 2 + .../add_lightning_invoice_screen_test.dart | 30 ---- .../screens/payout_invoice_screen_test.dart | 154 ++++++++++++++++++ 10 files changed, 253 insertions(+), 48 deletions(-) create mode 100644 test/features/order/screens/payout_invoice_screen_test.dart diff --git a/lib/features/order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart index 9b8b1473..0a8a0d85 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -143,24 +143,19 @@ class _AddLightningInvoiceScreenState // request a way to stop honest trades. final marketUnavailable = !blocked && market.isUnavailable; - // A re-prompt after a failed payout can arrive hours after the order - // was priced, and bitcoin moves further than the tolerance in that - // time. The gap is then real and the node did nothing to cause it, so - // refusing here would strand the user on the most fragile path in the - // flow — the one where the trade is already half-settled and the only - // way out of a refusal is to cancel. The gap is still named. - final isPayoutRetry = orderState.paymentFailed != null; - // 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 && - !isPayoutRetry && - check!.isAdverseTo(Role.buyer); + final marketBlocked = + offMarket && !marketOverridden && check!.isAdverseTo(Role.buyer); final marketCaution = offMarket && !marketBlocked; final headerBlock = (unverified || marketCaution || marketUnavailable) diff --git a/lib/features/order/screens/payout_invoice_screen.dart b/lib/features/order/screens/payout_invoice_screen.dart index ca28d009..0c08665b 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/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 7de562d7..f16aa6bc 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1787,6 +1787,8 @@ "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.", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index fb2be1fa..08f72fa0 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1832,6 +1832,9 @@ "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.", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index c4a0758e..e68cd872 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1762,6 +1762,8 @@ "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. Revisalo vos 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}. Revisalo antes de cobrar.", "orderTermsNotStored": "No se pudieron guardar los términos de la orden en este dispositivo, así que no se envió nada. Intentá 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. Revisalo vos antes de continuar.", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 344284b6..e487e382 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1787,6 +1787,8 @@ "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.", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index e04e76cc..ba1cdb40 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1828,6 +1828,8 @@ "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.", diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index 1a6f2aac..76940be5 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -1832,6 +1832,8 @@ "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.", diff --git a/test/features/order/screens/add_lightning_invoice_screen_test.dart b/test/features/order/screens/add_lightning_invoice_screen_test.dart index cd5dbb9e..ac8d37f0 100644 --- a/test/features/order/screens/add_lightning_invoice_screen_test.dart +++ b/test/features/order/screens/add_lightning_invoice_screen_test.dart @@ -7,7 +7,6 @@ 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/data/models/payment_failed.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'; @@ -71,16 +70,11 @@ Future pumpAddScreen( int? anchoredSats, MarketCheck? market, MarketCheckResult? marketResult, - bool payoutFailed = false, }) async { final state = OrderState( status: Status.waitingBuyerInvoice, action: mostro.Action.addInvoice, order: _request(requestedSats)?.getPayload(), - // Retained from the earlier payment-failed message, which is what marks - // this prompt as a retry rather than the first one. - paymentFailed: - payoutFailed ? PaymentFailed(paymentAttempts: 1, paymentRetriesInterval: 60) : null, ); final router = GoRouter( @@ -243,30 +237,6 @@ void main() { expect(find.text(s.invoiceOffMarketTitle), findsOneWidget); }); - testWidgets('cautions instead of refusing on a payout retry', - (tester) async { - // The re-prompt after a failed payout arrives hours after the order was - // priced, and bitcoin moves further than the tolerance in that time. - // Refusing there strands the user on the path where the only way out is - // to cancel a half-settled trade. - await pumpAddScreen( - tester, - requestedSats: 99700, - anchoredSats: 99700, - payoutFailed: true, - market: const 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('holds the invoice flow while the rate is still in flight', (tester) async { await pumpAddScreen( 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 00000000..e6d28858 --- /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); + }); + }); +} From 606e9b94b3f3c390ee29c85cd30038e90c158e70 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Tue, 25 Aug 2026 19:24:36 -0300 Subject: [PATCH 27/31] fix(invoice): do not let a range remainder's anchor block a release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Failing closed is right where nothing has been published and a retry costs nothing. Release and fiat-sent are neither: both call _prepareChildOrderIfNeeded before publishing, that chain reaches createChildOrderSession, and the call sites are fire-and-forget. A throw there closed the dialog, published nothing and said nothing, leaving the funds in escrow — on the action that moves the money. The child is a preparation of the range order's next leg. Refusing to release because its anchor could not be written trades the important thing for the incidental one, so the child now falls back to the terms a session predating pinning uses and the release goes out. It is marked legacy rather than left claiming anchors that survive nothing. A storage failure is not something the node can bring about, so this is not a downgrade it can reach for — the same reasoning the payout screen already follows: when there is no way back, naming the problem beats blocking the action. Separately, _readFailed latched for the whole process. One transient prefs error at startup left the user unable to take, create or release until they restarted the app. pin() re-reads before refusing now, and a store that reads again picks up what was on disk rather than starting empty. --- .../order/settlement_terms_store.dart | 33 ++++++-- lib/shared/notifiers/session_notifier.dart | 43 ++++++++--- .../order/settlement_terms_store_test.dart | 75 ++++++++++++++++++- .../session_anchor_durability_test.dart | 67 ++++++++++++++--- 4 files changed, 187 insertions(+), 31 deletions(-) diff --git a/lib/features/order/settlement_terms_store.dart b/lib/features/order/settlement_terms_store.dart index cd477c50..092af525 100644 --- a/lib/features/order/settlement_terms_store.dart +++ b/lib/features/order/settlement_terms_store.dart @@ -151,14 +151,24 @@ class SettlementTermsStore { 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'); - _readFailed = true; - _initialized = true; - return; + return false; } if (raw != null) { @@ -179,8 +189,7 @@ class SettlementTermsStore { } } - _initialized = true; - _prune(); + return true; } /// The terms [tradeKeyPublic] committed to, or null if it never pinned any. @@ -208,9 +217,17 @@ class SettlementTermsStore { if (_terms.containsKey(tradeKeyPublic)) return; if (_readFailed) { - throw const SettlementTermsNotDurable( - 'the persisted map went unread, so writing would erase it', - ); + // 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( diff --git a/lib/shared/notifiers/session_notifier.dart b/lib/shared/notifiers/session_notifier.dart index 94ffbdac..aa5bb03e 100644 --- a/lib/shared/notifiers/session_notifier.dart +++ b/lib/shared/notifiers/session_notifier.dart @@ -439,6 +439,35 @@ class SessionNotifier extends StateNotifier> { }) 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, + ); + } 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, @@ -447,18 +476,12 @@ class SessionNotifier extends StateNotifier> { fullPrivacy: _settings.fullPrivacyMode, parentOrderId: parentOrderId, role: role, - pinnedFeeRate: pinnedFeeRate, - pinnedFiatCode: pinnedFiatCode, - pinnedPremium: pinnedPremium, - termsPinned: termsPinned, + pinnedFeeRate: pinned ? pinnedFeeRate : null, + pinnedFiatCode: pinned ? pinnedFiatCode : null, + pinnedPremium: pinned ? pinnedPremium : null, + termsPinned: pinned, ); - // Before the child is registered, on the same terms as a take: the - // release message carrying NextTrade goes out once this returns, and a - // remainder published without its anchor settles on whatever the node - // advertises by the time a taker resolves it. - if (termsPinned) await _anchorTerms(session); - _pendingChildSessions[tradeKey.public] = session; _emitState(); diff --git a/test/features/order/settlement_terms_store_test.dart b/test/features/order/settlement_terms_store_test.dart index 86544fee..d9559dc2 100644 --- a/test/features/order/settlement_terms_store_test.dart +++ b/test/features/order/settlement_terms_store_test.dart @@ -194,7 +194,7 @@ void main() { expect(store.termsFor(_otherKey), isNotNull); }); - test('refuses to pin at all when the map went unread', () async { + 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(); @@ -205,6 +205,51 @@ void main() { ); }); + 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 @@ -262,6 +307,34 @@ void main() { } /// 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; diff --git a/test/shared/notifiers/session_anchor_durability_test.dart b/test/shared/notifiers/session_anchor_durability_test.dart index 20f00a53..0eb42628 100644 --- a/test/shared/notifiers/session_anchor_durability_test.dart +++ b/test/shared/notifiers/session_anchor_durability_test.dart @@ -104,21 +104,64 @@ void main() { }); group('createChildOrderSession when the anchors cannot be stored', () { - test('throws, so the release carrying NextTrade is never published', + 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 { - await expectLater( - notifier.createChildOrderSession( - tradeKey: keyPair, - keyIndex: 2, - parentOrderId: 'order-1', - role: Role.seller, - pinnedFeeRate: 0.006, - termsPinned: true, - ), - throwsA(isA()), + // 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(notifier.getSessionByTradeKey(keyPair.public), isNull); + 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'); }); }); } From 914438df5ae2096577c878a6943d323be59eaf49 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 27 Aug 2026 19:14:02 -0300 Subject: [PATCH 28/31] fix(l10n): standardize Spanish imperative forms to formal second person --- lib/l10n/intl_es.arb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index e68cd872..2a274e33 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1761,16 +1761,16 @@ }, "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. Revisalo vos antes de continuar.", + "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}. Revisalo antes de cobrar.", - "orderTermsNotStored": "No se pudieron guardar los términos de la orden en este dispositivo, así que no se envió nada. Intentá de nuevo.", + "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. Revisalo vos antes de continuar.", + "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 revisalo 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. Revisá los términos antes de continuar.", + "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í.", From a15c2278b548b277bd77d3945a3cf7b4d2e94076 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 27 Aug 2026 19:19:46 -0300 Subject: [PATCH 29/31] fix(order): fetch node info event before the order book to pin fee rates earlier --- .../repositories/open_orders_repository.dart | 83 ++++++++++++++--- .../open_orders_subscription_test.dart | 93 +++++++++++++++++++ 2 files changed, 161 insertions(+), 15 deletions(-) create mode 100644 test/data/repositories/open_orders_subscription_test.dart diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index d271b043..88304d54 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -29,6 +29,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 +52,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 +86,41 @@ 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], + ), + ], ); _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 +131,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 +179,7 @@ class OpenOrdersRepository implements OrderRepository { @override void dispose() { _subscription?.cancel(); + _infoSubscription?.cancel(); _initRetryTimer?.cancel(); _eventStreamController.close(); _mostroInstanceController.close(); 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 00000000..fa07e427 --- /dev/null +++ b/test/data/repositories/open_orders_subscription_test.dart @@ -0,0 +1,93 @@ +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); + }); + + 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]); + }); + }); +} From 98e7c8c5a796685129014cfea44bd95e5a9c5c9c Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 27 Aug 2026 19:33:16 -0300 Subject: [PATCH 30/31] fix(order): cap the book subscription at 1000 events to bound startup work --- .../repositories/open_orders_repository.dart | 16 ++++++++++++++++ .../open_orders_subscription_test.dart | 1 + 2 files changed, 17 insertions(+) diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index 88304d54..5f6df779 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; @@ -113,6 +128,7 @@ class OpenOrdersRepository implements OrderRepository { kinds: [orderEventKind], since: filterTime, authors: [nodePubkey], + limit: orderFilterLimit, ), ], ); diff --git a/test/data/repositories/open_orders_subscription_test.dart b/test/data/repositories/open_orders_subscription_test.dart index fa07e427..f4dfa2c6 100644 --- a/test/data/repositories/open_orders_subscription_test.dart +++ b/test/data/repositories/open_orders_subscription_test.dart @@ -72,6 +72,7 @@ void main() { expect(orders.kinds, [orderEventKind]); expect(orders.authors, [_nodePubkey]); expect(orders.since, isNotNull); + expect(orders.limit, orderFilterLimit); }); test('resubscribes both when the node changes', () { From 030d7b1ec7413b92979ab00ba2e16ad38e352cc5 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 27 Aug 2026 19:44:54 -0300 Subject: [PATCH 31/31] fix(invoice): compare late-arriving fee rates against the commitment instant --- .../providers/settlement_anchor_provider.dart | 93 +++++++++++-- lib/services/mostro_service.dart | 5 + lib/shared/notifiers/session_notifier.dart | 8 ++ .../settlement_anchor_provider_test.dart | 131 +++++++++++++++--- .../order/settlement_terms_store_test.dart | 19 +++ 5 files changed, 227 insertions(+), 29 deletions(-) diff --git a/lib/features/order/providers/settlement_anchor_provider.dart b/lib/features/order/providers/settlement_anchor_provider.dart index 0b0a2fdb..7cbd9252 100644 --- a/lib/features/order/providers/settlement_anchor_provider.dart +++ b/lib/features/order/providers/settlement_anchor_provider.dart @@ -1,6 +1,7 @@ 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'; @@ -46,7 +47,7 @@ final signedOrderAmountProvider = Provider.family((ref, orderId) { return ref.watch(publishedOrderAmountProvider(orderId)); }); -/// The node's fee rate, from its kind-38385 info event. +/// 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 @@ -58,24 +59,61 @@ final signedOrderAmountProvider = Provider.family((ref, orderId) { /// 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 nodeFeeRateProvider = Provider((ref) { +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 info.fee; + 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 @@ -107,17 +145,52 @@ final orderFeeRateProvider = Provider.family((ref, orderId) { final pinned = session?.pinnedFeeRate; if (pinned != null) return pinned; - // Pinning ran for this session and came up with no rate, so the node had - // none to offer at the moment of agreement. Reading the live rate here - // would let it supply the term afterwards, which is the whole of what - // pinning exists to prevent. Report it unknown instead and let the screen - // say the check could not be made. + // 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. // - // This cannot be inferred from a pinned amount being present: a + // 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) return null; + 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); }); diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 8bc7e96c..f98395ad 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -329,6 +329,11 @@ class MostroService { // 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( diff --git a/lib/shared/notifiers/session_notifier.dart b/lib/shared/notifiers/session_notifier.dart index aa5bb03e..3d222ffe 100644 --- a/lib/shared/notifiers/session_notifier.dart +++ b/lib/shared/notifiers/session_notifier.dart @@ -427,6 +427,12 @@ class SessionNotifier extends StateNotifier> { /// 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, @@ -435,6 +441,7 @@ class SessionNotifier extends StateNotifier> { double? pinnedFeeRate, String? pinnedFiatCode, double? pinnedPremium, + DateTime? committedAt, bool termsPinned = false, }) async { final masterKey = ref.read(keyManagerProvider).masterKeyPair!; @@ -459,6 +466,7 @@ class SessionNotifier extends StateNotifier> { feeRate: pinnedFeeRate, fiatCode: pinnedFiatCode, premium: pinnedPremium, + pinnedAt: committedAt, ); } on SettlementTermsNotDurable catch (e) { logger.e( diff --git a/test/features/order/providers/settlement_anchor_provider_test.dart b/test/features/order/providers/settlement_anchor_provider_test.dart index 546e7124..1a1d8268 100644 --- a/test/features/order/providers/settlement_anchor_provider_test.dart +++ b/test/features/order/providers/settlement_anchor_provider_test.dart @@ -4,6 +4,7 @@ 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'; @@ -18,16 +19,27 @@ 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. -NostrEvent _infoEvent({String? pubkey, String? fee}) => NostrEvent( +/// +/// [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: DateTime.utc(2026), + createdAt: createdAt ?? DateTime.utc(2026), tags: [ ['d', pubkey ?? _nodePubkey], if (fee != null) ['fee', fee], @@ -98,8 +110,21 @@ void main() { 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() { @@ -107,14 +132,23 @@ void main() { 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(() { + setUp(() async { repository = MockOpenOrdersRepository(); infoEvents = StreamController.broadcast(); orderEvents = StreamController>.broadcast(); @@ -124,6 +158,7 @@ void main() { settings = _StubSettingsNotifier(_nodePubkey); session = null; + termsStore = await emptyStore(); build(); addTearDown(infoEvents.close); @@ -215,17 +250,17 @@ void main() { expect(container.read(anchoredBuyerAmountProvider(_orderId)), 99700); }); - test('a fee missing at commitment does not become a term afterwards', + test('a fee published after the commitment does not become a term of it', () async { - // The take landed before the info event, so there was an amount to pin - // and no rate. Reading the rate later would let the node choose it. + // 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); - build(); + await commitAt(_commitment); container.read(anchoredSellerAmountProvider(_orderId)); orderEvents.add([_orderEvent()]); - // The node now publishes a rate more than sixteen times the usual. - infoEvents.add(_infoEvent(fee: '0.1')); + // 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); @@ -278,17 +313,16 @@ void main() { expect(container.read(anchoredSellerAmountProvider(_orderId)), 100300); }); - test('a market-price take with no rate at commitment stays unknown', - () async { + 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 info event had not arrived. The absent rate must - // not be filled in from a later event. + // fixes them, and the node had published no rate. One signed afterwards + // must not fill the absence in. session = _session(); - build(); + await commitAt(_commitment); container.read(anchoredSellerAmountProvider(_orderId)); orderEvents.add([_orderEvent()]); - infoEvents.add(_infoEvent(fee: '0.1')); + infoEvents.add(_infoEvent(fee: '0.1', createdAt: _afterCommitment)); await flush(); expect(container.read(orderFeeRateProvider(_orderId)), isNull); @@ -297,18 +331,77 @@ void main() { test('a range remainder inherits the absence its parent committed to', () async { - // The child session carries the parent's pinned rate, which is none. - // Reading one now would promote an event published after the agreement - // into a term of it. + // 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.05')); + 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/settlement_terms_store_test.dart b/test/features/order/settlement_terms_store_test.dart index d9559dc2..1b15fc47 100644 --- a/test/features/order/settlement_terms_store_test.dart +++ b/test/features/order/settlement_terms_store_test.dart @@ -114,6 +114,25 @@ void main() { 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();