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 e0975ebc..079fcdb4 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,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:quantus_sdk/quantus_sdk.dart' as crypto; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/components/wallet_app_bar.dart'; import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; 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 3f5b0ff2..ad796915 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 @@ -1,34 +1,28 @@ +// ignore_for_file: unused_import + +import 'dart:typed_data'; import 'dart:ui'; import 'package:convert/convert.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/svg.dart'; -import 'package:mobile_scanner/mobile_scanner.dart'; -import 'package:qr_flutter/qr_flutter.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/main/screens/send/steps/hardware_scan_step.dart'; +import 'package:resonance_network_wallet/features/main/screens/send/steps/hardware_sign_step.dart'; +import 'package:resonance_network_wallet/features/main/screens/send/steps/send_complete_step.dart'; +import 'package:resonance_network_wallet/features/main/screens/send/steps/send_confirm_step.dart'; +import 'package:resonance_network_wallet/features/main/screens/send/steps/send_progress_step.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:resonance_network_wallet/providers/pending_transactions_provider.dart'; +import 'package:resonance_network_wallet/services/hardware_wallet_service.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'; 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; final String recipientName; @@ -60,22 +54,6 @@ class SendConfirmationOverlayState extends ConsumerState _collectedUrParts = {}; - - int? _getTotalFragmentCount() { - if (_collectedUrParts.isEmpty) return null; - - for (final part in _collectedUrParts) { - final match = RegExp(r'/(\d+)-(\d+)/').firstMatch(part); - if (match != null) { - final total = int.tryParse(match.group(2) ?? ''); - if (total != null && total > 0) return total; - } - } - return null; - } void goHome() { if (!mounted) return; @@ -105,12 +83,6 @@ class SendConfirmationOverlayState extends ConsumerState _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)}']); + final hwService = ref.read(hardwareWalletServiceProvider); + final signatureWithPublicKey = await hwService.simulateSignature(account, unsignedData); + + final ur = hwService.encodePayloadAsUr(signatureWithPublicKey); + await _onHardwareSignatureScanned([ur]); } catch (e) { if (!mounted) return; setState(() { _errorMessage = 'Simulation failed: $e'; - _hasScannedSignature = false; _isHardwareSubmitting = false; - _collectedUrParts.clear(); }); } } @@ -283,665 +248,21 @@ 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) { - debugPrint('QR Scanner: onDetect called'); - if (_hasScannedSignature || _isHardwareSubmitting) { - debugPrint('QR Scanner: Already scanned or submitting, ignoring'); - return; - } - debugPrint('QR Scanner: Processing ${capture.barcodes.length} barcode(s)'); - for (final barcode in capture.barcodes) { - final v = barcode.rawValue; - debugPrint( - 'QR Scanner: Raw value: ${v?.substring(0, v.length > 100 ? 100 : v.length)}${v != null && v.length > 100 ? '...' : ''}', - ); - if (v == null) { - debugPrint('QR Scanner: Null value, skipping'); - continue; - } - - if (v.startsWith('UR:')) { - debugPrint('QR Scanner: UR code detected'); - final wasNew = _collectedUrParts.add(v); - debugPrint('QR Scanner: Was new part: $wasNew, Total parts: ${_collectedUrParts.length}'); - if (wasNew) { - final total = _getTotalFragmentCount(); - debugPrint('QR Scanner: Total fragments: $total'); - setState(() {}); - final isComplete = isCompleteUr(urParts: _collectedUrParts.toList()); - debugPrint('QR Scanner: Is complete: $isComplete'); - if (isComplete) { - debugPrint('QR Scanner: All parts collected, processing signature'); - _hasScannedSignature = true; - _onHardwareSignatureScanned(_collectedUrParts.toList()); - } - } else { - debugPrint('QR Scanner: Duplicate part, ignoring'); - } - } else { - debugPrint('QR Scanner: Non-UR code detected, ignoring'); - } - 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: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_collectedUrParts.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - () { - final scanned = _collectedUrParts.length; - final total = _getTotalFragmentCount(); - if (total != null) { - return 'Scanned $scanned of $total fragments'; - } - return 'Scanned $scanned fragment${scanned == 1 ? '' : 's'}...'; - }(), - textAlign: TextAlign.center, - style: context.themeText.paragraph?.copyWith( - color: context.themeColors.primary, - fontWeight: FontWeight.bold, - ), - ), - ), - Text( - _collectedUrParts.isEmpty - ? 'Position the QR code within the frame' - : 'Keep scanning until all parts are collected', - 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( List signatureQRParts, UnsignedTransactionData unsignedData, Account account, ) async { try { - Uint8List signatureBytes; - - if (signatureQRParts.isNotEmpty && signatureQRParts.first.startsWith('UR:')) { - try { - signatureBytes = decodeUr(urParts: signatureQRParts); - } catch (e) { - throw Exception('Invalid UR format: $e'); - } - } else { - throw Exception('Invalid signature format'); - } - - // print('signatureSize: ${hex.encode(signatureBytes)}'); - // print('sig PK hash: ${hex.encode(const Blake2bHasher(32).hash(signatureBytes))}'); - - final expectedTotalSize = signatureSize + publicKeySize; + final hwService = ref.read(hardwareWalletServiceProvider); - if (signatureBytes.length != expectedTotalSize) { - throw Exception('Invalid signature length: expected $expectedTotalSize bytes, got ${signatureBytes.length}'); - } + // 1. Decode UR + final signatureBytes = hwService.decodeSignatureUr(signatureQRParts); - final signature = signatureBytes.sublist(0, signatureSize); - final publicKey = signatureBytes.sublist(signatureSize); + // 2. Parse & Validate + final (:signature, :publicKey) = hwService.parseSignatureBytes(signatureBytes); + // 3. Submit final substrateService = SubstrateService(); final submissionService = ref.read(transactionSubmissionServiceProvider); final pendingTx = PendingTransactionEvent( @@ -961,8 +282,6 @@ class SendConfirmationOverlayState extends ConsumerState setState(() => currentState = SendOverlayState.hardwareSign), + onSignatureScanned: _onHardwareSignatureScanned, + showDebugButton: AppConstants.debugHardwareWallet, + onSimulate: _simulateHardwareSignature, + ); break; } final effectiveSheetHeightFraction = context.isSmallHeight ? 0.9 : AppConstants.sendingSheetHeightFraction; diff --git a/mobile-app/lib/features/main/screens/send/steps/hardware_scan_step.dart b/mobile-app/lib/features/main/screens/send/steps/hardware_scan_step.dart new file mode 100644 index 00000000..b09163c7 --- /dev/null +++ b/mobile-app/lib/features/main/screens/send/steps/hardware_scan_step.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:quantus_sdk/quantus_sdk.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:resonance_network_wallet/services/hardware_wallet_service.dart'; +import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; + +class HardwareScanStep extends ConsumerStatefulWidget { + final bool isSubmitting; + final String? errorMessage; + final VoidCallback onClose; + final VoidCallback onBack; + final Function(List) onSignatureScanned; + final VoidCallback? onSimulate; + final bool showDebugButton; + + const HardwareScanStep({ + super.key, + required this.isSubmitting, + this.errorMessage, + required this.onClose, + required this.onBack, + required this.onSignatureScanned, + this.onSimulate, + this.showDebugButton = false, + }); + + @override + ConsumerState createState() => _HardwareScanStepState(); +} + +class _HardwareScanStepState extends ConsumerState { + final MobileScannerController _signatureScannerController = MobileScannerController(); + final Set _collectedUrParts = {}; + bool _hasScannedSignature = false; + + @override + void dispose() { + _signatureScannerController.dispose(); + super.dispose(); + } + + void _handleDetect(BarcodeCapture capture) { + if (_hasScannedSignature || widget.isSubmitting) { + return; + } + + final hwService = ref.read(hardwareWalletServiceProvider); + + for (final barcode in capture.barcodes) { + final v = barcode.rawValue; + if (v == null) { + continue; + } + + if (v.startsWith('UR:')) { + final wasNew = _collectedUrParts.add(v); + + if (wasNew) { + // final total = hwService.getTotalFragmentCount(_collectedUrParts.toList()); + // debugPrint('QR Scanner: Total fragments: $total'); + setState(() {}); + + final isComplete = hwService.isComplete(_collectedUrParts.toList()); + if (isComplete) { + _hasScannedSignature = true; + widget.onSignatureScanned(_collectedUrParts.toList()); + } + } + } + break; + } + } + + @override + Widget build(BuildContext context) { + return SizedBox( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Back + close + Container( + width: double.infinity, + padding: const EdgeInsets.all(7), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + GestureDetector( + onTap: widget.onBack, + 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 (widget.isSubmitting) + 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: _handleDetect), + 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: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_collectedUrParts.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + () { + final scanned = _collectedUrParts.length; + final hwService = ref.read(hardwareWalletServiceProvider); + final total = hwService.getTotalFragmentCount(_collectedUrParts.toList()); + if (total != null) { + return 'Scanned $scanned of $total fragments'; + } + return 'Scanned $scanned fragment${scanned == 1 ? '' : 's'}...'; + }(), + textAlign: TextAlign.center, + style: context.themeText.paragraph?.copyWith( + color: context.themeColors.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + Text( + _collectedUrParts.isEmpty + ? 'Position the QR code within the frame' + : 'Keep scanning until all parts are collected', + textAlign: TextAlign.center, + style: context.themeText.paragraph?.copyWith( + color: context.themeColors.textPrimary.useOpacity(0.8), + ), + ), + ], + ), + ), + if (widget.showDebugButton && widget.onSimulate != null) + Positioned( + bottom: 56, + left: 0, + right: 0, + child: Center( + child: TextButton( + onPressed: widget.onSimulate, + 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 (widget.errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + widget.errorMessage!, + style: context.themeText.detail?.copyWith(color: context.themeColors.textError), + textAlign: TextAlign.center, + ), + ), + SizedBox(height: context.themeSize.bottomButtonSpacing), + ], + ), + ); + } +} diff --git a/mobile-app/lib/features/main/screens/send/steps/hardware_sign_step.dart b/mobile-app/lib/features/main/screens/send/steps/hardware_sign_step.dart new file mode 100644 index 00000000..f83b1346 --- /dev/null +++ b/mobile-app/lib/features/main/screens/send/steps/hardware_sign_step.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/features/components/button.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:resonance_network_wallet/services/hardware_wallet_service.dart'; +import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; + +class HardwareSignStep extends ConsumerWidget { + final UnsignedTransactionData? unsignedData; + final VoidCallback onClose; + final VoidCallback onNext; + + const HardwareSignStep({super.key, required this.unsignedData, required this.onClose, required this.onNext}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SizedBox( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Close button + Container( + width: double.infinity, + padding: const EdgeInsets.all(7), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + GestureDetector( + onTap: 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 with Keystone Wallet', textAlign: TextAlign.center, style: context.themeText.largeTitle), + ], + ), + const SizedBox(height: 28), + + if (unsignedData == null) + SizedBox( + height: 250, + child: Center(child: CircularProgressIndicator(color: context.themeColors.primary)), + ) + else + Container( + width: context.isTablet ? 300 : 250, + height: context.isTablet ? 300 : 250, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)), + child: Builder( + builder: (context) { + final hwService = ref.read(hardwareWalletServiceProvider); + final qrData = hwService.encodePayloadAsUr(unsignedData!.encodedPayloadRaw); + print('QR Code payload: $qrData'); + return QrImageView( + data: qrData, + version: QrVersions.auto, + size: double.infinity, + padding: EdgeInsets.zero, + backgroundColor: Colors.white, + eyeStyle: const QrEyeStyle(eyeShape: QrEyeShape.square, color: Colors.black), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Colors.black, + ), + ); + }, + ), + ), + + const Spacer(), + // Continue button + SizedBox( + width: context.themeSize.sendOverlayContainerWidth, + child: Button( + variant: ButtonVariant.neutral, + label: 'Next', + onPressed: unsignedData == null ? null : onNext, + ), + ), + SizedBox(height: context.themeSize.bottomButtonSpacing), + ], + ), + ); + } +} diff --git a/mobile-app/lib/features/main/screens/send/steps/send_complete_step.dart b/mobile-app/lib/features/main/screens/send/steps/send_complete_step.dart new file mode 100644 index 00000000..d71e3028 --- /dev/null +++ b/mobile-app/lib/features/main/screens/send/steps/send_complete_step.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:resonance_network_wallet/features/components/button.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:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; + +class SendCompleteStep extends StatelessWidget { + final String formattedAmount; + final String recipientName; + final String recipientAddress; + final String tokenSymbol; + final bool isReversible; + final String formattedReversibleTime; + final VoidCallback onDone; + + const SendCompleteStep({ + super.key, + required this.formattedAmount, + required this.recipientName, + required this.recipientAddress, + required this.tokenSymbol, + required this.isReversible, + required this.formattedReversibleTime, + required this.onDone, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Close button + Container( + width: double.infinity, + padding: const EdgeInsets.all(7), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + GestureDetector( + onTap: onDone, + child: SizedBox( + width: 24, + height: 24, + child: Icon(Icons.close, color: Colors.white, size: context.themeSize.overlayCloseIconSize), + ), + ), + ], + ), + ), + const SizedBox(height: 28), + + // Sent 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('SENDING', textAlign: TextAlign.center, style: context.themeText.largeTitle), + ], + ), + const SizedBox(height: 28), + + // Transaction details + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Amount + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text.rich( + TextSpan( + children: [ + TextSpan(text: formattedAmount, style: context.themeText.mediumTitle), + TextSpan(text: ' $tokenSymbol', style: context.themeText.paragraph), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + + // Recipient information + Text( + 'will be sent to', + textAlign: TextAlign.center, + style: context.themeText.smallParagraph?.copyWith(color: context.themeColors.textMuted), + ), + const SizedBox(height: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + child: Text( + recipientName, + textAlign: TextAlign.center, + style: context.themeText.paragraph?.copyWith(color: context.themeColors.checksum), + ), + ), + const SizedBox(height: 12), + Text(recipientAddress, style: context.themeText.tiny), + ], + ), + + if (isReversible) const SizedBox(height: 14), + // Reversible time information + if (isReversible) + Container( + width: context.themeSize.sendOverlayContainerWidth, + padding: const EdgeInsets.symmetric(vertical: 5), + decoration: ShapeDecoration( + color: const Color(0xFF313131), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 10, + children: [ + SizedBox( + width: context.isTablet ? null : 299, + child: Text.rich( + TextSpan( + children: [ + TextSpan(text: 'Reversible for: ', style: context.themeText.smallParagraph), + TextSpan(text: formattedReversibleTime, style: context.themeText.detail), + ], + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ], + ), + + const Spacer(), + // Done Button + Button(variant: ButtonVariant.glassOutline, label: 'Done', onPressed: onDone), + SizedBox(height: context.themeSize.bottomButtonSpacing), + ], + ), + ); + } +} diff --git a/mobile-app/lib/features/main/screens/send/steps/send_confirm_step.dart b/mobile-app/lib/features/main/screens/send/steps/send_confirm_step.dart new file mode 100644 index 00000000..03e9fa0a --- /dev/null +++ b/mobile-app/lib/features/main/screens/send/steps/send_confirm_step.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:resonance_network_wallet/features/components/button.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:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; + +class SendConfirmStep extends StatelessWidget { + final BigInt amount; + final String formattedAmount; + final String formattedFee; + final String recipientName; + final String recipientAddress; + final String tokenSymbol; + final bool isReversible; + final String formattedReversibleTime; + final String? errorMessage; + final bool isSending; + final VoidCallback onClose; + final VoidCallback onConfirm; + + const SendConfirmStep({ + super.key, + required this.amount, + required this.formattedAmount, + required this.formattedFee, + required this.recipientName, + required this.recipientAddress, + required this.tokenSymbol, + required this.isReversible, + required this.formattedReversibleTime, + this.errorMessage, + required this.isSending, + required this.onClose, + required this.onConfirm, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Close button + Container( + width: double.infinity, + padding: const EdgeInsets.all(7), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + GestureDetector( + onTap: 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), + + // Send 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('SEND', textAlign: TextAlign.center, style: context.themeText.largeTitle), + ], + ), + const SizedBox(height: 28), + + // Transaction details + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text.rich( + TextSpan( + children: [ + TextSpan(text: formattedAmount, style: context.themeText.mediumTitle), + TextSpan(text: ' $tokenSymbol', style: context.themeText.paragraph), + ], + ), + ), + ], + ), + const SizedBox(height: 21), + + // Recipient information + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text('To:', style: context.themeText.smallParagraph?.copyWith(color: context.themeColors.textMuted)), + const SizedBox(height: 12), + Text( + recipientName, + textAlign: TextAlign.center, + style: context.themeText.paragraph?.copyWith(color: context.themeColors.checksum), + ), + const SizedBox(height: 12), + Text(recipientAddress, style: context.themeText.tiny), + ], + ), + + if (isReversible) const SizedBox(height: 21), + // Reversible time information + if (isReversible) + Container( + width: context.themeSize.sendOverlayContainerWidth, + padding: const EdgeInsets.symmetric(vertical: 5), + decoration: ShapeDecoration( + color: const Color(0xFF313131), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 10, + children: [ + SizedBox( + width: context.isTablet ? null : 299, + child: Text.rich( + TextSpan( + children: [ + TextSpan(text: 'Reversible for: ', style: context.themeText.smallParagraph), + TextSpan(text: formattedReversibleTime, style: context.themeText.detail), + ], + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 28), + + // Error message + if (errorMessage != null) + SizedBox( + height: 70, + child: Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: SingleChildScrollView( + child: Text( + errorMessage!, + style: context.themeText.detail?.copyWith(color: context.themeColors.textError), + textAlign: TextAlign.center, + ), + ), + ), + ), + const Spacer(), + // Network fee and confirm button + SizedBox( + width: context.themeSize.sendOverlayContainerWidth, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Network fee', style: context.themeText.detail?.copyWith(fontWeight: FontWeight.w500)), + + Row( + spacing: 8, + children: [ + Text( + '$formattedFee $tokenSymbol', + style: context.themeText.detail?.copyWith(fontWeight: FontWeight.w500), + ), + SvgPicture.asset('assets/settings_icon.svg', width: context.isTablet ? 20 : 14), + ], + ), + ], + ), + const SizedBox(height: 15), + Button(variant: ButtonVariant.neutral, label: 'Confirm', onPressed: isSending ? null : onConfirm), + ], + ), + ), + SizedBox(height: context.themeSize.bottomButtonSpacing), + ], + ), + ); + } +} diff --git a/mobile-app/lib/features/main/screens/send/steps/send_progress_step.dart b/mobile-app/lib/features/main/screens/send/steps/send_progress_step.dart new file mode 100644 index 00000000..2ef5e24b --- /dev/null +++ b/mobile-app/lib/features/main/screens/send/steps/send_progress_step.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.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:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; + +class SendProgressStep extends StatelessWidget { + final VoidCallback onClose; + + const SendProgressStep({super.key, required this.onClose}); + + @override + Widget build(BuildContext context) { + return SizedBox( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(7), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + GestureDetector( + onTap: 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: 91), + Column( + spacing: 18, + children: [ + SizedBox( + width: context.isTablet ? 111 : 91, + height: context.isTablet ? 105 : 85, + child: SvgPicture.asset('assets/logo/logo.svg'), + ), + Text('TRANSACTION \nIN PROGRESS', textAlign: TextAlign.center, style: context.themeText.largeTitle), + ], + ), + ], + ), + ); + } +} diff --git a/mobile-app/lib/services/hardware_wallet_service.dart b/mobile-app/lib/services/hardware_wallet_service.dart new file mode 100644 index 00000000..26bafb62 --- /dev/null +++ b/mobile-app/lib/services/hardware_wallet_service.dart @@ -0,0 +1,92 @@ +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; + +/// Service for handling hardware wallet interactions, specifically Keystone. +/// This includes UR (Uniform Resources) encoding/decoding and signature verification. +class HardwareWalletService { + /// Encodes a payload (bytes) into a single UR string. + /// Throws if encoding fails. + String encodePayloadAsUr(List payload) { + final urParts = encodeUr(data: payload); + if (urParts.isEmpty) { + throw Exception('Failed to encode UR: empty result'); + } + // Keystone usually expects a single part for simple transactions, + // or handles multipart if the payload is large. + // encodeUr returns a list of parts. + return urParts.first; + } + + /// Checks if a collection of UR parts forms a complete payload. + bool isComplete(List urParts) { + if (urParts.isEmpty) return false; + return isCompleteUr(urParts: urParts); + } + + /// Parses the total fragment count from a UR part string (e.g., "UR:Type/1-5/..."). + int? getTotalFragmentCount(List urParts) { + if (urParts.isEmpty) return null; + + for (final part in urParts) { + // Regex to find the sequence indicator like "1-5" in the UR string + final match = RegExp(r'/(\d+)-(\d+)/').firstMatch(part); + if (match != null) { + final total = int.tryParse(match.group(2) ?? ''); + if (total != null && total > 0) return total; + } + } + return null; + } + + /// Decodes a list of UR parts into the raw bytes of the signature/payload. + Uint8List decodeSignatureUr(List signatureQRParts) { + if (signatureQRParts.isEmpty) { + throw Exception('No signature parts provided'); + } + + // Check if it's a UR format + if (!signatureQRParts.first.toUpperCase().startsWith('UR:')) { + throw Exception('Invalid signature format: Not a UR code'); + } + + try { + return decodeUr(urParts: signatureQRParts); + } catch (e) { + throw Exception('Invalid UR format: $e'); + } + } + + /// Validates the decoded signature bytes and splits them into signature and public key. + /// Returns a record (signature, publicKey). + ({Uint8List signature, Uint8List publicKey}) parseSignatureBytes(Uint8List signatureBytes) { + final expectedTotalSize = signatureSize + publicKeySize; + + if (signatureBytes.length != expectedTotalSize) { + throw Exception('Invalid signature length: expected $expectedTotalSize bytes, got ${signatureBytes.length}'); + } + + final signature = signatureBytes.sublist(0, signatureSize); + final publicKey = signatureBytes.sublist(signatureSize); + + return (signature: signature, publicKey: publicKey); + } + + /// Helper for debugging: simulates a hardware signature using a local keypair. + Future simulateSignature(Account account, UnsignedTransactionData unsignedData) async { + 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); + + return signatureWithPublicKey; + } +} + +final hardwareWalletServiceProvider = Provider((ref) { + return HardwareWalletService(); +}); diff --git a/mobile-app/lib/services/pending_transaction_reconciliation_service.dart b/mobile-app/lib/services/pending_transaction_reconciliation_service.dart index 065882db..73e62c6d 100644 --- a/mobile-app/lib/services/pending_transaction_reconciliation_service.dart +++ b/mobile-app/lib/services/pending_transaction_reconciliation_service.dart @@ -229,11 +229,9 @@ class PendingTransactionReconciliationService { // Update to inHistory state first to show completion _ref.read(pendingTransactionsProvider.notifier).updateState(pendingTx.id, TransactionState.inHistory); - // Remove after a short delay to let UI show the completion - Timer(const Duration(seconds: 1), () { - _ref.read(pendingTransactionsProvider.notifier).remove(pendingTx.id); - print('PendingReconciliation: Removed pending transaction ${pendingTx.id}'); - }); + // Remove immediately to avoid duplicates since we found it in history + _ref.read(pendingTransactionsProvider.notifier).remove(pendingTx.id); + print('PendingReconciliation: Removed pending transaction ${pendingTx.id}'); } /// Marks a pending transaction as failed