From e1de79573983bb28d2ba1d64b02934b4f3bba173 Mon Sep 17 00:00:00 2001 From: codaMW Date: Thu, 20 Aug 2026 07:44:04 +0200 Subject: [PATCH 1/2] feat(#270): waiting-state countdown from the node's real expiration_seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The waiting-state countdown was cosmetic and wrong on two counts: it hardcoded 900 s instead of the node's advertised `expiration_seconds` (Kind 38385 instance event), and it counted down to `OrderInfo.expiresAt` (the 24 h pending-order expiry) rather than the waiting-state deadline. Fix, extracted into a shared `waitingCountdownDeadline` helper so every surface agrees: - Pending orders keep counting to the 24 h pending expiry (`expiresAt`). - Waiting states (buyer-invoice / payment) count to the state-change deadline: the trade's `timeoutAt` when the daemon persisted one, else now + the node's `expiration_seconds`, falling back to 900 s only when the instance event omits it. Both data sources were already in Dart (no bridge regen): `expirationSeconds` on `MostroInstance` via `mostroNodeProvider`, and `timeoutAt` on `TradeInfo` via `tradeInfoProvider`. Applied on both surfaces that render the countdown — the trade-detail screen and the chat trade-state header — which shared the same `expiresAt` bug (the chat header showed ~30 days for a waiting-payment order). UI only informs; the daemon stays the authority on expiry (no local cancellation at zero). Device-verified on hardware: About advertises 900 s; a waiting-for-payment order shows 15:00 counting down on both the trade-detail screen and the counterpart chat header, matching. --- .../chat/widgets/trade_state_header.dart | 23 ++++++- .../order/utils/waiting_countdown.dart | 50 ++++++++++++++ .../trades/screens/trade_detail_screen.dart | 69 +++++++++++++------ 3 files changed, 118 insertions(+), 24 deletions(-) create mode 100644 lib/features/order/utils/waiting_countdown.dart diff --git a/lib/features/chat/widgets/trade_state_header.dart b/lib/features/chat/widgets/trade_state_header.dart index e9021ab2..961e7bac 100644 --- a/lib/features/chat/widgets/trade_state_header.dart +++ b/lib/features/chat/widgets/trade_state_header.dart @@ -8,8 +8,11 @@ import 'package:mostro/core/app_routes.dart'; import 'package:mostro/core/app_theme.dart'; import 'package:mostro/features/home/providers/home_order_providers.dart'; import 'package:mostro/features/order/providers/trade_state_provider.dart'; +import 'package:mostro/features/about/providers/mostro_node_provider.dart'; +import 'package:mostro/features/order/utils/waiting_countdown.dart'; import 'package:mostro/features/trades/providers/trades_providers.dart'; import 'package:mostro/l10n/app_localizations.dart'; +import 'package:mostro/shared/utils/platform_int64.dart'; import 'package:mostro/shared/widgets/status_chip.dart'; import 'package:mostro/src/rust/api/orders.dart' as orders_api; import 'package:mostro/src/rust/api/types.dart' as rust_types; @@ -89,6 +92,20 @@ class TradeStateHeader extends ConsumerWidget { // Live status overrides the snapshot baked into the resolved order. final liveStatus = ref.watch(tradeStatusProvider(orderId)).valueOrNull; final statusFilter = orderStatusToFilter(liveStatus ?? order.status); + // #270: base the countdown on the waiting-state deadline (timeout_at or + // now + node expiration_seconds), not the 24 h pending expiry. Shared with + // the trade-detail screen so both surfaces show the same value. + final tradeInfo = ref.watch(tradeInfoProvider(orderId)).valueOrNull; + final expirationSeconds = + ref.watch(mostroNodeProvider).valueOrNull?.expirationSeconds; + final countdown = waitingCountdownDeadline( + status: liveStatus, + pendingExpiresAt: order.expiresAt, + timeoutAtEpoch: tradeInfo?.timeoutAt != null + ? platformInt64ToInt(tradeInfo!.timeoutAt!) + : null, + expirationSeconds: expirationSeconds, + ); final (pillBg, pillFg) = _statusColors(statusFilter); // Buyer/seller role: in-memory map → persisted DB → derive from order. @@ -171,9 +188,11 @@ class TradeStateHeader extends ConsumerWidget { ), ), ], - if (order.expiresAt != null) + if (countdown != null) _CountdownChip( - expiresAt: order.expiresAt!, + expiresAt: DateTime.fromMillisecondsSinceEpoch( + countdown.deadlineEpochSeconds * 1000, + ), color: amber, separatorColor: secondary, showSeparator: order.paymentMethod.isNotEmpty, diff --git a/lib/features/order/utils/waiting_countdown.dart b/lib/features/order/utils/waiting_countdown.dart new file mode 100644 index 00000000..86a91857 --- /dev/null +++ b/lib/features/order/utils/waiting_countdown.dart @@ -0,0 +1,50 @@ +import 'package:mostro/src/rust/api/types.dart'; + +/// The countdown target for a trade, resolved for #270. +/// +/// [deadlineEpochSeconds] is the unix-second instant the countdown runs to; +/// [totalWindowSeconds] sizes the progress ring (the full window). +typedef CountdownDeadline = ({int deadlineEpochSeconds, int totalWindowSeconds}); + +/// Last-resort waiting-state window (seconds) when the node's instance event +/// omits `expiration_seconds`. Matches the Mostro daemon default (15 min). +const int kWaitingCountdownFallbackSeconds = 900; + +/// Chooses the countdown target for a trade (#270): +/// +/// - **Pending**: counts to the 24 h pending-order expiry ([pendingExpiresAt]). +/// - **Waiting states** (buyer-invoice / payment): counts to the state-change +/// deadline — the trade's [timeoutAtEpoch] when the daemon persisted one, +/// else `now + expiration_seconds` (falling back to +/// [kWaitingCountdownFallbackSeconds] when the node omits it). +/// - **Any other state**: no countdown (null). +/// +/// The UI only informs — the daemon stays the authority on expiry, so callers +/// never cancel locally at zero. +CountdownDeadline? waitingCountdownDeadline({ + required OrderStatus? status, + DateTime? pendingExpiresAt, + int? timeoutAtEpoch, + int? expirationSeconds, +}) { + final window = expirationSeconds ?? kWaitingCountdownFallbackSeconds; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + switch (status) { + case OrderStatus.pending: + if (pendingExpiresAt == null) return null; + final deadline = pendingExpiresAt.millisecondsSinceEpoch ~/ 1000; + final total = deadline - now; + return ( + deadlineEpochSeconds: deadline, + totalWindowSeconds: total > 0 ? total : window, + ); + case OrderStatus.waitingBuyerInvoice: + case OrderStatus.waitingPayment: + if (timeoutAtEpoch != null) { + return (deadlineEpochSeconds: timeoutAtEpoch, totalWindowSeconds: window); + } + return (deadlineEpochSeconds: now + window, totalWindowSeconds: window); + default: + return null; + } +} diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index ecadeb50..e5c6fe54 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -16,6 +16,8 @@ import 'package:mostro/features/chat/providers/chat_providers.dart'; import 'package:mostro/features/disputes/providers/disputes_providers.dart'; import 'package:mostro/features/home/providers/home_order_providers.dart'; import 'package:mostro/features/order/providers/trade_state_provider.dart'; +import 'package:mostro/features/about/providers/mostro_node_provider.dart'; +import 'package:mostro/features/order/utils/waiting_countdown.dart'; import 'package:mostro/features/trades/providers/trades_providers.dart'; import 'package:mostro/features/trades/widgets/dispute_confirmation_dialog.dart'; import 'package:mostro/features/trades/widgets/release_confirmation_dialog.dart'; @@ -38,7 +40,9 @@ class TradeDetailScreen extends ConsumerStatefulWidget { ConsumerState createState() => _TradeDetailScreenState(); } -/// Default trade countdown duration (matches Mostro daemon default). +/// Last-resort waiting-state countdown fallback, used only when the node's +/// instance event omits `expiration_seconds` (#270). The real window comes from +/// `MostroInstance.expirationSeconds` and the real deadline from `timeoutAt`. const _kCountdownSeconds = 900; // 15 minutes /// Type-safe trade status for the detail screen. @@ -102,7 +106,8 @@ class _TradeDetailScreenState extends ConsumerState { @override void initState() { super.initState(); - _loadExpiresAt(); + // #270: the deadline is fed reactively from build() once tradeInfoProvider + // (timeoutAt) and mostroNodeProvider (expiration_seconds) resolve. _startCountdown(); } @@ -112,26 +117,21 @@ class _TradeDetailScreenState extends ConsumerState { super.dispose(); } - /// Fetches the real `expiresAt` from the order and resets [_remaining]. - /// - /// Falls back to the default [_kCountdownSeconds] when the field is null or - /// the order is no longer available. - Future _loadExpiresAt() async { - try { - final info = await orders_api.getOrder(orderId: widget.orderId); - final raw = info?.expiresAt; - if (raw == null || !mounted) return; - final expiresAtSeconds = platformInt64ToInt(raw); - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final diff = expiresAtSeconds - now; - if (!mounted) return; - setState(() { - _totalCountdownSeconds = diff > 0 ? diff : _kCountdownSeconds; - _remaining = diff > 0 ? Duration(seconds: diff) : Duration.zero; - }); - } catch (_) { - // Keep the default remaining time on error. - } + /// Applies a resolved countdown deadline (unix seconds) to the ticking timer, + /// sizing the progress ring off [totalSeconds]. Only resets when the target + /// actually changes, so the per-second tick isn't clobbered on every rebuild. + int? _appliedDeadline; + void _applyDeadline(int deadlineEpochSeconds, int totalSeconds) { + if (_appliedDeadline == deadlineEpochSeconds) return; + _appliedDeadline = deadlineEpochSeconds; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final diff = deadlineEpochSeconds - now; + if (!mounted) return; + setState(() { + _totalCountdownSeconds = + totalSeconds > 0 ? totalSeconds : _kCountdownSeconds; + _remaining = diff > 0 ? Duration(seconds: diff) : Duration.zero; + }); } void _startCountdown() { @@ -497,6 +497,31 @@ class _TradeDetailScreenState extends ConsumerState { final allOrders = ref.watch(orderBookProvider).valueOrNull ?? []; final order = allOrders.where((o) => o.id == widget.orderId).firstOrNull; + // #270: drive the waiting-state countdown from the node's real + // expiration_seconds and the trade's timeout_at, not the 24 h pending + // expiry. _applyDeadline only resets the ticking timer when the target + // changes, so unrelated rebuilds don't disturb the per-second tick. UI + // only informs — the daemon stays the authority on expiry. + final tradeInfo = ref.watch(tradeInfoProvider(widget.orderId)).valueOrNull; + final expirationSeconds = + ref.watch(mostroNodeProvider).valueOrNull?.expirationSeconds; + final countdown = waitingCountdownDeadline( + status: tradeStatusAsync.valueOrNull, + pendingExpiresAt: order?.expiresAt, + timeoutAtEpoch: tradeInfo?.timeoutAt != null + ? platformInt64ToInt(tradeInfo!.timeoutAt!) + : null, + expirationSeconds: expirationSeconds, + ); + if (countdown != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _applyDeadline( + countdown.deadlineEpochSeconds, countdown.totalWindowSeconds); + } + }); + } + final inFlight = const { TradeStatus.waitingInvoice, TradeStatus.waitingPayment, From f2bd61e0b53613b6fcfd1d6476b47d46d45c6d2e Mon Sep 17 00:00:00 2001 From: codaMW Date: Thu, 20 Aug 2026 11:29:00 +0200 Subject: [PATCH 2/2] fix(#270): stable fallback deadline, snapshot-status fallback, clear on exit Addresses the CodeRabbit review: - Fallback deadline is now stable. When timeout_at is absent, the waiting-state deadline is anchored on the trade's startedAt (state-change timestamp) plus the window, not `now + window`. The helper no longer reads the clock, so the per-second rebuilds that drive the ticking UI can't slide the deadline forward and prevent it from reaching zero. Returns null when neither timeout_at nor a start anchor is available (no bogus countdown). - Chat header uses the order snapshot status (`liveStatus ?? order.status`) while tradeStatusProvider resolves, matching the status pill, so a known waiting/pending order still shows a countdown during the loading frame. - Trade detail clears the countdown when the resolved state has none (active / fiat-sent / terminal), so a stale countdown doesn't linger across the transition out of a waiting state. Added waiting_countdown_test.dart: pending/waiting/timeout_at/fallback/null branches, the 900 s default, and a regression test asserting the fallback deadline is stable across repeated resolutions. flutter analyze + test green. --- .../chat/widgets/trade_state_header.dart | 7 +- .../order/utils/waiting_countdown.dart | 27 ++-- .../trades/screens/trade_detail_screen.dart | 34 +++-- .../order/utils/waiting_countdown_test.dart | 118 ++++++++++++++++++ 4 files changed, 168 insertions(+), 18 deletions(-) create mode 100644 test/features/order/utils/waiting_countdown_test.dart diff --git a/lib/features/chat/widgets/trade_state_header.dart b/lib/features/chat/widgets/trade_state_header.dart index 961e7bac..0482fea9 100644 --- a/lib/features/chat/widgets/trade_state_header.dart +++ b/lib/features/chat/widgets/trade_state_header.dart @@ -99,11 +99,16 @@ class TradeStateHeader extends ConsumerWidget { final expirationSeconds = ref.watch(mostroNodeProvider).valueOrNull?.expirationSeconds; final countdown = waitingCountdownDeadline( - status: liveStatus, + // Fall back to the order snapshot's status while tradeStatusProvider + // resolves, mirroring the pill above — otherwise a known waiting/pending + // order shows no countdown during the provider's loading frame. + status: liveStatus ?? order.status, pendingExpiresAt: order.expiresAt, timeoutAtEpoch: tradeInfo?.timeoutAt != null ? platformInt64ToInt(tradeInfo!.timeoutAt!) : null, + waitingSinceEpoch: + tradeInfo != null ? platformInt64ToInt(tradeInfo.startedAt) : null, expirationSeconds: expirationSeconds, ); final (pillBg, pillFg) = _statusColors(statusFilter); diff --git a/lib/features/order/utils/waiting_countdown.dart b/lib/features/order/utils/waiting_countdown.dart index 86a91857..2a6753c8 100644 --- a/lib/features/order/utils/waiting_countdown.dart +++ b/lib/features/order/utils/waiting_countdown.dart @@ -15,35 +15,44 @@ const int kWaitingCountdownFallbackSeconds = 900; /// - **Pending**: counts to the 24 h pending-order expiry ([pendingExpiresAt]). /// - **Waiting states** (buyer-invoice / payment): counts to the state-change /// deadline — the trade's [timeoutAtEpoch] when the daemon persisted one, -/// else `now + expiration_seconds` (falling back to -/// [kWaitingCountdownFallbackSeconds] when the node omits it). +/// else [waitingSinceEpoch] + `expiration_seconds` (the state-change message +/// timestamp plus the window, per the v1 timeout contract), falling back to +/// [kWaitingCountdownFallbackSeconds] for the window when the node omits it. /// - **Any other state**: no countdown (null). /// +/// The fallback is anchored on [waitingSinceEpoch] (a fixed timestamp such as +/// `TradeInfo.startedAt`) rather than the current time, so the deadline is +/// stable across the per-second rebuilds that drive the ticking UI — otherwise +/// a `now + window` deadline would slide forward every second and never reach +/// zero. This keeps the helper pure (no clock read, no cache). +/// /// The UI only informs — the daemon stays the authority on expiry, so callers /// never cancel locally at zero. CountdownDeadline? waitingCountdownDeadline({ required OrderStatus? status, DateTime? pendingExpiresAt, int? timeoutAtEpoch, + int? waitingSinceEpoch, int? expirationSeconds, }) { final window = expirationSeconds ?? kWaitingCountdownFallbackSeconds; - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; switch (status) { case OrderStatus.pending: if (pendingExpiresAt == null) return null; final deadline = pendingExpiresAt.millisecondsSinceEpoch ~/ 1000; - final total = deadline - now; - return ( - deadlineEpochSeconds: deadline, - totalWindowSeconds: total > 0 ? total : window, - ); + return (deadlineEpochSeconds: deadline, totalWindowSeconds: window); case OrderStatus.waitingBuyerInvoice: case OrderStatus.waitingPayment: if (timeoutAtEpoch != null) { return (deadlineEpochSeconds: timeoutAtEpoch, totalWindowSeconds: window); } - return (deadlineEpochSeconds: now + window, totalWindowSeconds: window); + if (waitingSinceEpoch != null) { + return ( + deadlineEpochSeconds: waitingSinceEpoch + window, + totalWindowSeconds: window, + ); + } + return null; default: return null; } diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index e5c6fe54..4c4aa832 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -134,6 +134,18 @@ class _TradeDetailScreenState extends ConsumerState { }); } + /// Clears the countdown when the current state no longer has one (e.g. a + /// waiting order advanced to active / fiat-sent). Idempotent: does nothing if + /// no deadline is currently applied, so it won't setState on every rebuild. + void _clearDeadline() { + if (_appliedDeadline == null && _remaining == Duration.zero) return; + _appliedDeadline = null; + if (!mounted) return; + setState(() { + _remaining = Duration.zero; + }); + } + void _startCountdown() { _countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) { if (!mounted) return; @@ -511,16 +523,22 @@ class _TradeDetailScreenState extends ConsumerState { timeoutAtEpoch: tradeInfo?.timeoutAt != null ? platformInt64ToInt(tradeInfo!.timeoutAt!) : null, + waitingSinceEpoch: + tradeInfo != null ? platformInt64ToInt(tradeInfo.startedAt) : null, expirationSeconds: expirationSeconds, ); - if (countdown != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _applyDeadline( - countdown.deadlineEpochSeconds, countdown.totalWindowSeconds); - } - }); - } + // When the resolved state has no countdown (active / fiat-sent / terminal), + // clear any timer left over from a prior waiting state so a stale countdown + // never lingers across the transition. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (countdown != null) { + _applyDeadline( + countdown.deadlineEpochSeconds, countdown.totalWindowSeconds); + } else { + _clearDeadline(); + } + }); final inFlight = const { TradeStatus.waitingInvoice, diff --git a/test/features/order/utils/waiting_countdown_test.dart b/test/features/order/utils/waiting_countdown_test.dart new file mode 100644 index 00000000..450fa8da --- /dev/null +++ b/test/features/order/utils/waiting_countdown_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro/features/order/utils/waiting_countdown.dart'; +import 'package:mostro/src/rust/api/types.dart'; + +void main() { + group('waitingCountdownDeadline', () { + test('pending counts to the pending expiry, window from expiration_seconds', + () { + final expiresAt = DateTime.fromMillisecondsSinceEpoch(2000 * 1000); + final r = waitingCountdownDeadline( + status: OrderStatus.pending, + pendingExpiresAt: expiresAt, + expirationSeconds: 900, + ); + expect(r, isNotNull); + expect(r!.deadlineEpochSeconds, 2000); + expect(r.totalWindowSeconds, 900); + }); + + test('pending with no expiry yields no countdown', () { + final r = waitingCountdownDeadline( + status: OrderStatus.pending, + pendingExpiresAt: null, + expirationSeconds: 900, + ); + expect(r, isNull); + }); + + test('waiting state prefers the persisted timeout_at', () { + for (final status in [ + OrderStatus.waitingBuyerInvoice, + OrderStatus.waitingPayment, + ]) { + final r = waitingCountdownDeadline( + status: status, + timeoutAtEpoch: 5000, + waitingSinceEpoch: 1000, + expirationSeconds: 900, + ); + expect(r, isNotNull, reason: '$status'); + expect(r!.deadlineEpochSeconds, 5000, reason: '$status'); + expect(r.totalWindowSeconds, 900, reason: '$status'); + } + }); + + test('waiting falls back to startedAt + window when timeout_at is absent', + () { + final r = waitingCountdownDeadline( + status: OrderStatus.waitingPayment, + timeoutAtEpoch: null, + waitingSinceEpoch: 1000, + expirationSeconds: 900, + ); + expect(r, isNotNull); + expect(r!.deadlineEpochSeconds, 1900); // 1000 + 900, not now-based + expect(r.totalWindowSeconds, 900); + }); + + test('waiting fallback uses the 900 s default when the node omits ' + 'expiration_seconds', () { + final r = waitingCountdownDeadline( + status: OrderStatus.waitingPayment, + timeoutAtEpoch: null, + waitingSinceEpoch: 1000, + expirationSeconds: null, + ); + expect(r, isNotNull); + expect(r!.totalWindowSeconds, kWaitingCountdownFallbackSeconds); + expect(r.deadlineEpochSeconds, 1000 + kWaitingCountdownFallbackSeconds); + }); + + test('waiting with neither timeout_at nor a start anchor yields no ' + 'countdown', () { + final r = waitingCountdownDeadline( + status: OrderStatus.waitingBuyerInvoice, + timeoutAtEpoch: null, + waitingSinceEpoch: null, + expirationSeconds: 900, + ); + expect(r, isNull); + }); + + test('non-countdown states produce no countdown', () { + for (final status in [ + OrderStatus.active, + OrderStatus.fiatSent, + OrderStatus.success, + OrderStatus.canceled, + null, + ]) { + final r = waitingCountdownDeadline( + status: status, + pendingExpiresAt: DateTime.fromMillisecondsSinceEpoch(2000 * 1000), + timeoutAtEpoch: 5000, + waitingSinceEpoch: 1000, + expirationSeconds: 900, + ); + expect(r, isNull, reason: '$status'); + } + }); + + test('the fallback deadline is stable across repeated resolutions ' + '(#270 regression: no now-drift)', () { + CountdownDeadline? resolve() => waitingCountdownDeadline( + status: OrderStatus.waitingPayment, + timeoutAtEpoch: null, + waitingSinceEpoch: 1000, + expirationSeconds: 900, + ); + final first = resolve(); + final second = resolve(); + final third = resolve(); + expect(first!.deadlineEpochSeconds, 1900); + expect(second!.deadlineEpochSeconds, first.deadlineEpochSeconds); + expect(third!.deadlineEpochSeconds, first.deadlineEpochSeconds); + }); + }); +}