From e1de79573983bb28d2ba1d64b02934b4f3bba173 Mon Sep 17 00:00:00 2001 From: codaMW Date: Thu, 20 Aug 2026 07:44:04 +0200 Subject: [PATCH 1/3] 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 e9021ab2a..961e7bacb 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 000000000..86a918576 --- /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 ecadeb505..e5c6fe543 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/3] 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 961e7bacb..0482fea95 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 86a918576..2a6753c83 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 e5c6fe543..4c4aa8325 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 000000000..450fa8dae --- /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); + }); + }); +} From 366911dc20a84e78bd2cbc9a67c9f2382fd83148 Mon Sep 17 00:00:00 2001 From: codaMW Date: Mon, 31 Aug 2026 13:52:54 +0200 Subject: [PATCH 3/3] fix(#270): drop the startedAt fallback that made the maker countdown start at zero (#306 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catrya's review: the helper's fallback anchored on TradeInfo.startedAt, which for a maker is the order-creation time (orders.rs:546, timeout_at: None). A sell order that sat in the book before being taken yielded a deadline of creation + 900s — already in the past. That maker, watching the waiting-payment countdown to pay the hold invoice, is exactly who #270 was meant to help, and the countdown was born at zero. - Waiting states now count down only when timeout_at is present; with no anchor they return null. 'No anchor, no countdown' is portable today and removes the maker bug immediately (a missing countdown beats one that starts at zero). - The real fix — stamping timeout_at on waiting-state entry from the node's expiration_seconds — belongs in the daemon and is tracked as a follow-up; once it lands, this null path stops existing. - Pending progress ring now spans creation -> 24 h expiry (deadline - createdAt) instead of the waiting window, so the ring is meaningful. - Corrected the doc comment: timeout_at is currently a fixed 900 written client-side on take, not persisted by the daemon. - Kept the chat-header fix (same defect on both surfaces). Tests rewritten: the born-at-zero fallback cases become a null-return regression guard for the maker case; the stability test now pins the timeout_at path. flutter analyze clean; 7/7 helper tests pass. --- .../chat/widgets/trade_state_header.dart | 4 +- .../order/utils/waiting_countdown.dart | 41 +++++---- .../trades/screens/trade_detail_screen.dart | 4 +- .../order/utils/waiting_countdown_test.dart | 87 +++++++++---------- 4 files changed, 70 insertions(+), 66 deletions(-) diff --git a/lib/features/chat/widgets/trade_state_header.dart b/lib/features/chat/widgets/trade_state_header.dart index 0482fea95..338d05ec9 100644 --- a/lib/features/chat/widgets/trade_state_header.dart +++ b/lib/features/chat/widgets/trade_state_header.dart @@ -104,11 +104,11 @@ class TradeStateHeader extends ConsumerWidget { // order shows no countdown during the provider's loading frame. status: liveStatus ?? order.status, pendingExpiresAt: order.expiresAt, + pendingCreatedAtEpoch: + order.createdAt.millisecondsSinceEpoch ~/ 1000, 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 2a6753c83..0c9f37ea3 100644 --- a/lib/features/order/utils/waiting_countdown.dart +++ b/lib/features/order/utils/waiting_countdown.dart @@ -13,26 +13,27 @@ 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 [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. +/// - **Waiting states** (buyer-invoice / payment): counts to the trade's +/// [timeoutAtEpoch] when one is present, else **no countdown** (null). +/// There is deliberately no `startedAt`-based fallback: `startedAt` is the +/// order-creation time, so for a maker whose order sat in the book before +/// being taken it yields a deadline already in the past — a countdown born at +/// zero. "No anchor, no countdown" is correct until the daemon stamps +/// `timeout_at` on waiting-state entry from the node's `expiration_seconds` +/// (a follow-up daemon change; #306 review). Today `timeout_at` is a fixed +/// 900 written client-side on take, not persisted by the daemon. /// - **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 helper is pure — no clock read, no cache — so its result is stable across +/// the per-second rebuilds that drive the ticking UI. /// /// 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? pendingCreatedAtEpoch, int? timeoutAtEpoch, - int? waitingSinceEpoch, int? expirationSeconds, }) { final window = expirationSeconds ?? kWaitingCountdownFallbackSeconds; @@ -40,18 +41,22 @@ CountdownDeadline? waitingCountdownDeadline({ case OrderStatus.pending: if (pendingExpiresAt == null) return null; final deadline = pendingExpiresAt.millisecondsSinceEpoch ~/ 1000; - return (deadlineEpochSeconds: deadline, totalWindowSeconds: window); + // The ring spans the whole pending window (creation -> 24 h expiry), not + // the waiting window, so the progress ring is meaningful (#306 review). + final total = + (pendingCreatedAtEpoch != null && deadline > pendingCreatedAtEpoch) + ? deadline - pendingCreatedAtEpoch + : window; + return (deadlineEpochSeconds: deadline, totalWindowSeconds: total); case OrderStatus.waitingBuyerInvoice: case OrderStatus.waitingPayment: + // Only count down when timeout_at is present. The startedAt fallback + // produced a past deadline for makers (startedAt is creation time), so + // "no anchor, no countdown" until the daemon stamps timeout_at properly + // (#306 review). if (timeoutAtEpoch != null) { return (deadlineEpochSeconds: timeoutAtEpoch, 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 4c4aa8325..882a7a659 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -520,11 +520,11 @@ class _TradeDetailScreenState extends ConsumerState { final countdown = waitingCountdownDeadline( status: tradeStatusAsync.valueOrNull, pendingExpiresAt: order?.expiresAt, + pendingCreatedAtEpoch: + order != null ? order.createdAt.millisecondsSinceEpoch ~/ 1000 : null, timeoutAtEpoch: tradeInfo?.timeoutAt != null ? platformInt64ToInt(tradeInfo!.timeoutAt!) : null, - waitingSinceEpoch: - tradeInfo != null ? platformInt64ToInt(tradeInfo.startedAt) : null, expirationSeconds: expirationSeconds, ); // When the resolved state has no countdown (active / fiat-sent / terminal), diff --git a/test/features/order/utils/waiting_countdown_test.dart b/test/features/order/utils/waiting_countdown_test.dart index 450fa8dae..a483661ef 100644 --- a/test/features/order/utils/waiting_countdown_test.dart +++ b/test/features/order/utils/waiting_countdown_test.dart @@ -4,12 +4,29 @@ import 'package:mostro/src/rust/api/types.dart'; void main() { group('waitingCountdownDeadline', () { - test('pending counts to the pending expiry, window from expiration_seconds', + test('pending counts to the pending expiry; ring spans creation to expiry', () { - final expiresAt = DateTime.fromMillisecondsSinceEpoch(2000 * 1000); + // 24 h window: created at epoch 1000, expires at 1000 + 86400. + const createdAt = 1000; + final expiresAt = + DateTime.fromMillisecondsSinceEpoch((createdAt + 86400) * 1000); final r = waitingCountdownDeadline( status: OrderStatus.pending, pendingExpiresAt: expiresAt, + pendingCreatedAtEpoch: createdAt, + expirationSeconds: 900, + ); + expect(r, isNotNull); + expect(r!.deadlineEpochSeconds, createdAt + 86400); + // The ring spans the whole pending window, not the waiting window (#306). + expect(r.totalWindowSeconds, 86400); + }); + + test('pending ring falls back to the window when no createdAt is given', () { + final r = waitingCountdownDeadline( + status: OrderStatus.pending, + pendingExpiresAt: DateTime.fromMillisecondsSinceEpoch(2000 * 1000), + pendingCreatedAtEpoch: null, expirationSeconds: 900, ); expect(r, isNotNull); @@ -26,7 +43,7 @@ void main() { expect(r, isNull); }); - test('waiting state prefers the persisted timeout_at', () { + test('waiting state counts to the persisted timeout_at', () { for (final status in [ OrderStatus.waitingBuyerInvoice, OrderStatus.waitingPayment, @@ -34,7 +51,6 @@ void main() { final r = waitingCountdownDeadline( status: status, timeoutAtEpoch: 5000, - waitingSinceEpoch: 1000, expirationSeconds: 900, ); expect(r, isNotNull, reason: '$status'); @@ -43,41 +59,24 @@ void main() { } }); - 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( + 'a waiting order with no timeout_at yields no countdown, not a past ' + 'deadline (#306: no startedAt fallback)', () { + // startedAt is order-creation time; for a maker whose order sat in the + // book before being taken, anchoring on it produced a deadline already in + // the past — a countdown born at zero. With no timeout_at the helper now + // returns null rather than a bogus deadline. + for (final status in [ + OrderStatus.waitingBuyerInvoice, + OrderStatus.waitingPayment, + ]) { + final r = waitingCountdownDeadline( + status: status, + timeoutAtEpoch: null, + expirationSeconds: 900, + ); + expect(r, isNull, reason: '$status'); + } }); test('non-countdown states produce no countdown', () { @@ -92,25 +91,25 @@ void main() { 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 ' + test('the resolved deadline is stable across repeated resolutions ' '(#270 regression: no now-drift)', () { + // The helper is pure: given the same inputs it returns the same deadline, + // so the ticking per-second rebuild never slides the target forward. CountdownDeadline? resolve() => waitingCountdownDeadline( status: OrderStatus.waitingPayment, - timeoutAtEpoch: null, - waitingSinceEpoch: 1000, + timeoutAtEpoch: 5000, expirationSeconds: 900, ); final first = resolve(); final second = resolve(); final third = resolve(); - expect(first!.deadlineEpochSeconds, 1900); + expect(first!.deadlineEpochSeconds, 5000); expect(second!.deadlineEpochSeconds, first.deadlineEpochSeconds); expect(third!.deadlineEpochSeconds, first.deadlineEpochSeconds); });