Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 22 additions & 33 deletions mobile-app/lib/services/global_history_polling_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@ import 'dart:async';

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:resonance_network_wallet/providers/account_providers.dart';
import 'package:resonance_network_wallet/providers/all_transactions_provider.dart';
import 'package:resonance_network_wallet/providers/wallet_providers.dart';
import 'package:resonance_network_wallet/providers/connectivity_provider.dart';
import 'package:resonance_network_wallet/services/pending_transaction_reconciliation_service.dart';
import 'package:resonance_network_wallet/services/telemetry_service.dart';
import 'package:resonance_network_wallet/shared/utils/tx_filter_family_provider.dart';
import 'package:resonance_network_wallet/shared/utils/polling_refresh_scope.dart';
import 'package:resonance_network_wallet/shared/utils/print.dart';

/// Service that handles global history polling - refreshes transaction history
/// every minute to keep the UI up to date with the latest blockchain state.
Expand Down Expand Up @@ -56,6 +55,8 @@ class GlobalHistoryPollingService {
}

void _scheduleNextPoll() {
_pollingTimer?.cancel();

_pollingTimer = Timer(const Duration(minutes: 1), () {
_performPoll();
});
Expand All @@ -73,35 +74,13 @@ class GlobalHistoryPollingService {
}

try {
// Check if we have accounts available
final accountsState = _ref.read(accountsProvider);
if (accountsState.value?.isEmpty ?? true) {
Comment thread
cursor[bot] marked this conversation as resolved.
_scheduleNextPoll();
return;
}

print('Performing global history poll...');

// Refresh balance silently (transactions might have changed balance)
_ref.invalidate(balanceProviderFamily);
quantusDebugPrint('Performing global history poll for active account...');

// Silently refresh without showing loading indicators for global
// and active filtered
_ref.read(paginationControllerProvider.notifier).silentRefresh();
final accountIds = _ref.read(accountsProvider).value?.map((a) => a.accountId).toList() ?? [];
final targetIds = [
...accountIds.map((id) => [id]),
accountIds,
];

for (final ids in targetIds) {
updatePaginationFiltersFor(_ref.read, ids, (notifier, _) {
notifier.silentRefresh();
});
}
invalidateActiveAccountBalance(_ref);
await silentRefreshActiveAccount(_ref);

// Reconcile pending transactions with confirmed transactions
_ref.read(pendingTransactionReconciliationServiceProvider).reconcilePendingTransactions();
await _ref.read(pendingTransactionReconciliationServiceProvider).reconcilePendingTransactions();

print('Global history poll completed');
} catch (e) {
Expand All @@ -125,12 +104,14 @@ class GlobalHistoryPollingService {
return;
}

await _ref.read(paginationControllerProvider.notifier).loadingRefresh();
final active = _ref.read(activeAccountProvider).value;
if (active != null) {
updatePaginationFiltersFor(_ref.read, [active.account.accountId], (notifier, _) {
notifier.loadingRefresh();
});
await refreshAccountsPagination(
_ref,
accountIds: [active.account.accountId],
action: (notifier) => notifier.loadingRefresh(),
);
invalidateActiveAccountBalance(_ref);
}

// Also reconcile pending transactions during manual refresh
Expand Down Expand Up @@ -169,6 +150,14 @@ final globalHistoryPollingServiceProvider = Provider<GlobalHistoryPollingService
);
});

ref.listen(activeAccountProvider, (previous, next) {
final previousId = previous?.value?.account.accountId;
final nextId = next.value?.account.accountId;
if (nextId == null || previousId == null || previousId == nextId) return;

refreshActiveAccountOnSwitch(ref);
});

// Clean up when provider is disposed
ref.onDispose(() => service.dispose());

Expand Down
21 changes: 5 additions & 16 deletions mobile-app/lib/services/history_polling_manager.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:resonance_network_wallet/providers/account_providers.dart';
import 'package:resonance_network_wallet/providers/all_transactions_provider.dart';
import 'package:resonance_network_wallet/providers/wallet_providers.dart';
import 'package:resonance_network_wallet/services/global_history_polling_service.dart';
import 'package:resonance_network_wallet/services/reversible_transfer_monitoring_service.dart';
import 'package:resonance_network_wallet/shared/utils/polling_refresh_scope.dart';

