From 4865bdf379679fbbcb2f9dd3748bc2dbae949e41 Mon Sep 17 00:00:00 2001 From: Alfreedom <00tango.bromine@icloud.com> Date: Mon, 3 Feb 2025 20:48:36 +0100 Subject: [PATCH] Bug bashes fixes --- .../base/lib/utils/crypto/helpers.dart | 4 +- .../lib/modal/appkit_modal_impl.dart | 7 + .../lib/modal/models/send_data.dart | 5 +- .../lib/modal/pages/activity_page.dart | 1 + .../pages/preview_send/preview_send_evm.dart | 368 ++++++++++ .../pages/preview_send/preview_send_page.dart | 84 +++ .../preview_send/preview_send_solana.dart | 384 ++++++++++ .../lib/modal/pages/preview_send/utils.dart | 39 ++ .../lib/modal/pages/preview_send/widgets.dart | 284 ++++++++ .../lib/modal/pages/preview_send_page.dart | 661 ------------------ .../appkit_modal_main_wallets_page.dart | 1 + .../pages/receive_compatible_networks.dart | 12 +- .../lib/modal/pages/receive_page.dart | 12 +- .../lib/modal/pages/select_token_page.dart | 2 +- .../lib/modal/pages/send_page.dart | 11 +- .../lib/modal/pages/smart_account_page.dart | 2 +- .../lib/modal/pages/social_login_page.dart | 3 +- .../models/analytics_event.dart | 219 +++++- .../blockchain_service.dart | 10 +- .../i_blockchain_service.dart | 5 +- .../models/token_balance.dart | 5 +- .../explorer_service/explorer_service.dart | 52 +- .../services/magic_service/magic_service.dart | 10 + .../phantom_service/phantom_service.dart | 5 +- .../services/third_party_wallet_service.dart | 2 +- .../modal/services/uri_service/url_utils.dart | 2 +- .../miscellaneous/all_wallets_header.dart | 1 + packages/reown_appkit/pubspec.yaml | 6 + 28 files changed, 1478 insertions(+), 719 deletions(-) create mode 100644 packages/reown_appkit/lib/modal/pages/preview_send/preview_send_evm.dart create mode 100644 packages/reown_appkit/lib/modal/pages/preview_send/preview_send_page.dart create mode 100644 packages/reown_appkit/lib/modal/pages/preview_send/preview_send_solana.dart create mode 100644 packages/reown_appkit/lib/modal/pages/preview_send/utils.dart create mode 100644 packages/reown_appkit/lib/modal/pages/preview_send/widgets.dart delete mode 100644 packages/reown_appkit/lib/modal/pages/preview_send_page.dart diff --git a/packages/reown_appkit/example/base/lib/utils/crypto/helpers.dart b/packages/reown_appkit/example/base/lib/utils/crypto/helpers.dart index 49e49b24..eb31b2a3 100644 --- a/packages/reown_appkit/example/base/lib/utils/crypto/helpers.dart +++ b/packages/reown_appkit/example/base/lib/utils/crypto/helpers.dart @@ -94,11 +94,13 @@ Future getParams( ], ); case 'solana_signMessage': + final bytes = utf8.encode('Welcome to Flutter AppKit on Solana'); + final message = base58.encode(bytes); return SessionRequestParams( method: method, params: { 'pubkey': address, - 'message': 'Welcome to Flutter AppKit on Solana', + 'message': message, }, ); case 'solana_signTransaction': diff --git a/packages/reown_appkit/lib/modal/appkit_modal_impl.dart b/packages/reown_appkit/lib/modal/appkit_modal_impl.dart index dc77a141..d83438fa 100644 --- a/packages/reown_appkit/lib/modal/appkit_modal_impl.dart +++ b/packages/reown_appkit/lib/modal/appkit_modal_impl.dart @@ -830,8 +830,10 @@ class ReownAppKitModal bool inBrowser = false, }) { final walletName = _selectedWallet!.listing.name; + final walletId = _selectedWallet!.listing.id; final event = SelectWalletEvent( name: walletName, + explorerId: walletId, platform: inBrowser ? AnalyticsPlatform.web : AnalyticsPlatform.mobile, ); _analyticsService.sendEvent(event); @@ -1569,6 +1571,8 @@ class ReownAppKitModal blockchainId.toJson(), ); } catch (_) {} + } else { + _blockchainIdentity = null; } _status = ReownAppKitModalStatus.initialized; @@ -2162,6 +2166,7 @@ extension _AppKitModalExtension on ReownAppKitModal { if (_selectedWallet == null) { _analyticsService.sendEvent(ConnectSuccessEvent( name: 'WalletConnect', + explorerId: '', method: AnalyticsPlatform.qrcode, )); await _storage.delete(StorageConstants.recentWalletId); @@ -2169,8 +2174,10 @@ extension _AppKitModalExtension on ReownAppKitModal { } else { _explorerService.storeConnectedWallet(_selectedWallet); final walletName = _selectedWallet!.listing.name; + final walletId = _selectedWallet!.listing.id; _analyticsService.sendEvent(ConnectSuccessEvent( name: walletName, + explorerId: walletId, method: AnalyticsPlatform.mobile, )); } diff --git a/packages/reown_appkit/lib/modal/models/send_data.dart b/packages/reown_appkit/lib/modal/models/send_data.dart index e78e8023..166a4f0f 100644 --- a/packages/reown_appkit/lib/modal/models/send_data.dart +++ b/packages/reown_appkit/lib/modal/models/send_data.dart @@ -21,8 +21,6 @@ class SendData { factory SendData.fromRawJson(String str) => SendData.fromJson(json.decode(str)); - String toRawJson() => json.encode(toJson()); - factory SendData.fromJson(Map json) => SendData( amount: json['amount'], address: json['address'], @@ -32,4 +30,7 @@ class SendData { 'amount': amount, 'address': address, }; + + @override + String toString() => json.encode(toJson()); } diff --git a/packages/reown_appkit/lib/modal/pages/activity_page.dart b/packages/reown_appkit/lib/modal/pages/activity_page.dart index 6d164c85..e4b0d8f6 100644 --- a/packages/reown_appkit/lib/modal/pages/activity_page.dart +++ b/packages/reown_appkit/lib/modal/pages/activity_page.dart @@ -101,6 +101,7 @@ class _ActivityListViewBuilderState extends State { final activityData = await _blockchainService.getHistory( address: _currentAddress, cursor: _currentCursor, + caip2Chain: _currentChain, ); _activities.clear(); final newItems = activityData.data ?? []; diff --git a/packages/reown_appkit/lib/modal/pages/preview_send/preview_send_evm.dart b/packages/reown_appkit/lib/modal/pages/preview_send/preview_send_evm.dart new file mode 100644 index 00000000..0799c6d0 --- /dev/null +++ b/packages/reown_appkit/lib/modal/pages/preview_send/preview_send_evm.dart @@ -0,0 +1,368 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get_it/get_it.dart'; +import 'dart:ui' as ui; + +import 'package:reown_appkit/modal/i_appkit_modal_impl.dart'; +import 'package:reown_appkit/modal/models/send_data.dart'; +import 'package:reown_appkit/modal/pages/preview_send/utils.dart'; +import 'package:reown_appkit/modal/pages/preview_send/widgets.dart'; +import 'package:reown_appkit/modal/services/analytics_service/i_analytics_service.dart'; +import 'package:reown_appkit/modal/services/analytics_service/models/analytics_event.dart'; +import 'package:reown_appkit/modal/services/blockchain_service/i_blockchain_service.dart'; +import 'package:reown_appkit/modal/services/blockchain_service/models/token_balance.dart'; +import 'package:reown_appkit/modal/services/toast_service/i_toast_service.dart'; +import 'package:reown_appkit/modal/services/toast_service/models/toast_message.dart'; +import 'package:reown_appkit/modal/utils/core_utils.dart'; +import 'package:reown_appkit/modal/widgets/icons/rounded_icon.dart'; +import 'package:reown_appkit/modal/widgets/widget_stack/widget_stack_singleton.dart'; +import 'package:reown_appkit/reown_appkit.dart' hide TransactionExtension; +import 'package:reown_appkit/modal/constants/style_constants.dart'; +import 'package:reown_appkit/modal/widgets/modal_provider.dart'; +import 'package:reown_appkit/modal/widgets/navigation/navbar.dart'; + +class PreviewSendEvm extends StatefulWidget { + const PreviewSendEvm({ + required this.sendData, + required this.sendTokenData, + required this.networkTokenData, + required this.originalSendValue, + required this.isMaxSend, + required this.isContractCall, + required this.senderAddress, + required this.recipientAddress, + }); + + final SendData sendData; + final TokenBalance sendTokenData; + final TokenBalance? networkTokenData; + final String originalSendValue; + final bool isMaxSend; + final bool isContractCall; + final String senderAddress; + final String recipientAddress; + + @override + State createState() => _PreviewSendEvmState(); +} + +class _PreviewSendEvmState extends State { + IBlockChainService get _blockchainService => GetIt.I(); + IAnalyticsService get _analyticsService => GetIt.I(); + IToastService get _toastService => GetIt.I(); + + late final IReownAppKitModal _appKitModal; + late SendData _sendData; + late final TokenBalance _sendTokenData; + late final TokenBalance? _networkTokenData; + late final String _originalSendValue; + late final bool _isMaxSend; + late final bool _isContractCall; + late final String _senderAddress; + late final String _recipientAddress; + + Transaction? _transaction; + bool _isSendEnabled = false; + + double _gasValue = 0.0; + Timer? _gasEstimationTimer; + + void _logger(String m) => _appKitModal.appKit!.core.logger.d(m); + // + @override + void initState() { + super.initState(); + _sendData = widget.sendData; + _sendTokenData = widget.sendTokenData; + _networkTokenData = widget.networkTokenData; + _originalSendValue = widget.originalSendValue; + _isMaxSend = widget.isMaxSend; + _isContractCall = widget.isContractCall; + _senderAddress = widget.senderAddress; + _recipientAddress = widget.recipientAddress; + // + + WidgetsBinding.instance.addPostFrameCallback((_) async { + _appKitModal = ModalProvider.of(context).instance; + await _constructTransaction(); + await _estimateNetworkCost(); + }); + } + + Future _reEstimateGas(_) async { + await _estimateNetworkCost(); + } + + int _getDecimals({required bool nativeToken}) { + if (nativeToken) { + final decimals = _networkTokenData?.quantity?.decimals ?? '18'; + return int.parse(decimals); + } + final decimals = _sendTokenData.quantity?.decimals ?? '18'; + return int.parse(decimals); + } + + BigInt _valueToBigInt(double value) { + final decimals = _getDecimals(nativeToken: false); + final factor = BigInt.from(pow(10, decimals)); + return BigInt.from(value * factor.toDouble()); + } + + Future _constructTransaction() async { + final actualValueToSend = + _originalSendValue.toDouble() - (_isMaxSend ? _gasValue : 0.0); + if (_isContractCall) { + final contractAddress = NamespaceUtils.getAccount( + _sendTokenData.address!, + ); + _sendData = _sendData.copyWith( + amount: CoreUtils.formatChainBalance( + max(0.000000, actualValueToSend), + precision: _getDecimals(nativeToken: false), + ), + ); + _transaction = Transaction( + from: EthereumAddress.fromHex(_senderAddress), + to: EthereumAddress.fromHex(contractAddress), + data: utf8.encode( + constructCallData( + _recipientAddress, + _valueToBigInt( + actualValueToSend, + ), + ), + ), + ); + } else { + // + _sendData = _sendData.copyWith( + amount: max(0.000000, actualValueToSend).toString(), + ); + _transaction = Transaction( + to: EthereumAddress.fromHex(_recipientAddress), + value: EtherAmount.fromBigInt( + EtherUnit.wei, + _valueToBigInt( + actualValueToSend, + ), + ), + data: utf8.encode('0x'), + ); + } + _logger('[$runtimeType] transaction ${jsonEncode(_transaction?.toJson())}'); + } + + Future _estimateNetworkCost() async { + try { + final chainId = _sendTokenData.chainId!; + final gasPrices = await _blockchainService.gasPrice( + caip2Chain: chainId, + ); + final standardGasPrice = gasPrices.standard ?? BigInt.zero; + // + final estimatedGas = await _blockchainService.estimateGas( + transaction: _isContractCall + ? _transaction! + .copyWith( + data: utf8.encode( + constructCallData( + _recipientAddress, + _valueToBigInt(_originalSendValue.toDouble()), + ), + ), + ) + .toJson() + : _transaction! + .copyWith( + from: EthereumAddress.fromHex(_senderAddress), + value: EtherAmount.fromBigInt( + EtherUnit.wei, + _valueToBigInt(_originalSendValue.toDouble()), + ), + ) + .toJson(), + caip2Chain: chainId, + ); + // + final decimals = BigInt.from(10).pow(_getDecimals(nativeToken: true)); + _gasValue = (standardGasPrice * estimatedGas) / decimals; + // Add a little buffer of 5% more since this is just an estimation + _gasValue = CoreUtils.formatChainBalance( + _gasValue * 1.1, + precision: _getDecimals(nativeToken: true), + ).toDouble(); + + await _constructTransaction(); + + setState(() => _isSendEnabled = _sendData.amount!.toDouble() > 0.0); + + if (_isSendEnabled) { + _gasEstimationTimer ??= Timer.periodic( + Duration(seconds: 10), + _reEstimateGas, + ); + } else { + _toastService.show(ToastMessage( + type: ToastType.error, + text: 'Insufficient funds', + )); + } + } on ArgumentError catch (e) { + _toastService.show(ToastMessage( + type: ToastType.error, + text: 'Invald ${e.name ?? 'argument'}', + )); + } on Exception catch (e) { + _toastService.show(ToastMessage( + type: ToastType.error, + text: e.toString(), + )); + } + } + + Future _sendTransaction() async { + final valueToSend = CoreUtils.formatStringBalance( + _sendData.amount!, + precision: 3, + ); + _analyticsService.sendEvent(WalletFeatureSendInitiated( + network: _sendTokenData.chainId!, + sendToken: _sendTokenData.symbol!, + sendAmount: valueToSend, + )); + try { + final appKitModal = ModalProvider.of(context).instance; + await appKitModal.request( + topic: appKitModal.session!.topic, + chainId: _sendTokenData.chainId!, + request: SessionRequestParams( + method: MethodsConstants.ethSendTransaction, + params: [ + _transaction!.toJson(), + ], + ), + ); + _analyticsService.sendEvent(WalletFeatureSendSuccess( + network: _sendTokenData.chainId!, + sendToken: _sendTokenData.symbol!, + sendAmount: valueToSend, + )); + } on ArgumentError catch (e) { + _toastService.show(ToastMessage( + type: ToastType.error, + text: 'Invald ${e.name ?? 'argument'}', + )); + _analyticsService.sendEvent(WalletFeatureSendError( + network: _sendTokenData.chainId!, + sendToken: _sendTokenData.symbol!, + sendAmount: valueToSend, + )); + } on Exception catch (e) { + _toastService.show(ToastMessage( + type: ToastType.error, + text: e.toString(), + )); + _analyticsService.sendEvent(WalletFeatureSendError( + network: _sendTokenData.chainId!, + sendToken: _sendTokenData.symbol!, + sendAmount: valueToSend, + )); + } + } + + @override + void dispose() { + _gasEstimationTimer?.cancel(); + _gasEstimationTimer = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final themeColors = ReownAppKitModalTheme.colorsOf(context); + final themeData = ReownAppKitModalTheme.getDataOf(context); + return ModalNavbar( + title: 'Review send', + divider: false, + body: Container( + padding: const EdgeInsets.only( + left: kPadding16, + right: kPadding16, + top: kPadding16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SendRow( + sendTokenData: _sendTokenData, + sendData: _sendData, + originalSendValue: _originalSendValue, + ), + const SizedBox.square(dimension: kPadding6), + Padding( + padding: const EdgeInsets.symmetric(horizontal: kPadding8), + child: SvgPicture.asset( + colorFilter: ColorFilter.mode( + themeColors.foreground200, + BlendMode.srcIn, + ), + 'lib/modal/assets/icons/arrow_down.svg', + package: 'reown_appkit', + width: 14.0, + height: 14.0, + ), + ), + ReceiveRow( + sendData: _sendData, + ), + const SizedBox.square(dimension: kPadding16), + if (_transaction != null) + DetailsRow( + nativeTokenData: _networkTokenData ?? _sendTokenData, + sendData: _sendData, + requiredGasInTokens: _gasValue, + ), + const SizedBox.square(dimension: kPadding16), + Center( + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( + children: [ + WidgetSpan( + child: RoundedIcon( + assetPath: 'lib/modal/assets/icons/warning.svg', + assetColor: themeColors.foreground250, + circleColor: Colors.transparent, + borderColor: Colors.transparent, + padding: 0.0, + size: 14.0, + ), + alignment: ui.PlaceholderAlignment.middle, + ), + TextSpan( + text: ' Review transaction carefully', + style: themeData.textStyles.small400.copyWith( + color: themeColors.foreground200, + ), + ), + ], + ), + ), + ), + const SizedBox.square(dimension: kPadding16), + SendButtonRow( + onCancel: () => widgetStack.instance.pop(), + onSend: _isSendEnabled ? _sendTransaction : null, + ), + const SizedBox.square(dimension: kPadding12), + ], + ), + ), + ); + } +} diff --git a/packages/reown_appkit/lib/modal/pages/preview_send/preview_send_page.dart b/packages/reown_appkit/lib/modal/pages/preview_send/preview_send_page.dart new file mode 100644 index 00000000..7312818a --- /dev/null +++ b/packages/reown_appkit/lib/modal/pages/preview_send/preview_send_page.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +import 'package:reown_appkit/modal/constants/key_constants.dart'; +import 'package:reown_appkit/modal/models/send_data.dart'; +import 'package:reown_appkit/modal/pages/preview_send/preview_send_evm.dart'; +import 'package:reown_appkit/modal/pages/preview_send/preview_send_solana.dart'; +import 'package:reown_appkit/modal/services/blockchain_service/models/token_balance.dart'; +import 'package:reown_appkit/modal/utils/core_utils.dart'; +import 'package:reown_appkit/reown_appkit.dart'; +import 'package:reown_appkit/modal/widgets/modal_provider.dart'; + +class PreviewSendPage extends StatelessWidget { + final SendData sendData; + final TokenBalance sendTokenData; + final TokenBalance? networkTokenData; + + const PreviewSendPage({ + required this.sendData, + required this.sendTokenData, + required this.networkTokenData, + }) : super(key: KeyConstants.previewSendPageKey); + + String get _namespace => NamespaceUtils.getNamespaceFromChain( + sendTokenData.chainId!, + ); + + String get _originalSendValue => CoreUtils.formatStringBalance( + sendData.amount!, + precision: _getDecimals(nativeToken: false), + ); + + bool get _isMaxSend { + final maxAllowance = CoreUtils.formatStringBalance( + sendTokenData.quantity!.numeric!, + precision: _getDecimals(nativeToken: false), + ); + + return _originalSendValue == maxAllowance; + } + + // if sendTokenData.address is not null it means a contract should be called + bool get _isContractCall => sendTokenData.address != null; + + int _getDecimals({required bool nativeToken}) { + if (nativeToken) { + final decimals = networkTokenData?.quantity?.decimals ?? '18'; + return int.parse(decimals); + } + final decimals = sendTokenData.quantity?.decimals ?? '18'; + return int.parse(decimals); + } + + @override + Widget build(BuildContext context) { + final appKitModal = ModalProvider.of(context).instance; + final chainId = sendTokenData.chainId!; + final namespace = NamespaceUtils.getNamespaceFromChain(chainId); + final senderAddress = appKitModal.session!.getAddress(namespace)!; + + if (_namespace == NetworkUtils.solana) { + return PreviewSendSolana( + sendData: sendData, + sendTokenData: sendTokenData, + networkTokenData: networkTokenData, + originalSendValue: _originalSendValue, + isMaxSend: _isMaxSend, + isContractCall: _isContractCall, + senderAddress: senderAddress, + recipientAddress: sendData.address!, + ); + } + + return PreviewSendEvm( + sendData: sendData, + sendTokenData: sendTokenData, + networkTokenData: networkTokenData, + originalSendValue: _originalSendValue, + isMaxSend: _isMaxSend, + isContractCall: _isContractCall, + senderAddress: senderAddress, + recipientAddress: sendData.address!, + ); + } +} diff --git a/packages/reown_appkit/lib/modal/pages/preview_send/preview_send_solana.dart b/packages/reown_appkit/lib/modal/pages/preview_send/preview_send_solana.dart new file mode 100644 index 00000000..22863161 --- /dev/null +++ b/packages/reown_appkit/lib/modal/pages/preview_send/preview_send_solana.dart @@ -0,0 +1,384 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get_it/get_it.dart'; +import 'dart:ui' as ui; + +import 'package:reown_appkit/modal/i_appkit_modal_impl.dart'; +import 'package:reown_appkit/modal/models/send_data.dart'; +import 'package:reown_appkit/modal/pages/preview_send/utils.dart'; +import 'package:reown_appkit/modal/pages/preview_send/widgets.dart'; +import 'package:reown_appkit/modal/services/analytics_service/i_analytics_service.dart'; +import 'package:reown_appkit/modal/services/analytics_service/models/analytics_event.dart'; +import 'package:reown_appkit/modal/services/blockchain_service/models/token_balance.dart'; +import 'package:reown_appkit/modal/services/toast_service/i_toast_service.dart'; +import 'package:reown_appkit/modal/services/toast_service/models/toast_message.dart'; +import 'package:reown_appkit/modal/utils/core_utils.dart'; +import 'package:reown_appkit/modal/widgets/icons/rounded_icon.dart'; +import 'package:reown_appkit/modal/widgets/widget_stack/widget_stack_singleton.dart'; +import 'package:reown_appkit/reown_appkit.dart' hide TransactionExtension; +import 'package:reown_appkit/modal/constants/style_constants.dart'; +import 'package:reown_appkit/modal/widgets/modal_provider.dart'; +import 'package:reown_appkit/modal/widgets/navigation/navbar.dart'; + +import 'package:solana_web3/solana_web3.dart' as solana; +import 'package:solana_web3/programs.dart' as programs; + +class PreviewSendSolana extends StatefulWidget { + const PreviewSendSolana({ + required this.sendData, + required this.sendTokenData, + required this.networkTokenData, + required this.originalSendValue, + required this.isMaxSend, + required this.isContractCall, + required this.senderAddress, + required this.recipientAddress, + }); + + final SendData sendData; + final TokenBalance sendTokenData; + final TokenBalance? networkTokenData; + final String originalSendValue; + final bool isMaxSend; + final bool isContractCall; + final String senderAddress; + final String recipientAddress; + + @override + State createState() => _PreviewSendSolanaState(); +} + +class _PreviewSendSolanaState extends State { + // IBlockChainService get _blockchainService => GetIt.I(); + IAnalyticsService get _analyticsService => GetIt.I(); + IToastService get _toastService => GetIt.I(); + + late final IReownAppKitModal _appKitModal; + late SendData _sendData; + late final TokenBalance _sendTokenData; + late final TokenBalance? _networkTokenData; + late final String _originalSendValue; + late final bool _isMaxSend; + late final String _senderAddress; + late final String _recipientAddress; + + solana.Transaction? _transaction; + bool _isSendEnabled = false; + + double _gasValue = 0.0; + Timer? _gasEstimationTimer; + + void _logger(String m) => _appKitModal.appKit!.core.logger.d(m); + // + @override + void initState() { + super.initState(); + _sendData = widget.sendData; + _sendTokenData = widget.sendTokenData; + _networkTokenData = widget.networkTokenData; + _originalSendValue = widget.originalSendValue; + _isMaxSend = widget.isMaxSend; + _senderAddress = widget.senderAddress; + _recipientAddress = widget.recipientAddress; + // + + WidgetsBinding.instance.addPostFrameCallback((_) async { + _appKitModal = ModalProvider.of(context).instance; + await _constructTransaction(); + await _estimateNetworkCost(); + }); + } + + Future _reEstimateGas(_) async { + await _estimateNetworkCost(); + } + + int _getDecimals({required bool nativeToken}) { + if (nativeToken) { + final decimals = _networkTokenData?.quantity?.decimals ?? '18'; + return int.parse(decimals); + } + final decimals = _sendTokenData.quantity?.decimals ?? '18'; + return int.parse(decimals); + } + + BigInt _valueToBigInt(double value) { + final decimals = _getDecimals(nativeToken: false); + final factor = BigInt.from(pow(10, decimals)); + return BigInt.from(value * factor.toDouble()); + } + + double _valueToDouble(BigInt value) { + final decimals = _getDecimals(nativeToken: false); + final factor = BigInt.from(pow(10, decimals)); + return value / factor; + } + + Future _contructSolanaTX(double valueToSend) async { + // Create a connection to the devnet cluster. + final chainId = _sendTokenData.chainId!.split(':').last; + final chainData = ReownAppKitModalNetworks.getNetworkById( + 'solana', + chainId, + ); + // Create a connection to the devnet cluster. + final cluster = solana.Cluster.https( + Uri.parse(chainData!.rpcUrl).authority, + ); + // final cluster = solana.Cluster.devnet; + final connection = solana.Connection(cluster); + + // Fetch the latest blockhash. + final blockhash = await connection.getLatestBlockhash(); + + // Define transfer amount in lamports (1 SOL = 1,000,000,000 lamports) + // Amount to send in lamports (0.001 SOL) + final lamports = _valueToBigInt(valueToSend); + + // Create the transfer instruction + final transferInstruction = programs.SystemProgram.transfer( + fromPubkey: solana.Pubkey.fromBase58(_senderAddress), + toPubkey: solana.Pubkey.fromBase58(_recipientAddress), + lamports: lamports, + ); + + final transactionv0 = solana.Transaction.v0( + payer: solana.Pubkey.fromBase58(_senderAddress), + recentBlockhash: blockhash.blockhash, + instructions: [ + transferInstruction, + ], + ); + + return transactionv0; + } + + Future _constructTransaction() async { + final actualValueToSend = + _originalSendValue.toDouble() - (_isMaxSend ? _gasValue : 0.0); + _sendData = _sendData.copyWith( + amount: CoreUtils.formatChainBalance( + max(0.000000, actualValueToSend), + precision: _getDecimals(nativeToken: false), + ), + ); + _transaction = await _contructSolanaTX(actualValueToSend); + _logger('[$runtimeType] transaction ${jsonEncode(_transaction?.toJson())}'); + setState(() {}); + } + + double get _baseFee => 0.000005; + + final Map _urgency = { + 'low': 10, + 'med': 100, + 'high': 500, + 'vhigh': 1000, + }; + + Future _estimateNetworkCost() async { + try { + final chainId = _sendTokenData.chainId!.split(':').last; + final chainData = ReownAppKitModalNetworks.getNetworkById( + 'solana', + chainId, + ); + + // Create a connection to the devnet cluster. + final cluster = solana.Cluster.https( + Uri.parse(chainData!.rpcUrl).authority, + ); + // final cluster = solana.Cluster.devnet; + final connection = solana.Connection(cluster); + final status = await connection.simulateTransaction(_transaction!); + final uc = (status.unitsConsumed ?? 1).toInt(); + final priorityFee = BigInt.from(uc * _urgency['med']!); + final networkCostInLamports = _valueToBigInt(_baseFee) + priorityFee; + _gasValue = _valueToDouble(networkCostInLamports); + + await _constructTransaction(); + + setState(() => _isSendEnabled = _sendData.amount!.toDouble() > 0.0); + + if (_isSendEnabled) { + _gasEstimationTimer ??= Timer.periodic( + Duration(seconds: 10), + _reEstimateGas, + ); + } else { + _toastService.show(ToastMessage( + type: ToastType.error, + text: 'Insufficient funds', + )); + } + } on ArgumentError catch (e) { + _toastService.show(ToastMessage( + type: ToastType.error, + text: 'Invald ${e.name ?? 'argument'}', + )); + } on Exception catch (e) { + _toastService.show(ToastMessage( + type: ToastType.error, + text: e.toString(), + )); + } + } + + String get _encodedTransaction { + const config = solana.TransactionSerializableConfig( + verifySignatures: false, + ); + final bytes = _transaction!.serialize(config).asUint8List(); + return base58.encode(bytes); + } + + Future _sendTransaction() async { + final valueToSend = CoreUtils.formatStringBalance( + _sendData.amount!, + precision: 3, + ); + _analyticsService.sendEvent(WalletFeatureSendInitiated( + network: _sendTokenData.chainId!, + sendToken: _sendTokenData.symbol!, + sendAmount: valueToSend, + )); + try { + final appKitModal = ModalProvider.of(context).instance; + + await appKitModal.request( + topic: appKitModal.session!.topic, + chainId: _sendTokenData.chainId!, + request: SessionRequestParams( + method: 'solana_signAndSendTransaction', + params: { + 'transaction': _encodedTransaction, + 'pubkey': _senderAddress, + 'feePayer': _senderAddress, + ..._transaction!.message.toJson(), + }, + ), + ); + _analyticsService.sendEvent(WalletFeatureSendSuccess( + network: _sendTokenData.chainId!, + sendToken: _sendTokenData.symbol!, + sendAmount: valueToSend, + )); + } on ArgumentError catch (e) { + _toastService.show(ToastMessage( + type: ToastType.error, + text: 'Invald ${e.name ?? 'argument'}', + )); + _analyticsService.sendEvent(WalletFeatureSendError( + network: _sendTokenData.chainId!, + sendToken: _sendTokenData.symbol!, + sendAmount: valueToSend, + )); + } on Exception catch (e) { + _toastService.show(ToastMessage( + type: ToastType.error, + text: e.toString(), + )); + _analyticsService.sendEvent(WalletFeatureSendError( + network: _sendTokenData.chainId!, + sendToken: _sendTokenData.symbol!, + sendAmount: valueToSend, + )); + } + } + + @override + void dispose() { + _gasEstimationTimer?.cancel(); + _gasEstimationTimer = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final themeColors = ReownAppKitModalTheme.colorsOf(context); + final themeData = ReownAppKitModalTheme.getDataOf(context); + return ModalNavbar( + title: 'Review send', + divider: false, + body: Container( + padding: const EdgeInsets.only( + left: kPadding16, + right: kPadding16, + top: kPadding16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SendRow( + sendTokenData: _sendTokenData, + sendData: _sendData, + originalSendValue: _originalSendValue, + ), + const SizedBox.square(dimension: kPadding6), + Padding( + padding: const EdgeInsets.symmetric(horizontal: kPadding8), + child: SvgPicture.asset( + colorFilter: ColorFilter.mode( + themeColors.foreground200, + BlendMode.srcIn, + ), + 'lib/modal/assets/icons/arrow_down.svg', + package: 'reown_appkit', + width: 14.0, + height: 14.0, + ), + ), + ReceiveRow( + sendData: _sendData, + ), + const SizedBox.square(dimension: kPadding16), + if (_transaction != null) + DetailsRow( + nativeTokenData: _networkTokenData ?? _sendTokenData, + sendData: _sendData, + requiredGasInTokens: _gasValue, + ), + const SizedBox.square(dimension: kPadding16), + Center( + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( + children: [ + WidgetSpan( + child: RoundedIcon( + assetPath: 'lib/modal/assets/icons/warning.svg', + assetColor: themeColors.foreground250, + circleColor: Colors.transparent, + borderColor: Colors.transparent, + padding: 0.0, + size: 14.0, + ), + alignment: ui.PlaceholderAlignment.middle, + ), + TextSpan( + text: ' Review transaction carefully', + style: themeData.textStyles.small400.copyWith( + color: themeColors.foreground200, + ), + ), + ], + ), + ), + ), + const SizedBox.square(dimension: kPadding16), + SendButtonRow( + onCancel: () => widgetStack.instance.pop(), + onSend: _isSendEnabled ? _sendTransaction : null, + ), + const SizedBox.square(dimension: kPadding12), + ], + ), + ), + ); + } +} diff --git a/packages/reown_appkit/lib/modal/pages/preview_send/utils.dart b/packages/reown_appkit/lib/modal/pages/preview_send/utils.dart new file mode 100644 index 00000000..171c7587 --- /dev/null +++ b/packages/reown_appkit/lib/modal/pages/preview_send/utils.dart @@ -0,0 +1,39 @@ +import 'dart:convert'; +import 'package:reown_appkit/reown_appkit.dart' hide TransactionExtension; + +extension TxExtension on Transaction { + Map toJson() { + return { + if (from != null) 'from': from!.hexEip55, + if (to != null) 'to': to!.hexEip55, + if (maxGas != null) 'gas': '0x${maxGas!.toRadixString(16)}', + if (gasPrice != null) + 'gasPrice': '0x${gasPrice!.getInWei.toRadixString(16)}', + if (value != null) 'value': '0x${value!.getInWei.toRadixString(16)}', + if (data != null) 'data': utf8.decode(data!), + if (nonce != null) 'nonce': nonce, + if (maxFeePerGas != null) + 'maxFeePerGas': '0x${maxFeePerGas!.getInWei.toRadixString(16)}', + if (maxPriorityFeePerGas != null) + 'maxPriorityFeePerGas': + '0x${maxPriorityFeePerGas!.getInWei.toRadixString(16)}', + }; + } +} + +extension StringExtension on String { + double toDouble() { + return double.parse(this); + } +} + +String constructCallData(String recipient, BigInt sendValue) { + // Keccak256 hash of `transfer(address,uint256)`'s signature + final transferMethodId = 'a9059cbb'; + // Remove '0x' and pad + final paddedReceiver = recipient.replaceFirst('0x', '').padLeft(64, '0'); + // Amount in hex, padded + final paddedAmount = sendValue.toRadixString(16).padLeft(64, '0'); + // + return '0x$transferMethodId$paddedReceiver$paddedAmount'; +} diff --git a/packages/reown_appkit/lib/modal/pages/preview_send/widgets.dart b/packages/reown_appkit/lib/modal/pages/preview_send/widgets.dart new file mode 100644 index 00000000..75933cdc --- /dev/null +++ b/packages/reown_appkit/lib/modal/pages/preview_send/widgets.dart @@ -0,0 +1,284 @@ +import 'package:flutter/material.dart'; +import 'package:reown_appkit/modal/models/send_data.dart'; +import 'package:reown_appkit/modal/pages/preview_send/utils.dart'; +import 'package:reown_appkit/modal/services/blockchain_service/models/token_balance.dart'; +import 'package:reown_appkit/modal/utils/core_utils.dart'; +import 'package:reown_appkit/modal/utils/render_utils.dart'; +import 'package:reown_appkit/modal/widgets/avatars/account_avatar.dart'; +import 'package:reown_appkit/modal/widgets/buttons/address_button.dart'; +import 'package:reown_appkit/modal/widgets/buttons/network_button.dart'; +import 'package:reown_appkit/modal/widgets/buttons/primary_button.dart'; +import 'package:reown_appkit/modal/widgets/buttons/secondary_button.dart'; +import 'package:reown_appkit/modal/widgets/lists/list_items/account_list_item.dart'; +import 'package:reown_appkit/reown_appkit.dart' hide TransactionExtension; +import 'package:reown_appkit/modal/constants/style_constants.dart'; +import 'package:reown_appkit/modal/widgets/modal_provider.dart'; + +class SendRow extends StatelessWidget { + const SendRow({ + required this.sendTokenData, + required this.sendData, + required this.originalSendValue, + }); + final TokenBalance sendTokenData; + final SendData sendData; + final String originalSendValue; + + @override + Widget build(BuildContext context) { + final appKitModal = ModalProvider.of(context).instance; + final themeData = ReownAppKitModalTheme.getDataOf(context); + final themeColors = ReownAppKitModalTheme.colorsOf(context); + final tokenPrice = sendTokenData.price ?? 0.0; + final balanceSend = sendData.amount!.toDouble() * tokenPrice; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: kPadding8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Send', + style: themeData.textStyles.small400.copyWith( + color: themeColors.foreground150, + height: 1.2, + ), + ), + Text( + '\$${CoreUtils.formatChainBalance(balanceSend)}', + style: themeData.textStyles.paragraph400.copyWith( + color: themeColors.foreground100, + height: 1.2, + ), + ), + ], + ), + Expanded(child: SizedBox()), + NetworkButton( + serviceStatus: appKitModal.status, + chainInfo: appKitModal.selectedChain, + iconUrl: sendTokenData.iconUrl, + title: '${CoreUtils.formatStringBalance( + originalSendValue, + precision: 8, + )} ${sendTokenData.symbol} ', + iconOnRight: true, + onTap: () {}, + ), + ], + ), + ); + } +} + +class ReceiveRow extends StatelessWidget { + const ReceiveRow({required this.sendData}); + final SendData sendData; + + String get _recipientAddress => sendData.address!; + + @override + Widget build(BuildContext context) { + final appKitModal = ModalProvider.of(context).instance; + final themeData = ReownAppKitModalTheme.getDataOf(context); + final themeColors = ReownAppKitModalTheme.colorsOf(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: kPadding8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'To', + style: themeData.textStyles.small400.copyWith( + color: themeColors.foreground150, + height: 1.2, + ), + ), + Expanded(child: SizedBox()), + AddressButton( + service: appKitModal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox.square(dimension: kPadding12), + Text(RenderUtils.truncate(_recipientAddress)), + const SizedBox.square(dimension: 6.0), + SizedBox.square( + dimension: BaseButtonSize.regular.height * 0.55, + child: GradientOrb( + address: _recipientAddress, + size: BaseButtonSize.regular.height * 0.55, + ), + ), + const SizedBox.square(dimension: 6.0), + ], + ), + onTap: () {}, + ), + ], + ), + ); + } +} + +class SendButtonRow extends StatelessWidget { + const SendButtonRow({ + required this.onCancel, + required this.onSend, + }); + final VoidCallback onCancel; + final VoidCallback? onSend; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SizedBox( + height: kListItemHeight, + child: SecondaryButton( + title: ' Cancel ', + onTap: onCancel, + ), + ), + const SizedBox.square(dimension: kPadding8), + Expanded( + child: SizedBox( + height: kListItemHeight, + child: PrimaryButton( + title: 'Send', + onTap: onSend, + ), + ), + ), + ], + ); + } +} + +class DetailsRow extends StatefulWidget { + const DetailsRow({ + required this.nativeTokenData, + required this.sendData, + required this.requiredGasInTokens, + }); + final TokenBalance nativeTokenData; + final SendData sendData; + final double requiredGasInTokens; + + @override + State createState() => _DetailsRowState(); +} + +class _DetailsRowState extends State { + bool _feesInTokens = false; + + String _formattedFee(double unformatted) { + if (_feesInTokens) { + return '${CoreUtils.formatChainBalance( + widget.requiredGasInTokens, + precision: 8, + )} ${widget.nativeTokenData.symbol}'; + } + final formatted = unformatted.toStringAsFixed(2); + if (double.parse(formatted) < 0.01) { + return '< \$0.01'; + } + return '\$$formatted'; + } + + @override + Widget build(BuildContext context) { + final appKitModal = ModalProvider.of(context).instance; + final themeData = ReownAppKitModalTheme.getDataOf(context); + final themeColors = ReownAppKitModalTheme.colorsOf(context); + final radiuses = ReownAppKitModalTheme.radiusesOf(context); + final chainId = appKitModal.selectedChain!.chainId; + final namespace = ReownAppKitModalNetworks.getNamespaceForChainId( + chainId, + ); + final address = appKitModal.session!.getAddress(namespace); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(kPadding8), + decoration: BoxDecoration( + color: themeColors.grayGlass002, + borderRadius: BorderRadius.all(Radius.circular( + radiuses.radius2XS, + )), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(kPadding8), + child: Text( + 'Details', + style: themeData.textStyles.small400.copyWith( + color: themeColors.foreground100, + ), + ), + ), + const SizedBox.square(dimension: kPadding8), + AccountListItem( + title: 'Network cost', + titleStyle: themeData.textStyles.small400.copyWith( + color: themeColors.foreground150, + ), + trailing: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Text( + _formattedFee( + (widget.requiredGasInTokens * widget.nativeTokenData.price!), + ), + style: themeData.textStyles.small400.copyWith( + color: themeColors.foreground100, + ), + ), + ), + onTap: () { + setState(() { + _feesInTokens = !_feesInTokens; + }); + }, + ), + const SizedBox.square(dimension: kPadding8), + AccountListItem( + title: 'Address', + titleStyle: themeData.textStyles.small400.copyWith( + color: themeColors.foreground150, + ), + trailing: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Text( + RenderUtils.truncate(address!), + style: themeData.textStyles.small400.copyWith( + color: themeColors.foreground100, + ), + ), + ), + ), + const SizedBox.square(dimension: kPadding8), + AccountListItem( + title: 'Network', + titleStyle: themeData.textStyles.small400.copyWith( + color: themeColors.foreground150, + ), + trailing: Padding( + padding: const EdgeInsets.only(right: 4.0), + child: NetworkButton( + serviceStatus: appKitModal.status, + chainInfo: appKitModal.selectedChain, + iconOnRight: true, + size: BaseButtonSize.small, + onTap: () {}, + ), + ), + ), + ], + ), + ); + } +} diff --git a/packages/reown_appkit/lib/modal/pages/preview_send_page.dart b/packages/reown_appkit/lib/modal/pages/preview_send_page.dart deleted file mode 100644 index b292958c..00000000 --- a/packages/reown_appkit/lib/modal/pages/preview_send_page.dart +++ /dev/null @@ -1,661 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:get_it/get_it.dart'; -import 'dart:ui' as ui; - -import 'package:reown_appkit/modal/constants/key_constants.dart'; -import 'package:reown_appkit/modal/i_appkit_modal_impl.dart'; -import 'package:reown_appkit/modal/models/send_data.dart'; -import 'package:reown_appkit/modal/services/blockchain_service/i_blockchain_service.dart'; -import 'package:reown_appkit/modal/services/blockchain_service/models/token_balance.dart'; -import 'package:reown_appkit/modal/services/toast_service/i_toast_service.dart'; -import 'package:reown_appkit/modal/services/toast_service/models/toast_message.dart'; -import 'package:reown_appkit/modal/utils/core_utils.dart'; -import 'package:reown_appkit/modal/utils/render_utils.dart'; -import 'package:reown_appkit/modal/widgets/avatars/account_avatar.dart'; -import 'package:reown_appkit/modal/widgets/buttons/address_button.dart'; -import 'package:reown_appkit/modal/widgets/buttons/network_button.dart'; -import 'package:reown_appkit/modal/widgets/buttons/primary_button.dart'; -import 'package:reown_appkit/modal/widgets/buttons/secondary_button.dart'; -import 'package:reown_appkit/modal/widgets/icons/rounded_icon.dart'; -import 'package:reown_appkit/modal/widgets/lists/list_items/account_list_item.dart'; -import 'package:reown_appkit/modal/widgets/widget_stack/widget_stack_singleton.dart'; -import 'package:reown_appkit/reown_appkit.dart' hide TransactionExtension; -import 'package:reown_appkit/modal/constants/style_constants.dart'; -import 'package:reown_appkit/modal/widgets/modal_provider.dart'; -import 'package:reown_appkit/modal/widgets/navigation/navbar.dart'; - -class PreviewSendPage extends StatefulWidget { - const PreviewSendPage({ - required this.sendData, - required this.sendTokenData, - required this.networkTokenData, - }) : super(key: KeyConstants.previewSendPageKey); - - final SendData sendData; - final TokenBalance sendTokenData; - final TokenBalance? networkTokenData; - - @override - State createState() => _PreviewSendPageState(); -} - -class _PreviewSendPageState extends State { - IBlockChainService get _blockchainService => GetIt.I(); - IToastService get _toastService => GetIt.I(); - late final IReownAppKitModal _appKitModal; - // - late SendData _sendData; - late final String _originalSendValue; - late final TokenBalance _sendTokenData; - late final TokenBalance? _networkTokenData; - - Transaction? _originalTransaction; - Transaction? _transaction; - double _requiredGasInNativeToken = 0.0; - bool _isSendEnabled = false; - Timer? _gasEstimationTimer; - // - @override - void initState() { - super.initState(); - _sendData = widget.sendData; - _originalSendValue = _sendData.amount!; - _sendTokenData = widget.sendTokenData; - _networkTokenData = widget.networkTokenData; - // - WidgetsBinding.instance.addPostFrameCallback((_) async { - _appKitModal = ModalProvider.of(context).instance; - await _constructTransaction(); - await _estimateNetworkCost(); - }); - } - - Future _reEstimateGas(_) async { - await _estimateNetworkCost(); - } - - void _log(String m) => _appKitModal.appKit!.core.logger.d(m); - - bool get _isMaxSend { - final valueToSend = CoreUtils.formatStringBalance( - _originalSendValue, - precision: _getDecimals(nativeToken: false), - ); - final maxAllowance = CoreUtils.formatStringBalance( - _sendTokenData.quantity!.numeric!, - precision: _getDecimals(nativeToken: false), - ); - - return valueToSend == maxAllowance; - } - - // if sendTokenData.address is not null it means a contract should be called - bool get _shouldCallContract => _sendTokenData.address != null; - - String get _senderAddress { - final appKitModal = ModalProvider.of(context).instance; - final chainId = _sendTokenData.chainId!; - final namespace = NamespaceUtils.getNamespaceFromChain(chainId); - return appKitModal.session!.getAddress(namespace)!; - } - - String get _recipientAddress => _sendData.address!; - - String _constructCallData(BigInt sendValue) { - // Keccak256 hash of `transfer(address,uint256)`'s signature - final transferMethodId = 'a9059cbb'; - // Remove '0x' and pad - final paddedReceiver = - _recipientAddress.replaceFirst('0x', '').padLeft(64, '0'); - // Amount in hex, padded - final paddedAmount = sendValue.toRadixString(16).padLeft(64, '0'); - // - return '0x$transferMethodId$paddedReceiver$paddedAmount'; - } - - Future _constructTransaction() async { - final valueToSend = CoreUtils.formatStringBalance( - _originalSendValue, - precision: _getDecimals(nativeToken: false), - ); - if (_shouldCallContract) { - final contractAddress = NamespaceUtils.getAccount( - _sendTokenData.address!, - ); - final actualValueToSend = double.parse(valueToSend) - - (_isMaxSend ? _requiredGasInNativeToken : 0.0); - _sendData = _sendData.copyWith( - amount: CoreUtils.formatChainBalance( - max(0.000000, actualValueToSend), - precision: _getDecimals(nativeToken: false), - ), - ); - _originalTransaction ??= Transaction( - from: EthereumAddress.fromHex(_senderAddress), - to: EthereumAddress.fromHex(contractAddress), - data: utf8.encode( - _constructCallData( - _valueToBigInt( - actualValueToSend, - ), - ), - ), - ); - _transaction = _originalTransaction!.copyWith( - data: utf8.encode( - _constructCallData( - _valueToBigInt( - actualValueToSend, - ), - ), - ), - ); - } else { - final actualValueToSend = double.parse(valueToSend) - - (_isMaxSend ? _requiredGasInNativeToken : 0.0); - _log('[$runtimeType] actualValueToSend $actualValueToSend'); - // - _sendData = _sendData.copyWith( - amount: max(0.000000, actualValueToSend).toString(), - ); - _originalTransaction ??= Transaction( - to: EthereumAddress.fromHex(_recipientAddress), - value: EtherAmount.fromBigInt( - EtherUnit.wei, - _valueToBigInt( - actualValueToSend, - ), - ), - data: utf8.encode('0x'), - ); - _transaction = _originalTransaction!.copyWith( - from: EthereumAddress.fromHex(_senderAddress), - value: EtherAmount.fromBigInt( - EtherUnit.wei, - _valueToBigInt( - actualValueToSend, - ), - ), - ); - } - _log('[$runtimeType] transaction ${jsonEncode(_transaction?.toJson())}'); - } - - Future _estimateNetworkCost() async { - try { - final chainId = _sendTokenData.chainId!; - final gasPrices = await _blockchainService.gasPrice( - caip2chain: chainId, - ); - final standardGasPrice = gasPrices.standard ?? BigInt.zero; - // - final estimatedGas = await _blockchainService.estimateGas( - transaction: _originalTransaction!.toJson(), - caip2Chain: chainId, - ); - // - final decimals = BigInt.from(10).pow(_getDecimals(nativeToken: true)); - _requiredGasInNativeToken = (standardGasPrice * estimatedGas) / decimals; - // Add a little buffer of 5% more since this is just an estimation - _requiredGasInNativeToken = double.parse(CoreUtils.formatChainBalance( - _requiredGasInNativeToken * 1.05, - precision: _getDecimals(nativeToken: true), - )); - - await _constructTransaction(); - - setState(() => _isSendEnabled = double.parse(_sendData.amount!) > 0.0); - - if (_isSendEnabled) { - _gasEstimationTimer ??= Timer.periodic( - Duration(seconds: 10), - _reEstimateGas, - ); - } else { - _toastService.show(ToastMessage( - type: ToastType.error, - text: 'Insufficient funds', - )); - } - } on ArgumentError catch (e) { - _toastService.show(ToastMessage( - type: ToastType.error, - text: 'Invald ${e.name ?? 'argument'}', - )); - } on Exception catch (e) { - _toastService.show(ToastMessage( - type: ToastType.error, - text: e.toString(), - )); - } - } - - int _getDecimals({required bool nativeToken}) { - if (nativeToken) { - final decimals = _networkTokenData?.quantity?.decimals ?? '18'; - return int.parse(decimals); - } - final decimals = _sendTokenData.quantity?.decimals ?? '18'; - return int.parse(decimals); - } - - BigInt _valueToBigInt(double value) { - final decimals = _getDecimals(nativeToken: false); - final factor = BigInt.from(pow(10, decimals)); - return BigInt.from(value * factor.toDouble()); - } - - Future _sendTransaction() async { - try { - final appKitModal = ModalProvider.of(context).instance; - await appKitModal.request( - topic: appKitModal.session!.topic, - chainId: _sendTokenData.chainId!, - request: SessionRequestParams( - method: MethodsConstants.ethSendTransaction, - params: [ - _transaction!.toJson(), - ], - ), - ); - } on ArgumentError catch (e) { - _toastService.show(ToastMessage( - type: ToastType.error, - text: 'Invald ${e.name ?? 'argument'}', - )); - } on Exception catch (e) { - _toastService.show(ToastMessage( - type: ToastType.error, - text: e.toString(), - )); - } - } - - @override - void dispose() { - _gasEstimationTimer?.cancel(); - _gasEstimationTimer = null; - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final themeColors = ReownAppKitModalTheme.colorsOf(context); - final themeData = ReownAppKitModalTheme.getDataOf(context); - return ModalNavbar( - title: 'Review send', - divider: false, - body: Container( - padding: const EdgeInsets.only( - left: kPadding16, - right: kPadding16, - top: kPadding16, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SendRow( - sendTokenData: _sendTokenData, - sendData: _sendData, - originalSendValue: _originalSendValue, - ), - const SizedBox.square(dimension: kPadding6), - Padding( - padding: const EdgeInsets.symmetric(horizontal: kPadding8), - child: SvgPicture.asset( - colorFilter: ColorFilter.mode( - themeColors.foreground200, - BlendMode.srcIn, - ), - 'lib/modal/assets/icons/arrow_down.svg', - package: 'reown_appkit', - width: 14.0, - height: 14.0, - ), - ), - _ReceiveRow( - sendData: _sendData, - ), - const SizedBox.square(dimension: kPadding16), - if (_transaction != null) - _DetailsRow( - transaction: _transaction!, - nativeTokenData: _networkTokenData ?? _sendTokenData, - sendData: _sendData, - requiredGasInTokens: _requiredGasInNativeToken, - ), - const SizedBox.square(dimension: kPadding16), - Center( - child: RichText( - textAlign: TextAlign.center, - text: TextSpan( - children: [ - WidgetSpan( - child: RoundedIcon( - assetPath: 'lib/modal/assets/icons/warning.svg', - assetColor: themeColors.foreground250, - circleColor: Colors.transparent, - borderColor: Colors.transparent, - padding: 0.0, - size: 14.0, - ), - alignment: ui.PlaceholderAlignment.middle, - ), - TextSpan( - text: ' Review transaction carefully', - style: themeData.textStyles.small400.copyWith( - color: themeColors.foreground200, - ), - ), - ], - ), - ), - ), - const SizedBox.square(dimension: kPadding16), - _SendButtonRow( - onCancel: () => widgetStack.instance.pop(), - onSend: _isSendEnabled ? _sendTransaction : null, - ), - ], - ), - ), - ); - } -} - -class _SendRow extends StatelessWidget { - const _SendRow({ - required this.sendTokenData, - required this.sendData, - required this.originalSendValue, - }); - final TokenBalance sendTokenData; - final SendData sendData; - final String originalSendValue; - - @override - Widget build(BuildContext context) { - final appKitModal = ModalProvider.of(context).instance; - final themeData = ReownAppKitModalTheme.getDataOf(context); - final themeColors = ReownAppKitModalTheme.colorsOf(context); - final tokenPrice = sendTokenData.price ?? 0.0; - final balanceSend = double.parse(sendData.amount!) * tokenPrice; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: kPadding8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Send', - style: themeData.textStyles.small400.copyWith( - color: themeColors.foreground150, - height: 1.2, - ), - ), - Text( - '\$${CoreUtils.formatChainBalance(balanceSend)}', - style: themeData.textStyles.paragraph400.copyWith( - color: themeColors.foreground100, - height: 1.2, - ), - ), - ], - ), - Expanded(child: SizedBox()), - NetworkButton( - serviceStatus: appKitModal.status, - chainInfo: appKitModal.selectedChain, - iconUrl: sendTokenData.iconUrl, - title: '${CoreUtils.formatStringBalance( - originalSendValue, - precision: 8, - )} ${sendTokenData.symbol} ', - iconOnRight: true, - onTap: () {}, - ), - ], - ), - ); - } -} - -class _ReceiveRow extends StatelessWidget { - const _ReceiveRow({required this.sendData}); - final SendData sendData; - - String get _recipientAddress => sendData.address!; - - @override - Widget build(BuildContext context) { - final appKitModal = ModalProvider.of(context).instance; - final themeData = ReownAppKitModalTheme.getDataOf(context); - final themeColors = ReownAppKitModalTheme.colorsOf(context); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: kPadding8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - 'To', - style: themeData.textStyles.small400.copyWith( - color: themeColors.foreground150, - height: 1.2, - ), - ), - Expanded(child: SizedBox()), - AddressButton( - service: appKitModal, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox.square(dimension: kPadding12), - Text(RenderUtils.truncate(_recipientAddress)), - const SizedBox.square(dimension: 6.0), - SizedBox.square( - dimension: BaseButtonSize.regular.height * 0.55, - child: GradientOrb( - address: _recipientAddress, - size: BaseButtonSize.regular.height * 0.55, - ), - ), - const SizedBox.square(dimension: 6.0), - ], - ), - onTap: () {}, - ), - ], - ), - ); - } -} - -class _SendButtonRow extends StatelessWidget { - const _SendButtonRow({ - required this.onCancel, - required this.onSend, - }); - final VoidCallback onCancel; - final VoidCallback? onSend; - - @override - Widget build(BuildContext context) { - return Row( - children: [ - SizedBox( - height: kListItemHeight, - child: SecondaryButton( - title: ' Cancel ', - onTap: onCancel, - ), - ), - const SizedBox.square(dimension: kPadding8), - Expanded( - child: SizedBox( - height: kListItemHeight, - child: PrimaryButton( - title: 'Send', - onTap: onSend, - ), - ), - ), - ], - ); - } -} - -class _DetailsRow extends StatefulWidget { - const _DetailsRow({ - required this.transaction, - required this.nativeTokenData, - required this.sendData, - required this.requiredGasInTokens, - }); - final Transaction transaction; - final TokenBalance nativeTokenData; - final SendData sendData; - final double requiredGasInTokens; - - @override - State<_DetailsRow> createState() => _DetailsRowState(); -} - -class _DetailsRowState extends State<_DetailsRow> { - bool _feesInTokens = false; - - String _formattedFee(double unformatted) { - if (_feesInTokens) { - return '${CoreUtils.formatChainBalance( - widget.requiredGasInTokens, - precision: 8, - )} ${widget.nativeTokenData.symbol}'; - } - final formatted = unformatted.toStringAsFixed(2); - if (double.parse(formatted) < 0.01) { - return '< \$0.01'; - } - return '\$$formatted'; - } - - @override - Widget build(BuildContext context) { - final appKitModal = ModalProvider.of(context).instance; - final themeData = ReownAppKitModalTheme.getDataOf(context); - final themeColors = ReownAppKitModalTheme.colorsOf(context); - final radiuses = ReownAppKitModalTheme.radiusesOf(context); - final chainId = appKitModal.selectedChain!.chainId; - final namespace = ReownAppKitModalNetworks.getNamespaceForChainId( - chainId, - ); - final address = appKitModal.session!.getAddress(namespace); - return Container( - width: double.infinity, - padding: const EdgeInsets.all(kPadding8), - decoration: BoxDecoration( - color: themeColors.grayGlass002, - borderRadius: BorderRadius.all(Radius.circular( - radiuses.radius2XS, - )), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(kPadding8), - child: Text( - 'Details', - style: themeData.textStyles.small400.copyWith( - color: themeColors.foreground100, - ), - ), - ), - const SizedBox.square(dimension: kPadding8), - AccountListItem( - title: 'Network cost', - titleStyle: themeData.textStyles.small400.copyWith( - color: themeColors.foreground150, - ), - trailing: Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Text( - _formattedFee( - (widget.requiredGasInTokens * widget.nativeTokenData.price!), - ), - style: themeData.textStyles.small400.copyWith( - color: themeColors.foreground100, - ), - ), - ), - onTap: () { - setState(() { - _feesInTokens = !_feesInTokens; - }); - }, - ), - const SizedBox.square(dimension: kPadding8), - AccountListItem( - title: 'Address', - titleStyle: themeData.textStyles.small400.copyWith( - color: themeColors.foreground150, - ), - trailing: Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Text( - RenderUtils.truncate(address!), - style: themeData.textStyles.small400.copyWith( - color: themeColors.foreground100, - ), - ), - ), - ), - const SizedBox.square(dimension: kPadding8), - AccountListItem( - title: 'Network', - titleStyle: themeData.textStyles.small400.copyWith( - color: themeColors.foreground150, - ), - trailing: Padding( - padding: const EdgeInsets.only(right: 4.0), - child: NetworkButton( - serviceStatus: appKitModal.status, - chainInfo: appKitModal.selectedChain, - iconOnRight: true, - size: BaseButtonSize.small, - onTap: () {}, - ), - ), - ), - ], - ), - ); - } -} - -extension on Transaction { - Map toJson() { - return { - if (from != null) 'from': from!.hexEip55, - if (to != null) 'to': to!.hexEip55, - if (maxGas != null) 'gas': '0x${maxGas!.toRadixString(16)}', - if (gasPrice != null) - 'gasPrice': '0x${gasPrice!.getInWei.toRadixString(16)}', - if (value != null) 'value': '0x${value!.getInWei.toRadixString(16)}', - if (data != null) 'data': utf8.decode(data!), - if (nonce != null) 'nonce': nonce, - if (maxFeePerGas != null) - 'maxFeePerGas': '0x${maxFeePerGas!.getInWei.toRadixString(16)}', - if (maxPriorityFeePerGas != null) - 'maxPriorityFeePerGas': - '0x${maxPriorityFeePerGas!.getInWei.toRadixString(16)}', - }; - } -} diff --git a/packages/reown_appkit/lib/modal/pages/public/appkit_modal_main_wallets_page.dart b/packages/reown_appkit/lib/modal/pages/public/appkit_modal_main_wallets_page.dart index 6b339963..f0e7c7ee 100644 --- a/packages/reown_appkit/lib/modal/pages/public/appkit_modal_main_wallets_page.dart +++ b/packages/reown_appkit/lib/modal/pages/public/appkit_modal_main_wallets_page.dart @@ -187,6 +187,7 @@ class _AppKitModalMainWalletsPageState const ReownAppKitModalQRCodePage(), event: SelectWalletEvent( name: 'WalletConnect', + explorerId: '', platform: AnalyticsPlatform.qrcode, ), ); diff --git a/packages/reown_appkit/lib/modal/pages/receive_compatible_networks.dart b/packages/reown_appkit/lib/modal/pages/receive_compatible_networks.dart index 01cbad01..5f50ca12 100644 --- a/packages/reown_appkit/lib/modal/pages/receive_compatible_networks.dart +++ b/packages/reown_appkit/lib/modal/pages/receive_compatible_networks.dart @@ -37,7 +37,7 @@ class ReceiveCompatibleNetworks extends StatelessWidget { ), constraints: BoxConstraints(maxHeight: maxHeight), child: ListView.separated( - itemBuilder: (BuildContext context, int index) { + itemBuilder: (_, index) { if (index == 0) { return AccountListItem( iconWidget: RoundedIcon( @@ -61,7 +61,7 @@ class ReceiveCompatibleNetworks extends StatelessWidget { separatorBuilder: (BuildContext context, int index) { return SizedBox.square(dimension: kPadding6); }, - itemCount: networks.length, + itemCount: networks.length + 1, ), ), ); @@ -75,7 +75,13 @@ class ReceiveCompatibleNetworks extends StatelessWidget { // List buttons = []; - final chainList = (appKitModal.getAvailableChains()!).map((c) { + final chainId = appKitModal.selectedChain!.chainId; + final namespace = ReownAppKitModalNetworks.getNamespaceForChainId(chainId); + final available = appKitModal.getAvailableChains()!.where((c) { + final ns = NamespaceUtils.getNamespaceFromChain(c); + return namespace == ns; + }).toList(); + final chainList = available.map((c) { final ns = c.split(':').first; final cid = c.split(':').last; return ReownAppKitModalNetworks.getNetworkById(ns, cid); diff --git a/packages/reown_appkit/lib/modal/pages/receive_page.dart b/packages/reown_appkit/lib/modal/pages/receive_page.dart index dd816b54..9f6f4350 100644 --- a/packages/reown_appkit/lib/modal/pages/receive_page.dart +++ b/packages/reown_appkit/lib/modal/pages/receive_page.dart @@ -170,8 +170,14 @@ class ReceivePage extends StatelessWidget { // List buttons = []; - final list = appKitModal.getAvailableChains()!; - final subList = list.sublist(0, min(5, list.length)); + final chainId = appKitModal.selectedChain!.chainId; + final namespace = ReownAppKitModalNetworks.getNamespaceForChainId(chainId); + final available = appKitModal.getAvailableChains()!.where((c) { + final ns = NamespaceUtils.getNamespaceFromChain(c); + return namespace == ns; + }).toList(); + + final subList = available.sublist(0, min(5, available.length)); final chainList = subList.map((c) { final ns = c.split(':').first; final cid = c.split(':').last; @@ -179,8 +185,8 @@ class ReceivePage extends StatelessWidget { }).toList(); final orderedList = [ - ...(chainList.where((e) => !e!.isTestNetwork)), ...(chainList.where((e) => e!.isTestNetwork)), + ...(chainList.where((e) => !e!.isTestNetwork)), ]; for (var chainInfo in orderedList) { diff --git a/packages/reown_appkit/lib/modal/pages/select_token_page.dart b/packages/reown_appkit/lib/modal/pages/select_token_page.dart index a379deec..7203670b 100644 --- a/packages/reown_appkit/lib/modal/pages/select_token_page.dart +++ b/packages/reown_appkit/lib/modal/pages/select_token_page.dart @@ -46,7 +46,7 @@ class _SelectTokenPageState extends State { } else { _tokens = await _blockchainService.getBalance( address: address, - caip2chain: '$namespace:$chainId', + caip2Chain: '$namespace:$chainId', ); } setState(() {}); diff --git a/packages/reown_appkit/lib/modal/pages/send_page.dart b/packages/reown_appkit/lib/modal/pages/send_page.dart index 6651fa49..9fcf5d8c 100644 --- a/packages/reown_appkit/lib/modal/pages/send_page.dart +++ b/packages/reown_appkit/lib/modal/pages/send_page.dart @@ -6,8 +6,10 @@ import 'package:get_it/get_it.dart'; import 'package:reown_appkit/modal/constants/key_constants.dart'; import 'package:reown_appkit/modal/models/send_data.dart'; -import 'package:reown_appkit/modal/pages/preview_send_page.dart'; +import 'package:reown_appkit/modal/pages/preview_send/preview_send_page.dart'; import 'package:reown_appkit/modal/pages/select_token_page.dart'; +import 'package:reown_appkit/modal/services/analytics_service/i_analytics_service.dart'; +import 'package:reown_appkit/modal/services/analytics_service/models/analytics_event.dart'; import 'package:reown_appkit/modal/services/blockchain_service/i_blockchain_service.dart'; import 'package:reown_appkit/modal/services/blockchain_service/models/token_balance.dart'; import 'package:reown_appkit/modal/services/explorer_service/i_explorer_service.dart'; @@ -33,6 +35,7 @@ class SendPage extends StatefulWidget { class _SendPageState extends State with WidgetsBindingObserver { IBlockChainService get _blockchainService => GetIt.I(); + IAnalyticsService get _analyticsService => GetIt.I(); final _amountController = TextEditingController(); final _addressController = TextEditingController(); @@ -57,6 +60,8 @@ class _SendPageState extends State with WidgetsBindingObserver { element.address == null && element.chainId == _selectedToken.chainId, ); + } else { + _networkToken = TokenBalance.fromJson(_selectedToken.toJson()); } _amountController.addListener(() { @@ -67,6 +72,10 @@ class _SendPageState extends State with WidgetsBindingObserver { _sendData = _sendData.copyWith(address: _addressController.text); setState(() {}); }); + + _analyticsService.sendEvent(WalletFeatureOpenSend( + network: _selectedToken.chainId!, + )); } void _setMaxAmount(String? maxAmount) { diff --git a/packages/reown_appkit/lib/modal/pages/smart_account_page.dart b/packages/reown_appkit/lib/modal/pages/smart_account_page.dart index 115803e7..1444597e 100644 --- a/packages/reown_appkit/lib/modal/pages/smart_account_page.dart +++ b/packages/reown_appkit/lib/modal/pages/smart_account_page.dart @@ -157,7 +157,7 @@ class _SmartAccountViewState extends State<_SmartAccountView> { try { _tokens = await _blockchainService.getBalance( address: address, - caip2chain: '$namespace:$chainId', + caip2Chain: '$namespace:$chainId', ); setState(() {}); } catch (_) {} diff --git a/packages/reown_appkit/lib/modal/pages/social_login_page.dart b/packages/reown_appkit/lib/modal/pages/social_login_page.dart index 070d9b19..c8091c82 100644 --- a/packages/reown_appkit/lib/modal/pages/social_login_page.dart +++ b/packages/reown_appkit/lib/modal/pages/social_login_page.dart @@ -223,10 +223,9 @@ class _SocialLoginPageState extends State { } void _cancelSocialLogin() { - debugPrint('[$runtimeType] _cancelSocialLogin'); errorEvent = ModalError('User canceled'); setState(() => _retrievingData = false); - _analyticsService.sendEvent(SocialLoginError( + _analyticsService.sendEvent(SocialLoginCanceled( provider: widget.socialOption.name.toLowerCase(), )); } diff --git a/packages/reown_appkit/lib/modal/services/analytics_service/models/analytics_event.dart b/packages/reown_appkit/lib/modal/services/analytics_service/models/analytics_event.dart index 1d60ef4b..66fdda03 100644 --- a/packages/reown_appkit/lib/modal/services/analytics_service/models/analytics_event.dart +++ b/packages/reown_appkit/lib/modal/services/analytics_service/models/analytics_event.dart @@ -163,11 +163,14 @@ class SwitchNetworkEvent implements AnalyticsEvent { class SelectWalletEvent implements AnalyticsEvent { final String _name; + final String _explorerId; final String? _platform; SelectWalletEvent({ required String name, + required String explorerId, AnalyticsPlatform? platform, }) : _name = name, + _explorerId = explorerId, _platform = platform?.name; @override @@ -179,6 +182,7 @@ class SelectWalletEvent implements AnalyticsEvent { @override Map? get properties => { 'name': _name, + 'explorer_id': _explorerId, if (_platform != null) 'platform': _platform, }; @@ -192,11 +196,14 @@ class SelectWalletEvent implements AnalyticsEvent { class ConnectSuccessEvent implements AnalyticsEvent { final String _name; + final String _explorerId; final String? _method; ConnectSuccessEvent({ required String name, + required String explorerId, AnalyticsPlatform? method, }) : _name = name, + _explorerId = explorerId, _method = method?.name; @override @@ -208,6 +215,7 @@ class ConnectSuccessEvent implements AnalyticsEvent { @override Map? get properties => { 'name': _name, + 'explorer_id': _explorerId, if (_method != null) 'method': _method, }; @@ -596,7 +604,7 @@ class SocialLoginStarted implements AnalyticsEvent { String get type => 'track'; @override - String get event => 'LOGIN_STARTED'; + String get event => 'SOCIAL_LOGIN_STARTED'; @override Map? get properties => { @@ -619,7 +627,7 @@ class SocialLoginSuccess implements AnalyticsEvent { String get type => 'track'; @override - String get event => 'LOGIN_SUCCESS'; + String get event => 'SOCIAL_LOGIN_SUCCESS'; @override Map? get properties => { @@ -642,7 +650,7 @@ class SocialLoginError implements AnalyticsEvent { String get type => 'track'; @override - String get event => 'LOGIN_ERROR'; + String get event => 'SOCIAL_LOGIN_ERROR'; @override Map? get properties => { @@ -656,3 +664,208 @@ class SocialLoginError implements AnalyticsEvent { if (properties != null) 'properties': properties, }; } + +class SocialLoginRequestUserData implements AnalyticsEvent { + final String _provider; + SocialLoginRequestUserData({required String provider}) : _provider = provider; + + @override + String get type => 'track'; + + @override + String get event => 'SOCIAL_LOGIN_REQUEST_USER_DATA'; + + @override + Map? get properties => { + 'provider': _provider, + }; + + @override + Map toMap() => { + 'type': type, + 'event': event, + if (properties != null) 'properties': properties, + }; +} + +class SocialLoginCanceled implements AnalyticsEvent { + final String _provider; + SocialLoginCanceled({required String provider}) : _provider = provider; + + @override + String get type => 'track'; + + @override + String get event => 'SOCIAL_LOGIN_CANCELED'; + + @override + Map? get properties => { + 'provider': _provider, + }; + + @override + Map toMap() => { + 'type': type, + 'event': event, + if (properties != null) 'properties': properties, + }; +} + +class WalletFeatureOpenSend implements AnalyticsEvent { + final String _network; + WalletFeatureOpenSend({required String network}) : _network = network; + + @override + String get type => 'track'; + + @override + String get event => 'OPEN_SEND'; + + @override + Map? get properties => { + 'network': _network, + }; + + @override + Map toMap() => { + 'type': type, + 'event': event, + if (properties != null) 'properties': properties, + }; +} + +class WalletFeatureSendInitiated implements AnalyticsEvent { + final String _network; + final String _sendToken; + final String _sendAmount; + + WalletFeatureSendInitiated({ + required String network, + required String sendToken, + required String sendAmount, + }) : _network = network, + _sendToken = sendToken, + _sendAmount = sendAmount; + + @override + String get type => 'track'; + + @override + String get event => 'SEND_INITIATED'; + + @override + Map? get properties => { + 'network': _network, + 'sendToken': _sendToken, + 'sendAmount': _sendAmount, + }; + + @override + Map toMap() => { + 'type': type, + 'event': event, + if (properties != null) 'properties': properties, + }; +} + +class WalletFeatureSendSuccess implements AnalyticsEvent { + final String _network; + final String _sendToken; + final String _sendAmount; + + WalletFeatureSendSuccess({ + required String network, + required String sendToken, + required String sendAmount, + }) : _network = network, + _sendToken = sendToken, + _sendAmount = sendAmount; + + @override + String get type => 'track'; + + @override + String get event => 'SEND_SUCCESS'; + + @override + Map? get properties => { + 'network': _network, + 'sendToken': _sendToken, + 'sendAmount': _sendAmount, + }; + + @override + Map toMap() => { + 'type': type, + 'event': event, + if (properties != null) 'properties': properties, + }; +} + +class WalletFeatureSendError implements AnalyticsEvent { + final String _network; + final String _sendToken; + final String _sendAmount; + + WalletFeatureSendError({ + required String network, + required String sendToken, + required String sendAmount, + }) : _network = network, + _sendToken = sendToken, + _sendAmount = sendAmount; + + @override + String get type => 'track'; + + @override + String get event => 'SEND_ERROR'; + + @override + Map? get properties => { + 'network': _network, + 'sendToken': _sendToken, + 'sendAmount': _sendAmount, + }; + + @override + Map toMap() => { + 'type': type, + 'event': event, + if (properties != null) 'properties': properties, + }; +} + +class WalletFeatureSignTransaction implements AnalyticsEvent { + final String _network; + final String _sendToken; + final String _sendAmount; + + WalletFeatureSignTransaction({ + required String network, + required String sendToken, + required String sendAmount, + }) : _network = network, + _sendToken = sendToken, + _sendAmount = sendAmount; + + @override + String get type => 'track'; + + @override + String get event => 'SIGN_TRANSACTION'; + + @override + Map? get properties => { + 'network': _network, + 'sendToken': _sendToken, + 'sendAmount': _sendAmount, + }; + + @override + Map toMap() => { + 'type': type, + 'event': event, + if (properties != null) 'properties': properties, + }; +} diff --git a/packages/reown_appkit/lib/modal/services/blockchain_service/blockchain_service.dart b/packages/reown_appkit/lib/modal/services/blockchain_service/blockchain_service.dart index dce59eb2..4f4f78ed 100644 --- a/packages/reown_appkit/lib/modal/services/blockchain_service/blockchain_service.dart +++ b/packages/reown_appkit/lib/modal/services/blockchain_service/blockchain_service.dart @@ -70,11 +70,13 @@ class BlockChainService implements IBlockChainService { @override Future getHistory({ required String address, + String? caip2Chain, String? cursor, }) async { final uri = Uri.parse('$_baseUrl/account/$address/history'); final queryParams = { ..._requiredParams, + if (caip2Chain != null) 'chainId': caip2Chain, if (cursor != null) 'cursor': cursor, }; final url = uri.replace(queryParameters: queryParams); @@ -101,13 +103,13 @@ class BlockChainService implements IBlockChainService { @override Future> getBalance({ required String address, - String? caip2chain, + String? caip2Chain, }) async { final uri = Uri.parse('$_baseUrl/account/$address/balance'); final queryParams = { ..._requiredParams, 'currency': 'usd', - if (caip2chain != null) 'chainId': caip2chain, + if (caip2Chain != null) 'chainId': caip2Chain, // 'forceUpdate': , }; final url = uri.replace(queryParameters: queryParams); @@ -172,9 +174,9 @@ class BlockChainService implements IBlockChainService { } @override - Future gasPrice({required String caip2chain}) async { + Future gasPrice({required String caip2Chain}) async { final uri = Uri.parse('$_baseUrl/convert/gas-price'); - final queryParams = {..._requiredParams, 'chainId': caip2chain}; + final queryParams = {..._requiredParams, 'chainId': caip2Chain}; final url = uri.replace(queryParameters: queryParams); final response = await http.get(url, headers: _requiredHeaders); _core.logger.i('[$runtimeType] gasPrice $url => ${response.body}'); diff --git a/packages/reown_appkit/lib/modal/services/blockchain_service/i_blockchain_service.dart b/packages/reown_appkit/lib/modal/services/blockchain_service/i_blockchain_service.dart index 9e3506f8..6dd2465e 100644 --- a/packages/reown_appkit/lib/modal/services/blockchain_service/i_blockchain_service.dart +++ b/packages/reown_appkit/lib/modal/services/blockchain_service/i_blockchain_service.dart @@ -17,18 +17,19 @@ abstract class IBlockChainService { Future getHistory({ required String address, + String? caip2Chain, String? cursor, }); Future> getBalance({ required String address, - String? caip2chain, + String? caip2Chain, }); void selectSendToken(TokenBalance? token); Future gasPrice({ - required String caip2chain, + required String caip2Chain, }); Future getTokenBalance({ diff --git a/packages/reown_appkit/lib/modal/services/blockchain_service/models/token_balance.dart b/packages/reown_appkit/lib/modal/services/blockchain_service/models/token_balance.dart index e96c3765..0715eacd 100644 --- a/packages/reown_appkit/lib/modal/services/blockchain_service/models/token_balance.dart +++ b/packages/reown_appkit/lib/modal/services/blockchain_service/models/token_balance.dart @@ -24,8 +24,6 @@ class TokenBalance { factory TokenBalance.fromRawJson(String str) => TokenBalance.fromJson(json.decode(str)); - String toRawJson() => json.encode(toJson()); - factory TokenBalance.fromJson(Map json) => TokenBalance( name: json['name'], symbol: json['symbol'], @@ -49,6 +47,9 @@ class TokenBalance { 'quantity': quantity?.toJson(), 'iconUrl': iconUrl, }; + + @override + String toString() => json.encode(toJson()); } class Quantity { diff --git a/packages/reown_appkit/lib/modal/services/explorer_service/explorer_service.dart b/packages/reown_appkit/lib/modal/services/explorer_service/explorer_service.dart index 2c713dd2..251c23f8 100644 --- a/packages/reown_appkit/lib/modal/services/explorer_service/explorer_service.dart +++ b/packages/reown_appkit/lib/modal/services/explorer_service/explorer_service.dart @@ -12,6 +12,7 @@ import 'package:reown_appkit/modal/services/explorer_service/models/native_app_d import 'package:reown_appkit/modal/services/explorer_service/models/redirect.dart'; import 'package:reown_appkit/modal/services/explorer_service/models/request_params.dart'; import 'package:reown_appkit/modal/services/explorer_service/models/wc_sample_wallets.dart'; +import 'package:reown_appkit/modal/services/phantom_service/i_phantom_service.dart'; import 'package:reown_appkit/modal/services/phantom_service/utils/phantom_utils.dart'; import 'package:reown_appkit/modal/services/uri_service/i_url_utils.dart'; import 'package:reown_appkit/modal/utils/core_utils.dart'; @@ -250,7 +251,6 @@ class ExplorerService implements IExplorerService { page: 1, entries: _installedWalletIds.length, include: _installedWalletsParam, - platform: _getPlatformType(), ); // this query gives me a count of installedWalletsParam.length final installedWallets = await _fetchListings(params: params); @@ -268,7 +268,6 @@ class ExplorerService implements IExplorerService { page: 1, entries: _featuredWalletsParam!.split(',').length, include: _featuredWalletsParam, - platform: _getPlatformType(), ); return await _fetchListings(params: params); } @@ -279,7 +278,6 @@ class ExplorerService implements IExplorerService { entries: _defaultEntriesCount, include: _includedWalletsParam, exclude: _excludedWalletsParam, - platform: _getPlatformType(), ); return await _fetchListings(params: _requestParams); } @@ -308,7 +306,14 @@ class ExplorerService implements IExplorerService { if (updateCount) { totalListings.value += apiResponse.count; } - return apiResponse.data.toList().toAppKitWalletInfo(); + return apiResponse.data + .where((a) { + return a.mobileLink != null || + a.id == CoinbaseUtils.walletId || + a.id == PhantomUtils.walletId; + }) + .toList() + .toAppKitWalletInfo(); } else { return []; } @@ -419,7 +424,6 @@ class ExplorerService implements IExplorerService { search: _currentSearchValue, include: include, exclude: exclude, - platform: _getPlatformType(), ), updateCount: false, ); @@ -472,7 +476,7 @@ class ExplorerService implements IExplorerService { final serviceData = ReownAppKitModalWalletInfo.fromJson( results.first.toJson(), ); - final mobileLink = PhantomUtils.defaultListingData.linkMode; + final mobileLink = PhantomUtils.defaultListingData.mobileLink; final linkMode = PhantomUtils.defaultListingData.linkMode; final installed = await _uriService.isInstalled(mobileLink); return serviceData.copyWith( @@ -528,33 +532,25 @@ class ExplorerService implements IExplorerService { web: walletInfo.listing.webappLink, ); } - - String _getPlatformType() { - final type = PlatformUtils.getPlatformType(); - final platform = type.toString().toLowerCase(); - switch (type) { - case PlatformType.mobile: - if (Platform.isIOS) { - return 'ios'; - } else if (Platform.isAndroid) { - return 'android'; - } else { - return 'mobile'; - } - default: - return platform; - } - } } extension on List { List toAppKitWalletInfo() { return map( - (item) => ReownAppKitModalWalletInfo( - listing: item, - installed: false, - recent: false, - ), + (item) { + bool isInstalled = false; + if (item.id == PhantomUtils.walletId) { + isInstalled = GetIt.I().isInstalled; + } + // if (item.id == CoinbaseUtils.walletId) { + // isInstalled = GetIt.I().isInstalled(); + // } + return ReownAppKitModalWalletInfo( + listing: item, + installed: isInstalled, + recent: false, + ); + }, ).toList(); } } diff --git a/packages/reown_appkit/lib/modal/services/magic_service/magic_service.dart b/packages/reown_appkit/lib/modal/services/magic_service/magic_service.dart index 86365f7b..8dcb5d4d 100644 --- a/packages/reown_appkit/lib/modal/services/magic_service/magic_service.dart +++ b/packages/reown_appkit/lib/modal/services/magic_service/magic_service.dart @@ -128,6 +128,11 @@ class MagicService implements IMagicService { }) : _core = core, _metadata = metadata, _features = featuresConfig { + _connectionChainId = null; + _onLoadCount = 0; + _packageName = ''; + _socialProvider = null; + _socialUsername = null; isEmailEnabled.value = _features.email; isSocialEnabled.value = _features.socials.isNotEmpty; // @@ -436,6 +441,11 @@ class MagicService implements IMagicService { cid = null; } } + if (_socialProvider != null) { + _analyticsService.sendEvent(SocialLoginRequestUserData( + provider: _socialProvider!.name.toLowerCase(), + )); + } final message = GetUser(chainId: cid).toString(); return await _webViewController.runJavaScript('sendMessage($message)'); } diff --git a/packages/reown_appkit/lib/modal/services/phantom_service/phantom_service.dart b/packages/reown_appkit/lib/modal/services/phantom_service/phantom_service.dart index db951910..9dd60a16 100644 --- a/packages/reown_appkit/lib/modal/services/phantom_service/phantom_service.dart +++ b/packages/reown_appkit/lib/modal/services/phantom_service/phantom_service.dart @@ -204,7 +204,7 @@ class PhantomService implements IPhantomService { } @override - Future isInstalled() async => true; + bool get isInstalled => _walletData.installed; @override void completePhantomRequest({required String url}) async { @@ -227,8 +227,7 @@ class PhantomService implements IPhantomService { } Future _checkInstalled() async { - final installed = await isInstalled(); - if (!installed) { + if (!isInstalled) { throw ThirdPartyWalletNotInstalled(walletName: 'Phantom Wallet'); } return true; diff --git a/packages/reown_appkit/lib/modal/services/third_party_wallet_service.dart b/packages/reown_appkit/lib/modal/services/third_party_wallet_service.dart index 48352bec..8c0396d1 100644 --- a/packages/reown_appkit/lib/modal/services/third_party_wallet_service.dart +++ b/packages/reown_appkit/lib/modal/services/third_party_wallet_service.dart @@ -31,7 +31,7 @@ abstract class IThirdPartyWalletService { Future init(); Future connect({String? chainId}); Future isConnected(); - Future isInstalled(); + bool get isInstalled; Future request({ required String chainId, diff --git a/packages/reown_appkit/lib/modal/services/uri_service/url_utils.dart b/packages/reown_appkit/lib/modal/services/uri_service/url_utils.dart index 8c2845ad..e497a861 100644 --- a/packages/reown_appkit/lib/modal/services/uri_service/url_utils.dart +++ b/packages/reown_appkit/lib/modal/services/uri_service/url_utils.dart @@ -96,7 +96,7 @@ class UriService extends IUriService { } return true; } catch (e) { - rethrow; + throw CanNotLaunchUrl(); } } diff --git a/packages/reown_appkit/lib/modal/widgets/miscellaneous/all_wallets_header.dart b/packages/reown_appkit/lib/modal/widgets/miscellaneous/all_wallets_header.dart index e84e628a..a10ed02e 100644 --- a/packages/reown_appkit/lib/modal/widgets/miscellaneous/all_wallets_header.dart +++ b/packages/reown_appkit/lib/modal/widgets/miscellaneous/all_wallets_header.dart @@ -42,6 +42,7 @@ class AllWalletsHeader extends StatelessWidget { const ReownAppKitModalQRCodePage(), event: SelectWalletEvent( name: 'WalletConnect', + explorerId: '', platform: AnalyticsPlatform.qrcode, ), ); diff --git a/packages/reown_appkit/pubspec.yaml b/packages/reown_appkit/pubspec.yaml index b52c386e..0860cbe1 100644 --- a/packages/reown_appkit/pubspec.yaml +++ b/packages/reown_appkit/pubspec.yaml @@ -35,11 +35,17 @@ dependencies: # reown_sign: # path: ../reown_sign/ shimmer: ^3.0.0 + solana_web3: ^0.1.3 uuid: ^4.5.1 webview_flutter: 4.10.0 webview_flutter_android: 4.2.0 webview_flutter_wkwebview: 3.17.0 +dependency_overrides: + # because of solana_web3 + pinenacl: ^0.6.0 + web_socket_channel: ^3.0.1 + dev_dependencies: build_runner: ^2.4.13 build_version: ^2.1.1