diff --git a/lib/core/trade_monitor.dart b/lib/core/trade_monitor.dart new file mode 100644 index 000000000..25eedfbbf --- /dev/null +++ b/lib/core/trade_monitor.dart @@ -0,0 +1,211 @@ +import 'dart:async'; +import 'package:cake_wallet/exchange/provider/simpleswap_exchange_provider.dart'; +import 'package:cake_wallet/exchange/trade.dart'; +import 'package:cake_wallet/exchange/trade_state.dart'; +import 'package:cake_wallet/store/dashboard/trades_store.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cake_wallet/entities/exchange_api_mode.dart'; +import 'package:cake_wallet/exchange/exchange_provider_description.dart'; +import 'package:cake_wallet/exchange/provider/chainflip_exchange_provider.dart'; +import 'package:cake_wallet/exchange/provider/changenow_exchange_provider.dart'; +import 'package:cake_wallet/exchange/provider/exchange_provider.dart'; +import 'package:cake_wallet/exchange/provider/exolix_exchange_provider.dart'; +import 'package:cake_wallet/exchange/provider/letsexchange_exchange_provider.dart'; +import 'package:cake_wallet/exchange/provider/swaptrade_exchange_provider.dart'; +import 'package:cake_wallet/exchange/provider/sideshift_exchange_provider.dart'; +import 'package:cake_wallet/exchange/provider/stealth_ex_exchange_provider.dart'; +import 'package:cake_wallet/exchange/provider/thorchain_exchange.provider.dart'; +import 'package:cake_wallet/exchange/provider/trocador_exchange_provider.dart'; +import 'package:cake_wallet/exchange/provider/xoswap_exchange_provider.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:hive/hive.dart'; + +class TradeMonitor { + static const int _tradeCheckIntervalMinutes = 1; + static const int _maxTradeAgeHours = 24; + + TradeMonitor({ + required this.tradesStore, + required this.settingsStore, + required this.trades, + }); + + final TradesStore tradesStore; + final Box trades; + final SettingsStore settingsStore; + final Map _tradeTimers = {}; + + ExchangeProvider? _getProviderByDescription(ExchangeProviderDescription description) { + switch (description) { + case ExchangeProviderDescription.changeNow: + return ChangeNowExchangeProvider(settingsStore: settingsStore); + case ExchangeProviderDescription.sideShift: + return SideShiftExchangeProvider(); + case ExchangeProviderDescription.simpleSwap: + return SimpleSwapExchangeProvider(); + case ExchangeProviderDescription.trocador: + return TrocadorExchangeProvider(); + case ExchangeProviderDescription.exolix: + return ExolixExchangeProvider(); + case ExchangeProviderDescription.thorChain: + return ThorChainExchangeProvider(tradesStore: trades); + case ExchangeProviderDescription.swapTrade: + return SwapTradeExchangeProvider(); + case ExchangeProviderDescription.letsExchange: + return LetsExchangeExchangeProvider(); + case ExchangeProviderDescription.stealthEx: + return StealthExExchangeProvider(); + case ExchangeProviderDescription.chainflip: + return ChainflipExchangeProvider(tradesStore: trades); + case ExchangeProviderDescription.xoSwap: + return XOSwapExchangeProvider(); + } + return null; + } + + void monitorActiveTrades(String walletId) { + final now = DateTime.now(); + final trades = tradesStore.trades; + final tradesToCancel = []; + + for (final item in trades) { + final trade = item.trade; + + if (_shouldCancelTradeTimer(trade, walletId, now)) { + if (_tradeTimers.containsKey(trade.id)) { + tradesToCancel.add(trade.id); + } + continue; + } + + if (!_tradeTimers.containsKey(trade.id)) { + printV('Starting trade monitoring for ${trade.id}'); + _startTradeMonitoring(trade); + } + } + + _cancelTradeTimers(tradesToCancel); + } + + bool _shouldCancelTradeTimer(Trade trade, String walletId, DateTime now) { + if (trade.walletId != walletId) return true; + + final createdAt = trade.createdAt; + if (createdAt == null) return true; + + if (now.difference(createdAt).inHours > _maxTradeAgeHours) return true; + + return _isFinalState(trade.state); + } + + void _startTradeMonitoring(Trade trade) { + if (_tradeTimers.containsKey(trade.id)) return; + + _checkTradeStatus(trade); + + final timer = Timer.periodic( + Duration(minutes: _tradeCheckIntervalMinutes), + (_) => _checkTradeStatus(trade) + ); + + _tradeTimers[trade.id] = timer; + } + + Future _checkTradeStatus(Trade trade) async { + printV('Checking trade status for ${trade.id}'); + + if (_isTradeOld(trade)) { + printV('The trade ${trade.id} is older than $_maxTradeAgeHours hours, we will cancel the timer'); + _cancelTradeTimer(trade.id); + return; + } + + final provider = _getProviderByDescription(trade.provider); + if (provider == null) { + printV('No provider found for trade ${trade.id}'); + return; + } + + if (!_isExchangeModeEnabled(provider)) { + return; + } + + try { + await _updateTradeStatus(trade, provider); + } catch (e) { + printV('Error fetching status for ${trade.id}: $e'); + } + } + + bool _isTradeOld(Trade trade) { + final now = DateTime.now(); + final createdAt = trade.createdAt; + return createdAt != null && now.difference(createdAt).inHours > _maxTradeAgeHours; + } + + bool _isExchangeModeEnabled(ExchangeProvider provider) { + final exchangeApiMode = settingsStore.exchangeStatus; + + if (exchangeApiMode == ExchangeApiMode.disabled) { + printV('Exchange API mode is disabled'); + return false; + } + + if (exchangeApiMode == ExchangeApiMode.torOnly && !provider.supportsOnionAddress) { + printV('Skipping ${provider.description}, no TOR support'); + return false; + } + + return true; + } + + Future _updateTradeStatus(Trade trade, ExchangeProvider provider) async { + final updated = await provider.findTradeById(id: trade.id); + trade + ..stateRaw = updated.state.raw + ..receiveAmount = updated.receiveAmount ?? trade.receiveAmount + ..outputTransaction = updated.outputTransaction ?? trade.outputTransaction; + printV('Trade ${trade.id} updated: ${trade.state}'); + await trade.save(); + + if (_isFinalState(updated.state)) { + printV('Trade ${trade.id} is in final state'); + _cancelTradeTimer(trade.id); + } + } + + void _cancelTradeTimer(String tradeId) { + _tradeTimers[tradeId]?.cancel(); + _tradeTimers.remove(tradeId); + } + + void _cancelTradeTimers(List tradeIds) { + for (final tradeId in tradeIds) { + _cancelTradeTimer(tradeId); + } + } + + void cancelAllTradeTimers() { + for (final timer in _tradeTimers.values) { + timer.cancel(); + } + _tradeTimers.clear(); + } + + bool _isFinalState(TradeState state) { + return { + TradeState.completed.raw, + TradeState.success.raw, + TradeState.confirmed.raw, + TradeState.settled.raw, + TradeState.finished.raw, + TradeState.expired.raw, + TradeState.failed.raw, + TradeState.notFound.raw, + }.contains(state.raw); + } + + void dispose() { + cancelAllTradeTimers(); + } +} diff --git a/lib/di.dart b/lib/di.dart index 140a26698..e4c94cf85 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -275,6 +275,7 @@ import 'src/screens/buy/buy_sell_page.dart'; import 'cake_pay/cake_pay_payment_credantials.dart'; import 'package:cake_wallet/view_model/dev/background_sync_logs_view_model.dart'; import 'package:cake_wallet/src/screens/dev/background_sync_logs_page.dart'; +import 'package:cake_wallet/core/trade_monitor.dart'; final getIt = GetIt.instance; @@ -507,19 +508,41 @@ Future setup({ settingsStore: getIt.get(), fiatConvertationStore: getIt.get())); - getIt.registerFactory(() => DashboardViewModel( - balanceViewModel: getIt.get(), - appStore: getIt.get(), + getIt.registerFactory( + () => ExchangeViewModel( + getIt.get(), + _tradesSource, + getIt.get(), + getIt.get(), + getIt.get().settingsStore, + getIt.get(), + getIt.get(), + getIt.get(), + ), + ); + + getIt.registerSingleton( + TradeMonitor( tradesStore: getIt.get(), - tradeFilterStore: getIt.get(), - transactionFilterStore: getIt.get(), - settingsStore: settingsStore, - yatStore: getIt.get(), - ordersStore: getIt.get(), - anonpayTransactionsStore: getIt.get(), - payjoinTransactionsStore: getIt.get(), - sharedPreferences: getIt.get(), - keyService: getIt.get())); + settingsStore: getIt.get(), + trades: _tradesSource + ), + ); + + getIt.registerFactory(() => DashboardViewModel( + tradeMonitor: getIt.get(), + balanceViewModel: getIt.get(), + appStore: getIt.get(), + tradesStore: getIt.get(), + tradeFilterStore: getIt.get(), + transactionFilterStore: getIt.get(), + settingsStore: settingsStore, + yatStore: getIt.get(), + ordersStore: getIt.get(), + anonpayTransactionsStore: getIt.get(), + payjoinTransactionsStore: getIt.get(), + sharedPreferences: getIt.get(), + keyService: getIt.get())); getIt.registerFactory( () => AuthService( @@ -1051,19 +1074,6 @@ Future setup({ getIt.registerFactoryParam((title, uri) => WebViewPage(title, uri)); - getIt.registerFactory( - () => ExchangeViewModel( - getIt.get(), - _tradesSource, - getIt.get(), - getIt.get(), - getIt.get(), - getIt.get(), - getIt.get(), - getIt.get(), - ), - ); - getIt.registerFactory( () => FeesViewModel( getIt.get(), diff --git a/lib/main.dart b/lib/main.dart index 167373d87..d0a52b9f1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -51,6 +51,7 @@ import 'package:cw_core/root_dir.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:cw_core/window_size.dart'; import 'package:logging/logging.dart'; +import 'package:cake_wallet/core/trade_monitor.dart'; final navigatorKey = GlobalKey(); final rootKey = GlobalKey(); diff --git a/lib/src/screens/dashboard/pages/transactions_page.dart b/lib/src/screens/dashboard/pages/transactions_page.dart index 47e900959..7a3aa156a 100644 --- a/lib/src/screens/dashboard/pages/transactions_page.dart +++ b/lib/src/screens/dashboard/pages/transactions_page.dart @@ -166,17 +166,19 @@ class TransactionsPage extends StatelessWidget { return Observer( builder: (_) => TradeRow( - key: item.key, - onTap: () => Navigator.of(context) - .pushNamed(Routes.tradeDetails, arguments: trade), + key: item.key, + onTap: () => Navigator.of(context) + .pushNamed(Routes.tradeDetails, arguments: trade), + swapState: trade.state, provider: trade.provider, - from: trade.from, - to: trade.to, - createdAtFormattedDate: trade.createdAt != null - ? DateFormat('HH:mm').format(trade.createdAt!) - : null, - formattedAmount: item.tradeFormattedAmount, - formattedReceiveAmount: item.tradeFormattedReceiveAmount), + from: trade.from, + to: trade.to, + createdAtFormattedDate: trade.createdAt != null + ? DateFormat('HH:mm').format(trade.createdAt!) + : null, + formattedAmount: item.tradeFormattedAmount, + formattedReceiveAmount: item.tradeFormattedReceiveAmount + ), ); } if (item is OrderListItem) { diff --git a/lib/src/screens/dashboard/widgets/trade_row.dart b/lib/src/screens/dashboard/widgets/trade_row.dart index 9f04d587d..e50cb9718 100644 --- a/lib/src/screens/dashboard/widgets/trade_row.dart +++ b/lib/src/screens/dashboard/widgets/trade_row.dart @@ -1,7 +1,9 @@ +import 'package:cake_wallet/exchange/trade_state.dart'; +import 'package:cake_wallet/palette.dart'; import 'package:cake_wallet/utils/image_utill.dart'; import 'package:flutter/material.dart'; -import 'package:cw_core/crypto_currency.dart'; import 'package:cake_wallet/exchange/exchange_provider_description.dart'; +import 'package:cw_core/crypto_currency.dart'; class TradeRow extends StatelessWidget { TradeRow({ @@ -12,6 +14,7 @@ class TradeRow extends StatelessWidget { this.onTap, this.formattedAmount, this.formattedReceiveAmount, + required this.swapState, super.key, }); @@ -22,6 +25,7 @@ class TradeRow extends StatelessWidget { final String? createdAtFormattedDate; final String? formattedAmount; final String? formattedReceiveAmount; + final TradeState swapState; @override Widget build(BuildContext context) { @@ -29,25 +33,39 @@ class TradeRow extends StatelessWidget { final receiveAmountCrypto = to.toString(); return InkWell( - onTap: onTap, - child: Container( - padding: EdgeInsets.fromLTRB(24, 8, 24, 8), - color: Colors.transparent, - child: Row( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(50), - child: ImageUtil.getImageFromPath( - imagePath: provider.image, - height: 36, - width: 36, + onTap: onTap, + child: Container( + padding: EdgeInsets.fromLTRB(24, 8, 24, 8), + color: Colors.transparent, + child: Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(50), + child: ImageUtil.getImageFromPath( + imagePath: provider.image, height: 36, width: 36),), + Positioned( + right: 0, + bottom: 2, + child: Container( + height: 8, + width: 8, + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: _statusColor(context, swapState), + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], ), - ), - SizedBox(width: 12), - Expanded( - child: Column( + SizedBox(width: 12), + Expanded( + child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( @@ -104,4 +122,21 @@ class TradeRow extends StatelessWidget { ), ); } + + Color _statusColor(BuildContext context, TradeState status) { + switch (status) { + case TradeState.complete: + case TradeState.completed: + case TradeState.finished: + case TradeState.success: + case TradeState.settled: + return PaletteDark.brightGreen; + case TradeState.failed: + case TradeState.expired: + case TradeState.notFound: + return Palette.darkRed; + default: + return const Color(0xffff6600); + } + } } diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index 0202d0d13..6eeefa4d9 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -23,7 +23,6 @@ import 'package:cake_wallet/store/dashboard/trades_store.dart'; import 'package:cake_wallet/store/dashboard/transaction_filter_store.dart'; import 'package:cake_wallet/store/settings_store.dart'; import 'package:cake_wallet/store/yat/yat_store.dart'; -import 'package:cake_wallet/themes/core/material_base_theme.dart'; import 'package:cake_wallet/view_model/dashboard/action_list_item.dart'; import 'package:cake_wallet/view_model/dashboard/anonpay_transaction_list_item.dart'; import 'package:cake_wallet/view_model/dashboard/balance_view_model.dart'; @@ -56,6 +55,8 @@ import 'package:mobx/mobx.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:cake_wallet/core/trade_monitor.dart'; + part 'dashboard_view_model.g.dart'; class DashboardViewModel = DashboardViewModelBase with _$DashboardViewModel; @@ -63,6 +64,7 @@ class DashboardViewModel = DashboardViewModelBase with _$DashboardViewModel; abstract class DashboardViewModelBase with Store { DashboardViewModelBase( {required this.balanceViewModel, + required this.tradeMonitor, required this.appStore, required this.tradesStore, required this.tradeFilterStore, @@ -298,6 +300,10 @@ abstract class DashboardViewModelBase with Store { _checkMweb(); reaction((_) => settingsStore.mwebAlwaysScan, (bool value) => _checkMweb()); + + reaction((_) => tradesStore.trades, (_) => tradeMonitor.monitorActiveTrades(wallet.id)); + + tradeMonitor.monitorActiveTrades(wallet.id); } bool _isTransactionDisposerCallbackRunning = false; @@ -773,6 +779,8 @@ abstract class DashboardViewModelBase with Store { BalanceViewModel balanceViewModel; + TradeMonitor tradeMonitor; + AppStore appStore; SettingsStore settingsStore;