/// Manager that coordinates all polling services: global history, transaction
/// tracking,
Expand Down Expand Up @@ -59,28 +58,18 @@ class HistoryPollingManager {
Future<void> triggerSilentRefresh() async {
print('History polling manager: Silent Refresh!');

// Refresh balance silently (no loading indicators)
_refreshBalance(showLoading: false);

// Use silent refresh for background updates
await _ref.read(paginationControllerProvider.notifier).silentRefresh();
await silentRefreshActiveAccount(_ref);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent refresh triggered twice for active account

Low Severity

triggerSilentRefresh calls silentRefreshActiveAccount(_ref) directly, and then _reversibleMonitor.forceCheckAllMonitoredTransfers() also calls silentRefreshActiveAccount(_ref) internally when there are active execution pollers. This results in the active account's pagination being refreshed twice redundantly during a single silent refresh cycle.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 62e6719. Configure here.

await _reversibleMonitor.forceCheckAllMonitoredTransfers();
}

/// Helper method to refresh balance with or without loading indicators
void _refreshBalance({required bool showLoading}) {
if (showLoading) {
// For manual refresh - invalidate balance providers to show loading
final activeDisplayAccount = _ref.read(activeAccountProvider).value;
if (activeDisplayAccount != null) {
_ref.invalidate(balanceProviderFamily);
}
_ref.invalidate(balanceProviderRaw); // Invalidate raw balance for loading state
// displayBalanceProvider (effective) will auto-update when raw balance changes
invalidateActiveAccountBalance(_ref);
_ref.invalidate(balanceProviderRaw);
} else {
// For silent refresh - just invalidate family to refresh data silently
_ref.invalidate(balanceProviderFamily);
// displayBalanceProvider (effective) will auto-update when raw balance changes
invalidateActiveAccountBalance(_ref);
}
}

Expand Down
18 changes: 3 additions & 15 deletions mobile-app/lib/services/pending_transaction_polling_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ import 'dart:async';

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:quantus_sdk/quantus_sdk.dart';
import 'package:resonance_network_wallet/providers/account_providers.dart';
import 'package:resonance_network_wallet/providers/all_transactions_provider.dart';
import 'package:resonance_network_wallet/providers/pending_transactions_provider.dart';
import 'package:resonance_network_wallet/providers/wallet_providers.dart';
import 'package:resonance_network_wallet/shared/utils/polling_refresh_scope.dart';
import 'package:resonance_network_wallet/shared/utils/tx_filter_family_provider.dart';

class PendingTransactionPollingService {
Expand Down Expand Up @@ -92,7 +91,7 @@ class PendingTransactionPollingService {
onFound?.call(result);

_ref.read(pendingTransactionsProvider.notifier).remove(pendingTx.id);
_ref.invalidate(balanceProviderFamily);
invalidateAccountBalances(_ref, {pendingTx.from, pendingTx.to});
} else {
print('[PendingTxPoller] no match yet for ${pendingTx.id}, will retry');
}
Expand All @@ -117,18 +116,7 @@ final pendingTransactionPollingServiceProvider = Provider<PendingTransactionPoll

void triggerSilentHistoryRefresh(Ref ref, {required Set<String> affectedAccountIds, TransactionEvent? newTransaction}) {
try {
final mainController = ref.read(paginationControllerProvider.notifier);
if (newTransaction != null) mainController.addTransactionToHistory(newTransaction);
mainController.silentRefresh();

final targets = affectedAccountIds.map((id) => [id]).toList();
final active = ref.read(activeAccountProvider).value;
if (active != null) targets.add([active.account.accountId]);

final accountIds = ref.read(accountsProvider).value?.map((a) => a.accountId).toList() ?? [];
if (accountIds.isNotEmpty) {
targets.add(accountIds);
}
final targets = accountRefreshTargets(affectedAccountIds: affectedAccountIds, activeId: activeAccountId(ref));

for (final targetIds in targets) {
if (newTransaction != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import 'dart:async';

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:quantus_sdk/quantus_sdk.dart';
import 'package:resonance_network_wallet/providers/all_transactions_provider.dart';
import 'package:resonance_network_wallet/models/filtered_transactions_params.dart';
import 'package:resonance_network_wallet/providers/account_id_list_cache.dart';
import 'package:resonance_network_wallet/providers/filtered_all_transactions_provider.dart';
import 'package:resonance_network_wallet/providers/pending_cancellations_provider.dart';
import 'package:resonance_network_wallet/providers/pending_transactions_provider.dart';
import 'package:resonance_network_wallet/providers/wallet_providers.dart';
import 'package:resonance_network_wallet/services/transaction_service.dart';
import 'package:resonance_network_wallet/shared/utils/polling_refresh_scope.dart';

/// Service that reconciles pending transactions with confirmed transactions
/// from blockchain history. This handles cases where the inBlock status
Expand Down Expand Up @@ -42,20 +45,19 @@ class PendingTransactionReconciliationService {
'PendingReconciliation: Checking ${pendingTxs.length} '
'pending transactions',
);
final activeId = activeAccountId(_ref);
final accountIds = reconciliationAccountIds(activeId: activeId, pendingTxs: pendingTxs);

for (final accountId in accountIds) {
await refreshAccountsPagination(
_ref,
accountIds: [accountId],
action: (notifier) => notifier.silentRefresh(),
onlyIfAlive: accountId == activeId,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reconciliation onlyIfAlive condition is inverted

Medium Severity

The onlyIfAlive: accountId == activeId condition is inverted. This sets onlyIfAlive to true for the active account (potentially skipping it) and false for non-active accounts (unconditionally creating their providers). The intent is the opposite: the active account's provider is the one that's definitely alive and always worth refreshing, while non-active accounts involved in pending transactions are the ones that might not have providers alive and shouldn't have them created. The condition likely needs to be accountId != activeId to align with the PR's goal of not polling non-active accounts.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 62e6719. Configure here.

);
}

// Get recent history to match against
final allTransactionsAsync = _ref.read(allTransactionsProvider);

final confirmedTransactions = allTransactionsAsync.when(
data: (transactions) => txService.combineAndDeduplicateTransactions(
pendingCancellationIds: transactions.pendingCancellationIds,
pendingTransactions: [], // Don't include pending here as we're comparing against them
scheduledReversibleTransfers: transactions.scheduledReversibleTransfers,
otherTransfers: transactions.otherTransfers,
),
loading: () => <TransactionEvent>[],
error: (_, _) => <TransactionEvent>[],
);
final confirmedTransactions = _loadConfirmedTransactions(txService, accountIds);

if (confirmedTransactions.isEmpty) {
print('PendingReconciliation: No confirmed transactions to match against');
Expand Down Expand Up @@ -85,6 +87,33 @@ class PendingTransactionReconciliationService {
}
}

List<TransactionEvent> _loadConfirmedTransactions(TransactionService txService, Set<String> accountIds) {
final pendingCancellationIds = _ref.read(pendingCancellationsProvider);
final confirmedById = <String, TransactionEvent>{};

for (final accountId in accountIds) {
final params = FilteredTransactionsParams(
accountIds: AccountIdListCache.get([accountId]),
filter: TransactionFilter.all,
);
final pagination = _ref.read(filteredPaginationControllerProviderFamily(params));
if (!pagination.hasLoadedChainData) continue;

final combined = txService.combineAndDeduplicateTransactions(
pendingCancellationIds: pendingCancellationIds,
pendingTransactions: [],
scheduledReversibleTransfers: pagination.scheduledReversibleTransfers,
otherTransfers: pagination.otherTransfers,
);

for (final tx in combined) {
confirmedById[tx.id] = tx;
}
}

return confirmedById.values.toList();
}

/// Determines if a pending transaction is stale and should be checked for
/// reconciliation
bool _isStalePendingTransaction(PendingTransactionEvent pendingTx, DateTime now) {
Expand Down Expand Up @@ -157,8 +186,7 @@ class PendingTransactionReconciliationService {

await _removePendingTransaction(pendingTx, 'Found matching confirmed transaction in history');

// Refresh balance since transaction was actually completed
_ref.invalidate(balanceProviderFamily);
invalidateAccountBalances(_ref, {pendingTx.from, pendingTx.to});
} else {
print('PendingReconciliation: No matching confirmed transaction found for ${pendingTx.id}');

Expand Down
46 changes: 13 additions & 33 deletions mobile-app/lib/services/reversible_transfer_monitoring_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:quantus_sdk/quantus_sdk.dart';
import 'package:resonance_network_wallet/app_lifecycle_manager.dart';
import 'package:resonance_network_wallet/providers/account_providers.dart';
import 'package:resonance_network_wallet/providers/all_transactions_provider.dart';
import 'package:resonance_network_wallet/providers/active_account_transactions_provider.dart';
import 'package:resonance_network_wallet/providers/pending_cancellations_provider.dart';
import 'package:resonance_network_wallet/providers/wallet_providers.dart';
import 'package:resonance_network_wallet/providers/connectivity_provider.dart';
import 'package:resonance_network_wallet/shared/utils/polling_refresh_scope.dart';
import 'package:resonance_network_wallet/shared/utils/tx_filter_family_provider.dart';

/// Service that monitors reversible transfers approaching execution time
Expand All @@ -18,6 +18,7 @@ class ReversibleTransferMonitoringService {
final Ref _ref;
final Map<String, Timer> _timers = {};
final Map<String, Timer> _executionPollers = {};
ProviderSubscription? _txSubscription;

static const Duration _pollInterval = Duration(seconds: 5); // Aggressive polling

Expand All @@ -26,6 +27,8 @@ class ReversibleTransferMonitoringService {
if (next == AppLifecycleState.resumed) {
_listenToTransactions();
} else {
_txSubscription?.close();
_txSubscription = null;
dispose();
}
});
Expand All @@ -43,14 +46,10 @@ class ReversibleTransferMonitoringService {
}

void _listenToTransactions() {
_ref.listen(allTransactionsProvider, (previous, current) {
current.when(
data: (combinedData) {
_handleTransactionsUpdate(combinedData.scheduledReversibleTransfers);
},
loading: () {},
error: (_, _) {},
);
_txSubscription?.close();
_txSubscription = _ref.listen(activeAccountPaginationProvider(TransactionFilter.all), (previous, current) {
if (current == null) return;
_handleTransactionsUpdate(current.scheduledReversibleTransfers);
});
}

Expand Down Expand Up @@ -144,14 +143,7 @@ class ReversibleTransferMonitoringService {
// Stop polling for this transfer
_stopExecutionPolling(transfer.id);

// Update the transfer status inline - move from reversible
// to executed list for both global and filtered controllers
_ref
.read(paginationControllerProvider.notifier)
.updateReversibleTransferToExecuted(transfer.txId, transaction.status);
_ref.read(pendingCancellationsProvider.notifier).removePendingCancellation(transfer.id);

// Also update filtered controllers for affected accounts so
// Update filtered controllers for affected accounts so
// active-account views reflect the change immediately
final affectedAccounts = <String>{transfer.from, transfer.to};
for (final accountId in affectedAccounts) {
Expand All @@ -160,15 +152,9 @@ class ReversibleTransferMonitoringService {
});
}

// Also update filtered controllers for all accounts so
// tx screen views for all accounts reflect the change immediately
final accountIds = _ref.read(accountsProvider).value?.map((a) => a.accountId).toList() ?? [];
updatePaginationFiltersFor(_ref.read, accountIds, (notifier, _) {
notifier.updateReversibleTransferToExecuted(transfer.txId, transaction.status);
});
invalidateAccountBalances(_ref, affectedAccounts);

// Refresh balance since transfer execution changes balance
_ref.invalidate(balanceProviderFamily);
_ref.read(pendingCancellationsProvider.notifier).removePendingCancellation(transfer.id);

print('Updated transfer status inline - moved to done list');
}
Expand All @@ -194,13 +180,7 @@ class ReversibleTransferMonitoringService {
/// Manually trigger a check for all monitored transfers (useful for testing)
Future<void> forceCheckAllMonitoredTransfers() async {
if (_executionPollers.isNotEmpty) {
await _ref.read(paginationControllerProvider.notifier).silentRefresh();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subscription not closed in dispose method

Medium Severity

The new _txSubscription field is closed in the lifecycle handler and in _listenToTransactions, but dispose() does not close it. When the provider is torn down via ref.onDispose(service.dispose), the ProviderSubscription will remain open, leaking the listener and preventing proper garbage collection.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 62e6719. Configure here.

final active = _ref.read(activeAccountProvider).value;
if (active != null) {
updatePaginationFiltersFor(_ref.read, [active.account.accountId], (notifier, _) {
notifier.silentRefresh();
});
}
await silentRefreshActiveAccount(_ref);
}
}

Expand Down
Loading
Loading