diff --git a/crypto_plugins/flutter_libepiccash b/crypto_plugins/flutter_libepiccash index 25e6cb3a3..ada6254ee 160000 --- a/crypto_plugins/flutter_libepiccash +++ b/crypto_plugins/flutter_libepiccash @@ -1 +1 @@ -Subproject commit 25e6cb3a3e7bee04e425af6beccb47e8d0708fdb +Subproject commit ada6254ee8bab4a50e0d45bb6206a970dbac960d diff --git a/lib/pages/settings_views/global_settings_view/hidden_settings.dart b/lib/pages/settings_views/global_settings_view/hidden_settings.dart index 52b78cbcf..f82a987fc 100644 --- a/lib/pages/settings_views/global_settings_view/hidden_settings.dart +++ b/lib/pages/settings_views/global_settings_view/hidden_settings.dart @@ -345,6 +345,24 @@ class HiddenSettings extends StatelessWidget { ); }, ), + const SizedBox(height: 12), + Consumer( + builder: (_, ref, __) { + return GestureDetector( + onTap: () => Navigator.of(context).pushNamed("/testing"), + child: RoundedWhiteContainer( + child: Text( + "Testing", + style: STextStyles.button(context).copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark, + ), + ), + ), + ); + }, + ), // const SizedBox( // height: 12, // ), diff --git a/lib/pages/testing/sub_widgets/test_suite_card.dart b/lib/pages/testing/sub_widgets/test_suite_card.dart new file mode 100644 index 000000000..bcd2e782f --- /dev/null +++ b/lib/pages/testing/sub_widgets/test_suite_card.dart @@ -0,0 +1,150 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2025-08-14 + * + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../services/testing/testing_models.dart'; +import '../../../services/testing/testing_service.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../widgets/loading_indicator.dart'; +import '../../../widgets/rounded_white_container.dart'; + +class TestSuiteCard extends ConsumerWidget { + const TestSuiteCard({ + super.key, + required this.testType, + required this.status, + this.onTap, + }); + + final TestType testType; + final TestSuiteStatus status; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final testingService = ref.read(testingServiceProvider.notifier); + final colors = Theme.of(context).extension()!; + + return GestureDetector( + onTap: onTap, + child: RoundedWhiteContainer( + padding: const EdgeInsets.all(2), + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.all(10), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + testingService.getDisplayNameForTest(testType), + style: STextStyles.titleBold12(context), + ), + const SizedBox(height: 2), + Text( + _getSubtitleForStatus(status), + style: STextStyles.label(context).copyWith( + color: _getColorForStatus(status, colors), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + const SizedBox( + width: 20, + height: 20, + ), + ], + ), + ), + Positioned.fill( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.only(right: 12), + child: _buildStatusIndicator(status, colors), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildStatusIndicator(TestSuiteStatus status, StackColors colors) { + switch (status) { + case TestSuiteStatus.waiting: + return Icon( + Icons.schedule, + size: 20, + color: colors.textSubtitle1, + ); + case TestSuiteStatus.running: + return const SizedBox( + width: 20, + height: 20, + child: LoadingIndicator( + width: 20, + height: 20, + ), + ); + case TestSuiteStatus.passed: + return Icon( + Icons.check_circle, + size: 20, + color: colors.accentColorGreen, + ); + case TestSuiteStatus.failed: + return Icon( + Icons.error, + size: 20, + color: colors.accentColorRed, + ); + } + } + + String _getSubtitleForStatus(TestSuiteStatus status) { + switch (status) { + case TestSuiteStatus.waiting: + return "Ready to test"; + case TestSuiteStatus.running: + return "Running tests..."; + case TestSuiteStatus.passed: + return "All tests passed"; + case TestSuiteStatus.failed: + return "Tests failed"; + } + } + + Color _getColorForStatus(TestSuiteStatus status, StackColors colors) { + switch (status) { + case TestSuiteStatus.waiting: + return colors.textSubtitle1; + case TestSuiteStatus.running: + return colors.accentColorGreen; + case TestSuiteStatus.passed: + return colors.accentColorGreen; + case TestSuiteStatus.failed: + return colors.accentColorRed; + } + } +} \ No newline at end of file diff --git a/lib/pages/testing/testing_view.dart b/lib/pages/testing/testing_view.dart new file mode 100644 index 000000000..c08e38ad4 --- /dev/null +++ b/lib/pages/testing/testing_view.dart @@ -0,0 +1,586 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2025-08-14 + * + */ + +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:tuple/tuple.dart'; + +import '../../services/testing/testing_service.dart'; +import '../../services/testing/testing_models.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/desktop/desktop_scaffold.dart'; +import '../../widgets/desktop/desktop_app_bar.dart'; +import '../../widgets/background.dart'; +import '../../widgets/stack_dialog.dart'; +import '../settings_views/global_settings_view/stack_backup_views/helpers/swb_file_system.dart'; +import '../settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart'; +import 'sub_widgets/test_suite_card.dart'; + +class TestingView extends ConsumerStatefulWidget { + const TestingView({super.key}); + + static const String routeName = "/testing"; + + @override + ConsumerState createState() => _TestingViewState(); +} + +class _TestingViewState extends ConsumerState { + late final StreamSubscription? _subscription; + late final SWBFileSystem _swbFileSystem; + String? _selectedSwbFile; + List? _walletsInSwb; + + bool swbLoaded = false; + + @override + void initState() { + super.initState(); + _subscription = null; + _swbFileSystem = SWBFileSystem(); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + + final testingState = ref.watch(testingServiceProvider); + final testingService = ref.read(testingServiceProvider.notifier); + final isDesktop = Util.isDesktop; + + return ConditionalParent( + condition: isDesktop, + builder: (child) => DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + ), + body: SizedBox( + width: 480, + child: child, + ), + ), + child: ConditionalParent( + condition: !isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + "Testing", + style: STextStyles.navBarTitle(context), + ), + ), + body: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: child, + ), + ), + ); + }, + ), + ), + ), + child: Column( + children: [ + ConditionalParent( + condition: isDesktop, + builder: (child) => Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + top: 16, + ), + child: child, + ), + child: ConditionalParent( + condition: !isDesktop, + builder: (child) => Padding( + padding: const EdgeInsets.only( + left: 16, + right: 16, + top: 16, + ), + child: child, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isDesktop) + Text( + "Testing", + style: STextStyles.desktopH3(context), + ), + if (isDesktop) + const SizedBox( + height: 24, + ), + + // Run integration tests button + ConditionalParent( + condition: isDesktop, + builder: (child) => SecondaryButton( + label: testingState.isRunning ? "Cancel" : "Run integration tests", + onPressed: testingState.isRunning + ? () => testingService.cancelTesting() + : () => testingService.runAllTests(), + ), + child: TextButton( + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), + onPressed: testingState.isRunning + ? () => testingService.cancelTesting() + : () => testingService.runAllTests(), + child: Text( + testingState.isRunning ? "Cancel" : "Run integration tests", + style: STextStyles.button(context).copyWith( + color: Theme.of(context) + .extension()! + .buttonTextPrimary, + ), + ), + ), + ), + const SizedBox(height: 16), + + // Integration test suite cards. + SizedBox( + height: 300, // Set a fixed height for the scrollable area. + child: ListView.builder( + itemCount: IntegrationTestType.values.length, + itemBuilder: (context, index) { + final type = IntegrationTestType.values[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: TestSuiteCard( + testType: type, + status: testingState.testStatuses[type] ?? TestSuiteStatus.waiting, + onTap: testingState.isRunning + ? null + : () => testingService.runTestSuite(type), + ), + ); + }, + ), + ), + const SizedBox(height: 16), + + // SWB button. + if (!swbLoaded) + ConditionalParent( + condition: isDesktop, + builder: (child) => + SecondaryButton( + label: "Load SWB for extended tests", + onPressed: testingState.isRunning + ? null + : () => _selectSwbFile(), + ), + child: TextButton( + style: testingState.isRunning + ? Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle(context) + : Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), + onPressed: testingState.isRunning + ? null + : () => _selectSwbFile(), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + Assets.svg.backupRestore, + color: testingState.isRunning + ? Theme.of(context) + .extension()! + .buttonTextPrimaryDisabled + : Theme.of(context) + .extension()! + .buttonTextPrimary, + width: 16, + height: 16, + ), + const SizedBox(width: 8), + Text( + "Load SWB for extended tests", + style: STextStyles.button(context).copyWith( + color: testingState.isRunning + ? Theme.of(context) + .extension()! + .buttonTextPrimaryDisabled + : Theme.of(context) + .extension()! + .buttonTextPrimary, + ), + ), + ], + ), + ), + ), + if (swbLoaded) + ConditionalParent( + condition: isDesktop, + builder: (child) => + SecondaryButton( + label: "Run extended SWB tests", + onPressed: testingState.isRunning + ? null + : () => _showPasswordDialog(), + ), + child: TextButton( + style: testingState.isRunning + ? Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle(context) + : Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), + onPressed: testingState.isRunning + ? null + : () => _selectSwbFile(), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + Assets.svg.backupRestore, + color: testingState.isRunning + ? Theme.of(context) + .extension()! + .buttonTextPrimaryDisabled + : Theme.of(context) + .extension()! + .buttonTextPrimary, + width: 16, + height: 16, + ), + const SizedBox(width: 8), + Text( + "Run extended SWB tests", + style: STextStyles.button(context).copyWith( + color: testingState.isRunning + ? Theme.of(context) + .extension()! + .buttonTextPrimaryDisabled + : Theme.of(context) + .extension()! + .buttonTextPrimary, + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 16), + + // Reset button + ConditionalParent( + condition: isDesktop, + builder: (child) => PrimaryButton( + label: "Reset", + enabled: !testingState.isRunning, + onPressed: testingState.isRunning + ? null + : () => testingService.resetTestResults(), + ), + child: TextButton( + style: testingState.isRunning + ? Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle(context) + : Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + onPressed: testingState.isRunning + ? null + : () => testingService.resetTestResults(), + child: Text( + "Reset", + style: STextStyles.button(context).copyWith( + color: testingState.isRunning + ? Theme.of(context) + .extension()! + .buttonTextPrimaryDisabled + : Theme.of(context) + .extension()! + .buttonTextSecondary, + ), + ), + ), + ), + ], + ), + ), + ), + const Spacer(), + ConditionalParent( + condition: isDesktop, + builder: (child) => const SizedBox( + height: 64, + ), + child: const SizedBox( + height: 32, + ), + ), + ], + ), + ), + ); + } + + Future _selectSwbFile() async { + try { + await _swbFileSystem.prepareStorage(); + if (mounted) { + await _swbFileSystem.openFile(context); + } + + if (_swbFileSystem.filePath != null) { + setState(() { + _selectedSwbFile = _swbFileSystem.filePath; + }); + + if (mounted) { + swbLoaded = true; + // await _showPasswordDialog(); + } + } + } catch (e) { + if (mounted) { + await showDialog( + context: context, + builder: (context) => StackDialog( + title: "Error", + message: "Failed to open SWB file: $e", + rightButton: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + "OK", + style: STextStyles.button(context), + ), + ), + ), + ); + } + } + } + + Future _showPasswordDialog() async { + final passwordController = TextEditingController(); + bool hidePassword = true; + + await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) => StackDialogBase( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Enter SWB Password", + style: STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 8), + Text( + "Please enter the password for the Stack Wallet Backup file:", + style: STextStyles.smallMed14(context), + ), + const SizedBox(height: 16), + TextField( + controller: passwordController, + obscureText: hidePassword, + style: STextStyles.field(context), + decoration: InputDecoration( + hintText: "Enter password", + hintStyle: STextStyles.fieldLabel(context), + suffixIcon: IconButton( + icon: Icon( + hidePassword ? Icons.visibility : Icons.visibility_off, + ), + onPressed: () { + setState(() { + hidePassword = !hidePassword; + }); + }, + ), + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + "Cancel", + style: STextStyles.button(context), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextButton( + onPressed: () async { + Navigator.of(context).pop(); + await _loadWalletsFromSwb(passwordController.text); + }, + child: Text( + "OK", + style: STextStyles.button(context), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + + passwordController.dispose(); + } + + Future _loadWalletsFromSwb(String password) async { + try { + if (_selectedSwbFile == null) { + throw Exception("No SWB file selected"); + } + + // Use the actual SWB decryption from the codebase + final String? jsonString = await SWB.decryptStackWalletWithPassphrase( + Tuple2(_selectedSwbFile!, password), + ); + + if (jsonString == null) { + swbLoaded = false; + throw Exception("Failed to decrypt SWB file. Please check your password."); + } + + // Parse the JSON to extract wallet names + final Map backupData = jsonDecode(jsonString) as Map; + final List wallets = backupData["wallets"] as List? ?? []; + + final List walletNames = wallets + .map((wallet) => wallet["name"] as String? ?? "Unknown Wallet") + .toList(); + + setState(() { + _walletsInSwb = walletNames; + }); + + if (mounted) { + await _showWalletListDialog(); + } + } catch (e) { + if (mounted) { + await showDialog( + context: context, + builder: (context) => StackDialog( + title: "Error", + message: "Failed to decrypt SWB file: $e", + rightButton: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + "OK", + style: STextStyles.button(context), + ), + ), + ), + ); + } + } + } + + Future _showWalletListDialog() async { + await showDialog( + context: context, + builder: (context) => StackDialogBase( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Wallets in SWB", + style: STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 8), + Text( + "The following wallets were found in the backup file:", + style: STextStyles.smallMed14(context), + ), + const SizedBox(height: 16), + if (_walletsInSwb != null) + ..._walletsInSwb!.map((wallet) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + children: [ + const Icon(Icons.account_balance_wallet, size: 16), + const SizedBox(width: 8), + Text( + wallet, + style: STextStyles.smallMed14(context), + ), + ], + ), + )), + const SizedBox(height: 20), + Row( + children: [ + const Spacer(), + Expanded( + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + "OK", + style: STextStyles.button(context), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/route_generator.dart b/lib/route_generator.dart index ee09bdba0..cdd332112 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -132,6 +132,7 @@ import 'pages/settings_views/global_settings_view/syncing_preferences_views/sync import 'pages/settings_views/global_settings_view/syncing_preferences_views/syncing_preferences_view.dart'; import 'pages/settings_views/global_settings_view/syncing_preferences_views/wallet_syncing_options_view.dart'; import 'pages/settings_views/global_settings_view/tor_settings/tor_settings_view.dart'; +import 'pages/testing/testing_view.dart'; import 'pages/settings_views/wallet_settings_view/frost_ms/frost_ms_options_view.dart'; import 'pages/settings_views/wallet_settings_view/frost_ms/frost_participants_view.dart'; import 'pages/settings_views/wallet_settings_view/frost_ms/initiate_resharing/complete_reshare_config_view.dart'; @@ -1192,6 +1193,13 @@ class RouteGenerator { settings: RouteSettings(name: settings.name), ); + case TestingView.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const TestingView(), + settings: RouteSettings(name: settings.name), + ); + case CoinNodesView.routeName: if (args is CryptoCurrency) { return getRoute( diff --git a/lib/services/testing/test_suite_interface.dart b/lib/services/testing/test_suite_interface.dart new file mode 100644 index 000000000..9779daf0b --- /dev/null +++ b/lib/services/testing/test_suite_interface.dart @@ -0,0 +1,22 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2025-08-05 + * + */ + +import 'package:flutter/material.dart'; +import 'testing_models.dart'; + +abstract class TestSuiteInterface { + String get displayName; + Widget get icon; + TestSuiteStatus get status; + Stream get statusStream; + + Future runTests(); + Future cleanup(); +} \ No newline at end of file diff --git a/lib/services/testing/test_suites/epiccash_integration_test_suite.dart b/lib/services/testing/test_suites/epiccash_integration_test_suite.dart new file mode 100644 index 000000000..af5c47ccd --- /dev/null +++ b/lib/services/testing/test_suites/epiccash_integration_test_suite.dart @@ -0,0 +1,175 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2025-08-14 + * + */ + +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:logger/logger.dart'; +import 'package:flutter_libepiccash/lib.dart' as lib_epic; +import '../../../utilities/logger.dart'; +import '../test_suite_interface.dart'; +import '../testing_models.dart'; + +class EpiccashIntegrationTestSuite implements TestSuiteInterface { + StreamController _statusController = + StreamController.broadcast(); + TestSuiteStatus _status = TestSuiteStatus.waiting; + + @override + String get displayName => "Epic Cash Integration"; + + @override + Widget get icon => const Icon(Icons.currency_exchange, size: 32); + + @override + TestSuiteStatus get status => _status; + + @override + Stream get statusStream { + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + return _statusController.stream; + } + + @override + Future runTests() async { + final stopwatch = Stopwatch()..start(); + + try { + _updateStatus(TestSuiteStatus.running); + + Logging.instance.log(Level.info, "Starting Epic Cash integration test suite..."); + + await _testEpicCashMnemonicGeneration(); + await _testEpicCashAddressValidation(); + + stopwatch.stop(); + _updateStatus(TestSuiteStatus.passed); + + return TestResult( + success: true, + message: "👍👍 All Epic Cash integration tests passed successfully", + executionTime: stopwatch.elapsed, + ); + + } catch (e, stackTrace) { + stopwatch.stop(); + _updateStatus(TestSuiteStatus.failed); + + Logging.instance.log(Level.error, + "Epic Cash integration test suite failed: $e\n$stackTrace" + ); + + return TestResult( + success: false, + message: "Epic Cash integration tests failed: $e", + executionTime: stopwatch.elapsed, + ); + } + } + + Future _testEpicCashMnemonicGeneration() async { + Logging.instance.log(Level.info, "Testing Epic Cash mnemonic generation..."); + + try { + // Test Epic Cash mnemonic generation. + final mnemonic = lib_epic.LibEpiccash.getMnemonic(); + + // Validate mnemonic. + if (mnemonic.isEmpty) { + throw Exception("Generated Epic Cash mnemonic is empty"); + } + + final words = mnemonic.split(' '); + + // Epic Cash supports 12 and 24 word mnemonics. + if (words.length != 12 && words.length != 24) { + throw Exception( + "Invalid Epic Cash mnemonic word count: expected 12 or 24, got ${words.length}" + ); + } + + // Validate all words are non-empty. + for (final word in words) { + if (word.trim().isEmpty) { + throw Exception("Epic Cash mnemonic contains empty word"); + } + } + + Logging.instance.log(Level.info, + "👍 Epic Cash mnemonic generation test passed: ${words.length} words" + ); + + } catch (e) { + throw Exception("Epic Cash mnemonic generation test failed: $e"); + } + } + + Future _testEpicCashAddressValidation() async { + Logging.instance.log(Level.info, "Testing Epic Cash address validation..."); + + try { + // Test valid Epic Cash addresses (different formats). + final validAddresses = [ + // Domain-based address. + "esXrtQYZzs7DveZV4pxmXr8nntSjEkmxLddCF4hoEjVUh9nQYP7j@epicbox.stackwallet.com", + "epicbox://esXrtQYZzs7DveZV4pxmXr8nntSjEkmxLddCF4hoEjVUh9nQYP7j@epicbox.fastepic.eu", + ]; + + final invalidAddresses = [ + "", + "invalid_address", + "http://", + "https://", + "@epicbox.stackwallet.com", // Missing username. + "esXrtQYZzs7DveZV4pxmXr8nntSjEkmxLddCF4hoEjVUh9nQYP7j@", // Missing domain. + "http://example.com@epicbox.fastepic.eu", // Mixed formats. + "http://example.com:3415/v2/foreign", + "https://example.com:3415/v2/foreign", + ]; + + // Test valid addresses. + for (final address in validAddresses) { + final isValid = lib_epic.LibEpiccash.validateSendAddress(address: address); + if (!isValid) { + throw Exception("Valid Epic Cash address marked as invalid: $address"); + } + } + + // Test invalid addresses. + for (final address in invalidAddresses) { + final isValid = lib_epic.LibEpiccash.validateSendAddress(address: address); + if (isValid) { + throw Exception("Invalid Epic Cash address marked as valid: $address"); + } + } + + Logging.instance.log(Level.info, + "👍 Epic Cash address validation test passed" + ); + + } catch (e) { + throw Exception("Epic Cash address validation test failed: $e"); + } + } + + void _updateStatus(TestSuiteStatus newStatus) { + _status = newStatus; + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + _statusController.add(newStatus); + } + + @override + Future cleanup() async { + await _statusController.close(); + } +} \ No newline at end of file diff --git a/lib/services/testing/test_suites/firo_integration_test_suite.dart b/lib/services/testing/test_suites/firo_integration_test_suite.dart new file mode 100644 index 000000000..020ef6627 --- /dev/null +++ b/lib/services/testing/test_suites/firo_integration_test_suite.dart @@ -0,0 +1,228 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2025-08-14 + * + */ + +import 'dart:async'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:logger/logger.dart'; +import 'package:flutter_libsparkmobile/flutter_libsparkmobile.dart' as lib_spark; +import '../../../utilities/logger.dart'; +import '../test_suite_interface.dart'; +import '../testing_models.dart'; + +class FiroIntegrationTestSuite implements TestSuiteInterface { + StreamController _statusController = + StreamController.broadcast(); + TestSuiteStatus _status = TestSuiteStatus.waiting; + + @override + String get displayName => "Firo Integration"; + + @override + Widget get icon => const Icon(Icons.local_fire_department, size: 32); + + @override + TestSuiteStatus get status => _status; + + @override + Stream get statusStream { + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + return _statusController.stream; + } + + @override + Future runTests() async { + final stopwatch = Stopwatch()..start(); + + try { + _updateStatus(TestSuiteStatus.running); + + Logging.instance.log(Level.info, "Starting Firo integration test suite..."); + + await _testLibSparkBasicIntegration(); + await _testSparkAddressGeneration(); + // await _testSparkCoinIdentification(); + + stopwatch.stop(); + _updateStatus(TestSuiteStatus.passed); + + return TestResult( + success: true, + message: "👍👍 All Firo integration tests passed successfully", + executionTime: stopwatch.elapsed, + ); + + } catch (e, stackTrace) { + stopwatch.stop(); + _updateStatus(TestSuiteStatus.failed); + + Logging.instance.log(Level.error, + "Firo integration test suite failed: $e\n$stackTrace" + ); + + return TestResult( + success: false, + message: "Firo integration tests failed: $e", + executionTime: stopwatch.elapsed, + ); + } + } + + Future _testLibSparkBasicIntegration() async { + Logging.instance.log(Level.info, "Testing LibSpark basic integration..."); + + try { + // Test basic LibSpark function calls to ensure FFI integration is working. + + // Test address validation - this should work without generating keys. + const validTestnetAddress = "st13nzr56g59tuaj2fs7xcy8hk98cv8ten4u64qzevv98tyt6mku5stnu6rtkan448g4erz0a85xjwjqdhf0xnxltymva68rmhr50smn0vyyluhflyzxx2f2x0u2ea8fq7zh2an9zc7g6lrj"; + const invalidAddress = "invalid_spark_address"; + + // Test address validation for testnet. + final isValidTestnet = lib_spark.LibSpark.validateAddress( + address: validTestnetAddress, + isTestNet: true, + ); + + if (!isValidTestnet) { + throw Exception("Valid testnet Spark address was marked as invalid"); + } + + // Test invalid address. + final isInvalid = lib_spark.LibSpark.validateAddress( + address: invalidAddress, + isTestNet: true, + ); + + if (isInvalid) { + throw Exception("Invalid Spark address was marked as valid"); + } + + Logging.instance.log(Level.info, + "👍 LibSpark basic integration test passed" + ); + + } catch (e) { + throw Exception("LibSpark basic integration test failed: $e"); + } + } + + Future _testSparkAddressGeneration() async { + Logging.instance.log(Level.info, "Testing Spark address generation..."); + + try { + // Generate test private key (32 bytes). + final testPrivateKey = Uint8List.fromList( + List.generate(32, (index) => index + 1) + ); + + // Test address generation for testnet. + final sparkAddress = await lib_spark.LibSpark.getAddress( + privateKey: testPrivateKey, + index: 1, + diversifier: 1, + isTestNet: true, + ); + + // Validate generated address. + if (sparkAddress.isEmpty) { + throw Exception("Generated Spark address is empty"); + } + + // Verify the generated address is valid. + final isValid = lib_spark.LibSpark.validateAddress( + address: sparkAddress, + isTestNet: true, + ); + + if (!isValid) { + throw Exception("Generated Spark address is invalid: $sparkAddress"); + } + + // Test address generation with different diversifier. + final sparkAddress2 = await lib_spark.LibSpark.getAddress( + privateKey: testPrivateKey, + index: 1, + diversifier: 2, + isTestNet: true, + ); + + if (sparkAddress2.isEmpty) { + throw Exception("Generated Spark address with diversifier 2 is empty"); + } + + // Addresses with different diversifiers should be different. + if (sparkAddress == sparkAddress2) { + throw Exception("Addresses with different diversifiers should be different"); + } + + Logging.instance.log(Level.info, + "👍 Spark address generation test passed: $sparkAddress" + ); + + } catch (e) { + throw Exception("Spark address generation test failed: $e"); + } + } + + Future _testSparkCoinIdentification() async { + Logging.instance.log(Level.info, "Testing Spark coin identification..."); + + try { + // Test with dummy data to ensure the identify function can be called. + // This tests the FFI binding is working correctly. + final testPrivateKey = Uint8List.fromList( + List.generate(32, (index) => index + 1) + ); + + // Create dummy serialized coin data (base64 encoded dummy data). + final dummySerializedCoin = "dGVzdERhdGE="; // "testData" in base64. + final dummyContext = Uint8List.fromList([1, 2, 3, 4]); + + // This should return null for dummy data but shouldn't crash. + final identifiedCoin = lib_spark.LibSpark.identifyAndRecoverCoin( + dummySerializedCoin, + privateKeyHex: testPrivateKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(), + index: 1, + context: dummyContext, + isTestNet: true, + ); + + // We expect null for dummy data, which is correct behavior. + if (identifiedCoin != null) { + Logging.instance.log(Level.info, + "Coin identification returned non-null for dummy data (unexpected but not necessarily an error)" + ); + } + + Logging.instance.log(Level.info, + "👍 Spark coin identification test passed" + ); + + } catch (e) { + throw Exception("Spark coin identification test failed: $e"); + } + } + + void _updateStatus(TestSuiteStatus newStatus) { + _status = newStatus; + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + _statusController.add(newStatus); + } + + @override + Future cleanup() async { + await _statusController.close(); + } +} \ No newline at end of file diff --git a/lib/services/testing/test_suites/litecoin_mweb_integration_test_suite.dart b/lib/services/testing/test_suites/litecoin_mweb_integration_test_suite.dart new file mode 100644 index 000000000..95c463fcf --- /dev/null +++ b/lib/services/testing/test_suites/litecoin_mweb_integration_test_suite.dart @@ -0,0 +1,322 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2025-08-14 + * + */ + +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:logger/logger.dart'; +import 'package:flutter_mwebd/flutter_mwebd.dart'; +import 'package:mweb_client/mweb_client.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/stack_file_system.dart'; +import '../test_suite_interface.dart'; +import '../testing_models.dart'; + +class LitecoinMwebIntegrationTestSuite implements TestSuiteInterface { + StreamController _statusController = + StreamController.broadcast(); + TestSuiteStatus _status = TestSuiteStatus.waiting; + + @override + String get displayName => "Litecoin MWEB Integration"; + + @override + Widget get icon => const Icon(Icons.currency_bitcoin, size: 32); + + @override + TestSuiteStatus get status => _status; + + @override + Stream get statusStream { + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + return _statusController.stream; + } + + @override + Future runTests() async { + final stopwatch = Stopwatch()..start(); + + try { + _updateStatus(TestSuiteStatus.running); + + Logging.instance.log(Level.info, "Starting Litecoin MWEB integration test suite..."); + + await _testMwebdServerCreation(); + // await _testMwebdServerStatus(); + // await _testMwebClientConnection(); + + stopwatch.stop(); + _updateStatus(TestSuiteStatus.passed); + + return TestResult( + success: true, + message: "👍👍 All Litecoin MWEB integration tests passed successfully", + executionTime: stopwatch.elapsed, + ); + + } catch (e, stackTrace) { + stopwatch.stop(); + _updateStatus(TestSuiteStatus.failed); + + Logging.instance.log(Level.error, + "Litecoin MWEB integration test suite failed: $e\n$stackTrace" + ); + + return TestResult( + success: false, + message: "Litecoin MWEB integration tests failed: $e", + executionTime: stopwatch.elapsed, + ); + } + } + + Future _testMwebdServerCreation() async { + Logging.instance.log(Level.info, "Testing MWEB server creation..."); + + MwebdServer? server; + try { + // Get a random unused port for testing. + final port = await _getRandomUnusedPort(); + if (port == null) { + throw Exception("Could not find an unused port for mwebd test"); + } + + // Get test data directory. + final dir = await StackFileSystem.applicationMwebdDirectory("testnet"); + + // Create MwebdServer instance - this tests FFI integration. + server = MwebdServer( + chain: "testnet", + dataDir: dir.path, + peer: "litecoin.stackwallet.com:19335", // testnet peer + proxy: "", // no proxy for test + serverPort: port, + ); + + // Test server creation - this exercises the FFI bindings. + await server.createServer(); + + // Wait for server to start. + await server.startServer(); + + // Verify server is running. + if (!server.isRunning) { + throw Exception("MWEB server did not start successfully"); + } + + Logging.instance.log(Level.info, + "MWEB server created and running on port $port" + ); + await server.stopServer(); + + Logging.instance.log(Level.info, + "👍 MWEB server creation test passed" + ); + } catch (e) { + throw Exception("MWEB server creation test failed: $e"); + } finally { + // Cleanup: stop server if it was created. + if (server != null) { + try { + await server.stopServer(); + } catch (e) { + Logging.instance.log(Level.warning, "Failed to stop test server: $e"); + } + } + } + } + + Future _testMwebdServerStatus() async { + Logging.instance.log(Level.info, "Testing MWEB server status..."); + + MwebdServer? server; + try { + // Get a random unused port for testing. + final port = await _getRandomUnusedPort(); + if (port == null) { + throw Exception("Could not find an unused port for mwebd test"); + } + + // Get test data directory. + final dir = await StackFileSystem.applicationMwebdDirectory("testnet"); + + // Create and start MwebdServer. + server = MwebdServer( + chain: "testnet", + dataDir: dir.path, + peer: "litecoin.stackwallet.com:19335", // Testnet peer. + proxy: "", // No proxy for test. + serverPort: port, + ); + + await server.createServer(); + await server.startServer(); + + // Test getting server status - this tests FFI status calls. + final status = await server.getStatus(); + + // Verify we got a status response. + if (status.blockHeaderHeight < 0) { + throw Exception("Invalid block header height in status: ${status.blockHeaderHeight}"); + } + + // Status should have reasonable values (not necessarily synced for test). + if (status.mwebHeaderHeight < 0) { + throw Exception("Invalid MWEB header height in status: ${status.mwebHeaderHeight}"); + } + + Logging.instance.log(Level.info, + "👍 MWEB server status test passed (blockHeight: ${status.blockHeaderHeight}, mwebHeight: ${status.mwebHeaderHeight})" + ); + + } catch (e) { + throw Exception("MWEB server status test failed: $e"); + } finally { + // Cleanup: stop server if it was created. + if (server != null) { + try { + await server.stopServer(); + } catch (e) { + Logging.instance.log(Level.warning, "Failed to stop test server: $e"); + } + } + } + } + + Future _testMwebClientConnection() async { + Logging.instance.log(Level.info, "Testing MWEB client connection..."); + + MwebdServer? server; + MwebClient? client; + try { + // Get a random unused port for testing. + final port = await _getRandomUnusedPort(); + if (port == null) { + throw Exception("Could not find an unused port for mwebd test"); + } + + // Get test data directory. + final dir = await StackFileSystem.applicationMwebdDirectory("testnet"); + + // Create and start MwebdServer. + server = MwebdServer( + chain: "testnet", + dataDir: dir.path, + peer: "litecoin.stackwallet.com:19335", // testnet peer. + proxy: "", // no proxy for test. + serverPort: port, + ); + + await server.createServer(); + await server.startServer(); + + // Create MwebClient to connect to the server. + client = MwebClient.fromHost("127.0.0.1", port); + + // Test basic client operations. + // Generate dummy scan and spend secrets for testing. + final testScanSecret = Uint8List.fromList( + List.generate(32, (index) => index + 1) + ); + final testSpendPub = Uint8List.fromList( + List.generate(33, (index) => index + 1) + ); + + // Test address generation - this tests the client FFI integration. + final mwebAddress = await client.address( + testScanSecret, + testSpendPub, + 0, // index + ); + + // Verify we got a valid MWEB address. + if (mwebAddress.isEmpty) { + throw Exception("Generated MWEB address is empty"); + } + + // MWEB addresses should start with "ltcmweb" for testnet. + if (!mwebAddress.startsWith("ltcmweb")) { + throw Exception("Generated MWEB address does not have expected prefix: $mwebAddress"); + } + + Logging.instance.log(Level.info, + "👍 MWEB client connection test passed: $mwebAddress" + ); + + } catch (e) { + throw Exception("MWEB client connection test failed: $e"); + } finally { + // Cleanup. + if (client != null) { + try { + await client.cleanup(); + } catch (e) { + Logging.instance.log(Level.warning, "Failed to cleanup test client: $e"); + } + } + if (server != null) { + try { + await server.stopServer(); + } catch (e) { + Logging.instance.log(Level.warning, "Failed to stop test server: $e"); + } + } + } + } + + void _updateStatus(TestSuiteStatus newStatus) { + _status = newStatus; + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + _statusController.add(newStatus); + } + + @override + Future cleanup() async { + await _statusController.close(); + } +} + +// Helper function to get a random unused port. +Future _getRandomUnusedPort({Set? excluded}) async { + excluded ??= {}; + const int minPort = 1024; + const int maxPort = 65535; + const int maxAttempts = 100; + + final random = Random.secure(); + + for (int i = 0; i < maxAttempts; i++) { + final int potentialPort = minPort + random.nextInt(maxPort - minPort + 1); + + if (excluded.contains(potentialPort)) { + continue; + } + + try { + final serverSocket = await ServerSocket.bind( + InternetAddress.anyIPv4, + potentialPort, + ); + await serverSocket.close(); + return potentialPort; + } catch (_) { + excluded.add(potentialPort); + continue; + } + } + + return null; +} \ No newline at end of file diff --git a/lib/services/testing/test_suites/monero_integration_test_suite.dart b/lib/services/testing/test_suites/monero_integration_test_suite.dart new file mode 100644 index 000000000..8f8e80928 --- /dev/null +++ b/lib/services/testing/test_suites/monero_integration_test_suite.dart @@ -0,0 +1,558 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2025-08-14 + * + */ + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'package:compat/old_cw_core/path_for_wallet.dart' as lib_monero_compat; +import 'package:flutter/material.dart'; +import 'package:logger/logger.dart'; +import 'package:cs_monero/cs_monero.dart' as lib_monero; +import 'package:tuple/tuple.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/stack_file_system.dart'; +import '../../../pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart'; +import '../test_suite_interface.dart'; +import '../testing_models.dart'; +import 'test_data/polyseed_vectors.dart'; + +class MoneroWalletTestSuite implements TestSuiteInterface { + StreamController _statusController = + StreamController.broadcast(); + TestSuiteStatus _status = TestSuiteStatus.waiting; + + @override + String get displayName => "Monero Wallet FFI"; + + @override + Widget get icon => const Icon(Icons.account_balance_wallet, size: 32); + + @override + TestSuiteStatus get status => _status; + + @override + Stream get statusStream { + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + return _statusController.stream; + } + + @override + Future runTests() async { + final stopwatch = Stopwatch()..start(); + + try { + _updateStatus(TestSuiteStatus.running); + + Logging.instance.log(Level.info, "Starting Monero wallet test suite..."); + + await _testMnemonicGeneration(); + + await _testStackWalletBackupRoundTrip(); + + // TODO: FIXME. + // await _testPolyseedRestoration(); + + stopwatch.stop(); + _updateStatus(TestSuiteStatus.passed); + + return TestResult( + success: true, + message: "👍👍 All Monero wallet FFI tests passed successfully", + executionTime: stopwatch.elapsed, + ); + + } catch (e, stackTrace) { + stopwatch.stop(); + _updateStatus(TestSuiteStatus.failed); + + Logging.instance.log(Level.error, + "Monero wallet test suite failed: $e\n$stackTrace" + ); + + return TestResult( + success: false, + message: "Monero wallet FFI tests failed: $e", + executionTime: stopwatch.elapsed, + ); + } + } + + Future _testMnemonicGeneration() async { + Logging.instance.log(Level.info, "Testing mnemonic generation and wallet creation..."); + + final tempDir = await StackFileSystem.applicationRootDirectory(); + final walletName = "test_wallet_${Random().nextInt(10000)}"; + final walletPath = "${tempDir.path}/$walletName"; + const walletPassword = "1"; + + try { + // Test 16-word mnemonic generation. + await _testWalletCreation( + walletPath: "${walletPath}_16", + password: walletPassword, + seedType: lib_monero.MoneroSeedType.sixteen, + expectedWordCount: 16, + ); + + // Test 25-word mnemonic generation. + await _testWalletCreation( + walletPath: "${walletPath}_25", + password: walletPassword, + seedType: lib_monero.MoneroSeedType.twentyFive, + expectedWordCount: 25, + ); + + Logging.instance.log(Level.info, "👍 Mnemonic generation tests passed"); + + } finally { + // Cleanup test wallet files + await _cleanupTestWallets([ + "${walletPath}_16", + "${walletPath}_25", + ]); + } + } + + /// Tests wallet creation with different seed types. + /// + /// Attempts to ensure validity of the Monero FFI integration. + Future _testWalletCreation({ + required String walletPath, + required String password, + required lib_monero.MoneroSeedType seedType, + required int expectedWordCount, + }) async { + lib_monero.Wallet? wallet; + + try { + // Create new wallet with specified seed type. + wallet = await lib_monero.MoneroWallet.create( + path: walletPath, + password: password, + seedType: seedType, + seedOffset: "", + ); + + // Validate mnemonic word count + final mnemonic = await wallet.getSeed(); + final words = mnemonic.split(' '); + + if (words.length != expectedWordCount) { + throw Exception( + "Expected $expectedWordCount words, got ${words.length}: $mnemonic" + ); + } + + // Validate wallet address generation. + final address = await wallet.getAddress(); + if (address.value.isEmpty) { + throw Exception("Generated wallet has empty address"); + } + + // Validate key derivation + final secretSpendKey = wallet.getPrivateSpendKey(); + final secretViewKey = wallet.getPrivateViewKey(); + + if (secretSpendKey.isEmpty || secretViewKey.isEmpty) { + throw Exception("Generated wallet has empty keys"); + } + + Logging.instance.log(Level.info, + "Successfully created $expectedWordCount-word wallet: $address" + ); + + } finally { + await wallet?.close(); + } + } + + /// Tests restoration of a wallet from a polyseed vector. + /// + /// Attempts to ensure soundness of the Monero FFI integration. + Future _testPolyseedRestoration() async { + Logging.instance.log(Level.info, "Testing polyseed vector restoration..."); + + final walletName = "polyseed_restore_${Random().nextInt(10000)}"; + const walletPassword = "1"; + + final Directory root = await StackFileSystem.applicationRootDirectory(); + final walletPath = await lib_monero_compat.pathForWalletDir( + name: walletName, + type: "monero", + appRoot: root, + ); + + lib_monero.Wallet? wallet; + + try { + const testVector = MoneroTestVectors.polyseedVector; + + // Restore wallet from polyseed mnemonic. + wallet = await lib_monero.MoneroWallet.restoreWalletFromSeed( + path: walletPath, + password: walletPassword, + seed: testVector.mnemonic, + restoreHeight: 0, // Polyseed vectors don't require a restore height. + seedOffset: "", + ); + + // Validate restored mnemonic matches vector. + final restoredMnemonic = wallet.getSeed(); + if (restoredMnemonic != testVector.mnemonic) { + throw Exception( + "Restored mnemonic doesn't match: expected '${testVector.mnemonic}', got '$restoredMnemonic'" + ); + } + + final address = wallet.getAddress().value; + if (address != testVector.expectedMainAddress.toString()) { + throw Exception( + "Address mismatch: expected '${testVector.expectedMainAddress}', got '$address'" + ); + } + + final secretSpendKey = wallet.getPrivateSpendKey(); + if (secretSpendKey != testVector.expectedSecretSpendKey) { + throw Exception( + "Secret spend key mismatch: expected '${testVector.expectedSecretSpendKey}', got '$secretSpendKey'" + ); + } + + final secretViewKey = wallet.getPrivateViewKey(); + if (secretViewKey != testVector.expectedSecretViewKey) { + throw Exception( + "Secret view key mismatch: expected '${testVector.expectedSecretViewKey}', got '$secretViewKey'" + ); + } + + final publicSpendKey = wallet.getPublicSpendKey(); + if (publicSpendKey != testVector.expectedPublicSpendKey) { + throw Exception( + "Public spend key mismatch: expected '${testVector.expectedPublicSpendKey}', got '$publicSpendKey'" + ); + } + + final publicViewKey = wallet.getPublicViewKey(); + if (publicViewKey != testVector.expectedPublicViewKey) { + throw Exception( + "Public view key mismatch: expected '${testVector.expectedPublicViewKey}', got '$publicViewKey'" + ); + } + + Logging.instance.log(Level.info, "👍 Polyseed restoration test passed successfully"); + + } finally { + await wallet?.close(); + await _cleanupTestWallets([walletPath]); + } + } + + /// Cleans up test wallet files and dir created during the tests. + Future _cleanupTestWallets(List walletPaths) async { + for (final walletPath in walletPaths) { + try { + final walletFile = File(walletPath); + final keysFile = File("$walletPath.keys"); + final addressFile = File("$walletPath.address.txt"); + + if (await walletFile.exists()) { + await walletFile.delete(); + } + if (await keysFile.exists()) { + await keysFile.delete(); + } + if (await addressFile.exists()) { + await addressFile.delete(); + } + + // Clean the directory if it's empty. + final dir = Directory(walletPath); + if (await dir.exists() && (await dir.list().isEmpty)) { + await dir.delete(); + } + + Logging.instance.log(Level.info, "Cleaned up test wallet: $walletPath"); + } catch (e) { + Logging.instance.log(Level.warning, "Failed to cleanup wallet $walletPath: $e"); + } + } + } + + /// Tests Stack Wallet Backup round-trip functionality. + /// + /// Creates Monero wallets with both 16-word and 25-word mnemonics, saves the mnemonics, + /// creates backups, restores the backups, and verifies the restored mnemonics match the originals. + Future _testStackWalletBackupRoundTrip() async { + Logging.instance.log(Level.info, "Testing Stack Wallet Backup round-trip for Monero..."); + + final tempDir = await StackFileSystem.applicationRootDirectory(); + final testId = Random().nextInt(10000); + + try { + // Test 16-word mnemonic backup. + await _testBackupWithSeedType( + tempDir: tempDir, + testId: testId, + seedType: lib_monero.MoneroSeedType.sixteen, + expectedWordCount: 16, + suffix: "16", + ); + + // Test 25-word mnemonic backup. + await _testBackupWithSeedType( + tempDir: tempDir, + testId: testId, + seedType: lib_monero.MoneroSeedType.twentyFive, + expectedWordCount: 25, + suffix: "25", + ); + + Logging.instance.log(Level.info, "✓ All Stack Wallet Backup round-trip tests passed successfully!"); + } catch (e) { + Logging.instance.log(Level.error, "Stack Wallet Backup round-trip test failed: $e"); + rethrow; + } + } + + /// Tests Stack Wallet Backup round-trip functionality for a specific seed type. + Future _testBackupWithSeedType({ + required Directory tempDir, + required int testId, + required lib_monero.MoneroSeedType seedType, + required int expectedWordCount, + required String suffix, + }) async { + Logging.instance.log(Level.info, "Testing ${expectedWordCount}-word mnemonic backup..."); + + final walletName = "test_monero_backup_${testId}_$suffix"; + final walletPath = "${tempDir.path}/$walletName"; + final backupPath = "${tempDir.path}/${walletName}_backup.swb"; + const walletPassword = "testpass123"; + const backupPassword = "backuppass456"; + + lib_monero.Wallet? originalWallet; + String? originalMnemonic; + + try { + // Step 1: Create a new Monero wallet using lib_monero directly. + Logging.instance.log(Level.info, "Step 1: Creating new ${expectedWordCount}-word Monero wallet..."); + + originalWallet = await lib_monero.MoneroWallet.create( + path: walletPath, + password: walletPassword, + seedType: seedType, + seedOffset: "", + ); + + // Step 2: Save the original mnemonic out-of-band. + Logging.instance.log(Level.info, "Step 2: Saving original mnemonic..."); + originalMnemonic = await originalWallet.getSeed(); + + if (originalMnemonic.isEmpty) { + throw Exception("Failed to retrieve mnemonic from created wallet"); + } + + final originalWords = originalMnemonic.split(' '); + Logging.instance.log(Level.info, "Original mnemonic has ${originalWords.length} words"); + + // Validate the mnemonic format. + if (originalWords.length != expectedWordCount) { + throw Exception("Expected ${expectedWordCount}-word mnemonic, got ${originalWords.length} words"); + } + + // Step 3: Create a Stack Wallet Backup. + Logging.instance.log(Level.info, "Step 3: Creating Stack Wallet Backup..."); + + // Create a minimal backup JSON with just our test wallet. + final backupJson = { + "wallets": [ + { + "name": walletName, + "id": "test_wallet_${testId}_$suffix", + "mnemonic": originalMnemonic, + "mnemonicPassphrase": "", + "coinName": "monero", + "storedChainHeight": 0, + "restoreHeight": 0, + "notes": {}, + "isFavorite": false, + "otherDataJsonString": null, + } + ], + "prefs": { + "currency": "USD", + "useBiometrics": false, + "hasPin": false, + "language": "en", + "showFavoriteWallets": true, + "wifiOnly": false, + "syncType": "allWalletsOnStartup", + "walletIdsSyncOnStartup": [], + "showTestNetCoins": false, + "isAutoBackupEnabled": false, + "autoBackupLocation": null, + "backupFrequencyType": "BackupFrequencyType.everyAppStart", + "lastAutoBackup": DateTime.now().toString(), + }, + "nodes": [], + "addressBookEntries": [], + "tradeHistory": [], + "tradeTxidLookupData": [], + "tradeNotes": {}, + }; + + final jsonString = jsonEncode(backupJson); + + // Encrypt and save the backup. + final success = await SWB.encryptStackWalletWithPassphrase( + backupPath, + backupPassword, + jsonString, + ); + + if (!success) { + throw Exception("Failed to create Stack Wallet Backup"); + } + + Logging.instance.log(Level.info, "Backup created successfully at: $backupPath"); + + // Step 4: Restore the Stack Wallet Backup. + Logging.instance.log(Level.info, "Step 4: Restoring Stack Wallet Backup..."); + + final restoredJsonString = await SWB.decryptStackWalletWithPassphrase( + Tuple2(backupPath, backupPassword), + ); + + if (restoredJsonString == null) { + throw Exception("Failed to decrypt Stack Wallet Backup"); + } + + final restoredJson = jsonDecode(restoredJsonString) as Map; + final restoredWallets = restoredJson["wallets"] as List; + + if (restoredWallets.isEmpty) { + throw Exception("No wallets found in restored backup"); + } + + final restoredWalletData = restoredWallets.first as Map; + final restoredMnemonic = restoredWalletData["mnemonic"] as String; + + // Step 5: Verify that the restored mnemonic matches the original. + Logging.instance.log(Level.info, "Step 5: Verifying mnemonic integrity..."); + + if (restoredMnemonic != originalMnemonic) { + throw Exception( + "Mnemonic mismatch!\n" + "Original: $originalMnemonic\n" + "Restored: $restoredMnemonic" + ); + } + + // Additional verification: check word count. + final restoredWords = restoredMnemonic.split(' '); + + if (originalWords.length != restoredWords.length) { + throw Exception( + "Word count mismatch: original ${originalWords.length}, restored ${restoredWords.length}" + ); + } + + // Verify each word matches. + for (int i = 0; i < originalWords.length; i++) { + if (originalWords[i] != restoredWords[i]) { + throw Exception( + "Word mismatch at position $i: '${originalWords[i]}' != '${restoredWords[i]}'" + ); + } + } + + // Step 6: Additional test - verify we can recreate the wallet from the restored mnemonic. + Logging.instance.log(Level.info, "Step 6: Testing wallet restoration with recovered mnemonic..."); + + final testWalletPath = "${tempDir.path}/test_restore_${testId}_$suffix"; + lib_monero.Wallet? restoredWallet; + + try { + restoredWallet = await lib_monero.MoneroWallet.restoreWalletFromSeed( + path: testWalletPath, + password: walletPassword, + seed: restoredMnemonic, + restoreHeight: 0, + seedOffset: "", + ); + + final restoredMnemonicFromWallet = await restoredWallet.getSeed(); + + if (restoredMnemonicFromWallet != originalMnemonic) { + throw Exception( + "Restored wallet mnemonic doesn't match original!\n" + "Original: $originalMnemonic\n" + "From restored wallet: $restoredMnemonicFromWallet" + ); + } + + Logging.instance.log(Level.info, "✓ Successfully restored ${expectedWordCount}-word wallet from backup mnemonic"); + + } finally { + await restoredWallet?.close(); + // Clean up restored wallet files. + final testWalletFile = File(testWalletPath); + final testKeysFile = File("$testWalletPath.keys"); + final testAddressFile = File("$testWalletPath.address.txt"); + + if (await testWalletFile.exists()) await testWalletFile.delete(); + if (await testKeysFile.exists()) await testKeysFile.delete(); + if (await testAddressFile.exists()) await testAddressFile.delete(); + } + + Logging.instance.log(Level.info, "✓ ${expectedWordCount}-word Stack Wallet Backup round-trip test passed!"); + Logging.instance.log(Level.info, "✓ Original and restored mnemonics match perfectly"); + Logging.instance.log(Level.info, "✓ Verified ${originalWords.length}-word mnemonic integrity"); + Logging.instance.log(Level.info, "✓ Confirmed ${expectedWordCount}-word wallet can be restored from backup mnemonic"); + + } finally { + // Cleanup. + try { + await originalWallet?.close(); + + // Clean up test files. + final walletFile = File(walletPath); + final keysFile = File("$walletPath.keys"); + final addressFile = File("$walletPath.address.txt"); + final backupFile = File(backupPath); + + if (await walletFile.exists()) await walletFile.delete(); + if (await keysFile.exists()) await keysFile.delete(); + if (await addressFile.exists()) await addressFile.delete(); + if (await backupFile.exists()) await backupFile.delete(); + + Logging.instance.log(Level.info, "Cleaned up test files for ${expectedWordCount}-word Monero backup test"); + } catch (e) { + Logging.instance.log(Level.warning, "Cleanup error for ${expectedWordCount}-word test: $e"); + } + } + } + + void _updateStatus(TestSuiteStatus newStatus) { + _status = newStatus; + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + _statusController.add(newStatus); + } + + @override + Future cleanup() async { + await _statusController.close(); + } +} \ No newline at end of file diff --git a/lib/services/testing/test_suites/salvium_integration_test_suite.dart b/lib/services/testing/test_suites/salvium_integration_test_suite.dart new file mode 100644 index 000000000..546a02ed2 --- /dev/null +++ b/lib/services/testing/test_suites/salvium_integration_test_suite.dart @@ -0,0 +1,459 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2025-08-14 + * + */ + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:logger/logger.dart'; +import 'package:cs_salvium/cs_salvium.dart' as lib_salvium; +import 'package:tuple/tuple.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/stack_file_system.dart'; +import '../../../pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart'; +import '../test_suite_interface.dart'; +import '../testing_models.dart'; + +class SalviumIntegrationTestSuite implements TestSuiteInterface { + StreamController _statusController = + StreamController.broadcast(); + TestSuiteStatus _status = TestSuiteStatus.waiting; + + @override + String get displayName => "Salvium Integration"; + + @override + Widget get icon => const Icon(Icons.currency_exchange, size: 32); + + @override + TestSuiteStatus get status => _status; + + @override + Stream get statusStream { + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + return _statusController.stream; + } + + @override + Future runTests() async { + final stopwatch = Stopwatch()..start(); + + try { + _updateStatus(TestSuiteStatus.running); + + Logging.instance.log(Level.info, "Starting Salvium integration test suite..."); + + await _testSalviumMnemonicGeneration(); + + await _testSalviumStackWalletBackupRoundTrip(); + + stopwatch.stop(); + _updateStatus(TestSuiteStatus.passed); + + return TestResult( + success: true, + message: "👍👍 All Salvium integration tests passed successfully", + executionTime: stopwatch.elapsed, + ); + + } catch (e, stackTrace) { + stopwatch.stop(); + _updateStatus(TestSuiteStatus.failed); + + Logging.instance.log(Level.error, + "Salvium integration test suite failed: $e\n$stackTrace" + ); + + return TestResult( + success: false, + message: "Salvium integration tests failed: $e", + executionTime: stopwatch.elapsed, + ); + } + } + + Future _testSalviumMnemonicGeneration() async { + Logging.instance.log(Level.info, "Testing Salvium mnemonic generation and wallet creation..."); + + final tempDir = await StackFileSystem.applicationRootDirectory(); + final walletName = "test_salvium_wallet_${Random().nextInt(10000)}"; + final walletPath = "${tempDir.path}/$walletName"; + const walletPassword = "1"; + + try { + // Test 25-word mnemonic generation for Salvium. + await _testSalviumWalletCreation( + walletPath: "${walletPath}_25", + password: walletPassword, + seedType: lib_salvium.SalviumSeedType.twentyFive, + expectedWordCount: 25, + ); + + Logging.instance.log(Level.info, "👍 Salvium mnemonic generation tests passed"); + + } finally { + // Cleanup test wallet files. + await _cleanupTestWallets([ + "${walletPath}_25", + ]); + } + } + + /// Tests Salvium wallet creation with different seed types. + /// + /// Attempts to ensure validity of the Salvium FFI integration. + Future _testSalviumWalletCreation({ + required String walletPath, + required String password, + required lib_salvium.SalviumSeedType seedType, + required int expectedWordCount, + }) async { + lib_salvium.Wallet? wallet; + + try { + // Create new Salvium wallet with specified seed type. + wallet = await lib_salvium.SalviumWallet.create( + path: walletPath, + password: password, + seedType: seedType, + seedOffset: "", + ); + + // Validate mnemonic word count. + final mnemonic = wallet.getSeed(); + final words = mnemonic.split(' '); + + if (words.length != expectedWordCount) { + throw Exception( + "Expected $expectedWordCount words, got ${words.length}: $mnemonic" + ); + } + + // Validate wallet address generation. + final address = wallet.getAddress(); + if (address.value.isEmpty) { + throw Exception("Generated Salvium wallet has empty address"); + } + + // Validate that this is a Salvium address (starts with 'S' for mainnet). + if (!address.value.startsWith('S')) { + throw Exception("Generated address does not appear to be a valid Salvium address: ${address.value}"); + } + + // Validate key derivation. + final secretSpendKey = wallet.getPrivateSpendKey(); + final secretViewKey = wallet.getPrivateViewKey(); + + if (secretSpendKey.isEmpty || secretViewKey.isEmpty) { + throw Exception("Generated Salvium wallet has empty keys"); + } + + Logging.instance.log(Level.info, + "Successfully created $expectedWordCount-word Salvium wallet: $address" + ); + + } finally { + await wallet?.close(); + } + } + + /// Cleans up test wallet files and dir created during the tests. + Future _cleanupTestWallets(List walletPaths) async { + for (final walletPath in walletPaths) { + try { + final walletFile = File(walletPath); + final keysFile = File("$walletPath.keys"); + final addressFile = File("$walletPath.address.txt"); + + if (await walletFile.exists()) { + await walletFile.delete(); + } + if (await keysFile.exists()) { + await keysFile.delete(); + } + if (await addressFile.exists()) { + await addressFile.delete(); + } + + // Clean the directory if it's empty. + final dir = Directory(walletPath); + if (await dir.exists() && (await dir.list().isEmpty)) { + await dir.delete(); + } + + Logging.instance.log(Level.info, "Cleaned up test Salvium wallet: $walletPath"); + } catch (e) { + Logging.instance.log(Level.warning, "Failed to cleanup Salvium wallet $walletPath: $e"); + } + } + } + + /// Tests Stack Wallet Backup round-trip functionality. + /// + /// Creates Salvium wallets with 25-word mnemonics, saves the mnemonics, + /// creates backups, restores the backups, and verifies the restored mnemonics match the originals. + Future _testSalviumStackWalletBackupRoundTrip() async { + Logging.instance.log(Level.info, "Testing Stack Wallet Backup round-trip for Salvium..."); + + final tempDir = await StackFileSystem.applicationRootDirectory(); + final testId = Random().nextInt(10000); + + try { + // Test 25-word mnemonic backup (Salvium only supports 25-word mnemonics). + await _testSalviumBackupWithSeedType( + tempDir: tempDir, + testId: testId, + seedType: lib_salvium.SalviumSeedType.twentyFive, + expectedWordCount: 25, + suffix: "25", + ); + + Logging.instance.log(Level.info, "✓ Salvium Stack Wallet Backup round-trip test passed successfully!"); + } catch (e) { + Logging.instance.log(Level.error, "Salvium Stack Wallet Backup round-trip test failed: $e"); + rethrow; + } + } + + /// Tests Stack Wallet Backup round-trip functionality for Salvium. + Future _testSalviumBackupWithSeedType({ + required Directory tempDir, + required int testId, + required lib_salvium.SalviumSeedType seedType, + required int expectedWordCount, + required String suffix, + }) async { + Logging.instance.log(Level.info, "Testing ${expectedWordCount}-word Salvium mnemonic backup..."); + + final walletName = "test_salvium_backup_${testId}_$suffix"; + final walletPath = "${tempDir.path}/$walletName"; + final backupPath = "${tempDir.path}/${walletName}_backup.swb"; + const walletPassword = "testpass123"; + const backupPassword = "backuppass456"; + + lib_salvium.Wallet? originalWallet; + String? originalMnemonic; + + try { + // Step 1: Create a new Salvium wallet using lib_salvium directly. + Logging.instance.log(Level.info, "Step 1: Creating new ${expectedWordCount}-word Salvium wallet..."); + + originalWallet = await lib_salvium.SalviumWallet.create( + path: walletPath, + password: walletPassword, + seedType: seedType, + seedOffset: "", + ); + + // Step 2: Save the original mnemonic out-of-band. + Logging.instance.log(Level.info, "Step 2: Saving original mnemonic..."); + originalMnemonic = originalWallet.getSeed(); + + if (originalMnemonic.isEmpty) { + throw Exception("Failed to retrieve mnemonic from created Salvium wallet"); + } + + final originalWords = originalMnemonic.split(' '); + Logging.instance.log(Level.info, "Original Salvium mnemonic has ${originalWords.length} words"); + + // Validate the mnemonic format. + if (originalWords.length != expectedWordCount) { + throw Exception("Expected ${expectedWordCount}-word mnemonic, got ${originalWords.length} words"); + } + + // Step 3: Create a Stack Wallet Backup. + Logging.instance.log(Level.info, "Step 3: Creating Stack Wallet Backup..."); + + // Create a minimal backup JSON with just our test wallet. + final backupJson = { + "wallets": [ + { + "name": walletName, + "id": "test_salvium_wallet_${testId}_$suffix", + "mnemonic": originalMnemonic, + "mnemonicPassphrase": "", + "coinName": "salvium", + "storedChainHeight": 0, + "restoreHeight": 0, + "notes": {}, + "isFavorite": false, + "otherDataJsonString": null, + } + ], + "prefs": { + "currency": "USD", + "useBiometrics": false, + "hasPin": false, + "language": "en", + "showFavoriteWallets": true, + "wifiOnly": false, + "syncType": "allWalletsOnStartup", + "walletIdsSyncOnStartup": [], + "showTestNetCoins": false, + "isAutoBackupEnabled": false, + "autoBackupLocation": null, + "backupFrequencyType": "BackupFrequencyType.everyAppStart", + "lastAutoBackup": DateTime.now().toString(), + }, + "nodes": [], + "addressBookEntries": [], + "tradeHistory": [], + "tradeTxidLookupData": [], + "tradeNotes": {}, + }; + + final jsonString = jsonEncode(backupJson); + + // Encrypt and save the backup. + final success = await SWB.encryptStackWalletWithPassphrase( + backupPath, + backupPassword, + jsonString, + ); + + if (!success) { + throw Exception("Failed to create Stack Wallet Backup for Salvium"); + } + + Logging.instance.log(Level.info, "Backup created successfully at: $backupPath"); + + // Step 4: Restore the Stack Wallet Backup. + Logging.instance.log(Level.info, "Step 4: Restoring Stack Wallet Backup..."); + + final restoredJsonString = await SWB.decryptStackWalletWithPassphrase( + Tuple2(backupPath, backupPassword), + ); + + if (restoredJsonString == null) { + throw Exception("Failed to decrypt Stack Wallet Backup for Salvium"); + } + + final restoredJson = jsonDecode(restoredJsonString) as Map; + final restoredWallets = restoredJson["wallets"] as List; + + if (restoredWallets.isEmpty) { + throw Exception("No wallets found in restored Salvium backup"); + } + + final restoredWalletData = restoredWallets.first as Map; + final restoredMnemonic = restoredWalletData["mnemonic"] as String; + + // Step 5: Verify that the restored mnemonic matches the original. + Logging.instance.log(Level.info, "Step 5: Verifying Salvium mnemonic integrity..."); + + if (restoredMnemonic != originalMnemonic) { + throw Exception( + "Salvium mnemonic mismatch!\n" + "Original: $originalMnemonic\n" + "Restored: $restoredMnemonic" + ); + } + + // Additional verification: check word count. + final restoredWords = restoredMnemonic.split(' '); + + if (originalWords.length != restoredWords.length) { + throw Exception( + "Word count mismatch: original ${originalWords.length}, restored ${restoredWords.length}" + ); + } + + // Verify each word matches. + for (int i = 0; i < originalWords.length; i++) { + if (originalWords[i] != restoredWords[i]) { + throw Exception( + "Word mismatch at position $i: '${originalWords[i]}' != '${restoredWords[i]}'" + ); + } + } + + // Step 6: Additional test - verify we can recreate the wallet from the restored mnemonic. + Logging.instance.log(Level.info, "Step 6: Testing Salvium wallet restoration with recovered mnemonic..."); + + final testWalletPath = "${tempDir.path}/test_salvium_restore_${testId}_$suffix"; + lib_salvium.Wallet? restoredWallet; + + try { + restoredWallet = await lib_salvium.SalviumWallet.restoreWalletFromSeed( + path: testWalletPath, + password: walletPassword, + seed: restoredMnemonic, + restoreHeight: 0, + seedOffset: "", + ); + + final restoredMnemonicFromWallet = restoredWallet.getSeed(); + + if (restoredMnemonicFromWallet != originalMnemonic) { + throw Exception( + "Restored Salvium wallet mnemonic doesn't match original!\n" + "Original: $originalMnemonic\n" + "From restored wallet: $restoredMnemonicFromWallet" + ); + } + + Logging.instance.log(Level.info, "✓ Successfully restored ${expectedWordCount}-word Salvium wallet from backup mnemonic"); + + } finally { + await restoredWallet?.close(); + // Clean up restored wallet files. + final testWalletFile = File(testWalletPath); + final testKeysFile = File("$testWalletPath.keys"); + final testAddressFile = File("$testWalletPath.address.txt"); + + if (await testWalletFile.exists()) await testWalletFile.delete(); + if (await testKeysFile.exists()) await testKeysFile.delete(); + if (await testAddressFile.exists()) await testAddressFile.delete(); + } + + Logging.instance.log(Level.info, "✓ ${expectedWordCount}-word Salvium Stack Wallet Backup round-trip test passed!"); + Logging.instance.log(Level.info, "✓ Original and restored Salvium mnemonics match perfectly"); + Logging.instance.log(Level.info, "✓ Verified ${originalWords.length}-word Salvium mnemonic integrity"); + Logging.instance.log(Level.info, "✓ Confirmed ${expectedWordCount}-word Salvium wallet can be restored from backup mnemonic"); + + } finally { + // Cleanup. + try { + await originalWallet?.close(); + + // Clean up test files. + final walletFile = File(walletPath); + final keysFile = File("$walletPath.keys"); + final addressFile = File("$walletPath.address.txt"); + final backupFile = File(backupPath); + + if (await walletFile.exists()) await walletFile.delete(); + if (await keysFile.exists()) await keysFile.delete(); + if (await addressFile.exists()) await addressFile.delete(); + if (await backupFile.exists()) await backupFile.delete(); + + Logging.instance.log(Level.info, "Cleaned up test files for ${expectedWordCount}-word Salvium backup test"); + } catch (e) { + Logging.instance.log(Level.warning, "Cleanup error for ${expectedWordCount}-word Salvium test: $e"); + } + } + } + + void _updateStatus(TestSuiteStatus newStatus) { + _status = newStatus; + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + _statusController.add(newStatus); + } + + @override + Future cleanup() async { + await _statusController.close(); + } +} \ No newline at end of file diff --git a/lib/services/testing/test_suites/test_data/polyseed_vectors.dart b/lib/services/testing/test_suites/test_data/polyseed_vectors.dart new file mode 100644 index 000000000..fdf7bd711 --- /dev/null +++ b/lib/services/testing/test_suites/test_data/polyseed_vectors.dart @@ -0,0 +1,39 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2025-08-14 + * + */ + +class PolyseedTestVector { + final String mnemonic; + final String expectedMainAddress; + final String expectedSecretSpendKey; + final String expectedSecretViewKey; + final String expectedPublicSpendKey; + final String expectedPublicViewKey; + + const PolyseedTestVector({ + required this.mnemonic, + required this.expectedMainAddress, + required this.expectedSecretSpendKey, + required this.expectedSecretViewKey, + required this.expectedPublicSpendKey, + required this.expectedPublicViewKey, + }); +} + +// TODO: Use stagenet vectors. +class MoneroTestVectors { + static const polyseedVector = PolyseedTestVector( + mnemonic: "capital chief route liar question fix clutch water outside pave hamster occur always learn license knife", + expectedMainAddress: "465cUW8wTMSCV8oVVh7CuWWHs7yeB1oxhNPrsEM5FKSqadTXmobLqsNEtRnyGsbN1rbDuBtWdtxtXhTJda1Lm9vcH2ZdrD1", + expectedSecretSpendKey: "c584b326f1a8472e210d80e4fc87271ffa371f94b95a0794eef80e851fb4e303", + expectedSecretViewKey: "3b8ffd9a88e9cdbbd311629c38d696df07551bcea08e0df1942507db8f832007", + expectedPublicSpendKey: "759ca40019178944aa2fe8062dfe61af1e3678be2ceed67fe83c34edde8492c9", + expectedPublicViewKey: "0d57d0165de6015305e5c1e2c54f75cc9a385348929980f1db140ac459e9958e", + ); +} \ No newline at end of file diff --git a/lib/services/testing/test_suites/tor_test_suite.dart b/lib/services/testing/test_suites/tor_test_suite.dart new file mode 100644 index 000000000..a6174f91b --- /dev/null +++ b/lib/services/testing/test_suites/tor_test_suite.dart @@ -0,0 +1,324 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2025-08-14 + * + */ + +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:logger/logger.dart'; +import '../../../utilities/stack_file_system.dart'; +import '../test_suite_interface.dart'; +import '../testing_models.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/prefs.dart'; +import '../../tor_service.dart'; +import '../../../networking/http.dart'; +import '../../event_bus/events/global/tor_connection_status_changed_event.dart'; +import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../electrumx_rpc/electrumx_client.dart'; +import '../../../utilities/tor_plain_net_option_enum.dart'; + +class TorTestSuite implements TestSuiteInterface { + StreamController _statusController = StreamController.broadcast(); + TestSuiteStatus _status = TestSuiteStatus.waiting; + + @override + String get displayName => "Tor Service"; + + @override + Widget get icon => const Icon(Icons.security, size: 32); + + @override + TestSuiteStatus get status => _status; + + @override + Stream get statusStream { + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + return _statusController.stream; + } + + @override + Future runTests() async { + final stopwatch = Stopwatch()..start(); + + try { + _updateStatus(TestSuiteStatus.running); + + Logging.instance.log(Level.info, ("Starting Tor service test suite...")); + + await _testTorConnection(); + + await _testProxyFunctionality(); + + await _testNodeAccessThroughTor(); + + stopwatch.stop(); + _updateStatus(TestSuiteStatus.passed); + + Logging.instance.log(Level.info, ("👍👍👍 All Tor service tests completed successfully!")); + + return TestResult( + success: true, + message: "All Tor service tests passed", + executionTime: stopwatch.elapsed, + ); + + } catch (e) { + stopwatch.stop(); + _updateStatus(TestSuiteStatus.failed); + + Logging.instance.log(Level.info, ("✗ Test failed: ${e.toString()}")); + + return TestResult( + success: false, + message: "Tor service test failed: ${e.toString()}", + executionTime: stopwatch.elapsed, + ); + } + } + + /// Tests the Tor connection by initializing, starting the service, and verifying proxy info. + /// + /// Mostly inits Tor if it wasn't already. + Future _testTorConnection() async { + Logging.instance.log(Level.info, ("Testing Tor connection establishment...")); + final torService = TorService.sharedInstance; + + // Check current Tor connection status. + final currentStatus = torService.status; + Logging.instance.log(Level.info, ("Current Tor status: $currentStatus")); + + if (currentStatus != TorConnectionStatus.connected) { + // If not connected, attempt to initialize and start Tor for testing. + Logging.instance.log(Level.info, ("Tor is not connected. Attempting to initialize and connect...")); + + try { + // TODO: Use a temporary directory for testing purposes. + // Start Tor service. + final torDataPath = (await StackFileSystem.applicationTorDirectory()).path; + Logging.instance.log(Level.info, ("Tor init...")); + torService.init(torDataDirPath: torDataPath); + Logging.instance.log(Level.info, ("Starting Tor service...")); + await torService.start().timeout( + const Duration(seconds: 30), + onTimeout: () => throw Exception("Tor startup timed out after 30 seconds"), + ); + + // Verify the connection was established. + final newStatus = torService.status; + if (newStatus != TorConnectionStatus.connected) { + throw Exception("Tor failed to connect. Final status: $newStatus"); + } + + Logging.instance.log(Level.info, ("✓ Tor service reportedly started")); + } catch (e) { + throw Exception("Failed to start Tor service: $e"); + } + } else { + Logging.instance.log(Level.info, ("✓ Tor service is already connected")); + // TODO: Stop tor and do all of the above thereafter. + } + + // Test that we can get proxy info. + // + // Very very basic sanity checks: port should be a valid small number and host should be loopback. + try { + final proxyInfo = torService.getProxyInfo(); + Logging.instance.log(Level.info, ("✓ Proxy info retrieved: ${proxyInfo.host.address}:${proxyInfo.port}")); + + // Validate proxy info. + if (proxyInfo.port <= 0 || proxyInfo.port > 65535) { + throw Exception("Invalid proxy port: ${proxyInfo.port}"); + } + if (proxyInfo.host != InternetAddress.loopbackIPv4) { + throw Exception("Expected loopback address, got: ${proxyInfo.host.address}"); + } + } catch (e) { + throw Exception("Failed to get valid proxy info: $e"); + } + + Logging.instance.log(Level.info, ("✓ Tor service looks connected and ready... host and port are valid")); + Logging.instance.log(Level.info, ("👍 Tor connection test passed")); + } + + /// Tests the Tor proxy functionality by making a request through it. + /// + /// Connects to torproject.org. + Future _testProxyFunctionality() async { + Logging.instance.log(Level.info, ("Testing proxy functionality verification...")); + final torService = TorService.sharedInstance; + + try { + // Get Tor proxy info. + final proxyInfo = torService.getProxyInfo(); + Logging.instance.log(Level.info, ("Tor proxy info: ${proxyInfo.host}:${proxyInfo.port}")); + + // Test if we can get proxy info without throwing. + if (proxyInfo.port <= 0 || proxyInfo.port > 65535) { + throw Exception("Invalid proxy port: ${proxyInfo.port}"); + } + + Logging.instance.log(Level.info, ("✓ Proxy info retrieved successfully")); + + // Test actual proxy functionality by making a request through it. + final http = HTTP(); + final testUrl = Uri.parse("https://check.torproject.org/api/ip"); + + Logging.instance.log(Level.info, ("Testing proxy by making request to: $testUrl")); + + try { + final response = await http.get( + url: testUrl, + proxyInfo: proxyInfo, + ).timeout(const Duration(seconds: 10)); + + if (response.code == 200) { + Logging.instance.log(Level.info, ("✓ Successfully made HTTP request through Tor proxy")); + Logging.instance.log(Level.info, ("Response code: ${response.code}")); + + // Parse response to check if we're using Tor. + if (response.body.contains('"IsTor":true')) { + Logging.instance.log(Level.info, ("✓ Confirmed traffic is routed through Tor network")); + // TODO: Replace test with a more reliable check. + } else { + Logging.instance.log(Level.info, ("⚠ Warning: Response doesn't confirm Tor usage")); + } + } else { + throw Exception("HTTP request failed with code: ${response.code}"); + } + } catch (e) { + throw Exception("Failed to make HTTP request through proxy: $e"); + } + + } catch (e) { + throw Exception("Proxy functionality test failed: $e"); + } + + Logging.instance.log(Level.info, ("👍 Proxy functionality test passed")); + } + + /// Tests access to the default Bitcoin node through Tor. + /// + /// Validates we can ping the node, get server features, and retrieve block headers. + Future _testNodeAccessThroughTor() async { + Logging.instance.log(Level.info, ("Testing node access through Tor...")); + final torService = TorService.sharedInstance; + + try { + // Ensure we can get proxy info (validates Tor is working). + torService.getProxyInfo(); + + // Test accessing Stack Wallet's default Bitcoin node through Tor. + final bitcoin = Bitcoin(CryptoCurrencyNetwork.main); + final defaultNode = bitcoin.defaultNode(isPrimary: true); + + Logging.instance.log(Level.info, ("Testing Bitcoin node access through Tor: ${defaultNode.host}:${defaultNode.port}")); + + // Create an ElectrumX client connected through Tor to test actual node access. + final electrumClient = ElectrumXClient( + host: defaultNode.host, + port: defaultNode.port, + useSSL: defaultNode.useSSL, + prefs: Prefs.instance, + netType: TorPlainNetworkOption.tor, + failovers: [], + cryptoCurrency: bitcoin, + ); + + try { + // Test basic connectivity with a ping. + Logging.instance.log(Level.info, ("Sending ping to Bitcoin node through Tor...")); + final pingResult = await electrumClient.ping(retryCount: 1).timeout( + const Duration(seconds: 30), + ); + + if (pingResult) { + Logging.instance.log(Level.info, ("✓ Successfully pinged Bitcoin node through Tor")); + } else { + throw Exception("Ping failed or returned false"); + } + + // Test a basic ElectrumX command (getting server features). + Logging.instance.log(Level.info, ("Getting server features from Bitcoin node...")); + final serverFeatures = await electrumClient.getServerFeatures().timeout( + const Duration(seconds: 15), + ); + + if (serverFeatures.isNotEmpty) { + Logging.instance.log(Level.info, ("✓ Successfully retrieved server features")); + + // Verify this is actually a Bitcoin node by checking genesis hash. + final genesisHash = serverFeatures['genesis_hash'] as String?; + if (genesisHash == bitcoin.genesisHash) { + Logging.instance.log(Level.info, ("✓ Confirmed connection to valid Bitcoin mainnet node")); + Logging.instance.log(Level.info, ("Server version: ${serverFeatures['server_version'] ?? 'Unknown'}")); + } else { + Logging.instance.log(Level.info, ("⚠ Warning: Genesis hash mismatch. Expected: ${bitcoin.genesisHash}, Got: $genesisHash")); + } + } else { + throw Exception("Empty server features response"); + } + + // Test getting block header. + Logging.instance.log(Level.info, ("Getting latest block header...")); + final blockHeader = await electrumClient.getBlockHeadTip().timeout( + const Duration(seconds: 15), + ); + + if (blockHeader.containsKey('height') && blockHeader.containsKey('hex')) { + final height = blockHeader['height'] as int; + Logging.instance.log(Level.info, ("✓ Successfully retrieved block header")); + Logging.instance.log(Level.info, ("Current block height: $height")); + } else { + throw Exception("Invalid block header response format"); + } + + } catch (e) { + throw Exception("Failed to communicate with Bitcoin node through Tor: $e"); + } finally { + // Clean up the client connection. + try { + await electrumClient.closeAdapter(); + } catch (e) { + Logging.instance.log(Level.info, ("Note: Error closing ElectrumX client: $e")); + } + } + + } catch (e) { + throw Exception("Bitcoin node access test failed: $e"); + } + + Logging.instance.log(Level.info, ("👍 Node access through Tor test passed")); + } + + void _updateStatus(TestSuiteStatus newStatus) { + _status = newStatus; + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + _statusController.add(_status); + } + + @override + Future cleanup() async { + Logging.instance.log(Level.info, "Cleaning up Tor test suite"); + + try { + // Note: We don't disable TorService here as it might be used by the main app. + // The TorService will remain in whatever state the tests left it in. + Logging.instance.log(Level.warning, "TorService cleanup is not performed in tests, it should be handled by the app lifecycle."); + } catch (e) { + Logging.instance.log(Level.warning, "Error during Tor test cleanup: $e"); + } + + await _statusController.close(); + } +} \ No newline at end of file diff --git a/lib/services/testing/test_suites/wownero_integration_test_suite.dart b/lib/services/testing/test_suites/wownero_integration_test_suite.dart new file mode 100644 index 000000000..9d37abbf5 --- /dev/null +++ b/lib/services/testing/test_suites/wownero_integration_test_suite.dart @@ -0,0 +1,478 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2025-08-14 + * + */ + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'package:compat/old_cw_core/path_for_wallet.dart' as lib_monero_compat; +import 'package:flutter/material.dart'; +import 'package:logger/logger.dart'; +import 'package:cs_monero/cs_monero.dart' as lib_monero; +import 'package:tuple/tuple.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/stack_file_system.dart'; +import '../../../pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart'; +import '../test_suite_interface.dart'; +import '../testing_models.dart'; + +class WowneroIntegrationTestSuite implements TestSuiteInterface { + StreamController _statusController = + StreamController.broadcast(); + TestSuiteStatus _status = TestSuiteStatus.waiting; + + @override + String get displayName => "Wownero Integration"; + + @override + Widget get icon => const Icon(Icons.currency_exchange, size: 32); + + @override + TestSuiteStatus get status => _status; + + @override + Stream get statusStream { + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + return _statusController.stream; + } + + @override + Future runTests() async { + final stopwatch = Stopwatch()..start(); + + try { + _updateStatus(TestSuiteStatus.running); + + Logging.instance.log(Level.info, "Starting Wownero integration test suite..."); + + await _testWowneroMnemonicGeneration(); + + await _testWowneroStackWalletBackupRoundTrip(); + + stopwatch.stop(); + _updateStatus(TestSuiteStatus.passed); + + return TestResult( + success: true, + message: "👍👍 All Wownero integration tests passed successfully", + executionTime: stopwatch.elapsed, + ); + + } catch (e, stackTrace) { + stopwatch.stop(); + _updateStatus(TestSuiteStatus.failed); + + Logging.instance.log(Level.error, + "Wownero integration test suite failed: $e\n$stackTrace" + ); + + return TestResult( + success: false, + message: "Wownero integration tests failed: $e", + executionTime: stopwatch.elapsed, + ); + } + } + + Future _testWowneroMnemonicGeneration() async { + Logging.instance.log(Level.info, "Testing Wownero mnemonic generation and wallet creation..."); + + final tempDir = await StackFileSystem.applicationRootDirectory(); + final walletName = "test_wownero_wallet_${Random().nextInt(10000)}"; + final walletPath = "${tempDir.path}/$walletName"; + const walletPassword = "1"; + + try { + // Test 16-word mnemonic generation for Wownero. + await _testWowneroWalletCreation( + walletPath: "${walletPath}_16", + password: walletPassword, + seedType: lib_monero.WowneroSeedType.sixteen, + expectedWordCount: 16, + ); + + // Test 25-word mnemonic generation for Wownero. + await _testWowneroWalletCreation( + walletPath: "${walletPath}_25", + password: walletPassword, + seedType: lib_monero.WowneroSeedType.twentyFive, + expectedWordCount: 25, + ); + + Logging.instance.log(Level.info, "👍 Wownero mnemonic generation tests passed"); + + } finally { + // Cleanup test wallet files. + await _cleanupTestWallets([ + "${walletPath}_16", + "${walletPath}_25", + ]); + } + } + + /// Tests Wownero wallet creation with different seed types. + /// + /// Attempts to ensure validity of the Wownero FFI integration. + Future _testWowneroWalletCreation({ + required String walletPath, + required String password, + required lib_monero.WowneroSeedType seedType, + required int expectedWordCount, + }) async { + lib_monero.Wallet? wallet; + + try { + // Create new Wownero wallet with specified seed type. + wallet = await lib_monero.WowneroWallet.create( + path: walletPath, + password: password, + seedType: seedType, + seedOffset: "", + ); + + // Validate mnemonic word count. + final mnemonic = wallet.getSeed(); + final words = mnemonic.split(' '); + + if (words.length != expectedWordCount) { + throw Exception( + "Expected $expectedWordCount words, got ${words.length}: $mnemonic" + ); + } + + // Validate wallet address generation. + final address = wallet.getAddress(); + if (address.value.isEmpty) { + throw Exception("Generated Wownero wallet has empty address"); + } + + // Validate that this is a Wownero address (starts with 'W' for mainnet). + if (!address.value.startsWith('W')) { + throw Exception("Generated address does not appear to be a valid Wownero address: ${address.value}"); + } + + // Validate key derivation + final secretSpendKey = wallet.getPrivateSpendKey(); + final secretViewKey = wallet.getPrivateViewKey(); + + if (secretSpendKey.isEmpty || secretViewKey.isEmpty) { + throw Exception("Generated Wownero wallet has empty keys"); + } + + Logging.instance.log(Level.info, + "Successfully created $expectedWordCount-word Wownero wallet: $address" + ); + + } finally { + await wallet?.close(); + } + } + + /// Cleans up test wallet files and dir created during the tests. + Future _cleanupTestWallets(List walletPaths) async { + for (final walletPath in walletPaths) { + try { + final walletFile = File(walletPath); + final keysFile = File("$walletPath.keys"); + final addressFile = File("$walletPath.address.txt"); + + if (await walletFile.exists()) { + await walletFile.delete(); + } + if (await keysFile.exists()) { + await keysFile.delete(); + } + if (await addressFile.exists()) { + await addressFile.delete(); + } + + // Clean the directory if it's empty. + final dir = Directory(walletPath); + if (await dir.exists() && (await dir.list().isEmpty)) { + await dir.delete(); + } + + Logging.instance.log(Level.info, "Cleaned up test Wownero wallet: $walletPath"); + } catch (e) { + Logging.instance.log(Level.warning, "Failed to cleanup Wownero wallet $walletPath: $e"); + } + } + } + + /// Tests Stack Wallet Backup round-trip functionality. + /// + /// Creates Wownero wallets with both 16-word and 25-word mnemonics, saves the mnemonics, + /// creates backups, restores the backups, and verifies the restored mnemonics match the originals. + Future _testWowneroStackWalletBackupRoundTrip() async { + Logging.instance.log(Level.info, "Testing Stack Wallet Backup round-trip for Wownero..."); + + final tempDir = await StackFileSystem.applicationRootDirectory(); + final testId = Random().nextInt(10000); + + try { + // Test 16-word mnemonic backup. + await _testWowneroBackupWithSeedType( + tempDir: tempDir, + testId: testId, + seedType: lib_monero.WowneroSeedType.sixteen, + expectedWordCount: 16, + suffix: "16", + ); + + // Test 25-word mnemonic backup. + await _testWowneroBackupWithSeedType( + tempDir: tempDir, + testId: testId, + seedType: lib_monero.WowneroSeedType.twentyFive, + expectedWordCount: 25, + suffix: "25", + ); + + Logging.instance.log(Level.info, "✓ All Wownero Stack Wallet Backup round-trip tests passed successfully!"); + } catch (e) { + Logging.instance.log(Level.error, "Wownero Stack Wallet Backup round-trip test failed: $e"); + rethrow; + } + } + + /// Tests Stack Wallet Backup round-trip functionality for a specific Wownero seed type. + Future _testWowneroBackupWithSeedType({ + required Directory tempDir, + required int testId, + required lib_monero.WowneroSeedType seedType, + required int expectedWordCount, + required String suffix, + }) async { + Logging.instance.log(Level.info, "Testing ${expectedWordCount}-word Wownero mnemonic backup..."); + + final walletName = "test_wownero_backup_${testId}_$suffix"; + final walletPath = "${tempDir.path}/$walletName"; + final backupPath = "${tempDir.path}/${walletName}_backup.swb"; + const walletPassword = "testpass123"; + const backupPassword = "backuppass456"; + + lib_monero.Wallet? originalWallet; + String? originalMnemonic; + + try { + // Step 1: Create a new Wownero wallet using lib_monero directly. + Logging.instance.log(Level.info, "Step 1: Creating new ${expectedWordCount}-word Wownero wallet..."); + + originalWallet = await lib_monero.WowneroWallet.create( + path: walletPath, + password: walletPassword, + seedType: seedType, + seedOffset: "", + ); + + // Step 2: Save the original mnemonic out-of-band. + Logging.instance.log(Level.info, "Step 2: Saving original mnemonic..."); + originalMnemonic = originalWallet.getSeed(); + + if (originalMnemonic.isEmpty) { + throw Exception("Failed to retrieve mnemonic from created Wownero wallet"); + } + + final originalWords = originalMnemonic.split(' '); + Logging.instance.log(Level.info, "Original Wownero mnemonic has ${originalWords.length} words"); + + // Validate the mnemonic format. + if (originalWords.length != expectedWordCount) { + throw Exception("Expected ${expectedWordCount}-word mnemonic, got ${originalWords.length} words"); + } + + // Step 3: Create a Stack Wallet Backup. + Logging.instance.log(Level.info, "Step 3: Creating Stack Wallet Backup..."); + + // Create a minimal backup JSON with just our test wallet. + final backupJson = { + "wallets": [ + { + "name": walletName, + "id": "test_wownero_wallet_${testId}_$suffix", + "mnemonic": originalMnemonic, + "mnemonicPassphrase": "", + "coinName": "wownero", + "storedChainHeight": 0, + "restoreHeight": 0, + "notes": {}, + "isFavorite": false, + "otherDataJsonString": null, + } + ], + "prefs": { + "currency": "USD", + "useBiometrics": false, + "hasPin": false, + "language": "en", + "showFavoriteWallets": true, + "wifiOnly": false, + "syncType": "allWalletsOnStartup", + "walletIdsSyncOnStartup": [], + "showTestNetCoins": false, + "isAutoBackupEnabled": false, + "autoBackupLocation": null, + "backupFrequencyType": "BackupFrequencyType.everyAppStart", + "lastAutoBackup": DateTime.now().toString(), + }, + "nodes": [], + "addressBookEntries": [], + "tradeHistory": [], + "tradeTxidLookupData": [], + "tradeNotes": {}, + }; + + final jsonString = jsonEncode(backupJson); + + // Encrypt and save the backup. + final success = await SWB.encryptStackWalletWithPassphrase( + backupPath, + backupPassword, + jsonString, + ); + + if (!success) { + throw Exception("Failed to create Stack Wallet Backup for Wownero"); + } + + Logging.instance.log(Level.info, "Backup created successfully at: $backupPath"); + + // Step 4: Restore the Stack Wallet Backup. + Logging.instance.log(Level.info, "Step 4: Restoring Stack Wallet Backup..."); + + final restoredJsonString = await SWB.decryptStackWalletWithPassphrase( + Tuple2(backupPath, backupPassword), + ); + + if (restoredJsonString == null) { + throw Exception("Failed to decrypt Stack Wallet Backup for Wownero"); + } + + final restoredJson = jsonDecode(restoredJsonString) as Map; + final restoredWallets = restoredJson["wallets"] as List; + + if (restoredWallets.isEmpty) { + throw Exception("No wallets found in restored Wownero backup"); + } + + final restoredWalletData = restoredWallets.first as Map; + final restoredMnemonic = restoredWalletData["mnemonic"] as String; + + // Step 5: Verify that the restored mnemonic matches the original. + Logging.instance.log(Level.info, "Step 5: Verifying Wownero mnemonic integrity..."); + + if (restoredMnemonic != originalMnemonic) { + throw Exception( + "Wownero mnemonic mismatch!\n" + "Original: $originalMnemonic\n" + "Restored: $restoredMnemonic" + ); + } + + // Additional verification: check word count. + final restoredWords = restoredMnemonic.split(' '); + + if (originalWords.length != restoredWords.length) { + throw Exception( + "Word count mismatch: original ${originalWords.length}, restored ${restoredWords.length}" + ); + } + + // Verify each word matches. + for (int i = 0; i < originalWords.length; i++) { + if (originalWords[i] != restoredWords[i]) { + throw Exception( + "Word mismatch at position $i: '${originalWords[i]}' != '${restoredWords[i]}'" + ); + } + } + + // Step 6: Additional test - verify we can recreate the wallet from the restored mnemonic. + Logging.instance.log(Level.info, "Step 6: Testing Wownero wallet restoration with recovered mnemonic..."); + + final testWalletPath = "${tempDir.path}/test_wownero_restore_${testId}_$suffix"; + lib_monero.Wallet? restoredWallet; + + try { + restoredWallet = await lib_monero.WowneroWallet.restoreWalletFromSeed( + path: testWalletPath, + password: walletPassword, + seed: restoredMnemonic, + restoreHeight: 0, + seedOffset: "", + ); + + final restoredMnemonicFromWallet = restoredWallet.getSeed(); + + if (restoredMnemonicFromWallet != originalMnemonic) { + throw Exception( + "Restored Wownero wallet mnemonic doesn't match original!\n" + "Original: $originalMnemonic\n" + "From restored wallet: $restoredMnemonicFromWallet" + ); + } + + Logging.instance.log(Level.info, "✓ Successfully restored ${expectedWordCount}-word Wownero wallet from backup mnemonic"); + + } finally { + await restoredWallet?.close(); + // Clean up restored wallet files. + final testWalletFile = File(testWalletPath); + final testKeysFile = File("$testWalletPath.keys"); + final testAddressFile = File("$testWalletPath.address.txt"); + + if (await testWalletFile.exists()) await testWalletFile.delete(); + if (await testKeysFile.exists()) await testKeysFile.delete(); + if (await testAddressFile.exists()) await testAddressFile.delete(); + } + + Logging.instance.log(Level.info, "✓ ${expectedWordCount}-word Wownero Stack Wallet Backup round-trip test passed!"); + Logging.instance.log(Level.info, "✓ Original and restored Wownero mnemonics match perfectly"); + Logging.instance.log(Level.info, "✓ Verified ${originalWords.length}-word Wownero mnemonic integrity"); + Logging.instance.log(Level.info, "✓ Confirmed ${expectedWordCount}-word Wownero wallet can be restored from backup mnemonic"); + + } finally { + // Cleanup. + try { + await originalWallet?.close(); + + // Clean up test files. + final walletFile = File(walletPath); + final keysFile = File("$walletPath.keys"); + final addressFile = File("$walletPath.address.txt"); + final backupFile = File(backupPath); + + if (await walletFile.exists()) await walletFile.delete(); + if (await keysFile.exists()) await keysFile.delete(); + if (await addressFile.exists()) await addressFile.delete(); + if (await backupFile.exists()) await backupFile.delete(); + + Logging.instance.log(Level.info, "Cleaned up test files for ${expectedWordCount}-word Wownero backup test"); + } catch (e) { + Logging.instance.log(Level.warning, "Cleanup error for ${expectedWordCount}-word Wownero test: $e"); + } + } + } + + void _updateStatus(TestSuiteStatus newStatus) { + _status = newStatus; + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + _statusController.add(newStatus); + } + + @override + Future cleanup() async { + await _statusController.close(); + } +} \ No newline at end of file diff --git a/lib/services/testing/testing_models.dart b/lib/services/testing/testing_models.dart new file mode 100644 index 000000000..2461df4ed --- /dev/null +++ b/lib/services/testing/testing_models.dart @@ -0,0 +1,224 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2025-08-14 + * + */ + +// TODO: Implement actual Monero wallet tests +// These tests should include: +// - Wallet creation from SWB backup +// - Balance verification +// - Transaction history validation +// - Address generation testing +// - Backup/restore functionality + +enum TestSuiteStatus { waiting, running, passed, failed } + +/// Base class for all test types. +abstract class TestType { + String get displayName; + String get description; +} + +/// Integration tests verify FFI plugins are correctly integrated. +enum IntegrationTestType implements TestType { + tor, + moneroIntegration, + wowneroIntegration, + salviumIntegration, + epiccashIntegration, + firoIntegration, + litecoinMwebIntegration; + + @override + String get displayName { + switch (this) { + case IntegrationTestType.tor: + return "Tor Integration"; + case IntegrationTestType.moneroIntegration: + return "Monero Integration"; + case IntegrationTestType.wowneroIntegration: + return "Wownero Integration"; + case IntegrationTestType.salviumIntegration: + return "Salvium Integration"; + case IntegrationTestType.epiccashIntegration: + return "Epic Cash Integration"; + case IntegrationTestType.firoIntegration: + return "Firo Integration"; + case IntegrationTestType.litecoinMwebIntegration: + return "Litecoin MWEB Integration"; + } + } + + @override + String get description { + switch (this) { + case IntegrationTestType.tor: + return "Tests Tor network connectivity and proxy functionality"; + case IntegrationTestType.moneroIntegration: + return "Tests Monero FFI plugin integration and basic functionality"; + case IntegrationTestType.wowneroIntegration: + return "Tests Wownero FFI plugin integration and basic functionality"; + case IntegrationTestType.salviumIntegration: + return "Tests Salvium FFI plugin integration and basic functionality"; + case IntegrationTestType.epiccashIntegration: + return "Tests Epic Cash FFI plugin integration and basic functionality"; + case IntegrationTestType.firoIntegration: + return "Tests Firo flutter_libsparkmobile FFI plugin integration and basic functionality"; + case IntegrationTestType.litecoinMwebIntegration: + return "Tests Litecoin MWEB flutter_mwebd FFI plugin integration and basic functionality"; + } + } +} + +/// Wallet tests operate on SWB files and test various wallet functionalities +enum WalletTestType implements TestType { + moneroWallet; + + @override + String get displayName { + switch (this) { + case WalletTestType.moneroWallet: + return "Monero Wallet"; + } + } + + @override + String get description { + switch (this) { + case WalletTestType.moneroWallet: + return "Tests Monero wallet creation, restoration, and transaction functionality"; + } + } +} + +class TestResult { + final bool success; + final String message; + final Duration executionTime; + final String? details; + final Map? metadata; + + const TestResult({ + required this.success, + required this.message, + required this.executionTime, + this.details, + this.metadata, + }); + + TestResult copyWith({ + bool? success, + String? message, + Duration? executionTime, + String? details, + Map? metadata, + }) { + return TestResult( + success: success ?? this.success, + message: message ?? this.message, + executionTime: executionTime ?? this.executionTime, + details: details ?? this.details, + metadata: metadata ?? this.metadata, + ); + } +} + +/// Specific result for integration tests +class IntegrationTestResult extends TestResult { + final IntegrationTestType testType; + + const IntegrationTestResult({ + required this.testType, + required super.success, + required super.message, + required super.executionTime, + super.details, + super.metadata, + }); +} + +/// Specific result for wallet tests +class WalletTestResult extends TestResult { + final WalletTestType testType; + final String? walletId; + final String? walletName; + + const WalletTestResult({ + required this.testType, + required super.success, + required super.message, + required super.executionTime, + this.walletId, + this.walletName, + super.details, + super.metadata, + }); +} + +class TestingSessionState { + final Map testStatuses; + final Map integrationTestStatuses; + final Map walletTestStatuses; + final bool isRunning; + final int completed; + final int total; + + const TestingSessionState({ + required this.testStatuses, + required this.integrationTestStatuses, + required this.walletTestStatuses, + required this.isRunning, + required this.completed, + required this.total, + }); + + TestingSessionState copyWith({ + Map? suiteStatuses, + Map? integrationTestStatuses, + Map? walletTestStatuses, + bool? isRunning, + int? completed, + int? total, + }) { + return TestingSessionState( + testStatuses: suiteStatuses ?? this.testStatuses, + integrationTestStatuses: integrationTestStatuses ?? this.integrationTestStatuses, + walletTestStatuses: walletTestStatuses ?? this.walletTestStatuses, + isRunning: isRunning ?? this.isRunning, + completed: completed ?? this.completed, + total: total ?? this.total, + ); + } +} + +/// Configuration for wallet tests that require SWB files +class WalletTestConfig { + final String? swbFilePath; + final String? password; + final List? selectedWalletIds; + + const WalletTestConfig({ + this.swbFilePath, + this.password, + this.selectedWalletIds, + }); + + bool get isValid => swbFilePath != null && password != null; + + WalletTestConfig copyWith({ + String? swbFilePath, + String? password, + List? selectedWalletIds, + }) { + return WalletTestConfig( + swbFilePath: swbFilePath ?? this.swbFilePath, + password: password ?? this.password, + selectedWalletIds: selectedWalletIds ?? this.selectedWalletIds, + ); + } +} \ No newline at end of file diff --git a/lib/services/testing/testing_service.dart b/lib/services/testing/testing_service.dart new file mode 100644 index 000000000..df2b59c91 --- /dev/null +++ b/lib/services/testing/testing_service.dart @@ -0,0 +1,395 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2025-08-14 + * + */ + +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logger/logger.dart'; + +import '../../utilities/logger.dart'; +import 'testing_models.dart'; +import 'test_suite_interface.dart'; +import 'test_suites/tor_test_suite.dart'; +import 'test_suites/monero_integration_test_suite.dart'; +import 'test_suites/wownero_integration_test_suite.dart'; +import 'test_suites/salvium_integration_test_suite.dart'; +import 'test_suites/epiccash_integration_test_suite.dart'; +import 'test_suites/firo_integration_test_suite.dart'; +import 'test_suites/litecoin_mweb_integration_test_suite.dart'; + +final testingServiceProvider = StateNotifierProvider((ref) { + return TestingService(); +}); + +class TestingService extends StateNotifier { + TestingService() : super(TestingSessionState( + testStatuses: { + for (var type in IntegrationTestType.values) type: TestSuiteStatus.waiting, + for (var type in WalletTestType.values) type: TestSuiteStatus.waiting, + }, + integrationTestStatuses: { + for (var type in IntegrationTestType.values) type: TestSuiteStatus.waiting + }, + walletTestStatuses: { + for (var type in WalletTestType.values) type: TestSuiteStatus.waiting + }, + isRunning: false, + completed: 0, + total: IntegrationTestType.values.length + WalletTestType.values.length, + )); + + final Map _integrationTestSuites = {}; + final Map _walletTestSuites = {}; + final StreamController _statusController = StreamController.broadcast(); + WalletTestConfig? _walletTestConfig; + bool _cancelled = false; + + Stream get statusStream => _statusController.stream; + + void _initializeIntegrationTestSuites() { + _integrationTestSuites[IntegrationTestType.tor] = TorTestSuite(); + _integrationTestSuites[IntegrationTestType.moneroIntegration] = MoneroWalletTestSuite(); + _integrationTestSuites[IntegrationTestType.wowneroIntegration] = WowneroIntegrationTestSuite(); + _integrationTestSuites[IntegrationTestType.salviumIntegration] = SalviumIntegrationTestSuite(); + _integrationTestSuites[IntegrationTestType.epiccashIntegration] = EpiccashIntegrationTestSuite(); + _integrationTestSuites[IntegrationTestType.firoIntegration] = FiroIntegrationTestSuite(); + _integrationTestSuites[IntegrationTestType.litecoinMwebIntegration] = LitecoinMwebIntegrationTestSuite(); + } + + void _initializeWalletTestSuites() { + _walletTestSuites[WalletTestType.moneroWallet] = MoneroWalletTestSuite(); + } + + Future runAllIntegrationTests() async { + if (state.isRunning) return; + + _cancelled = false; + _initializeIntegrationTestSuites(); + + state = state.copyWith( + isRunning: true, + completed: 0, + integrationTestStatuses: { + for (var type in IntegrationTestType.values) type: TestSuiteStatus.waiting + }, + ); + if (!_statusController.isClosed) { + _statusController.add(state); + } + + try { + for (final type in IntegrationTestType.values) { + if (_cancelled) break; + await runIntegrationTestSuite(type); + } + } catch (e) { + Logging.instance.log(Level.error, "Error running integration test suites: $e"); + } finally { + state = state.copyWith(isRunning: false); + if (!_statusController.isClosed) { + _statusController.add(state); + } + } + } + + Future runAllWalletTests() async { + if (state.isRunning) return; + if (_walletTestConfig == null || !_walletTestConfig!.isValid) { + Logging.instance.log(Level.error, "Wallet test config not set or invalid"); + return; + } + + _cancelled = false; + _initializeWalletTestSuites(); + + state = state.copyWith( + isRunning: true, + completed: 0, + walletTestStatuses: { + for (var type in WalletTestType.values) type: TestSuiteStatus.waiting + }, + ); + if (!_statusController.isClosed) { + _statusController.add(state); + } + + try { + for (final type in WalletTestType.values) { + if (_cancelled) break; + await runWalletTestSuite(type); + } + } catch (e) { + Logging.instance.log(Level.error, "Error running wallet test suites: $e"); + } finally { + state = state.copyWith(isRunning: false); + if (!_statusController.isClosed) { + _statusController.add(state); + } + } + } + + @Deprecated('Use runAllIntegrationTests() instead') + Future runAllTests() async { + await runAllIntegrationTests(); + } + + Future runIntegrationTestSuite(IntegrationTestType type) async { + if (_cancelled) return; + + if (_integrationTestSuites.isEmpty) { + _initializeIntegrationTestSuites(); + } + + final suite = _integrationTestSuites[type]; + if (suite == null) return; + + final updatedStatuses = Map.from(state.integrationTestStatuses); + final updatedAllStatuses = Map.from(state.testStatuses); + updatedStatuses[type] = TestSuiteStatus.running; + updatedAllStatuses[type] = TestSuiteStatus.running; + + state = state.copyWith( + integrationTestStatuses: updatedStatuses, + suiteStatuses: updatedAllStatuses, + ); + if (!_statusController.isClosed) { + _statusController.add(state); + } + + try { + final result = await suite.runTests().timeout( + const Duration(seconds: 30), + onTimeout: () => const TestResult( + success: false, + message: "Test suite timed out", + executionTime: Duration(seconds: 30), + ), + ); + + if (_cancelled) return; + + final status = result.success ? TestSuiteStatus.passed : TestSuiteStatus.failed; + updatedStatuses[type] = status; + updatedAllStatuses[type] = status; + + final completed = _calculateCompletedTests(); + + state = state.copyWith( + integrationTestStatuses: updatedStatuses, + suiteStatuses: updatedAllStatuses, + completed: completed, + ); + if (!_statusController.isClosed) { + _statusController.add(state); + } + + await suite.cleanup(); + } catch (e) { + if (_cancelled) return; + + updatedStatuses[type] = TestSuiteStatus.failed; + updatedAllStatuses[type] = TestSuiteStatus.failed; + + final completed = _calculateCompletedTests(); + + state = state.copyWith( + integrationTestStatuses: updatedStatuses, + suiteStatuses: updatedAllStatuses, + completed: completed, + ); + if (!_statusController.isClosed) { + _statusController.add(state); + } + + Logging.instance.log(Level.error, "Error running $type integration test suite: $e"); + } + } + + Future runWalletTestSuite(WalletTestType type) async { + if (_cancelled) return; + if (_walletTestConfig == null || !_walletTestConfig!.isValid) { + Logging.instance.log(Level.error, "Wallet test config not set or invalid for $type"); + return; + } + + if (_walletTestSuites.isEmpty) { + _initializeWalletTestSuites(); + } + + final suite = _walletTestSuites[type]; + if (suite == null) return; + + final updatedStatuses = Map.from(state.walletTestStatuses); + final updatedAllStatuses = Map.from(state.testStatuses); + updatedStatuses[type] = TestSuiteStatus.running; + updatedAllStatuses[type] = TestSuiteStatus.running; + + state = state.copyWith( + walletTestStatuses: updatedStatuses, + suiteStatuses: updatedAllStatuses, + ); + if (!_statusController.isClosed) { + _statusController.add(state); + } + + try { + // TODO: Pass wallet config to suite when implementing actual wallet tests. + final result = await suite.runTests().timeout( + const Duration(minutes: 5), // Wallet tests may take longer. + onTimeout: () => const TestResult( + success: false, + message: "Wallet test suite timed out", + executionTime: Duration(minutes: 5), + ), + ); + + if (_cancelled) return; + + final status = result.success ? TestSuiteStatus.passed : TestSuiteStatus.failed; + updatedStatuses[type] = status; + updatedAllStatuses[type] = status; + + final completed = _calculateCompletedTests(); + + state = state.copyWith( + walletTestStatuses: updatedStatuses, + suiteStatuses: updatedAllStatuses, + completed: completed, + ); + if (!_statusController.isClosed) { + _statusController.add(state); + } + + await suite.cleanup(); + } catch (e) { + if (_cancelled) return; + + updatedStatuses[type] = TestSuiteStatus.failed; + updatedAllStatuses[type] = TestSuiteStatus.failed; + + final completed = _calculateCompletedTests(); + + state = state.copyWith( + walletTestStatuses: updatedStatuses, + suiteStatuses: updatedAllStatuses, + completed: completed, + ); + if (!_statusController.isClosed) { + _statusController.add(state); + } + + Logging.instance.log(Level.error, "Error running $type wallet test suite: $e"); + } + } + + @Deprecated('Use runIntegrationTestSuite() or runWalletTestSuite() instead') + Future runTestSuite(TestType type) async { + if (type is IntegrationTestType) { + await runIntegrationTestSuite(type); + } else if (type is WalletTestType) { + await runWalletTestSuite(type); + } + } + + Future cancelTesting() async { + _cancelled = true; + state = state.copyWith(isRunning: false); + + // Only add to stream controller if it's not closed. + if (!_statusController.isClosed) { + _statusController.add(state); + } + + for (final suite in _integrationTestSuites.values) { + await suite.cleanup(); + } + for (final suite in _walletTestSuites.values) { + await suite.cleanup(); + } + } + + Future resetTestResults() async { + // Clear test suites to ensure fresh state. + _integrationTestSuites.clear(); + _walletTestSuites.clear(); + _cancelled = false; + + state = TestingSessionState( + testStatuses: { + for (final type in IntegrationTestType.values) type: TestSuiteStatus.waiting, + for (final type in WalletTestType.values) type: TestSuiteStatus.waiting, + }, + integrationTestStatuses: { + for (final type in IntegrationTestType.values) type: TestSuiteStatus.waiting + }, + walletTestStatuses: { + for (final type in WalletTestType.values) type: TestSuiteStatus.waiting + }, + isRunning: false, + completed: 0, + total: IntegrationTestType.values.length + WalletTestType.values.length, + ); + + // Only add to stream controller if it's not closed. + if (!_statusController.isClosed) { + _statusController.add(state); + } + } + + // Wallet test configuration methods. + void setWalletTestConfig(WalletTestConfig config) { + _walletTestConfig = config; + } + + WalletTestConfig? get walletTestConfig => _walletTestConfig; + + // Helper method to calculate completed tests across both types. + int _calculateCompletedTests() { + final integrationCompleted = state.integrationTestStatuses.values + .where((status) => status == TestSuiteStatus.passed || status == TestSuiteStatus.failed) + .length; + final walletCompleted = state.walletTestStatuses.values + .where((status) => status == TestSuiteStatus.passed || status == TestSuiteStatus.failed) + .length; + return integrationCompleted + walletCompleted; + } + + TestSuiteInterface? getIntegrationTestSuite(IntegrationTestType type) { + return _integrationTestSuites[type]; + } + + TestSuiteInterface? getWalletTestSuite(WalletTestType type) { + return _walletTestSuites[type]; + } + + @Deprecated('Use getIntegrationTestSuite() or getWalletTestSuite() instead') + TestSuiteInterface? getTestSuite(TestType type) { + if (type is IntegrationTestType) { + return getIntegrationTestSuite(type); + } else if (type is WalletTestType) { + return getWalletTestSuite(type); + } + return null; + } + + @override + void dispose() { + _statusController.close(); + super.dispose(); + } + + // Convenience methods for getting display names. + String getDisplayNameForTest(TestType type) { + return type.displayName; + } + + String getDescriptionForTest(TestType type) { + return type.description; + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 4ef194c2b..a1f02743c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -813,11 +813,11 @@ packages: dependency: "direct main" description: path: "." - ref: f0b1300140d45c13e7722f8f8d20308efeba8449 - resolved-ref: f0b1300140d45c13e7722f8f8d20308efeba8449 + ref: "794ab2d7b88b34d64a89518f9b9f41dcc235aca1" + resolved-ref: "794ab2d7b88b34d64a89518f9b9f41dcc235aca1" url: "https://github.com/cypherstack/electrum_adapter.git" source: git - version: "3.0.0" + version: "3.0.2" emojis: dependency: "direct main" description: @@ -1119,8 +1119,8 @@ packages: dependency: "direct main" description: path: "." - ref: afaad488f5215a9c2c211e5e2f8460237eef60f1 - resolved-ref: afaad488f5215a9c2c211e5e2f8460237eef60f1 + ref: "540d0bc7dc27a97d45d63f412f26818a7f3b8b51" + resolved-ref: "540d0bc7dc27a97d45d63f412f26818a7f3b8b51" url: "https://github.com/cypherstack/fusiondart.git" source: git version: "1.0.0" @@ -1963,11 +1963,10 @@ packages: socks_socket: dependency: transitive description: - path: "." - ref: master - resolved-ref: e6232c53c1595469931ababa878759a067c02e94 - url: "https://github.com/cypherstack/socks_socket.git" - source: git + name: socks_socket + sha256: "53bc7eae40a3aa16ea810b0e9de3bb23ba7beb0b40d09357b89190f2f44374cc" + url: "https://pub.dev" + source: hosted version: "1.1.1" solana: dependency: "direct main" @@ -2200,8 +2199,8 @@ packages: dependency: "direct main" description: path: "." - ref: "752f054b65c500adb9cad578bf183a978e012502" - resolved-ref: "752f054b65c500adb9cad578bf183a978e012502" + ref: "16c9e709e984ec89e8715ce378b038c93ad7add3" + resolved-ref: "16c9e709e984ec89e8715ce378b038c93ad7add3" url: "https://github.com/cypherstack/tor.git" source: git version: "0.0.1"