From dcc4ee26a4fe5c9ccb869e8ce6d11c9e9b67e3a5 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Tue, 16 Dec 2025 12:08:49 +0800 Subject: [PATCH 01/32] hardware wallet simulator --- .../main/screens/send/qr_scanner_screen.dart | 61 +- .../screens/send/send_progress_overlay.dart | 143 +- .../send/transaction_qr_display_screen.dart | 80 + .../transaction_submission_service.dart | 6 +- .../test/widget/send_screen_widget_test.dart | 565 ++++--- .../widget/send_screen_widget_test.mocks.dart | 1426 ++++++++--------- quantus_sdk/lib/quantus_sdk.dart | 1 + .../src/models/unsigned_transaction_data.dart | 25 + 8 files changed, 1283 insertions(+), 1024 deletions(-) create mode 100644 mobile-app/lib/features/main/screens/send/transaction_qr_display_screen.dart create mode 100644 quantus_sdk/lib/src/models/unsigned_transaction_data.dart 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..605a6fac 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,12 +1,16 @@ import 'package:flutter/material.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:quantus_sdk/src/rust/api/crypto.dart' as crypto; +import 'package:convert/convert.dart'; +import 'dart:typed_data'; 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'; class QRScannerScreen extends StatefulWidget { - const QRScannerScreen({super.key}); + final List? payloadToSign; // Optional payload for debug simulation + const QRScannerScreen({super.key, this.payloadToSign}); @override State createState() => _QRScannerScreenState(); @@ -16,6 +20,42 @@ class _QRScannerScreenState extends State { final MobileScannerController controller = MobileScannerController(); bool _hasScanned = false; // Add flag to track if we've already scanned + Future _simulateHardwareSignature() async { + if (widget.payloadToSign == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('No payload provided for simulation'))); + return; + } + + try { + // 1. Get the debug wallet (Crystal Alice) + final debugWallet = crypto.crystalAlice(); + + // 2. Sign the payload using the debug wallet + // We use signMessage which returns the raw signature + final signature = crypto.signMessage(keypair: debugWallet, message: widget.payloadToSign!); + + // 3. Combine signature and public key (this is what the hardware wallet should return) + final signatureWithPublicKey = Uint8List(signature.length + debugWallet.publicKey.length); + signatureWithPublicKey.setAll(0, signature); + signatureWithPublicKey.setAll(signature.length, debugWallet.publicKey); + + // 4. Encode as hex string (simulating QR code content) + final hexSignature = '0x${hex.encode(signatureWithPublicKey)}'; + + print('Simulated Hardware Signature: $hexSignature'); + + // 5. Return the result as if it was scanned + if (mounted) { + Navigator.pop(context, hexSignature); + } + } catch (e) { + print('Simulation error: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Simulation failed: $e'))); + } + } + } + @override void dispose() { controller.dispose(); @@ -101,6 +141,25 @@ class _QRScannerScreenState extends State { style: context.themeText.paragraph?.copyWith(color: context.themeColors.textPrimary.useOpacity(0.8)), ), ), + + // Debug Simulation Button (Only visible in debug mode or if payload is provided) + if (widget.payloadToSign != null) + Positioned( + bottom: 40, + left: 0, + right: 0, + child: Center( + child: TextButton( + onPressed: _simulateHardwareSignature, + style: TextButton.styleFrom( + backgroundColor: Colors.red.withOpacity(0.7), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + child: const Text('DEBUG: SIMULATE SIGNATURE'), + ), + ), + ), ], ), ); 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..2cb72549 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,14 +1,21 @@ +import 'dart:typed_data'; 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:resonance_network_wallet/features/main/screens/send/qr_scanner_screen.dart'; +import 'package:resonance_network_wallet/features/main/screens/send/transaction_qr_display_screen.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'; @@ -101,31 +108,10 @@ class SendConfirmationOverlayState extends ConsumerState _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, + ); + } + } + + Future _handleHardwareWalletTransaction(Account account) async { + final substrateService = SubstrateService(); + final balancesService = BalancesService(); + final reversibleTransfersService = ReversibleTransfersService(); + + RuntimeCall call; + if (widget.reversibleTimeSeconds <= 0) { + call = balancesService.getBalanceTransferCall(widget.recipientAddress, widget.amount); + } else { + final delay = qp.Timestamp(BigInt.from(widget.reversibleTimeSeconds) * BigInt.from(1000)); + call = reversibleTransfersService.getReversibleTransferCall( + widget.recipientAddress, + widget.amount, + delay, + ); + } + + final unsignedData = await substrateService.getUnsignedTransactionPayload(account, call); + + if (!mounted) return; + + final qrDisplayResult = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => TransactionQRDisplayScreen(payloadToSign: unsignedData.payloadToSign), + ), + ); + + if (qrDisplayResult != true || !mounted) { + throw Exception('Transaction cancelled'); + } + + final signatureQR = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => QRScannerScreen(payloadToSign: unsignedData.payloadToSign), fullscreenDialog: true), + ); + + if (signatureQR == null || !mounted) { + throw Exception('Signature scan cancelled'); + } + + final 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 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); + + final submissionBuilder = () async { + return await substrateService.submitExtrinsicWithExternalSignature( + unsignedData, + signature, + publicKey, + ); + }; + + TelemetryService().sendEvent('send_transfer_hardware'); + await submissionService.submitAndTrackTransaction(submissionBuilder, pendingTx); + } + Widget _buildConfirmState() { final formattedAmount = _formattingService.formatBalance(widget.amount); final formattedFee = _formattingService.formatBalance(widget.fee); diff --git a/mobile-app/lib/features/main/screens/send/transaction_qr_display_screen.dart b/mobile-app/lib/features/main/screens/send/transaction_qr_display_screen.dart new file mode 100644 index 00000000..18898259 --- /dev/null +++ b/mobile-app/lib/features/main/screens/send/transaction_qr_display_screen.dart @@ -0,0 +1,80 @@ +import 'package:convert/convert.dart'; +import 'package:flutter/material.dart'; +import 'package:qr_flutter/qr_flutter.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/components/wallet_app_bar.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 TransactionQRDisplayScreen extends StatelessWidget { + const TransactionQRDisplayScreen({super.key, required this.payloadToSign}); + + final List payloadToSign; + + @override + Widget build(BuildContext context) { + final hexPayload = '0x${hex.encode(payloadToSign)}'; + + return ScaffoldBase( + appBar: WalletAppBar(title: 'Sign Transaction'), + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 40), + Text( + 'Please scan with Keystone Wallet', + style: context.themeText.smallTitle, + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + 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: QrImageView( + data: hexPayload, + 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 SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Text( + 'Scan this QR code with your Keystone hardware wallet to sign the transaction.', + style: context.themeText.smallParagraph?.copyWith(color: Colors.white70), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + Button( + variant: ButtonVariant.primary, + label: 'Done', + onPressed: () => Navigator.of(context).pop(true), + ), + SizedBox(height: context.themeSize.bottomButtonSpacing), + ], + ), + ); + } +} 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/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