diff --git a/lib/pages/token_view/my_tokens_view.dart b/lib/pages/token_view/my_tokens_view.dart index 965f2cc54..f5a7f8bcd 100644 --- a/lib/pages/token_view/my_tokens_view.dart +++ b/lib/pages/token_view/my_tokens_view.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:stackwallet/pages/token_view/sub_widgets/my_tokens_list.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; import 'package:stackwallet/utilities/theme/stack_colors.dart'; import 'package:stackwallet/utilities/util.dart'; @@ -22,29 +24,33 @@ import 'package:stackwallet/widgets/icon_widgets/x_icon.dart'; import 'package:stackwallet/widgets/stack_text_field.dart'; import 'package:stackwallet/widgets/textfield_icon_button.dart'; import 'package:stackwallet/pages/wallet_view/sub_widgets/no_transactions_found.dart'; +import 'package:stackwallet/utilities/eth_commons.dart'; class MyTokensView extends ConsumerStatefulWidget { const MyTokensView({ Key? key, - required this.walletId, + required this.walletAddress, + required this.tokens, + required this.walletName, }) : super(key: key); static const String routeName = "/myTokens"; - final String walletId; + final String walletAddress; + final List tokens; + final String walletName; @override ConsumerState createState() => _TokenDetailsViewState(); } class _TokenDetailsViewState extends ConsumerState { - late final String walletId; - + late final String walletAddress; late final TextEditingController _searchController; final searchFieldFocusNode = FocusNode(); @override void initState() { - walletId = widget.walletId; + walletAddress = widget.walletAddress; _searchController = TextEditingController(); super.initState(); @@ -95,7 +101,7 @@ class _TokenDetailsViewState extends ConsumerState { width: 12, ), Text( - "My ETH Wallet Tokens", + "${widget.walletName} Tokens", style: STextStyles.desktopH3(context), ), ], @@ -117,7 +123,7 @@ class _TokenDetailsViewState extends ConsumerState { }, ), title: Text( - "My ETH Wallet Tokens", + "${widget.walletName} Tokens", style: STextStyles.navBarTitle(context), ), actions: [ @@ -250,13 +256,13 @@ class _TokenDetailsViewState extends ConsumerState { ], ), ), - if (isDesktop) - const SizedBox( - height: 8, - ), const SizedBox( height: 8, ), + Expanded( + child: MyTokensList( + tokens: widget.tokens, walletAddress: walletAddress), + ), ], ), ), diff --git a/lib/pages/token_view/sub_widgets/my_token_select_item.dart b/lib/pages/token_view/sub_widgets/my_token_select_item.dart new file mode 100644 index 000000000..8d62aaeef --- /dev/null +++ b/lib/pages/token_view/sub_widgets/my_token_select_item.dart @@ -0,0 +1,103 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:stackwallet/pages/token_view/token_view.dart'; +import 'package:stackwallet/utilities/assets.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/util.dart'; + +import 'package:stackwallet/widgets/rounded_white_container.dart'; +import 'package:tuple/tuple.dart'; + +class MyTokenSelectItem extends ConsumerWidget { + const MyTokenSelectItem( + {Key? key, + required this.walletAddress, + required this.tokenData, + required}) + : super(key: key); + + final String walletAddress; + final Map tokenData; + + @override + Widget build(BuildContext context, WidgetRef ref) { + int balance = int.parse(tokenData["balance"] as String); + int tokenDecimals = int.parse(tokenData["decimals"] as String); + final balanceInDecimal = (balance / (pow(10, tokenDecimals))); + + return RoundedWhiteContainer( + padding: const EdgeInsets.all(0), + child: MaterialButton( + // splashColor: Theme.of(context).extension()!.highlight, + key: Key("walletListItemButtonKey_${tokenData["symbol"]}"), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(Constants.size.circularBorderRadius), + ), + onPressed: () { + Navigator.of(context).pushNamed( + TokenView.routeName, + arguments: Tuple2(walletAddress, tokenData["contractAddress"]), + ); + }, + + child: Row( + children: [ + SvgPicture.asset( + Assets.svg.iconFor(coin: Coin.ethereum), + width: 28, + height: 28, + ), + const SizedBox( + width: 10, + ), + Expanded( + child: Consumer( + builder: (_, ref, __) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text( + tokenData["name"] as String, + style: STextStyles.titleBold12(context), + ), + const Spacer(), + Text( + "$balanceInDecimal ${tokenData["symbol"]}", + style: STextStyles.itemSubtitle(context), + ), + ], + ), + const SizedBox( + height: 1, + ), + Row( + children: [ + Text( + tokenData["symbol"] as String, + style: STextStyles.itemSubtitle(context), + ), + const Spacer(), + const Text("0 USD"), + ], + ), + ], + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/token_view/sub_widgets/my_tokens_list.dart b/lib/pages/token_view/sub_widgets/my_tokens_list.dart new file mode 100644 index 000000000..f9c5b31b9 --- /dev/null +++ b/lib/pages/token_view/sub_widgets/my_tokens_list.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:stackwallet/pages/token_view/sub_widgets/my_token_select_item.dart'; + +class MyTokensList extends StatelessWidget { + const MyTokensList({ + Key? key, + required this.tokens, + required this.walletAddress, + }) : super(key: key); + + final List tokens; + final String walletAddress; + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (_, ref, __) { + print("TOKENS LENGTH IS ${tokens.length}"); + return ListView.builder( + itemCount: tokens.length, + itemBuilder: (ctx, index) { + return Padding( + padding: const EdgeInsets.all(4), + child: MyTokenSelectItem( + walletAddress: walletAddress, + tokenData: tokens[index] as Map, + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/pages/token_view/token_view.dart b/lib/pages/token_view/token_view.dart new file mode 100644 index 000000000..046a4fb4a --- /dev/null +++ b/lib/pages/token_view/token_view.dart @@ -0,0 +1,823 @@ +import 'dart:async'; + +import 'package:decimal/decimal.dart'; +import 'package:event_bus/event_bus.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:stackwallet/notifications/show_flush_bar.dart'; +import 'package:stackwallet/pages/exchange_view/sub_widgets/exchange_rate_sheet.dart'; +import 'package:stackwallet/pages/exchange_view/wallet_initiated_exchange_view.dart'; +import 'package:stackwallet/pages/home_view/home_view.dart'; +import 'package:stackwallet/pages/notification_views/notifications_view.dart'; +import 'package:stackwallet/pages/receive_view/receive_view.dart'; +import 'package:stackwallet/pages/send_view/send_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart'; +import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_settings_view.dart'; +import 'package:stackwallet/pages/token_view/my_tokens_view.dart'; +import 'package:stackwallet/pages/wallet_view/sub_widgets/transactions_list.dart'; +import 'package:stackwallet/pages/wallet_view/sub_widgets/wallet_navigation_bar.dart'; +import 'package:stackwallet/pages/wallet_view/sub_widgets/wallet_summary.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/all_transactions_view.dart'; +import 'package:stackwallet/providers/global/auto_swb_service_provider.dart'; +import 'package:stackwallet/providers/providers.dart'; +import 'package:stackwallet/providers/ui/transaction_filter_provider.dart'; +import 'package:stackwallet/providers/ui/unread_notifications_provider.dart'; +import 'package:stackwallet/providers/wallet/public_private_balance_state_provider.dart'; +import 'package:stackwallet/providers/wallet/wallet_balance_toggle_state_provider.dart'; +import 'package:stackwallet/services/coins/firo/firo_wallet.dart'; +import 'package:stackwallet/services/coins/manager.dart'; +import 'package:stackwallet/services/event_bus/events/global/node_connection_status_changed_event.dart'; +import 'package:stackwallet/services/event_bus/events/global/wallet_sync_status_changed_event.dart'; +import 'package:stackwallet/services/event_bus/global_event_bus.dart'; +import 'package:stackwallet/services/exchange/change_now/change_now_exchange.dart'; +import 'package:stackwallet/services/exchange/exchange_data_loading_service.dart'; +import 'package:stackwallet/utilities/assets.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/enums/backup_frequency_type.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/enums/flush_bar_type.dart'; +import 'package:stackwallet/utilities/enums/wallet_balance_toggle_state.dart'; +import 'package:stackwallet/utilities/eth_commons.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/text_styles.dart'; +import 'package:stackwallet/utilities/theme/stack_colors.dart'; +import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart'; +import 'package:stackwallet/widgets/custom_loading_overlay.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; +import 'package:tuple/tuple.dart'; + +/// [eventBus] should only be set during testing +class TokenView extends ConsumerStatefulWidget { + const TokenView({ + Key? key, + required this.walletId, + required this.contractAddress, + required this.managerProvider, + this.eventBus, + required this.walletAddress, + }) : super(key: key); + + static const String routeName = "/token"; + static const double navBarHeight = 65.0; + + final String walletId; + final String contractAddress; + final String walletAddress; + final ChangeNotifierProvider managerProvider; + + final EventBus? eventBus; + + @override + ConsumerState createState() => _TokenViewState(); +} + +class _TokenViewState extends ConsumerState { + late final EventBus eventBus; + late final String walletId; + late final ChangeNotifierProvider managerProvider; + + late final bool _shouldDisableAutoSyncOnLogOut; + + late WalletSyncStatus _currentSyncStatus; + late NodeConnectionStatus _currentNodeStatus; + + late StreamSubscription _syncStatusSubscription; + late StreamSubscription _nodeStatusSubscription; + + final _cnLoadingService = ExchangeDataLoadingService(); + + @override + void initState() { + walletId = widget.walletId; + managerProvider = widget.managerProvider; + + ref.read(managerProvider).isActiveWallet = true; + if (!ref.read(managerProvider).shouldAutoSync) { + // enable auto sync if it wasn't enabled when loading wallet + ref.read(managerProvider).shouldAutoSync = true; + _shouldDisableAutoSyncOnLogOut = true; + } else { + _shouldDisableAutoSyncOnLogOut = false; + } + + ref.read(managerProvider).refresh(); + + if (ref.read(managerProvider).isRefreshing) { + _currentSyncStatus = WalletSyncStatus.syncing; + _currentNodeStatus = NodeConnectionStatus.connected; + } else { + _currentSyncStatus = WalletSyncStatus.synced; + if (ref.read(managerProvider).isConnected) { + _currentNodeStatus = NodeConnectionStatus.connected; + } else { + _currentNodeStatus = NodeConnectionStatus.disconnected; + _currentSyncStatus = WalletSyncStatus.unableToSync; + } + } + + eventBus = + widget.eventBus != null ? widget.eventBus! : GlobalEventBus.instance; + + _syncStatusSubscription = + eventBus.on().listen( + (event) async { + if (event.walletId == widget.walletId) { + // switch (event.newStatus) { + // case WalletSyncStatus.unableToSync: + // break; + // case WalletSyncStatus.synced: + // break; + // case WalletSyncStatus.syncing: + // break; + // } + setState(() { + _currentSyncStatus = event.newStatus; + }); + } + }, + ); + + _nodeStatusSubscription = + eventBus.on().listen( + (event) async { + if (event.walletId == widget.walletId) { + // switch (event.newStatus) { + // case NodeConnectionStatus.disconnected: + // break; + // case NodeConnectionStatus.connected: + // break; + // } + setState(() { + _currentNodeStatus = event.newStatus; + }); + } + }, + ); + + super.initState(); + } + + @override + void dispose() { + _nodeStatusSubscription.cancel(); + _syncStatusSubscription.cancel(); + super.dispose(); + } + + DateTime? _cachedTime; + + Future _onWillPop() async { + final now = DateTime.now(); + const timeout = Duration(milliseconds: 1500); + if (_cachedTime == null || now.difference(_cachedTime!) > timeout) { + _cachedTime = now; + unawaited(showDialog( + context: context, + barrierDismissible: false, + builder: (_) => WillPopScope( + onWillPop: () async { + Navigator.of(context).popUntil( + ModalRoute.withName(HomeView.routeName), + ); + _logout(); + return false; + }, + child: const StackDialog(title: "Tap back again to exit wallet"), + ), + ).timeout( + timeout, + onTimeout: () => Navigator.of(context).popUntil( + ModalRoute.withName(TokenView.routeName), + ), + )); + } + return false; + } + + void _logout() async { + if (_shouldDisableAutoSyncOnLogOut) { + // disable auto sync if it was enabled only when loading wallet + ref.read(managerProvider).shouldAutoSync = false; + } + ref.read(managerProvider.notifier).isActiveWallet = false; + ref.read(transactionFilterProvider.state).state = null; + if (ref.read(prefsChangeNotifierProvider).isAutoBackupEnabled && + ref.read(prefsChangeNotifierProvider).backupFrequencyType == + BackupFrequencyType.afterClosingAWallet) { + unawaited(ref.read(autoSWBServiceProvider).doBackup()); + } + } + + Widget _buildNetworkIcon(WalletSyncStatus status) { + switch (status) { + case WalletSyncStatus.unableToSync: + return SvgPicture.asset( + Assets.svg.radioProblem, + color: Theme.of(context).extension()!.accentColorRed, + width: 20, + height: 20, + ); + case WalletSyncStatus.synced: + return SvgPicture.asset( + Assets.svg.radio, + color: Theme.of(context).extension()!.accentColorGreen, + width: 20, + height: 20, + ); + case WalletSyncStatus.syncing: + return SvgPicture.asset( + Assets.svg.radioSyncing, + color: Theme.of(context).extension()!.accentColorYellow, + width: 20, + height: 20, + ); + } + } + + void _onExchangePressed(BuildContext context) async { + unawaited(_cnLoadingService.loadAll(ref)); + + final coin = ref.read(managerProvider).coin; + + if (coin == Coin.epicCash) { + await showDialog( + context: context, + builder: (_) => const StackOkDialog( + title: "Exchange not available for Epic Cash", + ), + ); + } else if (coin.name.endsWith("TestNet")) { + await showDialog( + context: context, + builder: (_) => const StackOkDialog( + title: "Exchange not available for test net coins", + ), + ); + } else { + ref.read(currentExchangeNameStateProvider.state).state = + ChangeNowExchange.exchangeName; + final walletId = ref.read(managerProvider).walletId; + ref.read(prefsChangeNotifierProvider).exchangeRateType = + ExchangeRateType.estimated; + + ref.read(exchangeFormStateProvider).exchange = ref.read(exchangeProvider); + ref.read(exchangeFormStateProvider).exchangeType = + ExchangeRateType.estimated; + + final currencies = ref + .read(availableChangeNowCurrenciesProvider) + .currencies + .where((element) => + element.ticker.toLowerCase() == coin.ticker.toLowerCase()); + + if (currencies.isNotEmpty) { + ref.read(exchangeFormStateProvider).setCurrencies( + currencies.first, + ref + .read(availableChangeNowCurrenciesProvider) + .currencies + .firstWhere( + (element) => + element.ticker.toLowerCase() != + coin.ticker.toLowerCase(), + ), + ); + } + + if (mounted) { + unawaited( + Navigator.of(context).pushNamed( + WalletInitiatedExchangeView.routeName, + arguments: Tuple3( + walletId, + coin, + _loadCNData, + ), + ), + ); + } + } + } + + Future attemptAnonymize() async { + bool shouldPop = false; + unawaited( + showDialog( + context: context, + builder: (context) => WillPopScope( + child: const CustomLoadingOverlay( + message: "Anonymizing balance", + eventBus: null, + ), + onWillPop: () async => shouldPop, + ), + ), + ); + final firoWallet = ref.read(managerProvider).wallet as FiroWallet; + + final publicBalance = await firoWallet.availablePublicBalance(); + if (publicBalance <= Decimal.zero) { + shouldPop = true; + if (mounted) { + Navigator.of(context).popUntil( + ModalRoute.withName(TokenView.routeName), + ); + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: "No funds available to anonymize!", + context: context, + ), + ); + } + return; + } + + try { + await firoWallet.anonymizeAllPublicFunds(); + shouldPop = true; + if (mounted) { + Navigator.of(context).popUntil( + ModalRoute.withName(TokenView.routeName), + ); + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Anonymize transaction submitted", + context: context, + ), + ); + } + } catch (e) { + shouldPop = true; + if (mounted) { + Navigator.of(context).popUntil( + ModalRoute.withName(TokenView.routeName), + ); + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Anonymize all failed", + message: "Reason: $e", + ), + ); + } + } + } + + void _loadCNData() { + // unawaited future + if (ref.read(prefsChangeNotifierProvider).externalCalls) { + _cnLoadingService.loadAll(ref, coin: ref.read(managerProvider).coin); + } else { + Logging.instance.log("User does not want to use external calls", + level: LogLevel.Info); + } + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + + final coin = ref.watch(managerProvider.select((value) => value.coin)); + + return WillPopScope( + onWillPop: _onWillPop, + child: Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + _logout(); + Navigator.of(context).pop(); + }, + ), + titleSpacing: 0, + title: Row( + children: [ + SvgPicture.asset( + Assets.svg.iconFor(coin: coin), + // color: Theme.of(context).extension()!.accentColorDark + width: 24, + height: 24, + ), + const SizedBox( + width: 16, + ), + Expanded( + child: Text( + ref.watch( + managerProvider.select((value) => value.walletName)), + style: STextStyles.navBarTitle(context), + overflow: TextOverflow.ellipsis, + ), + ) + ], + ), + actions: [ + Padding( + padding: const EdgeInsets.only( + top: 10, + bottom: 10, + right: 10, + ), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + key: const Key("tokenViewRadioButton"), + size: 36, + shadows: const [], + color: + Theme.of(context).extension()!.background, + icon: _buildNetworkIcon(_currentSyncStatus), + onPressed: () { + Navigator.of(context).pushNamed( + WalletNetworkSettingsView.routeName, + arguments: Tuple3( + walletId, + _currentSyncStatus, + _currentNodeStatus, + ), + ); + }, + ), + ), + ), + Padding( + padding: const EdgeInsets.only( + top: 10, + bottom: 10, + right: 10, + ), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + key: const Key("tokenViewAlertsButton"), + size: 36, + shadows: const [], + color: + Theme.of(context).extension()!.background, + icon: SvgPicture.asset( + ref.watch(notificationsProvider.select((value) => + value.hasUnreadNotificationsFor(walletId))) + ? Assets.svg.bellNew(context) + : Assets.svg.bell, + width: 20, + height: 20, + color: ref.watch(notificationsProvider.select((value) => + value.hasUnreadNotificationsFor(walletId))) + ? null + : Theme.of(context) + .extension()! + .topNavIconPrimary, + ), + onPressed: () { + // reset unread state + ref.refresh(unreadNotificationsStateProvider); + + Navigator.of(context) + .pushNamed( + NotificationsView.routeName, + arguments: walletId, + ) + .then((_) { + final Set unreadNotificationIds = ref + .read(unreadNotificationsStateProvider.state) + .state; + if (unreadNotificationIds.isEmpty) return; + + List> futures = []; + for (int i = 0; + i < unreadNotificationIds.length - 1; + i++) { + futures.add(ref + .read(notificationsProvider) + .markAsRead( + unreadNotificationIds.elementAt(i), false)); + } + + // wait for multiple to update if any + Future.wait(futures).then((_) { + // only notify listeners once + ref + .read(notificationsProvider) + .markAsRead(unreadNotificationIds.last, true); + }); + }); + }, + ), + ), + ), + Padding( + padding: const EdgeInsets.only( + top: 10, + bottom: 10, + right: 10, + ), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + key: const Key("tokenViewSettingsButton"), + size: 36, + shadows: const [], + color: + Theme.of(context).extension()!.background, + icon: SvgPicture.asset( + Assets.svg.bars, + color: Theme.of(context) + .extension()! + .accentColorDark, + width: 20, + height: 20, + ), + onPressed: () { + debugPrint("wallet view settings tapped"); + Navigator.of(context).pushNamed( + WalletSettingsView.routeName, + arguments: Tuple4( + walletId, + ref.read(managerProvider).coin, + _currentSyncStatus, + _currentNodeStatus, + ), + ); + }, + ), + ), + ), + ], + ), + body: SafeArea( + child: Container( + color: Theme.of(context).extension()!.background, + child: Column( + children: [ + const SizedBox( + height: 10, + ), + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: WalletSummary( + walletId: walletId, + managerProvider: managerProvider, + initialSyncStatus: ref.watch(managerProvider + .select((value) => value.isRefreshing)) + ? WalletSyncStatus.syncing + : WalletSyncStatus.synced, + ), + ), + ), + if (coin == Coin.firo) + const SizedBox( + height: 10, + ), + if (coin == Coin.firo) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonColor(context), + onPressed: () async { + await showDialog( + context: context, + builder: (context) => StackDialog( + title: "Attention!", + message: + "You're about to anonymize all of your public funds.", + leftButton: TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text( + "Cancel", + style: STextStyles.button(context) + .copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark, + ), + ), + ), + rightButton: TextButton( + onPressed: () async { + Navigator.of(context).pop(); + + unawaited(attemptAnonymize()); + }, + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonColor( + context), + child: Text( + "Continue", + style: STextStyles.button(context), + ), + ), + ), + ); + }, + child: Text( + "Anonymize funds", + style: STextStyles.button(context).copyWith( + color: Theme.of(context) + .extension()! + .buttonTextSecondary, + ), + ), + ), + ), + ], + ), + ), + const SizedBox( + height: 20, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Transactions", + style: STextStyles.itemSubtitle(context).copyWith( + color: Theme.of(context) + .extension()! + .textDark3, + ), + ), + BlueTextButton( + text: "See all", + onTap: () { + Navigator.of(context).pushNamed( + AllTransactionsView.routeName, + arguments: walletId, + ); + }, + ), + ], + ), + ), + const SizedBox( + height: 12, + ), + Expanded( + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Padding( + padding: const EdgeInsets.only(bottom: 14), + child: ClipRRect( + borderRadius: BorderRadius.vertical( + top: Radius.circular( + Constants.size.circularBorderRadius, + ), + bottom: Radius.circular( + // TokenView.navBarHeight / 2.0, + Constants.size.circularBorderRadius, + ), + ), + child: Container( + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + Expanded( + child: TransactionsList( + managerProvider: managerProvider, + walletId: walletId, + ), + ), + ], + ), + ), + ), + ), + ), + Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Spacer(), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only( + bottom: 14, + left: 16, + right: 16, + ), + child: SizedBox( + height: TokenView.navBarHeight, + child: WalletNavigationBar( + enableExchange: + Constants.enableExchange && + ref.watch(managerProvider.select( + (value) => value.coin)) != + Coin.epicCash, + height: TokenView.navBarHeight, + onExchangePressed: () => + _onExchangePressed(context), + onReceivePressed: () async { + final coin = + ref.read(managerProvider).coin; + if (mounted) { + unawaited( + Navigator.of(context).pushNamed( + ReceiveView.routeName, + arguments: Tuple2( + walletId, + coin, + ), + )); + } + }, + onSendPressed: () { + final walletId = + ref.read(managerProvider).walletId; + final coin = + ref.read(managerProvider).coin; + switch (ref + .read( + walletBalanceToggleStateProvider + .state) + .state) { + case WalletBalanceToggleState.full: + ref + .read( + publicPrivateBalanceStateProvider + .state) + .state = "Public"; + break; + case WalletBalanceToggleState + .available: + ref + .read( + publicPrivateBalanceStateProvider + .state) + .state = "Private"; + break; + } + Navigator.of(context).pushNamed( + SendView.routeName, + arguments: Tuple2( + walletId, + coin, + ), + ); + }, + onBuyPressed: () {}, + onTokensPressed: () async { + final walletAddress = await ref + .read(managerProvider) + .currentReceivingAddress; + + final walletName = ref + .read(managerProvider) + .walletName; + + List tokens = + await getWalletTokens( + walletAddress); + + await Navigator.of(context).pushNamed( + MyTokensView.routeName, + arguments: Tuple3(walletAddress, + tokens, walletName), + ); + }, + ), + ), + ), + ], + ), + ], + ) + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index 12869d6a9..b70570b05 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -38,6 +38,7 @@ import 'package:stackwallet/utilities/enums/backup_frequency_type.dart'; import 'package:stackwallet/utilities/enums/coin_enum.dart'; import 'package:stackwallet/utilities/enums/flush_bar_type.dart'; import 'package:stackwallet/utilities/enums/wallet_balance_toggle_state.dart'; +import 'package:stackwallet/utilities/eth_commons.dart'; import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/utilities/text_styles.dart'; import 'package:stackwallet/utilities/theme/stack_colors.dart'; @@ -777,10 +778,23 @@ class _WalletViewState extends ConsumerState { ); }, onBuyPressed: () {}, - onTokensPressed: () { - Navigator.of(context).pushNamed( + onTokensPressed: () async { + final walletAddress = await ref + .read(managerProvider) + .currentReceivingAddress; + + final walletName = ref + .read(managerProvider) + .walletName; + + List tokens = + await getWalletTokens( + walletAddress); + + await Navigator.of(context).pushNamed( MyTokensView.routeName, - arguments: walletId, + arguments: Tuple3(walletAddress, + tokens, walletName), ); }, ), diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 3d6eed831..0f076eade 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -79,6 +79,7 @@ import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_set import 'package:stackwallet/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart'; import 'package:stackwallet/pages/stack_privacy_calls.dart'; import 'package:stackwallet/pages/token_view/my_tokens_view.dart'; +import 'package:stackwallet/pages/token_view/token_view.dart'; import 'package:stackwallet/pages/wallet_view/transaction_views/all_transactions_view.dart'; import 'package:stackwallet/pages/wallet_view/transaction_views/edit_note_view.dart'; import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart'; @@ -1294,11 +1295,27 @@ class RouteGenerator { return _routeError("${settings.name} invalid args: ${args.toString()}"); case MyTokensView.routeName: - if (args is String) { + if (args is Tuple3, String>) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, builder: (_) => MyTokensView( - walletId: args, + walletAddress: args.item1, + tokens: args.item2, + walletName: args.item3), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case TokenView.routeName: + if (args is Tuple2) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => TokenView( + walletAddress: args.item1, + contractAddress: args.item2, ), settings: RouteSettings( name: settings.name, diff --git a/lib/services/coins/erc_20/erc_wallet.dart b/lib/services/coins/erc_20/erc_wallet.dart new file mode 100644 index 000000000..e36e2c46b --- /dev/null +++ b/lib/services/coins/erc_20/erc_wallet.dart @@ -0,0 +1,1007 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'package:bip39/bip39.dart' as bip39; +import 'package:decimal/decimal.dart'; +import 'package:devicelocale/devicelocale.dart'; +import 'package:ethereum_addresses/ethereum_addresses.dart'; +import 'package:stackwallet/models/node_model.dart'; +import 'package:stackwallet/models/paymint/fee_object_model.dart'; +import 'package:stackwallet/models/paymint/transactions_model.dart'; +import 'package:stackwallet/models/paymint/utxo_model.dart'; +import 'package:stackwallet/services/price.dart'; +import 'package:stackwallet/services/transaction_notification_tracker.dart'; +import 'package:stackwallet/utilities/constants.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/enums/fee_rate_type_enum.dart'; +import 'package:stackwallet/utilities/eth_commons.dart'; +import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; +import 'package:stackwallet/utilities/format.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:string_to_hex/string_to_hex.dart'; +import 'package:web3dart/web3dart.dart'; +import 'package:web3dart/web3dart.dart' as web3; +import 'package:web3dart/web3dart.dart' as Transaction; +import 'package:stackwallet/models/models.dart' as models; + +import 'package:http/http.dart'; + +import 'package:stackwallet/hive/db.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/services/coins/coin_service.dart'; + +import 'package:stackwallet/services/event_bus/events/global/node_connection_status_changed_event.dart'; +import 'package:stackwallet/services/event_bus/events/global/refresh_percent_changed_event.dart'; +import 'package:stackwallet/services/event_bus/events/global/wallet_sync_status_changed_event.dart'; +import 'package:stackwallet/services/event_bus/global_event_bus.dart'; + +import 'package:stackwallet/utilities/assets.dart'; +import 'package:stackwallet/services/notifications_api.dart'; +import 'package:stackwallet/services/event_bus/events/global/updated_in_background_event.dart'; +import 'package:stackwallet/services/node_service.dart'; +import 'package:stackwallet/utilities/default_nodes.dart'; + +const int MINIMUM_CONFIRMATIONS = 5; + +//THis is used for mapping transactions per address from the block explorer +class AddressTransaction { + final String message; + final List result; + final String status; + + const AddressTransaction({ + required this.message, + required this.result, + required this.status, + }); + + factory AddressTransaction.fromJson(Map json) { + return AddressTransaction( + message: json['message'] as String, + result: json['result'] as List, + status: json['status'] as String, + ); + } +} + +class GasTracker { + final int code; + final Map data; + + const GasTracker({ + required this.code, + required this.data, + }); + + factory GasTracker.fromJson(Map json) { + return GasTracker( + code: json['code'] as int, + data: json['data'] as Map, + ); + } +} + +class TokenWallet extends CoinServiceAPI { + NodeModel? _ethNode; + final _gasLimit = 21000; + final _blockExplorer = "https://blockscout.com/eth/mainnet/api?"; + final _gasTrackerUrl = "https://beaconcha.in/api/v1/execution/gasnow"; + + @override + set isFavorite(bool markFavorite) {} + + @override + bool get isFavorite { + throw UnimplementedError(); + } + + @override + Coin get coin => Coin.ethereum; + + late SecureStorageInterface _secureStore; + late final TransactionNotificationTracker txTracker; + late PriceAPI _priceAPI; + final _prefs = Prefs.instance; + bool longMutex = false; + + //TODO - move shared logic to eth_commons + Future getCurrentNode() async { + return NodeService(secureStorageInterface: _secureStore) + .getPrimaryNodeFor(coin: coin) ?? + DefaultNodes.getNodeFor(coin); + } + + Future getEthClient() async { + final node = await getCurrentNode(); + return Web3Client(node.host, Client()); + } + + late EthPrivateKey _credentials; + + TokenWallet({ + required String walletId, + required String walletName, + required Coin coin, + PriceAPI? priceAPI, + required SecureStorageInterface secureStore, + required TransactionNotificationTracker tracker, + }) { + txTracker = tracker; + _walletId = walletId; + _walletName = walletName; + // _coin = coin; + + _priceAPI = priceAPI ?? PriceAPI(Client()); + _secureStore = secureStore; + } + + bool _shouldAutoSync = false; + + @override + bool get shouldAutoSync => _shouldAutoSync; + + @override + set shouldAutoSync(bool shouldAutoSync) { + if (_shouldAutoSync != shouldAutoSync) { + _shouldAutoSync = shouldAutoSync; + if (!shouldAutoSync) { + timer?.cancel(); + timer = null; + stopNetworkAlivePinging(); + } else { + startNetworkAlivePinging(); + refresh(); + } + } + } + + @override + String get walletName => _walletName; + late String _walletName; + + Timer? timer; + Timer? _networkAliveTimer; + + @override + Future> get allOwnAddresses => + _allOwnAddresses ??= _fetchAllOwnAddresses(); + Future>? _allOwnAddresses; + + Future> _fetchAllOwnAddresses() async { + List addresses = []; + final ownAddress = _credentials.address; + addresses.add(ownAddress.toString()); + return addresses; + } + + @override + Future get availableBalance async { + Web3Client client = await getEthClient(); + EtherAmount ethBalance = await client.getBalance(_credentials.address); + return Decimal.parse(ethBalance.getValueInUnit(EtherUnit.ether).toString()); + } + + @override + Future get balanceMinusMaxFee async => + (await availableBalance) - + (Decimal.fromInt((await maxFee)) / + Decimal.fromInt(Constants.satsPerCoin(coin))) + .toDecimal(); + + @override + Future confirmSend({required Map txData}) async { + Web3Client client = await getEthClient(); + final int chainId = await client.getNetworkId(); + final amount = txData['recipientAmt']; + final decimalAmount = + Format.satoshisToAmount(amount as int, coin: Coin.ethereum); + final bigIntAmount = amountToBigInt(decimalAmount.toDouble()); + + final tx = Transaction.Transaction( + to: EthereumAddress.fromHex(txData['address'] as String), + gasPrice: + EtherAmount.fromUnitAndValue(EtherUnit.wei, txData['feeInWei']), + maxGas: _gasLimit, + value: EtherAmount.inWei(bigIntAmount)); + final transaction = + await client.sendTransaction(_credentials, tx, chainId: chainId); + + return transaction; + } + + BigInt amountToBigInt(num amount) { + const decimal = 18; //Eth has up to 18 decimal places + final amountToSendinDecimal = amount * (pow(10, decimal)); + return BigInt.from(amountToSendinDecimal); + } + + @override + Future get currentReceivingAddress async { + final _currentReceivingAddress = _credentials.address; + final checkSumAddress = + checksumEthereumAddress(_currentReceivingAddress.toString()); + return checkSumAddress; + } + + @override + Future estimateFeeFor(int satoshiAmount, int feeRate) async { + final gweiAmount = feeRate / (pow(10, 9)); + final fee = _gasLimit * gweiAmount; + + //Convert gwei to ETH + final feeInWei = fee * (pow(10, 9)); + final ethAmount = feeInWei / (pow(10, 18)); + return Format.decimalAmountToSatoshis( + Decimal.parse(ethAmount.toString()), coin); + } + + @override + Future exit() async { + _hasCalledExit = true; + timer?.cancel(); + timer = null; + stopNetworkAlivePinging(); + } + + @override + Future get fees => _feeObject ??= _getFees(); + Future? _feeObject; + + Future _getFees() async { + GasTracker fees = await getGasOracle(); + final feesMap = fees.data; + return FeeObject( + numberOfBlocksFast: 1, + numberOfBlocksAverage: 3, + numberOfBlocksSlow: 3, + fast: feesMap['fast'] as int, + medium: feesMap['standard'] as int, + slow: feesMap['slow'] as int); + } + + Future getGasOracle() async { + final response = await get(Uri.parse(_gasTrackerUrl)); + + if (response.statusCode == 200) { + return GasTracker.fromJson( + json.decode(response.body) as Map); + } else { + throw Exception('Failed to load gas oracle'); + } + } + + //Full rescan is not needed for ETH since we have a balance + @override + Future fullRescan( + int maxUnusedAddressGap, int maxNumberOfIndexesToCheck) { + // TODO: implement fullRescan + throw UnimplementedError(); + } + + @override + Future generateNewAddress() { + // TODO: implement generateNewAddress - might not be needed for ETH + throw UnimplementedError(); + } + + bool _hasCalledExit = false; + + @override + bool get hasCalledExit => _hasCalledExit; + + @override + Future initializeExisting() async { + //First get mnemonic so we can initialize credentials + final mnemonicString = + await _secureStore.read(key: '${_walletId}_mnemonic'); + + _credentials = + EthPrivateKey.fromHex(StringToHex.toHexString(mnemonicString)); + + Logging.instance.log("Opening existing ${coin.prettyName} wallet.", + level: LogLevel.Info); + + if ((DB.instance.get(boxName: walletId, key: "id")) == null) { + throw Exception( + "Attempted to initialize an existing wallet using an unknown wallet ID!"); + } + await _prefs.init(); + final data = + DB.instance.get(boxName: walletId, key: "latest_tx_model") + as TransactionData?; + if (data != null) { + _transactionData = Future(() => data); + } + } + + @override + Future initializeNew() async { + await _prefs.init(); + final String mnemonic = bip39.generateMnemonic(strength: 256); + _credentials = EthPrivateKey.fromHex(StringToHex.toHexString(mnemonic)); + await _secureStore.write(key: '${_walletId}_mnemonic', value: mnemonic); + + //Store credentials in secure store + await _secureStore.write( + key: '${_walletId}_credentials', value: _credentials.toString()); + + await DB.instance + .put(boxName: walletId, key: "id", value: _walletId); + await DB.instance.put( + boxName: walletId, key: 'receivingAddresses', value: ["0"]); + await DB.instance + .put(boxName: walletId, key: "receivingIndex", value: 0); + await DB.instance + .put(boxName: walletId, key: "changeIndex", value: 0); + await DB.instance.put( + boxName: walletId, + key: 'blocked_tx_hashes', + value: ["0xdefault"], + ); // A list of transaction hashes to represent frozen utxos in wallet + // initialize address book entries + await DB.instance.put( + boxName: walletId, + key: 'addressBookEntries', + value: {}); + await DB.instance + .put(boxName: walletId, key: "isFavorite", value: false); + } + + bool _isConnected = false; + + @override + bool get isConnected => _isConnected; + + @override + bool get isRefreshing => refreshMutex; + + bool refreshMutex = false; + + @override + Future get maxFee async { + final fee = (await fees).fast; + final feeEstimate = await estimateFeeFor(0, fee); + return feeEstimate; + } + + @override + Future> get mnemonic => _getMnemonicList(); + + Future get chainHeight async { + Web3Client client = await getEthClient(); + try { + final result = await client.getBlockNumber(); + + return result; + } catch (e, s) { + Logging.instance.log("Exception caught in chainHeight: $e\n$s", + level: LogLevel.Error); + return -1; + } + } + + int get storedChainHeight { + final storedHeight = DB.instance + .get(boxName: walletId, key: "storedChainHeight") as int?; + return storedHeight ?? 0; + } + + Future updateStoredChainHeight({required int newHeight}) async { + await DB.instance.put( + boxName: walletId, key: "storedChainHeight", value: newHeight); + } + + Future> _getMnemonicList() async { + final mnemonicString = + await _secureStore.read(key: '${_walletId}_mnemonic'); + if (mnemonicString == null) { + return []; + } + final List data = mnemonicString.split(' '); + return data; + } + + @override + // TODO: implement pendingBalance - Not needed since we don't use UTXOs to get a balance + Future get pendingBalance => throw UnimplementedError(); + + @override + Future> prepareSend( + {required String address, + required int satoshiAmount, + Map? args}) async { + final feeRateType = args?["feeRate"]; + int fee = 0; + final feeObject = await fees; + switch (feeRateType) { + case FeeRateType.fast: + fee = feeObject.fast; + break; + case FeeRateType.average: + fee = feeObject.medium; + break; + case FeeRateType.slow: + fee = feeObject.slow; + break; + } + + final feeEstimate = await estimateFeeFor(satoshiAmount, fee); + + bool isSendAll = false; + final balance = + Format.decimalAmountToSatoshis(await availableBalance, coin); + if (satoshiAmount == balance) { + isSendAll = true; + } + + if (isSendAll) { + //Subtract fee amount from send amount + satoshiAmount -= feeEstimate; + } + + Map txData = { + "fee": feeEstimate, + "feeInWei": fee, + "address": address, + "recipientAmt": satoshiAmount, + }; + + return txData; + } + + @override + Future recoverFromMnemonic( + {required String mnemonic, + required int maxUnusedAddressGap, + required int maxNumberOfIndexesToCheck, + required int height}) async { + longMutex = true; + final start = DateTime.now(); + + try { + if ((await _secureStore.read(key: '${_walletId}_mnemonic')) != null) { + longMutex = false; + throw Exception("Attempted to overwrite mnemonic on restore!"); + } + + await _secureStore.write( + key: '${_walletId}_mnemonic', value: mnemonic.trim()); + + _credentials = EthPrivateKey.fromHex(StringToHex.toHexString(mnemonic)); + + await DB.instance + .put(boxName: walletId, key: "id", value: _walletId); + await DB.instance + .put(boxName: walletId, key: "isFavorite", value: false); + } catch (e, s) { + Logging.instance.log( + "Exception rethrown from recoverFromMnemonic(): $e\n$s", + level: LogLevel.Error); + longMutex = false; + rethrow; + } + + longMutex = false; + final end = DateTime.now(); + Logging.instance.log( + "$walletName recovery time: ${end.difference(start).inMilliseconds} millis", + level: LogLevel.Info); + } + + Future refreshIfThereIsNewData() async { + Web3Client client = await getEthClient(); + if (longMutex) return false; + if (_hasCalledExit) return false; + final currentChainHeight = await chainHeight; + + try { + bool needsRefresh = false; + Set txnsToCheck = {}; + + for (final String txid in txTracker.pendings) { + if (!txTracker.wasNotifiedConfirmed(txid)) { + txnsToCheck.add(txid); + } + } + + for (String txid in txnsToCheck) { + final txn = await client.getTransactionByHash(txid); + final int txBlockNumber = txn.blockNumber.blockNum; + + final int txConfirmations = currentChainHeight - txBlockNumber; + bool isUnconfirmed = txConfirmations < MINIMUM_CONFIRMATIONS; + if (!isUnconfirmed) { + needsRefresh = true; + break; + } + } + if (!needsRefresh) { + var allOwnAddresses = await _fetchAllOwnAddresses(); + AddressTransaction addressTransactions = + await fetchAddressTransactions(allOwnAddresses.elementAt(0)); + final txData = await transactionData; + if (addressTransactions.message == "OK") { + final allTxs = addressTransactions.result; + allTxs.forEach((element) { + if (txData.findTransaction(element["hash"] as String) == null) { + Logging.instance.log( + " txid not found in address history already ${element["hash"]}", + level: LogLevel.Info); + needsRefresh = true; + } + }); + } + } + return needsRefresh; + } catch (e, s) { + Logging.instance.log( + "Exception caught in refreshIfThereIsNewData: $e\n$s", + level: LogLevel.Error); + rethrow; + } + } + + Future getAllTxsToWatch( + TransactionData txData, + ) async { + if (_hasCalledExit) return; + List unconfirmedTxnsToNotifyPending = []; + List unconfirmedTxnsToNotifyConfirmed = []; + + for (final chunk in txData.txChunks) { + for (final tx in chunk.transactions) { + if (tx.confirmedStatus) { + // get all transactions that were notified as pending but not as confirmed + if (txTracker.wasNotifiedPending(tx.txid) && + !txTracker.wasNotifiedConfirmed(tx.txid)) { + unconfirmedTxnsToNotifyConfirmed.add(tx); + } + } else { + // get all transactions that were not notified as pending yet + if (!txTracker.wasNotifiedPending(tx.txid)) { + unconfirmedTxnsToNotifyPending.add(tx); + } + } + } + } + + // notify on unconfirmed transactions + for (final tx in unconfirmedTxnsToNotifyPending) { + if (tx.txType == "Received") { + unawaited(NotificationApi.showNotification( + title: "Incoming transaction", + body: walletName, + walletId: walletId, + iconAssetName: Assets.svg.iconFor(coin: coin), + date: DateTime.fromMillisecondsSinceEpoch(tx.timestamp * 1000), + shouldWatchForUpdates: tx.confirmations < MINIMUM_CONFIRMATIONS, + coinName: coin.name, + txid: tx.txid, + confirmations: tx.confirmations, + requiredConfirmations: MINIMUM_CONFIRMATIONS, + )); + await txTracker.addNotifiedPending(tx.txid); + } else if (tx.txType == "Sent") { + unawaited(NotificationApi.showNotification( + title: "Sending transaction", + body: walletName, + walletId: walletId, + iconAssetName: Assets.svg.iconFor(coin: coin), + date: DateTime.fromMillisecondsSinceEpoch(tx.timestamp * 1000), + shouldWatchForUpdates: tx.confirmations < MINIMUM_CONFIRMATIONS, + coinName: coin.name, + txid: tx.txid, + confirmations: tx.confirmations, + requiredConfirmations: MINIMUM_CONFIRMATIONS, + )); + await txTracker.addNotifiedPending(tx.txid); + } + } + + // notify on confirmed + for (final tx in unconfirmedTxnsToNotifyConfirmed) { + if (tx.txType == "Received") { + unawaited(NotificationApi.showNotification( + title: "Incoming transaction confirmed", + body: walletName, + walletId: walletId, + iconAssetName: Assets.svg.iconFor(coin: coin), + date: DateTime.fromMillisecondsSinceEpoch(tx.timestamp * 1000), + shouldWatchForUpdates: false, + coinName: coin.name, + )); + await txTracker.addNotifiedConfirmed(tx.txid); + } else if (tx.txType == "Sent") { + unawaited(NotificationApi.showNotification( + title: "Outgoing transaction confirmed", + body: walletName, + walletId: walletId, + iconAssetName: Assets.svg.iconFor(coin: coin), + date: DateTime.fromMillisecondsSinceEpoch(tx.timestamp * 1000), + shouldWatchForUpdates: false, + coinName: coin.name, + )); + await txTracker.addNotifiedConfirmed(tx.txid); + } + } + } + + @override + Future refresh() async { + if (refreshMutex) { + Logging.instance.log("$walletId $walletName refreshMutex denied", + level: LogLevel.Info); + return; + } else { + refreshMutex = true; + } + + try { + GlobalEventBus.instance.fire( + WalletSyncStatusChangedEvent( + WalletSyncStatus.syncing, + walletId, + coin, + ), + ); + + GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.0, walletId)); + GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.1, walletId)); + + final currentHeight = await chainHeight; + const storedHeight = 1; //await storedChainHeight; + + Logging.instance + .log("chain height: $currentHeight", level: LogLevel.Info); + Logging.instance + .log("cached height: $storedHeight", level: LogLevel.Info); + + if (currentHeight != storedHeight) { + if (currentHeight != -1) { + // -1 failed to fetch current height + unawaited(updateStoredChainHeight(newHeight: currentHeight)); + } + + GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.2, walletId)); + + final newTxData = _fetchTransactionData(); + GlobalEventBus.instance + .fire(RefreshPercentChangedEvent(0.50, walletId)); + + final feeObj = _getFees(); + GlobalEventBus.instance + .fire(RefreshPercentChangedEvent(0.60, walletId)); + + _transactionData = Future(() => newTxData); + + GlobalEventBus.instance + .fire(RefreshPercentChangedEvent(0.70, walletId)); + _feeObject = Future(() => feeObj); + GlobalEventBus.instance + .fire(RefreshPercentChangedEvent(0.80, walletId)); + + final allTxsToWatch = getAllTxsToWatch(await newTxData); + await Future.wait([ + newTxData, + feeObj, + allTxsToWatch, + ]); + GlobalEventBus.instance + .fire(RefreshPercentChangedEvent(0.90, walletId)); + } + refreshMutex = false; + GlobalEventBus.instance.fire(RefreshPercentChangedEvent(1.0, walletId)); + GlobalEventBus.instance.fire( + WalletSyncStatusChangedEvent( + WalletSyncStatus.synced, + walletId, + coin, + ), + ); + + if (shouldAutoSync) { + timer ??= Timer.periodic(const Duration(seconds: 30), (timer) async { + Logging.instance.log( + "Periodic refresh check for $walletId $walletName in object instance: $hashCode", + level: LogLevel.Info); + if (await refreshIfThereIsNewData()) { + await refresh(); + GlobalEventBus.instance.fire(UpdatedInBackgroundEvent( + "New data found in $walletId $walletName in background!", + walletId)); + } + }); + } + } catch (error, strace) { + refreshMutex = false; + GlobalEventBus.instance.fire( + NodeConnectionStatusChangedEvent( + NodeConnectionStatus.disconnected, + walletId, + coin, + ), + ); + GlobalEventBus.instance.fire( + WalletSyncStatusChangedEvent( + WalletSyncStatus.unableToSync, + walletId, + coin, + ), + ); + Logging.instance.log( + "Caught exception in refreshWalletData(): $error\n$strace", + level: LogLevel.Warning); + } + } + + @override + Future send( + {required String toAddress, + required int amount, + Map args = const {}}) { + // TODO: implement send + throw UnimplementedError(); + } + + @override + Future testNetworkConnection() async { + Web3Client client = await getEthClient(); + try { + final result = await client.isListeningForNetwork(); + return result; + } catch (_) { + return false; + } + } + + void _periodicPingCheck() async { + bool hasNetwork = await testNetworkConnection(); + _isConnected = hasNetwork; + if (_isConnected != hasNetwork) { + NodeConnectionStatus status = hasNetwork + ? NodeConnectionStatus.connected + : NodeConnectionStatus.disconnected; + GlobalEventBus.instance + .fire(NodeConnectionStatusChangedEvent(status, walletId, coin)); + } + } + + @override + Future get totalBalance async { + Web3Client client = await getEthClient(); + EtherAmount ethBalance = await client.getBalance(_credentials.address); + return Decimal.parse(ethBalance.getValueInUnit(EtherUnit.ether).toString()); + } + + @override + Future get transactionData => + _transactionData ??= _fetchTransactionData(); + Future? _transactionData; + + TransactionData? cachedTxData; + + @override + // TODO: implement unspentOutputs - NOT NEEDED, ETH DOES NOT USE UTXOs + Future> get unspentOutputs => throw UnimplementedError(); + + @override + Future updateNode(bool shouldRefresh) async { + _ethNode = NodeService(secureStorageInterface: _secureStore) + .getPrimaryNodeFor(coin: coin) ?? + DefaultNodes.getNodeFor(coin); + + if (shouldRefresh) { + unawaited(refresh()); + } + } + + @override + Future updateSentCachedTxData(Map txData) async { + final priceData = + await _priceAPI.getPricesAnd24hChange(baseCurrency: _prefs.currency); + Decimal currentPrice = priceData[coin]?.item1 ?? Decimal.zero; + final locale = await Devicelocale.currentLocale; + final String worthNow = Format.localizedStringAsFixed( + value: + ((currentPrice * Decimal.fromInt(txData["recipientAmt"] as int)) / + Decimal.fromInt(Constants.satsPerCoin(coin))) + .toDecimal(scaleOnInfinitePrecision: 2), + decimalPlaces: 2, + locale: locale!); + + final tx = models.Transaction( + txid: txData["txid"] as String, + confirmedStatus: false, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + txType: "Sent", + amount: txData["recipientAmt"] as int, + worthNow: worthNow, + worthAtBlockTimestamp: worthNow, + fees: txData["fee"] as int, + inputSize: 0, + outputSize: 0, + inputs: [], + outputs: [], + address: txData["address"] as String, + height: -1, + confirmations: 0, + ); + + if (cachedTxData == null) { + final data = await _fetchTransactionData(); + _transactionData = Future(() => data); + } else { + final transactions = cachedTxData!.getAllTransactions(); + transactions[tx.txid] = tx; + cachedTxData = models.TransactionData.fromMap(transactions); + _transactionData = Future(() => cachedTxData!); + } + } + + @override + bool validateAddress(String address) { + return isValidEthereumAddress(address); + } + + Future fetchAddressTransactions(String address) async { + final response = await get(Uri.parse( + "${_blockExplorer}module=account&action=txlist&address=$address")); + + if (response.statusCode == 200) { + return AddressTransaction.fromJson( + json.decode(response.body) as Map); + } else { + throw Exception('Failed to load transactions'); + } + } + + Future _fetchTransactionData() async { + String thisAddress = await currentReceivingAddress; + final cachedTransactions = + DB.instance.get(boxName: walletId, key: 'latest_tx_model') + as TransactionData?; + int latestTxnBlockHeight = + DB.instance.get(boxName: walletId, key: "storedTxnDataHeight") + as int? ?? + 0; + + final priceData = + await _priceAPI.getPricesAnd24hChange(baseCurrency: _prefs.currency); + Decimal currentPrice = priceData[coin]?.item1 ?? Decimal.zero; + final List> midSortedArray = []; + + AddressTransaction txs = await fetchAddressTransactions(thisAddress); + + if (txs.message == "OK") { + final allTxs = txs.result; + allTxs.forEach((element) { + Map midSortedTx = {}; + // create final tx map + midSortedTx["txid"] = element["hash"]; + int confirmations = int.parse(element['confirmations'].toString()); + + int transactionAmount = int.parse(element['value'].toString()); + const decimal = 18; //Eth has up to 18 decimal places + final transactionAmountInDecimal = + transactionAmount / (pow(10, decimal)); + + //Convert to satoshi, default display for other coins + final satAmount = Format.decimalAmountToSatoshis( + Decimal.parse(transactionAmountInDecimal.toString()), coin); + + midSortedTx["confirmed_status"] = + (confirmations != 0) && (confirmations >= MINIMUM_CONFIRMATIONS); + midSortedTx["confirmations"] = confirmations; + midSortedTx["timestamp"] = element["timeStamp"]; + + if (checksumEthereumAddress(element["from"].toString()) == + thisAddress) { + midSortedTx["txType"] = "Sent"; + } else { + midSortedTx["txType"] = "Received"; + } + + midSortedTx["amount"] = satAmount; + final String worthNow = ((currentPrice * Decimal.fromInt(satAmount)) / + Decimal.fromInt(Constants.satsPerCoin(coin))) + .toDecimal(scaleOnInfinitePrecision: 2) + .toStringAsFixed(2); + + //Calculate fees (GasLimit * gasPrice) + int txFee = int.parse(element['gasPrice'].toString()) * + int.parse(element['gasUsed'].toString()); + final txFeeDecimal = txFee / (pow(10, decimal)); + + midSortedTx["worthNow"] = worthNow; + midSortedTx["worthAtBlockTimestamp"] = worthNow; + midSortedTx["aliens"] = []; + midSortedTx["fees"] = Format.decimalAmountToSatoshis( + Decimal.parse(txFeeDecimal.toString()), coin); + midSortedTx["address"] = element["to"]; + midSortedTx["inputSize"] = 1; + midSortedTx["outputSize"] = 1; + midSortedTx["inputs"] = []; + midSortedTx["outputs"] = []; + midSortedTx["height"] = int.parse(element['blockNumber'].toString()); + + midSortedArray.add(midSortedTx); + }); + } + + midSortedArray.sort((a, b) => + (int.parse(b['timestamp'].toString())) - + (int.parse(a['timestamp'].toString()))); + + // buildDateTimeChunks + final Map result = {"dateTimeChunks": []}; + final dateArray = []; + + for (int i = 0; i < midSortedArray.length; i++) { + final txObject = midSortedArray[i]; + final date = + extractDateFromTimestamp(int.parse(txObject['timestamp'].toString())); + final txTimeArray = [txObject["timestamp"], date]; + + if (dateArray.contains(txTimeArray[1])) { + result["dateTimeChunks"].forEach((dynamic chunk) { + if (extractDateFromTimestamp( + int.parse(chunk['timestamp'].toString())) == + txTimeArray[1]) { + if (chunk["transactions"] == null) { + chunk["transactions"] = >[]; + } + chunk["transactions"].add(txObject); + } + }); + } else { + dateArray.add(txTimeArray[1]); + final chunk = { + "timestamp": txTimeArray[0], + "transactions": [txObject], + }; + result["dateTimeChunks"].add(chunk); + } + } + + final transactionsMap = cachedTransactions?.getAllTransactions() ?? {}; + transactionsMap + .addAll(TransactionData.fromJson(result).getAllTransactions()); + + final txModel = TransactionData.fromMap(transactionsMap); + + await DB.instance.put( + boxName: walletId, + key: 'storedTxnDataHeight', + value: latestTxnBlockHeight); + await DB.instance.put( + boxName: walletId, key: 'latest_tx_model', value: txModel); + + cachedTxData = txModel; + return txModel; + } + + @override + String get walletId => _walletId; + late String _walletId; + + @override + set walletName(String newName) => _walletName = newName; + + void stopNetworkAlivePinging() { + _networkAliveTimer?.cancel(); + _networkAliveTimer = null; + } + + void startNetworkAlivePinging() { + // call once on start right away + _periodicPingCheck(); + + // then periodically check + _networkAliveTimer = Timer.periodic( + Constants.networkAliveTimerDuration, + (_) async { + _periodicPingCheck(); + }, + ); + } +} diff --git a/lib/services/coins/ethereum/ethereum_wallet.dart b/lib/services/coins/ethereum/ethereum_wallet.dart index 8c9f42455..0bf1a6373 100644 --- a/lib/services/coins/ethereum/ethereum_wallet.dart +++ b/lib/services/coins/ethereum/ethereum_wallet.dart @@ -14,6 +14,7 @@ import 'package:stackwallet/services/transaction_notification_tracker.dart'; import 'package:stackwallet/utilities/constants.dart'; import 'package:stackwallet/utilities/enums/coin_enum.dart'; import 'package:stackwallet/utilities/enums/fee_rate_type_enum.dart'; +import 'package:stackwallet/utilities/eth_commons.dart'; import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; import 'package:stackwallet/utilities/format.dart'; import 'package:stackwallet/utilities/prefs.dart'; @@ -41,8 +42,6 @@ import 'package:stackwallet/services/node_service.dart'; import 'package:stackwallet/utilities/default_nodes.dart'; const int MINIMUM_CONFIRMATIONS = 5; -const String GENESIS_HASH_MAINNET = - "0x11bbe8db4e347b4e8c937c1c8370e4b5ed33adb3db69cbdb7a38e1e50b1b82fa"; //THis is used for mapping transactions per address from the block explorer class AddressTransaction { @@ -438,8 +437,6 @@ class EthereumWallet extends CoinServiceAPI { } final feeEstimate = await estimateFeeFor(satoshiAmount, fee); - print("FEE ESTIMATE IS $feeEstimate"); - print("AMOUNT TO SEND IS $satoshiAmount"); bool isSendAll = false; final balance = diff --git a/lib/utilities/eth_commons.dart b/lib/utilities/eth_commons.dart new file mode 100644 index 000000000..a8286993b --- /dev/null +++ b/lib/utilities/eth_commons.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; + +class AccountModule { + final String message; + final List result; + final String status; + + const AccountModule({ + required this.message, + required this.result, + required this.status, + }); + + factory AccountModule.fromJson(Map json) { + return AccountModule( + message: json['message'] as String, + result: json['result'] as List, + status: json['status'] as String, + ); + } +} + +const _blockExplorer = "https://blockscout.com/eth/mainnet/api?"; + +Future fetchAccountModule(String action, String address) async { + final response = await get(Uri.parse( + "${_blockExplorer}module=account&action=$action&address=$address")); + if (response.statusCode == 200) { + return AccountModule.fromJson( + json.decode(response.body) as Map); + } else { + throw Exception('Failed to load transactions'); + } +} + +Future> getWalletTokens(String address) async { + AccountModule tokens = await fetchAccountModule("tokenlist", address); + //THIS IS ONLY HARD CODED UNTIL API WORKS AGAIN - TODO REMOVE HARDCODED + return [ + { + "balance": "369039500000000000", + "contractAddress": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "decimals": "18", + "name": "Uniswap", + "symbol": "UNI", + "type": "ERC-20" + } + ]; + + if (tokens.message == "OK") { + return tokens.result as List; + } + return []; +}