From b7df09d857de1f29481c29457643b07d1e43986c Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Fri, 14 Aug 2026 15:19:21 -0300 Subject: [PATCH 01/10] fix: reject forged or replayed admin resolutions without dispute evidence --- lib/features/order/models/order_state.dart | 67 +++++- ...der_state_admin_resolution_guard_test.dart | 205 ++++++++++++++++++ 2 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 test/features/order/models/order_state_admin_resolution_guard_test.dart diff --git a/lib/features/order/models/order_state.dart b/lib/features/order/models/order_state.dart index 4f27f8f6..48c9a214 100644 --- a/lib/features/order/models/order_state.dart +++ b/lib/features/order/models/order_state.dart @@ -93,6 +93,37 @@ class OrderState { ); } + /// Dispute statuses in which the dispute is over: a resolution has already + /// been applied and no further admin action is expected for it. + static const _terminalDisputeStatuses = { + 'resolved', + 'seller-refunded', + 'closed', + }; + + static bool _isAdminDisputeAction(Action action) => + action == Action.adminSettled || + action == Action.adminSettle || + action == Action.adminCanceled || + action == Action.adminCancel || + action == Action.adminTookDispute || + action == Action.adminTakeDispute; + + /// Whether local state corroborates that a dispute exists for this order. + /// + /// The incoming payload deliberately does not count: taking an + /// attacker-supplied Dispute as its own justification is the vector this + /// guards against. An already-resolved dispute is not re-resolved either, + /// which blocks replaying an authentic resolution onto a later state. + bool get _acceptsAdminDisputeAction { + final localDisputeStatus = dispute?.status?.toLowerCase(); + if (localDisputeStatus != null && + _terminalDisputeStatuses.contains(localDisputeStatus)) { + return false; + } + return dispute != null || status == Status.dispute; + } + OrderState updateWith(MostroMessage message) { logger.i('Updating OrderState with Action: ${message.action}'); @@ -101,6 +132,18 @@ class OrderState { return copyWith(cantDo: message.getPayload()); } + // An admin resolution only means something as the outcome of a dispute that + // already exists. Applied unconditionally, a forged or replayed admin-* + // message flips a live trade to a terminal state and drives the resolution + // UI, so drop it unless local state corroborates the dispute. + if (_isAdminDisputeAction(message.action) && !_acceptsAdminDisputeAction) { + logger.w( + 'Ignoring ${message.action} for order ${message.id}: no dispute ' + 'evidence in local state (status: $status, dispute: ${dispute?.status ?? 'none'})', + ); + return this; + } + // Track whether fiat was sent at any point in this order's lifecycle final bool newFiatWasSent = fiatWasSent || message.action == Action.fiatSent || @@ -155,12 +198,28 @@ class OrderState { newPeer = peer; // Preserve existing } - // Handle dispute status updates based on action - Dispute? updatedDispute = message.getPayload() ?? dispute; - + // Handle dispute status updates based on action. + // A payload dispute never re-points a tracked dispute at a different id: + // only the dispute this order already carries can be updated from the wire. + final localDispute = dispute; + final payloadDispute = message.getPayload(); + final bool payloadDisputeAccepted = payloadDispute != null && + (localDispute == null || + payloadDispute.disputeId == localDispute.disputeId); + + if (payloadDispute != null && !payloadDisputeAccepted) { + logger.w( + 'Ignoring dispute payload ${payloadDispute.disputeId} for order ' + '${message.id}: does not match tracked dispute ${localDispute!.disputeId}', + ); + } + + Dispute? updatedDispute = + payloadDisputeAccepted ? payloadDispute : localDispute; + // If we got a dispute from the message payload, ensure it has the message timestamp // This is critical for correct sorting in the dispute list - if (updatedDispute != null && message.getPayload() != null) { + if (updatedDispute != null && payloadDisputeAccepted) { // Use message timestamp if dispute doesn't have a createdAt or if message has a timestamp // Note: Nostr timestamps are in seconds, so convert to milliseconds if (message.timestamp != null) { diff --git a/test/features/order/models/order_state_admin_resolution_guard_test.dart b/test/features/order/models/order_state_admin_resolution_guard_test.dart new file mode 100644 index 00000000..ac71619c --- /dev/null +++ b/test/features/order/models/order_state_admin_resolution_guard_test.dart @@ -0,0 +1,205 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models.dart'; +import 'package:mostro_mobile/data/enums.dart'; +import 'package:mostro_mobile/features/order/models/order_state.dart'; + +/// Guards against forged or replayed admin resolutions. +/// +/// `admin-settled` / `admin-canceled` / `admin-took-dispute` are only +/// legitimate as the outcome of an existing dispute. Applying them without +/// dispute evidence lets a counterparty (v1 intake) or a replayed message flip +/// a live trade to a terminal state and drive the resolution UI. + +Order _testOrder({Status status = Status.active}) => Order( + id: 'test-order-id', + kind: OrderType.sell, + status: status, + amount: 50000, + fiatCode: 'USD', + fiatAmount: 500, + paymentMethod: 'SEPA', + premium: 0, + ); + +/// A live trade with no dispute anywhere: the victim's state before the forgery. +OrderState _stateWithoutDispute({ + Status status = Status.fiatSent, + Action action = Action.fiatSentOk, +}) { + return OrderState( + status: status, + action: action, + order: _testOrder(status: status), + dispute: null, + ); +} + +/// A trade with a real dispute under review: the legitimate precondition for +/// an admin resolution. +OrderState _stateWithDispute({String disputeStatus = 'in-progress'}) { + return OrderState( + status: Status.dispute, + action: Action.disputeInitiatedByYou, + order: _testOrder(status: Status.dispute), + dispute: Dispute( + disputeId: 'dispute-1', + orderId: 'test-order-id', + status: disputeStatus, + ), + ); +} + +MostroMessage _message(Action action, {Payload? payload}) { + return MostroMessage(id: 'test-order-id', action: action, payload: payload); +} + +const _adminPubkey = + 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90'; + +void main() { + group('Admin resolutions require dispute evidence', () { + test('bare admin-canceled does not flip a fiat-sent order', () { + final state = _stateWithoutDispute(); + + final updated = state.updateWith(_message(Action.adminCanceled)); + + expect(updated.status, equals(Status.fiatSent), + reason: 'no dispute exists, so the resolution must not apply'); + expect(updated.action, equals(Action.fiatSentOk)); + expect(updated.dispute, isNull); + }); + + test('bare admin-settled does not flip a fiat-sent order', () { + final state = _stateWithoutDispute(); + + final updated = state.updateWith(_message(Action.adminSettled)); + + expect(updated.status, equals(Status.fiatSent)); + expect(updated.action, equals(Action.fiatSentOk)); + expect(updated.dispute, isNull); + }); + + test('bare admin-took-dispute does not move an active order to dispute', + () { + final state = _stateWithoutDispute( + status: Status.active, + action: Action.holdInvoicePaymentAccepted, + ); + + final updated = state.updateWith(_message(Action.adminTookDispute)); + + expect(updated.status, equals(Status.active)); + expect(updated.dispute, isNull); + }); + + test('a one-field dispute payload does not invent a resolved dispute', () { + final state = _stateWithoutDispute( + status: Status.active, + action: Action.holdInvoicePaymentAccepted, + ); + + final updated = state.updateWith( + _message( + Action.adminCanceled, + payload: Dispute(disputeId: 'attacker-chosen-id'), + ), + ); + + expect(updated.status, equals(Status.active), + reason: 'an attacker-supplied dispute id is not dispute evidence'); + expect(updated.dispute, isNull, + reason: 'the dispute object must not be materialized from the wire'); + }); + + test('a payload dispute cannot re-point a tracked dispute at another id', + () { + final state = _stateWithDispute(); + + final updated = state.updateWith( + _message( + Action.adminCanceled, + payload: Dispute(disputeId: 'attacker-chosen-id'), + ), + ); + + expect(updated.dispute, isNotNull); + expect(updated.dispute!.disputeId, equals('dispute-1'), + reason: 'the tracked dispute id must survive the wire payload'); + }); + + test('admin-settled payload alone cannot fabricate a settled dispute', () { + final state = _stateWithoutDispute(); + + final updated = state.updateWith( + _message( + Action.adminSettled, + payload: Dispute(disputeId: 'attacker-chosen-id'), + ), + ); + + expect(updated.status, equals(Status.fiatSent)); + expect(updated.dispute, isNull); + }); + }); + + group('Legitimate admin resolutions still apply', () { + test('admin-settled resolves an existing in-progress dispute', () { + final state = _stateWithDispute(); + + final updated = state.updateWith(_message(Action.adminSettled)); + + expect(updated.status, equals(Status.settledByAdmin)); + expect(updated.action, equals(Action.adminSettled)); + expect(updated.dispute, isNotNull); + expect(updated.dispute!.status, equals('resolved')); + expect(updated.dispute!.action, equals('admin-settled')); + }); + + test('admin-canceled resolves an existing in-progress dispute', () { + final state = _stateWithDispute(); + + final updated = state.updateWith(_message(Action.adminCanceled)); + + // Status contract revisited in the admin-canceled session handling change. + expect(updated.status, equals(Status.canceled)); + expect(updated.action, equals(Action.adminCanceled)); + expect(updated.dispute, isNotNull); + expect(updated.dispute!.status, equals('seller-refunded')); + expect(updated.dispute!.action, equals('admin-canceled')); + }); + + test('admin-took-dispute assigns the admin on an existing dispute', () { + final state = _stateWithDispute(disputeStatus: 'initiated'); + + final updated = state.updateWith( + _message( + Action.adminTookDispute, + payload: Peer(publicKey: _adminPubkey), + ), + ); + + expect(updated.status, equals(Status.dispute)); + expect(updated.dispute, isNotNull); + expect(updated.dispute!.status, equals('in-progress')); + expect(updated.dispute!.adminPubkey, equals(_adminPubkey)); + }); + + test( + 'admin resolution applies when the order is already in dispute status ' + 'even without a local dispute object', () { + // Cold start / partial sync: the order is known to be disputed but the + // dispute object was never persisted locally. + final state = OrderState( + status: Status.dispute, + action: Action.disputeInitiatedByPeer, + order: _testOrder(status: Status.dispute), + dispute: null, + ); + + final updated = state.updateWith(_message(Action.adminSettled)); + + expect(updated.status, equals(Status.settledByAdmin), + reason: 'dispute status is itself dispute evidence'); + }); + }); +} From 0ae18fd6a680495310de605a7705fa8c18e33b9c Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Fri, 14 Aug 2026 15:23:30 -0300 Subject: [PATCH 02/10] docs: clarify MostroFSM is not wired and explain actual order status derivation --- CLAUDE.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index f0a67ce7..4bd623d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,10 @@ When implementing or debugging protocol-related features (order flows, actions, ### Nostr Integration - **NostrService** (`services/nostr_service.dart`) manages relay connections and messaging - All Nostr protocol interactions go through this service -- **MostroFSM** (`core/mostro_fsm.dart`) manages order state transitions +- **MostroFSM** (`core/mostro_fsm.dart`) defines a transition matrix but is **not wired in** — nothing imports it + - Order status is actually derived by `OrderState._getStatusFromAction` (`features/order/models/order_state.dart`), which maps actions to statuses without consulting the matrix + - Do not read `mostro_fsm.dart` as an active validation layer. Its role axis models who performs an action, not the local user's role in the trade (the app never assigns `Role.admin` to a session), so wiring it as-is would reject legitimate admin resolutions + - The only transition guard that runs today is the dispute-evidence check on `admin-*` actions in `OrderState.updateWith` ### Navigation and UI - **GoRouter** for navigation (configured in `core/app_routes.dart`) From 6b5eefc0f6a54f2e112f7902cba32ebd5595734c Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Fri, 14 Aug 2026 15:29:45 -0300 Subject: [PATCH 03/10] fix: keep admin-canceled orders visible under the canceled filter --- lib/features/order/models/order_state.dart | 9 +++-- .../notifiers/abstract_mostro_notifier.dart | 10 ++++++ .../trades/providers/trades_provider.dart | 16 +++++++-- ...der_state_admin_resolution_guard_test.dart | 24 +++++++++++-- test/features/trades/status_filter_test.dart | 35 +++++++++++++++++++ 5 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 test/features/trades/status_filter_test.dart diff --git a/lib/features/order/models/order_state.dart b/lib/features/order/models/order_state.dart index 48c9a214..3e3379c2 100644 --- a/lib/features/order/models/order_state.dart +++ b/lib/features/order/models/order_state.dart @@ -390,12 +390,17 @@ class OrderState { // Actions that should set status to canceled case Action.canceled: case Action.cancel: - case Action.adminCanceled: - case Action.adminCancel: case Action.cooperativeCancelAccepted: case Action.holdInvoicePaymentCanceled: return Status.canceled; + // A dispute resolved by cancelation is its own terminal state: it keeps + // the admin resolution visible to the user and out of the plain-cancel + // cleanup paths, which are built around Action.canceled. + case Action.adminCanceled: + case Action.adminCancel: + return Status.canceledByAdmin; + // Actions that should set status to cooperatively canceled (pending cancellation) case Action.cooperativeCancelInitiatedByYou: case Action.cooperativeCancelInitiatedByPeer: diff --git a/lib/features/order/notifiers/abstract_mostro_notifier.dart b/lib/features/order/notifiers/abstract_mostro_notifier.dart index 6be5d051..44a848e3 100644 --- a/lib/features/order/notifiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notifiers/abstract_mostro_notifier.dart @@ -668,6 +668,16 @@ class AbstractMostroNotifier extends StateNotifier { } break; + // Mirrors adminSettled: an admin resolution is terminal but keeps its + // session, so the user retains the record of how the dispute ended. + // Deliberately not routed through the Action.canceled cleanup, which + // deletes the session and would erase that record. + case Action.adminCanceled: + if (isRecent && !bypassTimestampGate) { + navProvider.go('/trade_detail/$orderId'); + } + break; + case Action.cantDo: final cantDo = event.getPayload(); diff --git a/lib/features/trades/providers/trades_provider.dart b/lib/features/trades/providers/trades_provider.dart index 91a0a33e..2fade4c5 100644 --- a/lib/features/trades/providers/trades_provider.dart +++ b/lib/features/trades/providers/trades_provider.dart @@ -13,6 +13,18 @@ import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; // Status filter provider - holds the currently selected status filter final statusFilterProvider = StateProvider((ref) => null); +/// Whether an order's status belongs under the selected filter. +/// +/// The picker offers no separate "canceled by admin" entry, so orders resolved +/// by an admin cancelation stay under "canceled" instead of dropping out of +/// every filter. The distinction is surfaced in the trade detail. +bool matchesStatusFilter(Status status, Status filter) { + if (filter == Status.canceled) { + return status == Status.canceled || status == Status.canceledByAdmin; + } + return status == filter; +} + // New provider that properly handles synthetic status filtering by checking OrderState final filteredTradesWithOrderStateProvider = Provider>>((ref) { @@ -59,10 +71,10 @@ final filteredTradesWithOrderStateProvider = final orderState = orderStates[order.orderId!]; if (orderState != null) { - return orderState.status == selectedStatusFilter; + return matchesStatusFilter(orderState.status, selectedStatusFilter); } else { // Fallback to raw status comparison if OrderState not available - return order.status == selectedStatusFilter; + return matchesStatusFilter(order.status, selectedStatusFilter); } }); } diff --git a/test/features/order/models/order_state_admin_resolution_guard_test.dart b/test/features/order/models/order_state_admin_resolution_guard_test.dart index ac71619c..d9f68c17 100644 --- a/test/features/order/models/order_state_admin_resolution_guard_test.dart +++ b/test/features/order/models/order_state_admin_resolution_guard_test.dart @@ -160,14 +160,34 @@ void main() { final updated = state.updateWith(_message(Action.adminCanceled)); - // Status contract revisited in the admin-canceled session handling change. - expect(updated.status, equals(Status.canceled)); + expect(updated.status, equals(Status.canceledByAdmin), + reason: 'an admin cancelation is its own terminal state'); expect(updated.action, equals(Action.adminCanceled)); expect(updated.dispute, isNotNull); expect(updated.dispute!.status, equals('seller-refunded')); expect(updated.dispute!.action, equals('admin-canceled')); }); + test('admin-canceled is distinguishable from a plain cancelation', () { + final adminCanceled = + _stateWithDispute().updateWith(_message(Action.adminCanceled)); + final plainCanceled = + _stateWithoutDispute().updateWith(_message(Action.canceled)); + + expect(adminCanceled.status, equals(Status.canceledByAdmin)); + expect(plainCanceled.status, equals(Status.canceled)); + expect(adminCanceled.status, isNot(equals(plainCanceled.status)), + reason: 'the user must be able to tell an admin resolution apart ' + 'from a cancelation by either party'); + }); + + test('admin-canceled keeps a terminal status', () { + final updated = + _stateWithDispute().updateWith(_message(Action.adminCanceled)); + + expect(updated.status.isTerminal, isTrue); + }); + test('admin-took-dispute assigns the admin on an existing dispute', () { final state = _stateWithDispute(disputeStatus: 'initiated'); diff --git a/test/features/trades/status_filter_test.dart b/test/features/trades/status_filter_test.dart new file mode 100644 index 00000000..58778ab7 --- /dev/null +++ b/test/features/trades/status_filter_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models/enums/status.dart'; +import 'package:mostro_mobile/features/trades/providers/trades_provider.dart'; + +void main() { + group('matchesStatusFilter', () { + test('canceled filter also matches admin-canceled orders', () { + expect(matchesStatusFilter(Status.canceledByAdmin, Status.canceled), + isTrue, + reason: 'the picker has no separate admin-canceled entry, so these ' + 'orders must not drop out of every filter'); + }); + + test('canceled filter matches plain canceled orders', () { + expect(matchesStatusFilter(Status.canceled, Status.canceled), isTrue); + }); + + test('canceled filter does not match unrelated statuses', () { + expect(matchesStatusFilter(Status.active, Status.canceled), isFalse); + expect(matchesStatusFilter(Status.settledByAdmin, Status.canceled), + isFalse); + expect( + matchesStatusFilter(Status.cooperativelyCanceled, Status.canceled), + isFalse, + reason: 'cooperative cancellation is its own filterable status'); + }); + + test('other filters match exactly', () { + for (final status in Status.values) { + expect(matchesStatusFilter(status, Status.active), + equals(status == Status.active)); + } + }); + }); +} From a11f203a3b6dd06d656798b6019208cf024bbf6b Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Fri, 14 Aug 2026 16:28:42 -0300 Subject: [PATCH 04/10] fix: reword dispute resolution messages to attribute outcomes to admin and prompt wallet confirmation --- lib/l10n/intl_de.arb | 10 +- lib/l10n/intl_en.arb | 10 +- lib/l10n/intl_es.arb | 10 +- lib/l10n/intl_fr.arb | 10 +- lib/l10n/intl_it.arb | 10 +- lib/l10n/intl_pt.arb | 10 +- .../dispute_resolution_message_test.dart | 104 ++++++++++++++++++ 7 files changed, 134 insertions(+), 30 deletions(-) create mode 100644 test/features/disputes/dispute_resolution_message_test.dart diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index a60934bb..7d2ce3da 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -853,11 +853,11 @@ "disputeDescriptionSellerRefunded": "Order abgebrochen – Verkäufer erhielt Rückerstattung", "disputeDescriptionUnknown": "Unbekannter Status", "disputeAdminSettledMessage": "Der Administrator hat die Order zugunsten einer Partei entschieden. Überprüfe deine Wallet auf Zahlungen.", - "disputeSellerRefundedMessage": "Der Administrator hat die Order abgebrochen und dem Verkäufer den Betrag erstattet. Der Streitfall ist nun geschlossen.", - "disputeSettledBuyerMessage": "Der Streitfall wurde zu deinen Gunsten gelöst. Die Order wurde erfolgreich abgeschlossen und du hast die Sats erhalten. Überprüfe deine Wallet.", - "disputeSettledSellerMessage": "Der Streitfall wurde gelöst. Die Order wurde erfolgreich abgeschlossen und der Käufer hat die Sats erhalten.", - "disputeCanceledBuyerMessage": "Der Administrator hat die Order abgebrochen. Dem Verkäufer wurde der Betrag erstattet und du hast keine Sats erhalten.", - "disputeCanceledSellerMessage": "Der Administrator hat die Order abgebrochen und dir den Betrag erstattet. Der Käufer hat keine Sats erhalten. Überprüfe deine Wallet auf die Rückerstattung.", + "disputeSellerRefundedMessage": "Der Administrator hat die Order abgebrochen und den Streitfall zugunsten des Verkäufers entschieden. Der Streitfall ist nun geschlossen.", + "disputeSettledBuyerMessage": "Der Administrator hat den Streitfall zu deinen Gunsten entschieden und die Order abgeschlossen. Überprüfe deine Wallet, um den Eingang der Sats zu bestätigen.", + "disputeSettledSellerMessage": "Der Administrator hat den Streitfall zugunsten des Käufers entschieden und die Order abgeschlossen.", + "disputeCanceledBuyerMessage": "Der Administrator hat die Order abgebrochen und den Streitfall zugunsten des Verkäufers entschieden. Diese Order wird nicht abgeschlossen.", + "disputeCanceledSellerMessage": "Der Administrator hat die Order abgebrochen und den Streitfall zu deinen Gunsten entschieden. Überprüfe deine Wallet, um den Eingang der Rückerstattung zu bestätigen.", "disputeOpenedByYou": "Du hast diesen Streitfall gegen den Käufer {counterparty} eröffnet, bitte lies das Folgende sorgfältig durch:", "@disputeOpenedByYou": { "placeholders": { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index ad67dda2..e588fe64 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -853,11 +853,11 @@ "disputeDescriptionSellerRefunded": "Order was canceled - seller refunded", "disputeDescriptionUnknown": "Unknown status", "disputeAdminSettledMessage": "The admin settled the order in favor of one party. Check your wallet for any payments.", - "disputeSellerRefundedMessage": "The admin canceled the order and refunded the seller. The dispute is now closed.", - "disputeSettledBuyerMessage": "The dispute was resolved in your favor. The order was completed successfully and you received the sats. Check your wallet.", - "disputeSettledSellerMessage": "The dispute was resolved. The order was completed successfully and the buyer received the sats.", - "disputeCanceledBuyerMessage": "The admin canceled the order. The seller was refunded and you did not receive the sats.", - "disputeCanceledSellerMessage": "The admin canceled the order and refunded you. The buyer did not receive the sats. Check your wallet for the refund.", + "disputeSellerRefundedMessage": "The admin canceled the order and resolved the dispute in the seller's favor. The dispute is now closed.", + "disputeSettledBuyerMessage": "The admin resolved the dispute in your favor and settled the order. Check your wallet to confirm the sats arrived.", + "disputeSettledSellerMessage": "The admin resolved the dispute in the buyer's favor and settled the order.", + "disputeCanceledBuyerMessage": "The admin canceled the order and resolved the dispute in the seller's favor. This order will not be completed.", + "disputeCanceledSellerMessage": "The admin canceled the order and resolved the dispute in your favor. Check your wallet to confirm the refund arrived.", "disputeOpenedByYou": "You opened this dispute against the buyer {counterparty}, please read carefully below:", "@disputeOpenedByYou": { "placeholders": { diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index e8c1bcbe..bb918938 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -851,11 +851,11 @@ "disputeDescriptionSellerRefunded": "Orden cancelada - vendedor reembolsado", "disputeDescriptionUnknown": "Estado desconocido", "disputeAdminSettledMessage": "El administrador resolvió la orden a favor de una de las partes. Revisa tu billetera para ver los pagos.", - "disputeSellerRefundedMessage": "El administrador canceló la orden y reembolsó al vendedor. La disputa está ahora cerrada.", - "disputeSettledBuyerMessage": "La disputa se resolvió a tu favor. La orden se completó exitosamente y recibiste los sats. Revisa tu billetera.", - "disputeSettledSellerMessage": "La disputa fue resuelta. La orden se completó exitosamente y el comprador recibió los sats.", - "disputeCanceledBuyerMessage": "El administrador canceló la orden. Se reembolsó al vendedor y no recibiste los sats.", - "disputeCanceledSellerMessage": "El administrador canceló la orden y te reembolsó. El comprador no recibió los sats. Revisa tu billetera para ver el reembolso.", + "disputeSellerRefundedMessage": "El administrador canceló la orden y resolvió la disputa a favor del vendedor. La disputa está ahora cerrada.", + "disputeSettledBuyerMessage": "El administrador resolvió la disputa a tu favor y liquidó la orden. Revisa tu billetera para confirmar que recibiste los sats.", + "disputeSettledSellerMessage": "El administrador resolvió la disputa a favor del comprador y liquidó la orden.", + "disputeCanceledBuyerMessage": "El administrador canceló la orden y resolvió la disputa a favor del vendedor. Esta orden no se completará.", + "disputeCanceledSellerMessage": "El administrador canceló la orden y resolvió la disputa a tu favor. Revisa tu billetera para confirmar que recibiste el reembolso.", "disputeOpenedByYou": "Abriste esta disputa contra el comprador {counterparty}, lee atentamente a continuación:", "@disputeOpenedByYou": { "placeholders": { diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 6d6f1980..4d3f7443 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -853,11 +853,11 @@ "disputeDescriptionSellerRefunded": "Commande annulée - vendeur remboursé", "disputeDescriptionUnknown": "Statut inconnu", "disputeAdminSettledMessage": "L'administrateur a réglé la commande en faveur d'une partie. Vérifiez votre portefeuille pour tout paiement.", - "disputeSellerRefundedMessage": "L'administrateur a annulé la commande et remboursé le vendeur. Le différend est maintenant fermé.", - "disputeSettledBuyerMessage": "Le différend a été résolu en votre faveur. La commande a été terminée avec succès et vous avez reçu les sats. Vérifiez votre portefeuille.", - "disputeSettledSellerMessage": "Le différend a été résolu. La commande a été terminée avec succès et l'acheteur a reçu les sats.", - "disputeCanceledBuyerMessage": "L'administrateur a annulé la commande. Le vendeur a été remboursé et vous n'avez pas reçu les sats.", - "disputeCanceledSellerMessage": "L'administrateur a annulé la commande et vous a remboursé. L'acheteur n'a pas reçu les sats. Vérifiez votre portefeuille pour le remboursement.", + "disputeSellerRefundedMessage": "L'administrateur a annulé la commande et résolu le différend en faveur du vendeur. Le différend est maintenant fermé.", + "disputeSettledBuyerMessage": "L'administrateur a résolu le différend en votre faveur et réglé la commande. Vérifiez votre portefeuille pour confirmer la réception des sats.", + "disputeSettledSellerMessage": "L'administrateur a résolu le différend en faveur de l'acheteur et réglé la commande.", + "disputeCanceledBuyerMessage": "L'administrateur a annulé la commande et résolu le différend en faveur du vendeur. Cette commande ne sera pas complétée.", + "disputeCanceledSellerMessage": "L'administrateur a annulé la commande et résolu le différend en votre faveur. Vérifiez votre portefeuille pour confirmer la réception du remboursement.", "disputeOpenedByYou": "Vous avez ouvert ce différend contre l'acheteur {counterparty}, veuillez lire attentivement ci-dessous :", "@disputeOpenedByYou": { "placeholders": { diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 371a1b4a..4eaecfd2 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -913,11 +913,11 @@ "disputeDescriptionSellerRefunded": "Ordine cancellato - venditore rimborsato", "disputeDescriptionUnknown": "Stato sconosciuto", "disputeAdminSettledMessage": "L'amministratore ha risolto l'ordine a favore di una delle parti. Controlla il tuo wallet per eventuali pagamenti.", - "disputeSellerRefundedMessage": "L'amministratore ha cancellato l'ordine e rimborsato il venditore. La disputa è ora chiusa.", - "disputeSettledBuyerMessage": "La disputa è stata risolta a tuo favore. L'ordine è stato completato con successo e hai ricevuto i sats. Controlla il tuo wallet.", - "disputeSettledSellerMessage": "La disputa è stata risolta. L'ordine è stato completato con successo e l'acquirente ha ricevuto i sats.", - "disputeCanceledBuyerMessage": "L'amministratore ha cancellato l'ordine. Il venditore è stato rimborsato e non hai ricevuto i sats.", - "disputeCanceledSellerMessage": "L'amministratore ha cancellato l'ordine e ti ha rimborsato. L'acquirente non ha ricevuto i sats. Controlla il tuo wallet per il rimborso.", + "disputeSellerRefundedMessage": "L'amministratore ha cancellato l'ordine e ha risolto la disputa a favore del venditore. La disputa è ora chiusa.", + "disputeSettledBuyerMessage": "L'amministratore ha risolto la disputa a tuo favore e ha liquidato l'ordine. Controlla il tuo wallet per confermare di aver ricevuto i sats.", + "disputeSettledSellerMessage": "L'amministratore ha risolto la disputa a favore dell'acquirente e ha liquidato l'ordine.", + "disputeCanceledBuyerMessage": "L'amministratore ha cancellato l'ordine e ha risolto la disputa a favore del venditore. Questo ordine non sarà completato.", + "disputeCanceledSellerMessage": "L'amministratore ha cancellato l'ordine e ha risolto la disputa a tuo favore. Controlla il tuo wallet per confermare di aver ricevuto il rimborso.", "disputeOpenedByYou": "Hai aperto questa disputa contro l'acquirente {counterparty}, leggi attentamente di seguito:", "@disputeOpenedByYou": { "placeholders": { diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index 61ef03a7..78d48619 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -853,11 +853,11 @@ "disputeDescriptionSellerRefunded": "A ordem foi cancelada - vendedor reembolsado", "disputeDescriptionUnknown": "Status desconhecido", "disputeAdminSettledMessage": "O administrador liquidou a ordem em favor de uma das partes. Verifique sua carteira para pagamentos.", - "disputeSellerRefundedMessage": "O administrador cancelou a ordem e reembolsou o vendedor. A disputa agora está fechada.", - "disputeSettledBuyerMessage": "A disputa foi resolvida a seu favor. A ordem foi concluída com sucesso e você recebeu os sats. Verifique sua carteira.", - "disputeSettledSellerMessage": "A disputa foi resolvida. A ordem foi concluída com sucesso e o comprador recebeu os sats.", - "disputeCanceledBuyerMessage": "O administrador cancelou a ordem. O vendedor foi reembolsado e você não recebeu os sats.", - "disputeCanceledSellerMessage": "O administrador cancelou a ordem e reembolsou você. O comprador não recebeu os sats. Verifique sua carteira para o reembolso.", + "disputeSellerRefundedMessage": "O administrador cancelou a ordem e resolveu a disputa em favor do vendedor. A disputa agora está fechada.", + "disputeSettledBuyerMessage": "O administrador resolveu a disputa a seu favor e liquidou a ordem. Verifique sua carteira para confirmar que você recebeu os sats.", + "disputeSettledSellerMessage": "O administrador resolveu a disputa em favor do comprador e liquidou a ordem.", + "disputeCanceledBuyerMessage": "O administrador cancelou a ordem e resolveu a disputa em favor do vendedor. Esta ordem não será concluída.", + "disputeCanceledSellerMessage": "O administrador cancelou a ordem e resolveu a disputa a seu favor. Verifique sua carteira para confirmar que você recebeu o reembolso.", "disputeOpenedByYou": "Você abriu esta disputa contra o comprador {counterparty}, por favor leia atentamente abaixo:", "@disputeOpenedByYou": { "placeholders": { diff --git a/test/features/disputes/dispute_resolution_message_test.dart b/test/features/disputes/dispute_resolution_message_test.dart new file mode 100644 index 00000000..4acf18f8 --- /dev/null +++ b/test/features/disputes/dispute_resolution_message_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models/dispute.dart'; +import 'package:mostro_mobile/features/disputes/widgets/dispute_status_content.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; + +/// The resolution banner is rendered from the message action plus the local +/// role alone: the app never reads the wallet or the escrow. It must therefore +/// attribute the outcome to the admin and ask the user to confirm, rather than +/// state that funds moved. + +Widget _wrap(DisputeData dispute) => ProviderScope( + child: MaterialApp( + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: S.supportedLocales, + home: Scaffold(body: DisputeStatusContent(dispute: dispute)), + ), + ); + +DisputeData _resolved({ + required String action, + required UserRole userRole, + String status = 'resolved', +}) => + DisputeData( + disputeId: 'dispute-1', + status: status, + descriptionKey: DisputeDescriptionKey.resolved, + createdAt: DateTime(2026, 1, 1), + userRole: userRole, + action: action, + ); + +String _renderedText(WidgetTester tester) { + final texts = tester + .widgetList(find.byType(Text)) + .map((t) => t.data ?? '') + .toList(); + return texts.join(' '); +} + +void main() { + testWidgets('settled resolution does not tell the buyer the sats arrived', + (tester) async { + await tester.pumpWidget( + _wrap(_resolved(action: 'admin-settled', userRole: UserRole.buyer)), + ); + await tester.pumpAndSettle(); + + final rendered = _renderedText(tester); + + expect(rendered, contains('admin'), + reason: 'the outcome must be attributed to who decided it'); + expect(rendered, contains('Check your wallet'), + reason: 'the user must be pointed at the only authoritative source'); + expect(rendered, isNot(contains('you received the sats')), + reason: 'the app never observed the funds moving'); + }); + + testWidgets('canceled resolution does not assert the refund to the seller', + (tester) async { + await tester.pumpWidget( + _wrap(_resolved( + action: 'admin-canceled', + userRole: UserRole.seller, + status: 'seller-refunded', + )), + ); + await tester.pumpAndSettle(); + + final rendered = _renderedText(tester); + + expect(rendered, contains('Check your wallet')); + expect(rendered, isNot(contains('refunded you')), + reason: 'the app never observed the refund'); + }); + + testWidgets('settled resolution does not assert what the counterparty got', + (tester) async { + await tester.pumpWidget( + _wrap(_resolved(action: 'admin-settled', userRole: UserRole.seller)), + ); + await tester.pumpAndSettle(); + + expect(_renderedText(tester), isNot(contains('received the sats'))); + }); + + testWidgets('canceled resolution does not assert the seller was refunded', + (tester) async { + await tester.pumpWidget( + _wrap(_resolved(action: 'admin-canceled', userRole: UserRole.buyer)), + ); + await tester.pumpAndSettle(); + + expect(_renderedText(tester), isNot(contains('seller was refunded'))); + }); +} From 9a48330898c9ea05396e9d1ad5b16d851f00ba54 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Fri, 14 Aug 2026 16:35:39 -0300 Subject: [PATCH 05/10] i18n: clarify admin attribution in dispute resolution messages across all locales --- lib/l10n/intl_de.arb | 6 +++--- lib/l10n/intl_en.arb | 6 +++--- lib/l10n/intl_es.arb | 6 +++--- lib/l10n/intl_fr.arb | 6 +++--- lib/l10n/intl_it.arb | 6 +++--- lib/l10n/intl_pt.arb | 6 +++--- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 7d2ce3da..c14d8b52 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -841,7 +841,7 @@ "disputeClosedUserCompleted": "Streitfall geschlossen – die Order wurde von den Parteien erfolgreich abgeschlossen.", "disputeClosedCooperativeCancel": "Streitfall geschlossen – die Order wurde von den Parteien kooperativ storniert.", "disputeInProgress": "Dieser Streitfall wird derzeit bearbeitet. Ein Schlichter prüft deinen Fall.", - "disputeSellerRefunded": "Dieser Streitfall wurde gelöst; dem Verkäufer wurde der Betrag zurückerstattet.", + "disputeSellerRefunded": "Der Administrator hat diesen Streitfall zugunsten des Verkäufers entschieden.", "disputeUnknownStatus": "Der Status dieses Streitfalls ist unbekannt.", "disputeChatClosed": "Dieser Streitfall wurde gelöst. Der Chat ist nun geschlossen.", "@_comment_dispute_descriptions": "Beschreibungen der Streitfall-Meldungen", @@ -849,8 +849,8 @@ "disputeDescriptionInitiatedByPeer": "Ein Streitfall gegen dich wurde eröffnet", "disputeDescriptionInitiatedPendingAdmin": "Ein Administrator wird diesen Streitfall bald übernehmen", "disputeDescriptionInProgress": "Noch keine Nachrichten", - "disputeDescriptionResolved": "Order abgeschlossen – Käufer hat die Sats erhalten", - "disputeDescriptionSellerRefunded": "Order abgebrochen – Verkäufer erhielt Rückerstattung", + "disputeDescriptionResolved": "Vom Administrator zugunsten des Käufers abgeschlossen", + "disputeDescriptionSellerRefunded": "Vom Administrator zugunsten des Verkäufers abgebrochen", "disputeDescriptionUnknown": "Unbekannter Status", "disputeAdminSettledMessage": "Der Administrator hat die Order zugunsten einer Partei entschieden. Überprüfe deine Wallet auf Zahlungen.", "disputeSellerRefundedMessage": "Der Administrator hat die Order abgebrochen und den Streitfall zugunsten des Verkäufers entschieden. Der Streitfall ist nun geschlossen.", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index e588fe64..d0f258e6 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -841,7 +841,7 @@ "disputeClosedUserCompleted": "Dispute closed — the order was successfully completed by the parties.", "disputeClosedCooperativeCancel": "Dispute closed — the order was cooperatively canceled by the parties.", "disputeInProgress": "This dispute is currently in progress. A solver is reviewing your case.", - "disputeSellerRefunded": "This dispute has been resolved with the seller being refunded.", + "disputeSellerRefunded": "The admin resolved this dispute in the seller's favor.", "disputeUnknownStatus": "The status of this dispute is unknown.", "disputeChatClosed": "This dispute has been resolved. The chat is now closed.", "@_comment_dispute_descriptions": "Dispute Description Messages", @@ -849,8 +849,8 @@ "disputeDescriptionInitiatedByPeer": "A dispute was opened against you", "disputeDescriptionInitiatedPendingAdmin": "An admin will take this dispute soon", "disputeDescriptionInProgress": "No messages yet", - "disputeDescriptionResolved": "Order was completed - buyer received the sats", - "disputeDescriptionSellerRefunded": "Order was canceled - seller refunded", + "disputeDescriptionResolved": "Settled by the admin in the buyer's favor", + "disputeDescriptionSellerRefunded": "Canceled by the admin in the seller's favor", "disputeDescriptionUnknown": "Unknown status", "disputeAdminSettledMessage": "The admin settled the order in favor of one party. Check your wallet for any payments.", "disputeSellerRefundedMessage": "The admin canceled the order and resolved the dispute in the seller's favor. The dispute is now closed.", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index bb918938..9a040a10 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -839,7 +839,7 @@ "disputeClosedUserCompleted": "Disputa cerrada — la orden fue completada exitosamente por las partes.", "disputeClosedCooperativeCancel": "Disputa cerrada — la orden fue cancelada cooperativamente por las partes.", "disputeInProgress": "Esta disputa está actualmente en progreso. Un mediador está revisando tu caso.", - "disputeSellerRefunded": "Esta disputa se ha resuelto con el vendedor siendo reembolsado.", + "disputeSellerRefunded": "El administrador resolvió esta disputa a favor del vendedor.", "disputeUnknownStatus": "El estado de esta disputa es desconocido.", "disputeChatClosed": "Esta disputa ha sido resuelta. El chat está cerrado.", "@_comment_dispute_descriptions": "Mensajes de Descripción de Disputas", @@ -847,8 +847,8 @@ "disputeDescriptionInitiatedByPeer": "Se abrió una disputa en tu contra", "disputeDescriptionInitiatedPendingAdmin": "Un administrador tomará esta disputa pronto", "disputeDescriptionInProgress": "Aún no hay mensajes", - "disputeDescriptionResolved": "Orden completada - el comprador recibió los sats", - "disputeDescriptionSellerRefunded": "Orden cancelada - vendedor reembolsado", + "disputeDescriptionResolved": "Liquidada por el administrador a favor del comprador", + "disputeDescriptionSellerRefunded": "Cancelada por el administrador a favor del vendedor", "disputeDescriptionUnknown": "Estado desconocido", "disputeAdminSettledMessage": "El administrador resolvió la orden a favor de una de las partes. Revisa tu billetera para ver los pagos.", "disputeSellerRefundedMessage": "El administrador canceló la orden y resolvió la disputa a favor del vendedor. La disputa está ahora cerrada.", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 4d3f7443..c896425d 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -841,7 +841,7 @@ "disputeClosedUserCompleted": "Différend clos — la commande a été complétée avec succès par les parties.", "disputeClosedCooperativeCancel": "Différend clos — la commande a été annulée de manière coopérative par les parties.", "disputeInProgress": "Ce différend est actuellement en cours. Un résolveur examine votre cas.", - "disputeSellerRefunded": "Ce différend a été résolu avec remboursement du vendeur.", + "disputeSellerRefunded": "L'administrateur a résolu ce différend en faveur du vendeur.", "disputeUnknownStatus": "Le statut de ce différend est inconnu.", "disputeChatClosed": "Ce différend a été résolu. Le chat est maintenant fermé.", "@_comment_dispute_descriptions": "Dispute Description Messages", @@ -849,8 +849,8 @@ "disputeDescriptionInitiatedByPeer": "Un différend a été ouvert contre vous", "disputeDescriptionInitiatedPendingAdmin": "Un administrateur prendra ce différend bientôt", "disputeDescriptionInProgress": "Aucun message pour le moment", - "disputeDescriptionResolved": "Commande terminée - l'acheteur a reçu les sats", - "disputeDescriptionSellerRefunded": "Commande annulée - vendeur remboursé", + "disputeDescriptionResolved": "Réglée par l'administrateur en faveur de l'acheteur", + "disputeDescriptionSellerRefunded": "Annulée par l'administrateur en faveur du vendeur", "disputeDescriptionUnknown": "Statut inconnu", "disputeAdminSettledMessage": "L'administrateur a réglé la commande en faveur d'une partie. Vérifiez votre portefeuille pour tout paiement.", "disputeSellerRefundedMessage": "L'administrateur a annulé la commande et résolu le différend en faveur du vendeur. Le différend est maintenant fermé.", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 4eaecfd2..0260fc04 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -901,7 +901,7 @@ "disputeClosedUserCompleted": "Disputa chiusa — l'ordine è stato completato con successo dalle parti.", "disputeClosedCooperativeCancel": "Disputa chiusa — l'ordine è stato annullato in modo cooperativo dalle parti.", "disputeInProgress": "Questa disputa è attualmente in corso. Un risolutore sta esaminando il tuo caso.", - "disputeSellerRefunded": "Questa disputa è stata risolta con il venditore che è stato rimborsato.", + "disputeSellerRefunded": "L'amministratore ha risolto questa disputa a favore del venditore.", "disputeUnknownStatus": "Lo stato di questa disputa è sconosciuto.", "disputeChatClosed": "Questa disputa è stata risolta. La chat è ora chiusa.", "@_comment_dispute_descriptions": "Messaggi di Descrizione delle Dispute", @@ -909,8 +909,8 @@ "disputeDescriptionInitiatedByPeer": "È stata aperta una disputa contro di te", "disputeDescriptionInitiatedPendingAdmin": "Un amministratore prenderà presto questa disputa", "disputeDescriptionInProgress": "Nessun messaggio ancora", - "disputeDescriptionResolved": "Ordine completato - l'acquirente ha ricevuto i sats", - "disputeDescriptionSellerRefunded": "Ordine cancellato - venditore rimborsato", + "disputeDescriptionResolved": "Liquidato dall'amministratore a favore dell'acquirente", + "disputeDescriptionSellerRefunded": "Cancellato dall'amministratore a favore del venditore", "disputeDescriptionUnknown": "Stato sconosciuto", "disputeAdminSettledMessage": "L'amministratore ha risolto l'ordine a favore di una delle parti. Controlla il tuo wallet per eventuali pagamenti.", "disputeSellerRefundedMessage": "L'amministratore ha cancellato l'ordine e ha risolto la disputa a favore del venditore. La disputa è ora chiusa.", diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index 78d48619..9bd50f02 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -841,7 +841,7 @@ "disputeClosedUserCompleted": "Disputa fechada — a ordem foi concluída com sucesso pelas partes.", "disputeClosedCooperativeCancel": "Disputa fechada — a ordem foi cancelada cooperativamente pelas partes.", "disputeInProgress": "Esta disputa está atualmente em andamento. Um mediador está analisando seu caso.", - "disputeSellerRefunded": "Esta disputa foi resolvida com o reembolso do vendedor.", + "disputeSellerRefunded": "O administrador resolveu esta disputa em favor do vendedor.", "disputeUnknownStatus": "O status desta disputa é desconhecido.", "disputeChatClosed": "Esta disputa foi resolvida. O chat agora está fechado.", "@_comment_dispute_descriptions": "Dispute Description Messages", @@ -849,8 +849,8 @@ "disputeDescriptionInitiatedByPeer": "Uma disputa foi aberta contra você", "disputeDescriptionInitiatedPendingAdmin": "Um administrador assumirá esta disputa em breve", "disputeDescriptionInProgress": "Nenhuma mensagem ainda", - "disputeDescriptionResolved": "A ordem foi concluída - o comprador recebeu os sats", - "disputeDescriptionSellerRefunded": "A ordem foi cancelada - vendedor reembolsado", + "disputeDescriptionResolved": "Liquidada pelo administrador em favor do comprador", + "disputeDescriptionSellerRefunded": "Cancelada pelo administrador em favor do vendedor", "disputeDescriptionUnknown": "Status desconhecido", "disputeAdminSettledMessage": "O administrador liquidou a ordem em favor de uma das partes. Verifique sua carteira para pagamentos.", "disputeSellerRefundedMessage": "O administrador cancelou a ordem e resolveu a disputa em favor do vendedor. A disputa agora está fechada.", From 1b0ffa07ce24a453d92b74f3dd6fd567bdf0d229 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Fri, 14 Aug 2026 16:39:45 -0300 Subject: [PATCH 06/10] fix: suppress side effects when admin dispute actions are rejected for lack of evidence --- lib/features/order/models/order_state.dart | 8 +++ .../notifiers/abstract_mostro_notifier.dart | 15 +++++ ...der_state_admin_resolution_guard_test.dart | 58 +++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/lib/features/order/models/order_state.dart b/lib/features/order/models/order_state.dart index 3e3379c2..ed324062 100644 --- a/lib/features/order/models/order_state.dart +++ b/lib/features/order/models/order_state.dart @@ -124,6 +124,14 @@ class OrderState { return dispute != null || status == Status.dispute; } + /// Whether [updateWith] would drop this action for lack of dispute evidence. + /// + /// Callers use this to suppress the message's side effects too. A rejected + /// resolution that still raises a notification or navigates would hand the + /// attacker the user-visible half of the forgery. + bool rejectsAdminDisputeAction(Action action) => + _isAdminDisputeAction(action) && !_acceptsAdminDisputeAction; + OrderState updateWith(MostroMessage message) { logger.i('Updating OrderState with Action: ${message.action}'); diff --git a/lib/features/order/notifiers/abstract_mostro_notifier.dart b/lib/features/order/notifiers/abstract_mostro_notifier.dart index 44a848e3..09bf8841 100644 --- a/lib/features/order/notifiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notifiers/abstract_mostro_notifier.dart @@ -107,9 +107,24 @@ class AbstractMostroNotifier extends StateNotifier { final wasUserInitiatedCancel = msg.action == Action.canceled && _userInitiatedCancels.remove(orderId); + // Evaluated before the state update, which is what consumes the + // dispute evidence this depends on. + final rejectedAdminAction = + state.rejectsAdminDisputeAction(msg.action); + if (mounted) { state = state.updateWith(msg); } + + // The state change was dropped; its side effects must go with it. + // Otherwise a forged admin resolution still reaches the user as a + // notification and a jump to the trade detail. + if (rejectedAdminAction) { + logger.w( + 'Dropping side effects for rejected ${msg.action} on order $orderId'); + return; + } + if (msg.timestamp != null && msg.timestamp! > DateTime.now() diff --git a/test/features/order/models/order_state_admin_resolution_guard_test.dart b/test/features/order/models/order_state_admin_resolution_guard_test.dart index d9f68c17..de49810d 100644 --- a/test/features/order/models/order_state_admin_resolution_guard_test.dart +++ b/test/features/order/models/order_state_admin_resolution_guard_test.dart @@ -142,6 +142,64 @@ void main() { }); }); + group('Rejected resolutions are reported so side effects can be dropped', () { + test('reports rejection for admin actions with no dispute evidence', () { + final state = _stateWithoutDispute(); + + expect(state.rejectsAdminDisputeAction(Action.adminCanceled), isTrue); + expect(state.rejectsAdminDisputeAction(Action.adminSettled), isTrue); + expect(state.rejectsAdminDisputeAction(Action.adminTookDispute), isTrue); + }); + + test('reports no rejection when a dispute is under review', () { + final state = _stateWithDispute(); + + expect(state.rejectsAdminDisputeAction(Action.adminCanceled), isFalse); + expect(state.rejectsAdminDisputeAction(Action.adminSettled), isFalse); + }); + + test('reports rejection once the dispute is already resolved', () { + final state = _stateWithDispute(disputeStatus: 'resolved'); + + expect(state.rejectsAdminDisputeAction(Action.adminSettled), isTrue, + reason: 'replaying a resolution onto a settled dispute is rejected'); + }); + + test('never reports rejection for non-admin actions', () { + final state = _stateWithoutDispute(); + + expect(state.rejectsAdminDisputeAction(Action.fiatSentOk), isFalse); + expect(state.rejectsAdminDisputeAction(Action.canceled), isFalse); + expect(state.rejectsAdminDisputeAction(Action.release), isFalse); + }); + + test('agrees with what updateWith actually does', () { + // The predicate drives side-effect suppression, so it must not diverge + // from the state machine it is meant to describe. + final states = [ + _stateWithoutDispute(), + _stateWithDispute(), + _stateWithDispute(disputeStatus: 'resolved'), + _stateWithDispute(disputeStatus: 'seller-refunded'), + ]; + const adminActions = [ + Action.adminCanceled, + Action.adminSettled, + Action.adminTookDispute, + ]; + + for (final state in states) { + for (final action in adminActions) { + final rejected = state.rejectsAdminDisputeAction(action); + final unchanged = identical(state.updateWith(_message(action)), state); + expect(rejected, equals(unchanged), + reason: 'predicate and updateWith disagree for $action on ' + '${state.status}/${state.dispute?.status}'); + } + } + }); + }); + group('Legitimate admin resolutions still apply', () { test('admin-settled resolves an existing in-progress dispute', () { final state = _stateWithDispute(); From d5e45500edbc5ac308128282f987510f80357517 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Fri, 14 Aug 2026 19:04:03 -0300 Subject: [PATCH 07/10] fix: require tracked Dispute object to authorize admin resolutions, not just dispute status --- lib/features/order/models/order_state.dart | 26 ++++++---- .../notifiers/abstract_mostro_notifier.dart | 11 ++++ .../order/notifiers/order_notifier.dart | 26 +++++++++- .../dispute_resolution_message_test.dart | 44 +++++++++++++++- ...der_state_admin_resolution_guard_test.dart | 51 ++++++++++++------- 5 files changed, 128 insertions(+), 30 deletions(-) diff --git a/lib/features/order/models/order_state.dart b/lib/features/order/models/order_state.dart index ed324062..9056e9f9 100644 --- a/lib/features/order/models/order_state.dart +++ b/lib/features/order/models/order_state.dart @@ -111,17 +111,23 @@ class OrderState { /// Whether local state corroborates that a dispute exists for this order. /// - /// The incoming payload deliberately does not count: taking an - /// attacker-supplied Dispute as its own justification is the vector this - /// guards against. An already-resolved dispute is not re-resolved either, - /// which blocks replaying an authentic resolution onto a later state. + /// Evidence is the tracked [Dispute] object and nothing else. In particular + /// [Status.dispute] does not qualify: any action mapping to that status sets + /// it without carrying a dispute, so accepting it would let a bare + /// `dispute` message stand in as the evidence for the `admin-*` message + /// right behind it. + /// + /// The incoming payload does not count either — taking an attacker-supplied + /// Dispute as its own justification is the vector this guards against. An + /// already-resolved dispute is not re-resolved, which blocks replaying an + /// authentic resolution onto a later state. bool get _acceptsAdminDisputeAction { - final localDisputeStatus = dispute?.status?.toLowerCase(); - if (localDisputeStatus != null && - _terminalDisputeStatuses.contains(localDisputeStatus)) { - return false; - } - return dispute != null || status == Status.dispute; + final localDispute = dispute; + if (localDispute == null) return false; + + final localDisputeStatus = localDispute.status?.toLowerCase(); + return localDisputeStatus == null || + !_terminalDisputeStatuses.contains(localDisputeStatus); } /// Whether [updateWith] would drop this action for lack of dispute evidence. diff --git a/lib/features/order/notifiers/abstract_mostro_notifier.dart b/lib/features/order/notifiers/abstract_mostro_notifier.dart index 09bf8841..e9e6b895 100644 --- a/lib/features/order/notifiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notifiers/abstract_mostro_notifier.dart @@ -122,6 +122,7 @@ class AbstractMostroNotifier extends StateNotifier { if (rejectedAdminAction) { logger.w( 'Dropping side effects for rejected ${msg.action} on order $orderId'); + onAdminResolutionRejected(msg); return; } @@ -180,6 +181,16 @@ class AbstractMostroNotifier extends StateNotifier { } } + /// Called when an admin resolution was dropped for lack of dispute evidence. + /// + /// The rejection is decided from in-memory state, which during startup may + /// not yet hold the dispute this resolution belongs to. Subclasses that + /// hydrate from storage override this to replay the persisted history, where + /// the dispute and the resolution are applied in order. Replaying is safe: + /// the guard runs on every message of the replay, so a resolution with no + /// dispute ahead of it in the history is still rejected. + void onAdminResolutionRejected(MostroMessage message) {} + Future handleEvent(MostroMessage event, {bool bypassTimestampGate = false, Status? previousStatus, diff --git a/lib/features/order/notifiers/order_notifier.dart b/lib/features/order/notifiers/order_notifier.dart index e3046533..0f401f20 100644 --- a/lib/features/order/notifiers/order_notifier.dart +++ b/lib/features/order/notifiers/order_notifier.dart @@ -13,7 +13,9 @@ class OrderNotifier extends AbstractMostroNotifier { late final MostroService mostroService; ProviderSubscription>>? _publicEventsSubscription; bool _isSyncing = false; // Only for sync() method - + bool _hydrated = false; // First sync() has completed + bool _resyncRequested = false; // A sync() was asked for while one was running + OrderNotifier(super.orderId, super.ref) { mostroService = ref.read(mostroServiceProvider); sync(); @@ -35,8 +37,23 @@ class OrderNotifier extends AbstractMostroNotifier { wasUserInitiatedCancel: wasUserInitiatedCancel); } + /// Replays the persisted history when a resolution was rejected only because + /// startup had not loaded its dispute yet. Once hydrated, a rejection is the + /// correct outcome and no replay is needed — which also keeps forged + /// resolutions from each costing a full storage read. + @override + void onAdminResolutionRejected(MostroMessage message) { + if (_hydrated) return; + logger.i( + 'Re-syncing order $orderId: ${message.action} arrived before hydration completed'); + sync(); + } + Future sync() async { - if (_isSyncing) return; + if (_isSyncing) { + _resyncRequested = true; + return; + } try { _isSyncing = true; @@ -81,6 +98,11 @@ class OrderNotifier extends AbstractMostroNotifier { ); } finally { _isSyncing = false; + _hydrated = true; + if (_resyncRequested) { + _resyncRequested = false; + sync(); + } } } diff --git a/test/features/disputes/dispute_resolution_message_test.dart b/test/features/disputes/dispute_resolution_message_test.dart index 4acf18f8..3820d4a7 100644 --- a/test/features/disputes/dispute_resolution_message_test.dart +++ b/test/features/disputes/dispute_resolution_message_test.dart @@ -11,8 +11,10 @@ import 'package:mostro_mobile/generated/l10n.dart'; /// attribute the outcome to the admin and ask the user to confirm, rather than /// state that funds moved. -Widget _wrap(DisputeData dispute) => ProviderScope( +Widget _wrap(DisputeData dispute, {Locale locale = const Locale('en')}) => + ProviderScope( child: MaterialApp( + locale: locale, localizationsDelegates: const [ S.delegate, GlobalMaterialLocalizations.delegate, @@ -101,4 +103,44 @@ void main() { expect(_renderedText(tester), isNot(contains('seller was refunded'))); }); + + // The wording is a security property, not a copy detail: a locale that + // reverts to asserting receipt puts those users back where they started. + group('every locale asks the buyer to confirm rather than asserting receipt', + () { + // (wallet, verb of confirmation, phrase unique to the old wording that + // announced the order as already completed). + const cases = { + 'en': ('Check your wallet', 'confirm', 'completed successfully'), + 'es': ('Revisa tu billetera', 'confirmar', 'se completó exitosamente'), + 'it': ('Controlla il tuo wallet', 'confermare', 'completato con successo'), + 'pt': ('Verifique sua carteira', 'confirmar', 'concluída com sucesso'), + 'de': ('Überprüfe deine Wallet', 'bestätigen', 'erfolgreich abgeschlossen'), + 'fr': ('Vérifiez votre portefeuille', 'confirmer', 'terminée avec succès'), + }; + + cases.forEach((code, expectations) { + final (wallet, confirms, oldWording) = expectations; + + testWidgets(code, (tester) async { + await tester.pumpWidget( + _wrap( + _resolved(action: 'admin-settled', userRole: UserRole.buyer), + locale: Locale(code), + ), + ); + await tester.pumpAndSettle(); + + final rendered = _renderedText(tester); + + expect(rendered, contains(wallet), + reason: '$code must point the user at their wallet'); + expect(rendered, contains(confirms), + reason: '$code must ask the user to confirm, not announce receipt'); + expect(rendered, isNot(contains(oldWording)), + reason: '$code must not go back to stating the order as settled ' + 'fact'); + }); + }); + }); } diff --git a/test/features/order/models/order_state_admin_resolution_guard_test.dart b/test/features/order/models/order_state_admin_resolution_guard_test.dart index de49810d..7f33c52e 100644 --- a/test/features/order/models/order_state_admin_resolution_guard_test.dart +++ b/test/features/order/models/order_state_admin_resolution_guard_test.dart @@ -127,6 +127,40 @@ void main() { reason: 'the tracked dispute id must survive the wire payload'); }); + test('a bare dispute message is not evidence for the resolution behind it', + () { + // Two-step forgery: Action.dispute maps to Status.dispute without + // carrying a Dispute, so the status alone must not authorize what + // follows it. + final disputed = + _stateWithoutDispute().updateWith(_message(Action.dispute)); + + expect(disputed.status, equals(Status.dispute)); + expect(disputed.dispute, isNull); + + for (final action in [Action.adminCanceled, Action.adminSettled]) { + final resolved = disputed.updateWith(_message(action)); + + expect(resolved.status, equals(Status.dispute), + reason: '$action must not be applied on a status-only dispute'); + expect(resolved.dispute, isNull); + } + }); + + test('dispute status without a tracked dispute object is not evidence', () { + final state = OrderState( + status: Status.dispute, + action: Action.disputeInitiatedByPeer, + order: _testOrder(status: Status.dispute), + dispute: null, + ); + + final updated = state.updateWith(_message(Action.adminSettled)); + + expect(updated.status, equals(Status.dispute), + reason: 'only a tracked Dispute object authorizes a resolution'); + }); + test('admin-settled payload alone cannot fabricate a settled dispute', () { final state = _stateWithoutDispute(); @@ -262,22 +296,5 @@ void main() { expect(updated.dispute!.adminPubkey, equals(_adminPubkey)); }); - test( - 'admin resolution applies when the order is already in dispute status ' - 'even without a local dispute object', () { - // Cold start / partial sync: the order is known to be disputed but the - // dispute object was never persisted locally. - final state = OrderState( - status: Status.dispute, - action: Action.disputeInitiatedByPeer, - order: _testOrder(status: Status.dispute), - dispute: null, - ); - - final updated = state.updateWith(_message(Action.adminSettled)); - - expect(updated.status, equals(Status.settledByAdmin), - reason: 'dispute status is itself dispute evidence'); - }); }); } From 1fa71ad1a56b61df02724d11c9c80b0f4beb0f01 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Fri, 14 Aug 2026 20:40:09 -0300 Subject: [PATCH 08/10] fix: prevent failed sync from claiming hydration and dropping recovery for rejected admin resolutions --- .../order/notifiers/order_notifier.dart | 32 ++++- lib/shared/utils/order_sync_helpers.dart | 33 ++++++ .../dispute_resolution_message_test.dart | 112 +++++++++++++----- .../shared/utils/order_sync_helpers_test.dart | 71 +++++++++++ 4 files changed, 213 insertions(+), 35 deletions(-) create mode 100644 lib/shared/utils/order_sync_helpers.dart create mode 100644 test/shared/utils/order_sync_helpers_test.dart diff --git a/lib/features/order/notifiers/order_notifier.dart b/lib/features/order/notifiers/order_notifier.dart index 0f401f20..ac4e0f3a 100644 --- a/lib/features/order/notifiers/order_notifier.dart +++ b/lib/features/order/notifiers/order_notifier.dart @@ -8,13 +8,19 @@ import 'package:mostro_mobile/shared/providers.dart'; import 'package:mostro_mobile/features/order/notifiers/abstract_mostro_notifier.dart'; import 'package:mostro_mobile/services/logger_service.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; +import 'package:mostro_mobile/shared/utils/order_sync_helpers.dart'; class OrderNotifier extends AbstractMostroNotifier { late final MostroService mostroService; ProviderSubscription>>? _publicEventsSubscription; bool _isSyncing = false; // Only for sync() method - bool _hydrated = false; // First sync() has completed + bool _hydrated = false; // A sync() has read the history successfully bool _resyncRequested = false; // A sync() was asked for while one was running + int _resyncAttempts = 0; + + /// Bounds the replay chain so a stream of rejected resolutions during + /// startup cannot keep queueing full history reads. + static const _maxChainedResyncs = 3; OrderNotifier(super.orderId, super.ref) { mostroService = ref.read(mostroServiceProvider); @@ -55,6 +61,7 @@ class OrderNotifier extends AbstractMostroNotifier { return; } + var succeeded = false; try { _isSyncing = true; @@ -62,6 +69,7 @@ class OrderNotifier extends AbstractMostroNotifier { final messages = await storage.getAllMessagesForOrderId(orderId); if (messages.isEmpty) { logger.w('No messages found for order $orderId'); + succeeded = true; return; } @@ -90,6 +98,7 @@ class OrderNotifier extends AbstractMostroNotifier { if (state.status == Status.canceled) { await reconcileCanceledBondedSession(); } + succeeded = true; } catch (e, stack) { logger.e( 'Error syncing order state for $orderId', @@ -98,10 +107,23 @@ class OrderNotifier extends AbstractMostroNotifier { ); } finally { _isSyncing = false; - _hydrated = true; - if (_resyncRequested) { - _resyncRequested = false; - sync(); + + final completion = resolveSyncCompletion( + succeeded: succeeded, + resyncRequested: _resyncRequested, + resyncAttempts: _resyncAttempts, + maxChainedResyncs: _maxChainedResyncs, + ); + _resyncRequested = false; + + switch (completion) { + case SyncCompletion.replay: + _resyncAttempts++; + sync(); + case SyncCompletion.hydrated: + _hydrated = true; + case SyncCompletion.unhydrated: + break; } } } diff --git a/lib/shared/utils/order_sync_helpers.dart b/lib/shared/utils/order_sync_helpers.dart new file mode 100644 index 00000000..d17a97cd --- /dev/null +++ b/lib/shared/utils/order_sync_helpers.dart @@ -0,0 +1,33 @@ +/// How a finished `sync()` pass should end. +enum SyncCompletion { + /// Another pass was requested while this one ran: replay the history again. + replay, + + /// The history was read successfully and nothing is queued behind it. + hydrated, + + /// The read failed, or the replay budget ran out before one succeeded. + /// Recovery stays available. + unhydrated, +} + +/// Decides how a `sync()` pass ends. +/// +/// Hydration is only claimed by a pass that actually read the history with +/// nothing queued behind it. A failed read or a pending replay must leave +/// recovery available, otherwise an admin resolution rejected during startup +/// — before its dispute was loaded — is never revisited. +/// +/// `maxChainedResyncs` bounds the replay chain so a stream of rejected +/// resolutions cannot keep queueing full history reads. +SyncCompletion resolveSyncCompletion({ + required bool succeeded, + required bool resyncRequested, + required int resyncAttempts, + required int maxChainedResyncs, +}) { + if (resyncRequested && resyncAttempts < maxChainedResyncs) { + return SyncCompletion.replay; + } + return succeeded ? SyncCompletion.hydrated : SyncCompletion.unhydrated; +} diff --git a/test/features/disputes/dispute_resolution_message_test.dart b/test/features/disputes/dispute_resolution_message_test.dart index 3820d4a7..2fb60916 100644 --- a/test/features/disputes/dispute_resolution_message_test.dart +++ b/test/features/disputes/dispute_resolution_message_test.dart @@ -105,41 +105,93 @@ void main() { }); // The wording is a security property, not a copy detail: a locale that - // reverts to asserting receipt puts those users back where they started. - group('every locale asks the buyer to confirm rather than asserting receipt', + // reverts to asserting fund movement puts those users back where they + // started. + group('localized resolutions attribute the outcome instead of asserting it', () { - // (wallet, verb of confirmation, phrase unique to the old wording that - // announced the order as already completed). - const cases = { - 'en': ('Check your wallet', 'confirm', 'completed successfully'), - 'es': ('Revisa tu billetera', 'confirmar', 'se completó exitosamente'), - 'it': ('Controlla il tuo wallet', 'confermare', 'completato con successo'), - 'pt': ('Verifique sua carteira', 'confirmar', 'concluída com sucesso'), - 'de': ('Überprüfe deine Wallet', 'bestätigen', 'erfolgreich abgeschlossen'), - 'fr': ('Vérifiez votre portefeuille', 'confirmer', 'terminée avec succès'), + // (admin attribution, wallet, verb of confirmation, phrase unique to the + // old wording that announced the order as already completed). + const locales = { + 'en': ('admin', 'Check your wallet', 'confirm', 'completed successfully'), + 'es': ( + 'administrador', + 'Revisa tu billetera', + 'confirmar', + 'se completó exitosamente' + ), + 'it': ( + 'amministratore', + 'Controlla il tuo wallet', + 'confermare', + 'completato con successo' + ), + 'pt': ( + 'administrador', + 'Verifique sua carteira', + 'confirmar', + 'concluída com sucesso' + ), + 'de': ( + 'Administrator', + 'Überprüfe deine Wallet', + 'bestätigen', + 'erfolgreich abgeschlossen' + ), + 'fr': ( + 'administrateur', + 'Vérifiez votre portefeuille', + 'confirmer', + 'terminée avec succès' + ), }; - cases.forEach((code, expectations) { - final (wallet, confirms, oldWording) = expectations; + /// The variants where the user is the one owed money. Only these may talk + /// about the wallet, and they must ask rather than announce. + const awaitingFunds = [ + ('admin-settled', UserRole.buyer), + ('admin-canceled', UserRole.seller), + ]; + const notAwaitingFunds = [ + ('admin-settled', UserRole.seller), + ('admin-canceled', UserRole.buyer), + ]; + + locales.forEach((code, expectations) { + final (adminWord, wallet, confirms, oldWording) = expectations; testWidgets(code, (tester) async { - await tester.pumpWidget( - _wrap( - _resolved(action: 'admin-settled', userRole: UserRole.buyer), - locale: Locale(code), - ), - ); - await tester.pumpAndSettle(); - - final rendered = _renderedText(tester); - - expect(rendered, contains(wallet), - reason: '$code must point the user at their wallet'); - expect(rendered, contains(confirms), - reason: '$code must ask the user to confirm, not announce receipt'); - expect(rendered, isNot(contains(oldWording)), - reason: '$code must not go back to stating the order as settled ' - 'fact'); + Future render(String action, UserRole role) async { + await tester.pumpWidget( + _wrap( + _resolved(action: action, userRole: role), + locale: Locale(code), + ), + ); + await tester.pumpAndSettle(); + return _renderedText(tester); + } + + for (final (action, role) in [...awaitingFunds, ...notAwaitingFunds]) { + final rendered = await render(action, role); + + expect(rendered, contains(adminWord), + reason: '$code/$action/${role.name} must attribute the outcome ' + 'to the admin who decided it'); + expect(rendered, isNot(contains(oldWording)), + reason: '$code/$action/${role.name} must not go back to stating ' + 'the order as settled fact'); + } + + for (final (action, role) in awaitingFunds) { + final rendered = await render(action, role); + + expect(rendered, contains(wallet), + reason: '$code/$action/${role.name} must point the user at ' + 'their wallet'); + expect(rendered, contains(confirms), + reason: '$code/$action/${role.name} must ask the user to ' + 'confirm, not announce receipt'); + } }); }); }); diff --git a/test/shared/utils/order_sync_helpers_test.dart b/test/shared/utils/order_sync_helpers_test.dart new file mode 100644 index 00000000..152b1141 --- /dev/null +++ b/test/shared/utils/order_sync_helpers_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/shared/utils/order_sync_helpers.dart'; + +/// A `sync()` pass may only claim hydration when it actually read the history. +/// Claiming it after a failed read leaves an admin resolution that was rejected +/// during startup permanently dropped, since nothing triggers another replay. + +const _maxChained = 3; + +SyncCompletion _resolve({ + required bool succeeded, + bool resyncRequested = false, + int resyncAttempts = 0, +}) => + resolveSyncCompletion( + succeeded: succeeded, + resyncRequested: resyncRequested, + resyncAttempts: resyncAttempts, + maxChainedResyncs: _maxChained, + ); + +void main() { + group('resolveSyncCompletion', () { + test('a clean pass with nothing queued hydrates', () { + expect(_resolve(succeeded: true), equals(SyncCompletion.hydrated)); + }); + + test('a failed read does not hydrate', () { + expect(_resolve(succeeded: false), equals(SyncCompletion.unhydrated), + reason: 'recovery must stay available after a failed history read'); + }); + + test('a queued replay defers hydration even on a successful pass', () { + expect(_resolve(succeeded: true, resyncRequested: true), + equals(SyncCompletion.replay), + reason: 'the message that queued the replay is not in this pass'); + }); + + test('a queued replay after a failed pass still replays', () { + expect(_resolve(succeeded: false, resyncRequested: true), + equals(SyncCompletion.replay)); + }); + + test('replays are bounded', () { + expect( + _resolve( + succeeded: true, + resyncRequested: true, + resyncAttempts: _maxChained), + equals(SyncCompletion.hydrated), + reason: 'a successful pass hydrates once the budget is spent'); + + expect( + _resolve( + succeeded: false, + resyncRequested: true, + resyncAttempts: _maxChained), + equals(SyncCompletion.unhydrated), + reason: 'an exhausted budget must not hydrate on a failed read'); + }); + + test('the last chained attempt still replays', () { + expect( + _resolve( + succeeded: true, + resyncRequested: true, + resyncAttempts: _maxChained - 1), + equals(SyncCompletion.replay)); + }); + }); +} From 6b7c9f9d6d324e0bf879b2afe3eb0c8c05db8f09 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Fri, 14 Aug 2026 21:08:11 -0300 Subject: [PATCH 09/10] fix: remove sync replay cap to prevent premature hydration with pending resolutions --- .../order/notifiers/order_notifier.dart | 8 ---- lib/shared/utils/order_sync_helpers.dart | 13 +++---- .../shared/utils/order_sync_helpers_test.dart | 38 ++++--------------- 3 files changed, 14 insertions(+), 45 deletions(-) diff --git a/lib/features/order/notifiers/order_notifier.dart b/lib/features/order/notifiers/order_notifier.dart index ac4e0f3a..be90e60f 100644 --- a/lib/features/order/notifiers/order_notifier.dart +++ b/lib/features/order/notifiers/order_notifier.dart @@ -16,11 +16,6 @@ class OrderNotifier extends AbstractMostroNotifier { bool _isSyncing = false; // Only for sync() method bool _hydrated = false; // A sync() has read the history successfully bool _resyncRequested = false; // A sync() was asked for while one was running - int _resyncAttempts = 0; - - /// Bounds the replay chain so a stream of rejected resolutions during - /// startup cannot keep queueing full history reads. - static const _maxChainedResyncs = 3; OrderNotifier(super.orderId, super.ref) { mostroService = ref.read(mostroServiceProvider); @@ -111,14 +106,11 @@ class OrderNotifier extends AbstractMostroNotifier { final completion = resolveSyncCompletion( succeeded: succeeded, resyncRequested: _resyncRequested, - resyncAttempts: _resyncAttempts, - maxChainedResyncs: _maxChainedResyncs, ); _resyncRequested = false; switch (completion) { case SyncCompletion.replay: - _resyncAttempts++; sync(); case SyncCompletion.hydrated: _hydrated = true; diff --git a/lib/shared/utils/order_sync_helpers.dart b/lib/shared/utils/order_sync_helpers.dart index d17a97cd..92d37d7e 100644 --- a/lib/shared/utils/order_sync_helpers.dart +++ b/lib/shared/utils/order_sync_helpers.dart @@ -18,16 +18,15 @@ enum SyncCompletion { /// recovery available, otherwise an admin resolution rejected during startup /// — before its dispute was loaded — is never revisited. /// -/// `maxChainedResyncs` bounds the replay chain so a stream of rejected -/// resolutions cannot keep queueing full history reads. +/// A pending replay is always honoured rather than capped. Capping it would +/// mean declaring a history hydrated while knowing a queued resolution may be +/// missing from it, which disables recovery for every later resolution too. +/// The chain is self-limiting instead: each replay needs a fresh rejection +/// arriving while the pass runs, and ends as soon as one pass sees none. SyncCompletion resolveSyncCompletion({ required bool succeeded, required bool resyncRequested, - required int resyncAttempts, - required int maxChainedResyncs, }) { - if (resyncRequested && resyncAttempts < maxChainedResyncs) { - return SyncCompletion.replay; - } + if (resyncRequested) return SyncCompletion.replay; return succeeded ? SyncCompletion.hydrated : SyncCompletion.unhydrated; } diff --git a/test/shared/utils/order_sync_helpers_test.dart b/test/shared/utils/order_sync_helpers_test.dart index 152b1141..a06e5c66 100644 --- a/test/shared/utils/order_sync_helpers_test.dart +++ b/test/shared/utils/order_sync_helpers_test.dart @@ -5,18 +5,13 @@ import 'package:mostro_mobile/shared/utils/order_sync_helpers.dart'; /// Claiming it after a failed read leaves an admin resolution that was rejected /// during startup permanently dropped, since nothing triggers another replay. -const _maxChained = 3; - SyncCompletion _resolve({ required bool succeeded, bool resyncRequested = false, - int resyncAttempts = 0, }) => resolveSyncCompletion( succeeded: succeeded, resyncRequested: resyncRequested, - resyncAttempts: resyncAttempts, - maxChainedResyncs: _maxChained, ); void main() { @@ -41,31 +36,14 @@ void main() { equals(SyncCompletion.replay)); }); - test('replays are bounded', () { - expect( - _resolve( - succeeded: true, - resyncRequested: true, - resyncAttempts: _maxChained), - equals(SyncCompletion.hydrated), - reason: 'a successful pass hydrates once the budget is spent'); - - expect( - _resolve( - succeeded: false, - resyncRequested: true, - resyncAttempts: _maxChained), - equals(SyncCompletion.unhydrated), - reason: 'an exhausted budget must not hydrate on a failed read'); - }); - - test('the last chained attempt still replays', () { - expect( - _resolve( - succeeded: true, - resyncRequested: true, - resyncAttempts: _maxChained - 1), - equals(SyncCompletion.replay)); + test('a pending replay is never traded for hydration', () { + // Declaring a history hydrated while a queued resolution may be missing + // from it disables recovery for every later resolution as well. + for (final succeeded in [true, false]) { + expect(_resolve(succeeded: succeeded, resyncRequested: true), + equals(SyncCompletion.replay), + reason: 'a queued replay outranks the outcome of this pass'); + } }); }); } From 35bd30966a31cd395a937525c274428a1fa58c93 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Fri, 14 Aug 2026 21:22:05 -0300 Subject: [PATCH 10/10] fix: cap chained sync replays to prevent unbounded history reads from rejected resolutions --- .../order/notifiers/order_notifier.dart | 9 +++++ lib/shared/utils/order_sync_helpers.dart | 19 ++++++---- .../shared/utils/order_sync_helpers_test.dart | 35 +++++++++++++++---- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/lib/features/order/notifiers/order_notifier.dart b/lib/features/order/notifiers/order_notifier.dart index be90e60f..00b85d57 100644 --- a/lib/features/order/notifiers/order_notifier.dart +++ b/lib/features/order/notifiers/order_notifier.dart @@ -16,6 +16,12 @@ class OrderNotifier extends AbstractMostroNotifier { bool _isSyncing = false; // Only for sync() method bool _hydrated = false; // A sync() has read the history successfully bool _resyncRequested = false; // A sync() was asked for while one was running + int _resyncAttempts = 0; + + /// Bounds the chain of replays a single startup may schedule. Rejected + /// resolutions are hostile input, so the chain must not be paced by how fast + /// they arrive. + static const _maxChainedResyncs = 3; OrderNotifier(super.orderId, super.ref) { mostroService = ref.read(mostroServiceProvider); @@ -106,11 +112,14 @@ class OrderNotifier extends AbstractMostroNotifier { final completion = resolveSyncCompletion( succeeded: succeeded, resyncRequested: _resyncRequested, + resyncAttempts: _resyncAttempts, + maxChainedResyncs: _maxChainedResyncs, ); _resyncRequested = false; switch (completion) { case SyncCompletion.replay: + _resyncAttempts++; sync(); case SyncCompletion.hydrated: _hydrated = true; diff --git a/lib/shared/utils/order_sync_helpers.dart b/lib/shared/utils/order_sync_helpers.dart index 92d37d7e..48ee74b3 100644 --- a/lib/shared/utils/order_sync_helpers.dart +++ b/lib/shared/utils/order_sync_helpers.dart @@ -18,15 +18,22 @@ enum SyncCompletion { /// recovery available, otherwise an admin resolution rejected during startup /// — before its dispute was loaded — is never revisited. /// -/// A pending replay is always honoured rather than capped. Capping it would -/// mean declaring a history hydrated while knowing a queued resolution may be -/// missing from it, which disables recovery for every later resolution too. -/// The chain is self-limiting instead: each replay needs a fresh rejection -/// arriving while the pass runs, and ends as soon as one pass sees none. +/// `maxChainedResyncs` bounds how many replays may be chained. Exhausting it +/// yields [SyncCompletion.unhydrated]: the pass neither claims a history it +/// knows may be missing the queued resolution, nor schedules yet another full +/// read. Recovery stays available because the notifier is still unhydrated, so +/// a later rejection can ask for a fresh pass — one read per message, rather +/// than a replay loop that rejected messages alone could keep running. SyncCompletion resolveSyncCompletion({ required bool succeeded, required bool resyncRequested, + required int resyncAttempts, + required int maxChainedResyncs, }) { - if (resyncRequested) return SyncCompletion.replay; + if (resyncRequested) { + return resyncAttempts < maxChainedResyncs + ? SyncCompletion.replay + : SyncCompletion.unhydrated; + } return succeeded ? SyncCompletion.hydrated : SyncCompletion.unhydrated; } diff --git a/test/shared/utils/order_sync_helpers_test.dart b/test/shared/utils/order_sync_helpers_test.dart index a06e5c66..85f563d8 100644 --- a/test/shared/utils/order_sync_helpers_test.dart +++ b/test/shared/utils/order_sync_helpers_test.dart @@ -5,13 +5,18 @@ import 'package:mostro_mobile/shared/utils/order_sync_helpers.dart'; /// Claiming it after a failed read leaves an admin resolution that was rejected /// during startup permanently dropped, since nothing triggers another replay. +const _maxChained = 3; + SyncCompletion _resolve({ required bool succeeded, bool resyncRequested = false, + int resyncAttempts = 0, }) => resolveSyncCompletion( succeeded: succeeded, resyncRequested: resyncRequested, + resyncAttempts: resyncAttempts, + maxChainedResyncs: _maxChained, ); void main() { @@ -36,13 +41,31 @@ void main() { equals(SyncCompletion.replay)); }); - test('a pending replay is never traded for hydration', () { - // Declaring a history hydrated while a queued resolution may be missing - // from it disables recovery for every later resolution as well. + test('the last attempt within budget still replays', () { + expect( + _resolve( + succeeded: true, + resyncRequested: true, + resyncAttempts: _maxChained - 1), + equals(SyncCompletion.replay)); + }); + + test('an exhausted budget with a replay pending neither hydrates nor ' + 'replays', () { + // Both properties matter and pull against each other. Hydrating would + // declare a history that may be missing the queued resolution, killing + // recovery for every later one. Replaying would let rejected messages — + // the hostile input this guard exists for — pace an unbounded chain of + // full history reads. for (final succeeded in [true, false]) { - expect(_resolve(succeeded: succeeded, resyncRequested: true), - equals(SyncCompletion.replay), - reason: 'a queued replay outranks the outcome of this pass'); + expect( + _resolve( + succeeded: succeeded, + resyncRequested: true, + resyncAttempts: _maxChained), + equals(SyncCompletion.unhydrated), + reason: 'succeeded=$succeeded must leave recovery available ' + 'without scheduling another read'); } }); });