diff --git a/.gitignore b/.gitignore index 022f26f2..c5dbf242 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,4 @@ app.*.symbols /mobile-app/android/.kotlin /mobile-app/android/.idea mobile-app/.env +/rust-transaction-parser/target diff --git a/mobile-app/lib/features/components/button.dart b/mobile-app/lib/features/components/button.dart index 7fcae5a6..9a7525db 100644 --- a/mobile-app/lib/features/components/button.dart +++ b/mobile-app/lib/features/components/button.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; -enum ButtonVariant { transparent, neutral, primary, success, danger, glass, glassOutline } +enum ButtonVariant { transparent, neutral, primary, success, danger, glass, glassOutline, dangerOutline } class Button extends StatelessWidget { final String label; @@ -138,6 +138,23 @@ class Button extends StatelessWidget { ); break; + case ButtonVariant.dangerOutline: + buttonWidget = Container( + width: width, + padding: padding, + decoration: ShapeDecoration( + color: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(buttonRadius), + side: BorderSide(color: context.themeColors.buttonDanger, width: 1), + ), + ), + child: Center( + child: Text(label, style: effectiveTextStyle.copyWith(color: context.themeColors.buttonDanger)), + ), + ); + break; + case ButtonVariant.success: buttonWidget = Container( width: width, diff --git a/mobile-app/lib/features/main/screens/account_settings_screen.dart b/mobile-app/lib/features/main/screens/account_settings_screen.dart index 35016941..846f2a11 100644 --- a/mobile-app/lib/features/main/screens/account_settings_screen.dart +++ b/mobile-app/lib/features/main/screens/account_settings_screen.dart @@ -1,10 +1,17 @@ import 'dart:ui'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/providers/account_associations_providers.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/components/account_gradient_image.dart'; +import 'package:resonance_network_wallet/features/components/app_modal_bottom_sheet.dart'; +import 'package:resonance_network_wallet/features/components/button.dart'; import 'package:resonance_network_wallet/features/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/features/styles/app_size_theme.dart'; import 'package:resonance_network_wallet/features/components/sphere.dart'; import 'package:resonance_network_wallet/features/components/wallet_app_bar.dart'; import 'package:resonance_network_wallet/features/main/screens/create_account_screen.dart'; @@ -14,7 +21,7 @@ import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.dart'; import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; -class AccountSettingsScreen extends StatefulWidget { +class AccountSettingsScreen extends ConsumerStatefulWidget { final Account account; final String balance; final String checksumName; @@ -22,10 +29,10 @@ class AccountSettingsScreen extends StatefulWidget { const AccountSettingsScreen({super.key, required this.account, required this.balance, required this.checksumName}); @override - State createState() => _AccountSettingsScreenState(); + ConsumerState createState() => _AccountSettingsScreenState(); } -class _AccountSettingsScreenState extends State { +class _AccountSettingsScreenState extends ConsumerState { void _editAccountName() { Navigator.push( context, @@ -38,6 +45,103 @@ class _AccountSettingsScreenState extends State { }); } + Widget _buildDisconnectWalletButton() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Button( + label: 'Disconnect Wallet', + onPressed: _showDisconnectConfirmation, + variant: ButtonVariant.dangerOutline, + ), + ); + } + + void _showDisconnectConfirmation() { + showAppModalBottomSheet( + context: context, + builder: (context) { + return SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 35, vertical: 16), + decoration: ShapeDecoration( + color: Colors.black, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: Alignment.topRight, + child: IconButton( + icon: Icon(Icons.close, size: context.themeSize.overlayCloseIconSize), + onPressed: () => Navigator.of(context).pop(), + ), + ), + const SizedBox(height: 10), + Text('Disconnect Wallet?', style: context.themeText.mediumTitle), + const SizedBox(height: 13), + Text( + 'This will remove this account from your wallet. If this is the last account for this hardware wallet, the wallet connection will be removed.', + style: context.themeText.smallParagraph, + ), + const SizedBox(height: 28), + Button( + variant: ButtonVariant.danger, + label: 'Disconnect', + onPressed: () async { + Navigator.of(context).pop(); + await _disconnectWallet(); + }, + ), + const SizedBox(height: 16), + Center( + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + 'Cancel', + style: context.themeText.smallParagraph?.copyWith(decoration: TextDecoration.underline), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } + + Future _disconnectWallet() async { + try { + final accountsService = AccountsService(); + await accountsService.removeAccount(widget.account); + + // Invalidate providers to refresh UI + // Use ref.read() for actions, but in this case ref is available on ConsumerState + // We don't need to read the providers, just invalidate them + ref.invalidate(accountsProvider); + ref.invalidate(activeAccountProvider); + ref.invalidate(accountAssociationsProvider); + ref.invalidate(balanceProviderFamily(widget.account.accountId)); + + if (mounted) { + Navigator.of(context).pop(true); // Return true to indicate change + } + } catch (e) { + print('Failed to disconnect: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to disconnect: $e'))); + } + } + } + @override Widget build(BuildContext context) { return ScaffoldBase( @@ -66,6 +170,9 @@ class _AccountSettingsScreenState extends State { _buildAddressSection(), const SizedBox(height: 20), _buildSecuritySection(), + const SizedBox(height: 20), + if (widget.account.accountType == AccountType.keystone) _buildDisconnectWalletButton(), + const SizedBox(height: 30), ], ), ], @@ -145,23 +252,23 @@ class _AccountSettingsScreenState extends State { return _buildSettingCard( child: Padding( padding: const EdgeInsets.only(top: 10.0, left: 10.0, bottom: 10.0, right: 18.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - SizedBox( - width: context.isTablet ? 550 : 251, - child: Text( - context.isTablet - ? widget.account.accountId - : AddressFormattingService.splitIntoChunks(widget.account.accountId).join(' '), - style: context.themeText.smallParagraph, + child: InkWell( + onTap: () => ClipboardExtensions.copyTextWithSnackbar(context, widget.account.accountId), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox( + width: context.isTablet ? 550 : 251, + child: Text( + context.isTablet + ? widget.account.accountId + : AddressFormattingService.splitIntoChunks(widget.account.accountId).join(' '), + style: context.themeText.smallParagraph, + ), ), - ), - InkWell( - child: Icon(Icons.copy, color: Colors.white, size: context.isTablet ? 26 : 22), - onTap: () => ClipboardExtensions.copyTextWithSnackbar(context, widget.account.accountId), - ), - ], + Icon(Icons.copy, color: Colors.white, size: context.isTablet ? 26 : 22), + ], + ), ), ), ); diff --git a/mobile-app/lib/features/main/screens/accounts_screen.dart b/mobile-app/lib/features/main/screens/accounts_screen.dart index 09ad0610..34698a7d 100644 --- a/mobile-app/lib/features/main/screens/accounts_screen.dart +++ b/mobile-app/lib/features/main/screens/accounts_screen.dart @@ -149,7 +149,7 @@ class _AccountsScreenState extends ConsumerState { ), child: Column( children: [ - _buildWalletSelector(), + // _buildWalletSelector(), Expanded(child: _buildAccountsList()), Button( @@ -172,33 +172,33 @@ class _AccountsScreenState extends ConsumerState { return _isHardwareWallet(selectedAccounts) ? 'Add Hardware Account' : 'Add Account'; } - Widget _buildWalletSelector() { - final accounts = ref.watch(accountsProvider).value ?? []; - final grouped = _groupByWallet(accounts); - if (grouped.length <= 1) return const SizedBox(height: 0); - - final walletIndexes = grouped.keys.toList()..sort(); - final initialWallet = _selectedWalletIndex ?? walletIndexes.first; - - final items = walletIndexes - .map((ix) => Item(value: ix, label: _walletLabel(ix, grouped[ix] ?? const []))) - .toList(); - - return Padding( - padding: const EdgeInsets.only(top: 8, bottom: 10), - child: Align( - alignment: Alignment.centerLeft, - child: Select( - width: 220, - items: items, - initialValue: initialWallet, - onSelect: (item) { - setState(() => _selectedWalletIndex = item.value); - }, - ), - ), - ); - } + // Widget _buildWalletSelector() { + // final accounts = ref.watch(accountsProvider).value ?? []; + // final grouped = _groupByWallet(accounts); + // if (grouped.length <= 1) return const SizedBox(height: 0); + + // final walletIndexes = grouped.keys.toList()..sort(); + // final initialWallet = _selectedWalletIndex ?? walletIndexes.first; + + // final items = walletIndexes + // .map((ix) => Item(value: ix, label: _walletLabel(ix, grouped[ix] ?? const []))) + // .toList(); + + // return Padding( + // padding: const EdgeInsets.only(top: 8, bottom: 10), + // child: Align( + // alignment: Alignment.centerLeft, + // child: Select( + // width: 220, + // items: items, + // initialValue: initialWallet, + // onSelect: (item) { + // setState(() => _selectedWalletIndex = item.value); + // }, + // ), + // ), + // ); + // } Widget _buildAccountsList() { final accountsAsync = ref.watch(accountsProvider); diff --git a/mobile-app/lib/features/main/screens/add_hardware_account_screen.dart b/mobile-app/lib/features/main/screens/add_hardware_account_screen.dart index c64b99e2..df483542 100644 --- a/mobile-app/lib/features/main/screens/add_hardware_account_screen.dart +++ b/mobile-app/lib/features/main/screens/add_hardware_account_screen.dart @@ -125,20 +125,12 @@ class _AddHardwareAccountScreenState extends ConsumerState createState() => _SelectWalletForRecoveryPhraseScreenState(); +} + +class _SelectWalletForRecoveryPhraseScreenState extends ConsumerState { + String _walletLabel(int walletIndex) { + return 'Wallet ${walletIndex + 1}'; + } + + @override + Widget build(BuildContext context) { + final accountsAsync = ref.watch(accountsProvider); + + return ScaffoldBase( + appBar: WalletAppBar(title: 'Select Wallet'), + child: accountsAsync.when( + loading: () => Center(child: CircularProgressIndicator(color: context.themeColors.circularLoader)), + error: (error, _) => Center( + child: Text( + 'Failed to load wallets: $error', + style: context.themeText.smallParagraph?.copyWith(color: Colors.white70), + ), + ), + data: (accounts) { + final walletIndices = getNonHardwareWalletIndices(accounts); + + if (walletIndices.isEmpty) { + return Center( + child: Text( + 'No wallets with recovery phrases found.', + style: context.themeText.smallParagraph?.copyWith(color: Colors.white70), + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 18), + itemCount: walletIndices.length, + separatorBuilder: (context, index) => const SizedBox(height: 22), + itemBuilder: (context, index) { + final walletIndex = walletIndices[index]; + return _buildWalletItem(walletIndex); + }, + ); + }, + ), + ); + } + + Widget _buildWalletItem(int walletIndex) { + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ShowRecoveryPhraseScreen(walletIndex: walletIndex), + ), + ); + }, + child: Container( + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: context.isTablet ? 16 : 12, horizontal: 18), + decoration: ShapeDecoration( + color: context.themeColors.buttonGlass, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(_walletLabel(walletIndex), style: context.themeText.smallParagraph), + Icon(Icons.arrow_forward_ios, size: context.themeSize.settingMenuIconSize), + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/features/main/screens/send/qr_scanner_screen.dart b/mobile-app/lib/features/main/screens/send/qr_scanner_screen.dart index 16031de4..957c2be3 100644 --- a/mobile-app/lib/features/main/screens/send/qr_scanner_screen.dart +++ b/mobile-app/lib/features/main/screens/send/qr_scanner_screen.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:quantus_sdk/quantus_sdk.dart' as crypto; import 'package:resonance_network_wallet/features/components/wallet_app_bar.dart'; import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; class QRScannerScreen extends StatefulWidget { const QRScannerScreen({super.key}); diff --git a/mobile-app/lib/features/main/screens/send/send_progress_overlay.dart b/mobile-app/lib/features/main/screens/send/send_progress_overlay.dart index cba74151..235ac5a0 100644 --- a/mobile-app/lib/features/main/screens/send/send_progress_overlay.dart +++ b/mobile-app/lib/features/main/screens/send/send_progress_overlay.dart @@ -3,16 +3,31 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:quantus_sdk/generated/schrodinger/types/qp_scheduler/block_number_or_timestamp.dart' as qp; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/components/button.dart'; import 'package:resonance_network_wallet/features/main/screens/navbar.dart'; import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_size_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; +import 'package:convert/convert.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:resonance_network_wallet/providers/pending_transactions_provider.dart'; +import 'package:resonance_network_wallet/services/telemetry_service.dart'; import 'package:resonance_network_wallet/services/transaction_submission_service.dart'; import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; +import 'package:flutter/foundation.dart'; -enum SendOverlayState { confirm, progress, complete } +enum SendOverlayState { confirm, progress, complete, hardwareSign, hardwareScan } + +String encodePayloadAsUr(List payload) { + final urParts = encodeUr(data: payload); + if (urParts.isEmpty) { + throw Exception('Failed to encode UR: empty result'); + } + return urParts.first; +} class SendConfirmationOverlay extends ConsumerStatefulWidget { final BigInt amount; @@ -42,6 +57,11 @@ class SendConfirmationOverlayState extends ConsumerState _startHardwareFlow(Account account) async { + setState(() { + currentState = SendOverlayState.hardwareSign; + _hardwareAccount = account; + _hardwareUnsignedData = null; + _isHardwareSubmitting = false; + _hasScannedSignature = false; + }); + + final substrateService = SubstrateService(); + final unsignedData = await substrateService.getUnsignedTransactionPayload(account, _buildRuntimeCall()); + if (!mounted) return; + + setState(() { + _hardwareUnsignedData = unsignedData; + _isSending = false; + }); + } + + void _goToHardwareScanStep() { + setState(() { + currentState = SendOverlayState.hardwareScan; + _hasScannedSignature = false; + _isHardwareSubmitting = false; + }); + } + + Future _onHardwareSignatureScanned(String signatureQR) async { + if (_isHardwareSubmitting) return; + final unsignedData = _hardwareUnsignedData; + final account = _hardwareAccount; + if (unsignedData == null || account == null) return; + + setState(() { + _isHardwareSubmitting = true; + }); + + await _processHardwareSignature(signatureQR, unsignedData, account); + } + + Future _simulateHardwareSignature() async { + final unsignedData = _hardwareUnsignedData; + final account = _hardwareAccount; + if (unsignedData == null || account == null) return; + + try { + final debugWallet = await account.getKeypair(); + final signature = signMessage(keypair: debugWallet, message: unsignedData.encodedPayloadToSign); + final signatureWithPublicKey = Uint8List(signature.length + debugWallet.publicKey.length); + signatureWithPublicKey.setAll(0, signature); + signatureWithPublicKey.setAll(signature.length, debugWallet.publicKey); + // printKatValues(unsignedData, signatureWithPublicKey); + await _onHardwareSignatureScanned('0x${hex.encode(signatureWithPublicKey)}'); + } catch (e) { + if (!mounted) return; + setState(() { + _errorMessage = 'Simulation failed: $e'; + _hasScannedSignature = false; + _isHardwareSubmitting = false; + }); + } + } + + Future _handleLocalWalletTransaction(Account account) async { + final submissionService = ref.read(transactionSubmissionServiceProvider); + + debugPrint('Attempting balance transfer...'); + debugPrint(' Recipient: ${widget.recipientAddress}'); + debugPrint(' Amount (BigInt): ${widget.amount}'); + debugPrint(' Fee: ${widget.fee}'); + debugPrint(' Reversible time: ${widget.reversibleTimeSeconds}'); + + if (widget.reversibleTimeSeconds <= 0) { + await submissionService.balanceTransfer( + account, + widget.recipientAddress, + widget.amount, + widget.fee, + widget.blockHeight, + ); + } else { + await submissionService.scheduleReversibleTransferWithDelaySeconds( + account: account, + recipientAddress: widget.recipientAddress, + amount: widget.amount, + delaySeconds: widget.reversibleTimeSeconds, + feeEstimate: widget.fee, + blockHeight: widget.blockHeight, + ); + } + } + Widget _buildConfirmState() { final formattedAmount = _formattingService.formatBalance(widget.amount); final formattedFee = _formattingService.formatBalance(widget.fee); @@ -482,6 +599,318 @@ class SendConfirmationOverlayState extends ConsumerState setState(() => currentState = SendOverlayState.hardwareSign), + child: SizedBox( + width: context.themeSize.overlayCloseIconSize, + height: context.themeSize.overlayCloseIconSize, + child: Icon(Icons.arrow_back, color: Colors.white, size: context.themeSize.overlayCloseIconSize), + ), + ), + GestureDetector( + onTap: widget.onClose, + child: SizedBox( + width: context.themeSize.overlayCloseIconSize, + height: context.themeSize.overlayCloseIconSize, + child: Icon(Icons.close, color: Colors.white, size: context.themeSize.overlayCloseIconSize), + ), + ), + ], + ), + ), + const SizedBox(height: 28), + + // Hardware wallet icon and title + Column( + children: [ + Center( + child: Image.asset( + 'assets/transaction/send_icon.png', + width: context.isTablet ? 101 : 61, + height: context.isTablet ? 92 : 52, + ), + ), + const SizedBox(height: 17), + Text('SCAN SIGNATURE', textAlign: TextAlign.center, style: context.themeText.largeTitle), + ], + ), + const SizedBox(height: 28), + + if (unsignedData == null) + SizedBox( + height: 320, + child: Center(child: CircularProgressIndicator(color: context.themeColors.primary)), + ) + else if (_isHardwareSubmitting) + SizedBox( + height: 320, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: context.themeColors.primary), + const SizedBox(height: 16), + Text('Submitting...', style: context.themeText.paragraph), + ], + ), + ), + ) + else + SizedBox( + height: 320, + child: Stack( + children: [ + MobileScanner( + controller: _signatureScannerController, + onDetect: (capture) { + if (_hasScannedSignature) return; + for (final barcode in capture.barcodes) { + final v = barcode.rawValue; + if (v == null) continue; + _hasScannedSignature = true; + _onHardwareSignatureScanned(v); + break; + } + }, + ), + Container( + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: Color(0xFF0CE6ED), width: 2), + ), + ), + margin: const EdgeInsets.all(50), + ), + Positioned( + bottom: 16, + left: 0, + right: 0, + child: Text( + 'Position the QR code within the frame', + textAlign: TextAlign.center, + style: context.themeText.paragraph?.copyWith( + color: context.themeColors.textPrimary.useOpacity(0.8), + ), + ), + ), + if (AppConstants.debugHardwareWallet) + Positioned( + bottom: 56, + left: 0, + right: 0, + child: Center( + child: TextButton( + onPressed: _simulateHardwareSignature, + style: TextButton.styleFrom( + backgroundColor: Colors.red.useOpacity(0.7), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + child: const Text('DEBUG: SIMULATE SIGNATURE'), + ), + ), + ), + ], + ), + ), + + const Spacer(), + if (_errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + _errorMessage!, + style: context.themeText.detail?.copyWith(color: context.themeColors.textError), + textAlign: TextAlign.center, + ), + ), + SizedBox(height: context.themeSize.bottomButtonSpacing), + ], + ), + ); + } + + Future _processHardwareSignature( + String signatureQR, + UnsignedTransactionData unsignedData, + Account account, + ) async { + try { + String signatureHex = signatureQR; + + if (signatureQR.startsWith('UR:')) { + try { + final decoded = decodeUr(urParts: [signatureQR]); + signatureHex = hex.encode(decoded); + } catch (e) { + throw Exception('Invalid UR format: $e'); + } + } else { + signatureHex = signatureQR.replaceAll('0x', '').replaceAll('0X', ''); + } + + final signatureBytes = hex.decode(signatureHex); + + if (signatureBytes.length < 64) { + throw Exception('Invalid signature length'); + } + + // For Dilithium, the signature + public key are combined in the signatureBytes. + // We pass the full blob as signature and an empty list as public key, + // because submitExtrinsicWithExternalSignature will concatenate them back anyway. + final signature = Uint8List.fromList(signatureBytes); + final publicKey = Uint8List(0); + + final substrateService = SubstrateService(); + final submissionService = ref.read(transactionSubmissionServiceProvider); + final pendingTx = PendingTransactionEvent( + tempId: 'pending_${DateTime.now().millisecondsSinceEpoch}', + from: account.accountId, + to: widget.recipientAddress, + amount: widget.amount, + timestamp: DateTime.now(), + transactionState: TransactionState.created, + fee: widget.fee, + blockNumber: widget.blockHeight, + ); + + ref.read(pendingTransactionsProvider.notifier).add(pendingTx); + + Future submissionBuilder() async { + return await substrateService.submitExtrinsicWithExternalSignature(unsignedData, signature, publicKey); + } + + RecentAddressesService().addAddress(widget.recipientAddress); + + TelemetryService().sendEvent('send_transfer_hardware'); + await submissionService.submitAndTrackTransaction(submissionBuilder, pendingTx); + + if (mounted) { + setState(() { + currentState = SendOverlayState.complete; + _isSending = false; + _isHardwareSubmitting = false; + }); + } + } catch (e) { + print('Hardware signature processing failed: $e'); + if (mounted) { + setState(() { + _errorMessage = 'Signature processing failed: ${e.toString()}'; + _isHardwareSubmitting = false; + _hasScannedSignature = false; + }); + } + } + } + @override Widget build(BuildContext context) { Widget content; @@ -495,6 +924,12 @@ class SendConfirmationOverlayState extends ConsumerState { ); } + void _navigateToRecoveryPhrase() { + final accountsAsync = ref.read(accountsProvider); + accountsAsync.whenData((accounts) { + final walletIndices = getNonHardwareWalletIndices(accounts); + + if (walletIndices.isEmpty) { + showTopSnackBar(context, title: 'No Wallets', message: 'No wallets with recovery phrases found.'); + return; + } + + if (walletIndices.length == 1) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ShowRecoveryPhraseScreen(walletIndex: walletIndices.first), + ), + ); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SelectWalletForRecoveryPhraseScreen(), + ), + ); + } + }); + } + @override Widget build(BuildContext context) { return ScaffoldBase( @@ -145,11 +175,7 @@ class _SettingsScreenState extends ConsumerState { }), const SizedBox(height: 22), _buildSettingsItem(context, 'Show Recovery Phrase', () { - final walletIndex = ref.read(activeAccountProvider).value?.walletIndex ?? 0; - Navigator.push( - context, - MaterialPageRoute(builder: (context) => ShowRecoveryPhraseScreen(walletIndex: walletIndex)), - ); + _navigateToRecoveryPhrase(); }), const SizedBox(height: 22), _buildSettingsItem(context, 'Referral', () { diff --git a/mobile-app/lib/services/transaction_submission_service.dart b/mobile-app/lib/services/transaction_submission_service.dart index f7dd073b..e593a725 100644 --- a/mobile-app/lib/services/transaction_submission_service.dart +++ b/mobile-app/lib/services/transaction_submission_service.dart @@ -53,7 +53,7 @@ class TransactionSubmissionService { TelemetryService().sendEvent('send_transfer'); // D. Submit and track the transaction - await _submitAndTrack(submissionBuilder, pendingTx); + await submitAndTrackTransaction(submissionBuilder, pendingTx); } Future scheduleReversibleTransferWithDelaySeconds({ @@ -90,7 +90,7 @@ class TransactionSubmissionService { TelemetryService().sendEvent('send_reversible'); - await _submitAndTrack(submissionBuilder, pending, maxRetries: maxRetries); + await submitAndTrackTransaction(submissionBuilder, pending, maxRetries: maxRetries); } PendingTransactionEvent createPendingTransaction({ @@ -123,7 +123,7 @@ class TransactionSubmissionService { /// waiting. /// Handles retries in the background for 'invalid' status. /// submissionBuilder: Function that creates fresh submission on each retry - Future _submitAndTrack( + Future submitAndTrackTransaction( Future Function() submissionBuilder, PendingTransactionEvent pendingTx, { int maxRetries = 3, diff --git a/mobile-app/lib/shared/utils/account_utils.dart b/mobile-app/lib/shared/utils/account_utils.dart new file mode 100644 index 00000000..885aec18 --- /dev/null +++ b/mobile-app/lib/shared/utils/account_utils.dart @@ -0,0 +1,11 @@ +import 'package:quantus_sdk/quantus_sdk.dart'; + +List getNonHardwareWalletIndices(List accounts) { + final nonHardwareWalletIndices = {}; + for (final account in accounts) { + if (account.accountType != AccountType.keystone) { + nonHardwareWalletIndices.add(account.walletIndex); + } + } + return nonHardwareWalletIndices.toList()..sort(); +} diff --git a/mobile-app/pubspec.yaml b/mobile-app/pubspec.yaml index a7ec195f..d88bc6b1 100644 --- a/mobile-app/pubspec.yaml +++ b/mobile-app/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: decimal: hex: + base32: ^2.0.0 ss58: bip39_mnemonic: intl: @@ -38,6 +39,7 @@ dependencies: human_checksum: git: url: https://github.com/Quantus-Network/human-checkphrase.git + ref: v1.1.0 path: dart provider: ^6.1.5 polkadart: ^0.7.3 diff --git a/mobile-app/test/widget/send_screen_widget_test.dart b/mobile-app/test/widget/send_screen_widget_test.dart index 4ee6a14d..97a6c7d1 100644 --- a/mobile-app/test/widget/send_screen_widget_test.dart +++ b/mobile-app/test/widget/send_screen_widget_test.dart @@ -1,283 +1,282 @@ -import 'dart:typed_data'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; -import 'package:quantus_sdk/quantus_sdk.dart'; -import 'package:resonance_network_wallet/features/components/button.dart'; -import 'package:resonance_network_wallet/features/main/screens/send/send_progress_overlay.dart'; -import 'package:resonance_network_wallet/features/main/screens/send/send_providers.dart'; -import 'package:resonance_network_wallet/features/main/screens/send/send_screen.dart'; -import 'package:resonance_network_wallet/providers/wallet_providers.dart'; - -import '../extensions.dart'; - -// Generate the mocks -@GenerateMocks([ - SettingsService, - SubstrateService, - HumanReadableChecksumService, - BalancesService, - ReversibleTransfersService, - NumberFormattingService, -]) -import 'send_screen_widget_test.mocks.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - late MockSettingsService mockSettingsService; - late MockSubstrateService mockSubstrateService; - late MockHumanReadableChecksumService mockChecksumService; - late MockBalancesService mockBalancesService; - late MockReversibleTransfersService mockReversibleService; - late MockNumberFormattingService mockFormattingService; - - setUp(() { - mockSettingsService = MockSettingsService(); - mockSubstrateService = MockSubstrateService(); - mockChecksumService = MockHumanReadableChecksumService(); - mockBalancesService = MockBalancesService(); - mockReversibleService = MockReversibleTransfersService(); - mockFormattingService = MockNumberFormattingService(); - - // --- 1. Settings Service Stubs --- - when(mockSettingsService.getActiveAccount()).thenAnswer((_) async { - return const Account(walletIndex: 0, index: 0, name: 'Test User', accountId: 'test_account_id'); - }); - when(mockSettingsService.getReversibleTimeSeconds()).thenAnswer((_) async => 600); - - // --- 2. Substrate Service Stubs --- - when(mockSubstrateService.isValidSS58Address(any)).thenAnswer((invocation) { - final String? arg = invocation.positionalArguments.first; - return arg != null && arg.isNotEmpty; - }); - - // --- 3. Checksum/Identity Stubs --- - when(mockChecksumService.getHumanReadableName(any)).thenAnswer((_) async => 'Alice'); - - // --- 4. Balances/Fee Stubs --- - final dummyFeeData = ExtrinsicFeeData( - fee: BigInt.from(1000000), - extrinsicData: ExtrinsicData(blockNumber: 100, nonce: 1, blockHash: '0xHash', payload: Uint8List(0)), - ); - - when(mockBalancesService.getBalanceTransferFee(any, any, any)).thenAnswer((_) async => dummyFeeData); - - when( - mockReversibleService.getReversibleTransferWithDelayFeeEstimate( - account: anyNamed('account'), - recipientAddress: anyNamed('recipientAddress'), - amount: anyNamed('amount'), - delaySeconds: anyNamed('delaySeconds'), - ), - ).thenAnswer((_) async => dummyFeeData); - - // --- 5. Number Formatting Stubs --- - when(mockFormattingService.parseAmount(any)).thenAnswer((invocation) { - final String input = invocation.positionalArguments.first; - if (input == '1.23') return BigInt.from(1230000000000); - if (input == '0') return BigInt.zero; - return BigInt.from(100); - }); - - when( - mockFormattingService.formatBalance( - any, - addSymbol: anyNamed('addSymbol'), - addThousandsSeparators: anyNamed('addThousandsSeparators'), - ), - ).thenAnswer((_) => '100.00'); // Simplified return for finding text - }); - - testWidgets('Send Screen full flow: Enter Address -> Verify Identity -> Enter Amount -> Verify Fee', (tester) async { - tester.view.physicalSize = tester.devicePixel; - tester.view.devicePixelRatio = tester.devicePixelRatio; - - // Reset size after test to avoid affecting other tests - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - final overrides = [ - settingsServiceProvider.overrideWithValue(mockSettingsService), - substrateServiceProvider.overrideWithValue(mockSubstrateService), - humanReadableChecksumServiceProvider.overrideWithValue(mockChecksumService), - balancesServiceProvider.overrideWithValue(mockBalancesService), - reversibleTransfersServiceProvider.overrideWithValue(mockReversibleService), - numberFormattingServiceProvider.overrideWithValue(mockFormattingService), - effectiveMaxBalanceProvider.overrideWithValue(AsyncValue.data(BigInt.from(5000000000000))), - existentialDepositToggleProvider.overrideWith((ref) => true), - ]; - - await tester.pumpApp(const ProviderScope(child: SendScreen()), overrides: overrides); - await tester.pumpAndSettle(); - - // 1. Verify Initial State - expect(find.text('To:'), findsOneWidget); - - // 2. Enter Recipient Address - final textFields = find.byType(TextField); - await tester.enterText(textFields.at(0), '5ValidAddressOfRecipient'); - await tester.pump(); - - // Wait for debounce - await tester.pump(const Duration(milliseconds: 350)); - await tester.pumpAndSettle(); - - // 3. Verify Identity Lookup - expect(find.text('Alice'), findsOneWidget); - - // 4. Enter Amount - await tester.enterText(textFields.at(1), '1.23'); - await tester.pump(); - - // Wait for debounce and fee fetch - await tester.pump(const Duration(milliseconds: 250)); - await tester.pumpAndSettle(); - - // 5. Click Send to trigger the Overlay - final sendButton = find.byType(Button); - expect(sendButton, findsOneWidget); - - // Tap the button to open the overlay (This is where it previously crashed) - await tester.tap(sendButton); - await tester.pumpAndSettle(); - - // 6. Verify Overlay Content - expect(find.byType(SendConfirmationOverlay), findsOneWidget); - - // You can now assert that the overlay details are correct - expect(find.text('SEND'), findsOneWidget); - expect(find.text('Alice'), findsNWidgets(2)); - }); - - testWidgets('Amount error because of insufficient balance', (tester) async { - tester.view.physicalSize = tester.devicePixel; - tester.view.devicePixelRatio = tester.devicePixelRatio; - - // Reset size after test to avoid affecting other tests - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - // 1. SETUP: Mock a LOW balance so we can easily trigger an "Insufficient Balance" error - final lowBalanceOverrides = [ - settingsServiceProvider.overrideWithValue(mockSettingsService), - substrateServiceProvider.overrideWithValue(mockSubstrateService), - humanReadableChecksumServiceProvider.overrideWithValue(mockChecksumService), - balancesServiceProvider.overrideWithValue(mockBalancesService), - reversibleTransfersServiceProvider.overrideWithValue(mockReversibleService), - numberFormattingServiceProvider.overrideWithValue(mockFormattingService), - effectiveMaxBalanceProvider.overrideWithValue(AsyncValue.data(BigInt.from(2_000_000_000))), - existentialDepositToggleProvider.overrideWith((ref) => true), - ]; - - await tester.pumpApp(const ProviderScope(child: SendScreen()), overrides: lowBalanceOverrides); - await tester.pumpAndSettle(); - - // 2. ACTION: Enter a valid address first (to ensure button isn't disabled due to address) - final textFields = find.byType(TextField); - await tester.enterText(textFields.at(0), '5ValidAddress'); - await tester.pump(const Duration(milliseconds: 350)); // Debounce - await tester.pumpAndSettle(); - - // 3. ACTION: Enter an amount HIGHER than the balance (e.g., 20.0 > 10.0) - when(mockFormattingService.parseAmount('1.0')).thenReturn(BigInt.from(1_000_000_000_000)); - - await tester.enterText(textFields.at(1), '1.0'); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 250)); // Debounce - await tester.pumpAndSettle(); - - // --- CHECK 1: IS BUTTON DISABLED? --- - // Find the widget by type - final buttonFinder = find.byType(Button); - expect(buttonFinder, findsOneWidget); - - // Get the actual widget instance to check properties - final buttonWidget = tester.widget