From 0d9ca33686ce893bcb9e3069e96172f1a839a66b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 14 Aug 2025 13:38:00 -0500 Subject: [PATCH 01/23] feat(test): add testing view stub to debug menu nonfunctional tor tests as example --- .../global_settings_view/hidden_settings.dart | 18 ++ .../testing/sub_widgets/test_suite_card.dart | 155 +++++++++++ lib/pages/testing/testing_view.dart | 246 ++++++++++++++++++ lib/route_generator.dart | 8 + .../testing/test_suite_interface.dart | 22 ++ .../testing/test_suites/tor_test_suite.dart | 105 ++++++++ lib/services/testing/testing_models.dart | 58 +++++ lib/services/testing/testing_service.dart | 168 ++++++++++++ 8 files changed, 780 insertions(+) create mode 100644 lib/pages/testing/sub_widgets/test_suite_card.dart create mode 100644 lib/pages/testing/testing_view.dart create mode 100644 lib/services/testing/test_suite_interface.dart create mode 100644 lib/services/testing/test_suites/tor_test_suite.dart create mode 100644 lib/services/testing/testing_models.dart create mode 100644 lib/services/testing/testing_service.dart 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..8bf1de3fc --- /dev/null +++ b/lib/pages/testing/sub_widgets/test_suite_card.dart @@ -0,0 +1,155 @@ +/* + * 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.testSuiteType, + required this.status, + this.onTap, + }); + + final TestSuiteType testSuiteType; + 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: [ + const SizedBox( + width: 32, + height: 32, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + testingService.getDisplayNameForTestSuite(testSuiteType), + 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 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..75feb88df --- /dev/null +++ b/lib/pages/testing/testing_view.dart @@ -0,0 +1,246 @@ +/* + * 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:flutter_riverpod/flutter_riverpod.dart'; + +import '../../services/testing/testing_service.dart'; +import '../../services/testing/testing_models.dart'; +import '../../themes/stack_colors.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 '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; + + @override + void initState() { + super.initState(); + _subscription = null; + } + + @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, + ), + + // Control buttons + Row( + children: [ + Expanded( + child: ConditionalParent( + condition: isDesktop, + builder: (child) => SecondaryButton( + label: testingState.isRunning ? "Cancel" : "Start All 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" : "Start All Tests", + style: STextStyles.button(context).copyWith( + color: Theme.of(context) + .extension()! + .buttonTextPrimary, + ), + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: 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 SizedBox(height: 32), + + // Test suite cards + ...TestSuiteType.values.map((type) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: TestSuiteCard( + testSuiteType: type, + status: testingState.suiteStatuses[type] ?? TestSuiteStatus.waiting, + onTap: testingState.isRunning + ? null + : () => testingService.runTestSuite(type), + ), + ); + }), + ], + ), + ), + ), + const Spacer(), + ConditionalParent( + condition: isDesktop, + builder: (child) => const SizedBox( + height: 64, + ), + child: const SizedBox( + height: 32, + ), + ), + ], + ), + ), + ); + } +} \ 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/tor_test_suite.dart b/lib/services/testing/test_suites/tor_test_suite.dart new file mode 100644 index 000000000..10f1835c4 --- /dev/null +++ b/lib/services/testing/test_suites/tor_test_suite.dart @@ -0,0 +1,105 @@ +/* + * 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 '../test_suite_interface.dart'; +import '../testing_models.dart'; +import '../../../utilities/logger.dart'; + +class TorTestSuite implements TestSuiteInterface { + final 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 => _statusController.stream; + + @override + Future runTests() async { + final stopwatch = Stopwatch()..start(); + final logs = []; + + try { + _updateStatus(TestSuiteStatus.running); + + logs.add("Starting Tor service test suite..."); + + logs.add("Testing Tor connection establishment..."); + await _testTorConnection(); + logs.add("✓ Tor connection test passed"); + + logs.add("Testing proxy functionality verification..."); + await _testProxyFunctionality(); + logs.add("✓ Proxy functionality test passed"); + + logs.add("Testing node access through Tor..."); + await _testNodeAccessThroughTor(); + logs.add("✓ Node access through Tor test passed"); + + stopwatch.stop(); + _updateStatus(TestSuiteStatus.passed); + + logs.add("All Tor service tests completed successfully!"); + + return TestResult( + success: true, + message: "All Tor service tests passed", + logs: logs, + executionTime: stopwatch.elapsed, + ); + + } catch (e) { + stopwatch.stop(); + _updateStatus(TestSuiteStatus.failed); + + logs.add("✗ Test failed: ${e.toString()}"); + + return TestResult( + success: false, + message: "Tor service test failed: ${e.toString()}", + logs: logs, + executionTime: stopwatch.elapsed, + ); + } + } + + Future _testTorConnection() async { + await Future.delayed(const Duration(seconds: 1)); + } + + Future _testProxyFunctionality() async { + await Future.delayed(const Duration(milliseconds: 800)); + } + + Future _testNodeAccessThroughTor() async { + await Future.delayed(const Duration(milliseconds: 900)); + } + + void _updateStatus(TestSuiteStatus newStatus) { + _status = newStatus; + _statusController.add(_status); + } + + @override + Future cleanup() async { + Logging.instance.log(Level.info, "Cleaning up Tor test suite"); + 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..3a6626f08 --- /dev/null +++ b/lib/services/testing/testing_models.dart @@ -0,0 +1,58 @@ +/* + * 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 + * + */ + + +enum TestSuiteStatus { waiting, running, passed, failed } + +enum TestSuiteType { + tor +} + +class TestResult { + final bool success; + final String message; + final List logs; + final Duration executionTime; + + const TestResult({ + required this.success, + required this.message, + required this.logs, + required this.executionTime, + }); +} + +class TestingSessionState { + final Map suiteStatuses; + final bool isRunning; + final int completed; + final int total; + + const TestingSessionState({ + required this.suiteStatuses, + required this.isRunning, + required this.completed, + required this.total, + }); + + TestingSessionState copyWith({ + Map? suiteStatuses, + bool? isRunning, + int? completed, + int? total, + }) { + return TestingSessionState( + suiteStatuses: suiteStatuses ?? this.suiteStatuses, + isRunning: isRunning ?? this.isRunning, + completed: completed ?? this.completed, + total: total ?? this.total, + ); + } +} \ 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..e197c0018 --- /dev/null +++ b/lib/services/testing/testing_service.dart @@ -0,0 +1,168 @@ +/* + * 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'; + +final testingServiceProvider = StateNotifierProvider((ref) { + return TestingService(); +}); + +class TestingService extends StateNotifier { + TestingService() : super(TestingSessionState( + suiteStatuses: { + for (var type in TestSuiteType.values) type: TestSuiteStatus.waiting + }, + isRunning: false, + completed: 0, + total: TestSuiteType.values.length, + )); + + final Map _testSuites = {}; + final StreamController _statusController = StreamController.broadcast(); + bool _cancelled = false; + + Stream get statusStream => _statusController.stream; + + void _initializeTestSuites() { + _testSuites[TestSuiteType.tor] = TorTestSuite(); + } + + Future runAllTests() async { + if (state.isRunning) return; + + _cancelled = false; + _initializeTestSuites(); + + state = state.copyWith( + isRunning: true, + completed: 0, + suiteStatuses: { + for (var type in TestSuiteType.values) type: TestSuiteStatus.waiting + }, + ); + _statusController.add(state); + + try { + for (final type in TestSuiteType.values) { + if (_cancelled) break; + + await runTestSuite(type); + } + } catch (e) { + Logging.instance.log(Level.error, "Error running test suites: $e"); + } finally { + state = state.copyWith(isRunning: false); + _statusController.add(state); + } + } + + Future runTestSuite(TestSuiteType type) async { + if (_cancelled) return; + + final suite = _testSuites[type]; + if (suite == null) return; + + final updatedStatuses = Map.from(state.suiteStatuses); + updatedStatuses[type] = TestSuiteStatus.running; + + state = state.copyWith(suiteStatuses: updatedStatuses); + _statusController.add(state); + + try { + final result = await suite.runTests().timeout( + const Duration(seconds: 30), + onTimeout: () => const TestResult( + success: false, + message: "Test suite timed out", + logs: ["Test execution exceeded 30 second timeout"], + executionTime: Duration(seconds: 30), + ), + ); + + if (_cancelled) return; + + updatedStatuses[type] = result.success ? TestSuiteStatus.passed : TestSuiteStatus.failed; + + final completed = updatedStatuses.values + .where((status) => status == TestSuiteStatus.passed || status == TestSuiteStatus.failed) + .length; + + state = state.copyWith( + suiteStatuses: updatedStatuses, + completed: completed, + ); + _statusController.add(state); + + await suite.cleanup(); + } catch (e) { + if (_cancelled) return; + + updatedStatuses[type] = TestSuiteStatus.failed; + + final completed = updatedStatuses.values + .where((status) => status == TestSuiteStatus.passed || status == TestSuiteStatus.failed) + .length; + + state = state.copyWith( + suiteStatuses: updatedStatuses, + completed: completed, + ); + _statusController.add(state); + + Logging.instance.log(Level.error, "Error running $type test suite: $e"); + } + } + + Future cancelTesting() async { + _cancelled = true; + state = state.copyWith(isRunning: false); + _statusController.add(state); + + for (final suite in _testSuites.values) { + await suite.cleanup(); + } + } + + Future resetTestResults() async { + state = TestingSessionState( + suiteStatuses: { + for (var type in TestSuiteType.values) type: TestSuiteStatus.waiting + }, + isRunning: false, + completed: 0, + total: TestSuiteType.values.length, + ); + _statusController.add(state); + } + + TestSuiteInterface? getTestSuite(TestSuiteType type) { + return _testSuites[type]; + } + + String getDisplayNameForTestSuite(TestSuiteType type) { + switch (type) { + case TestSuiteType.tor: + return "Tor Service"; + } + } + + @override + void dispose() { + _statusController.close(); + super.dispose(); + } +} \ No newline at end of file From 4e83345880db3549d4fb2aebba058bd410f3b619 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 14 Aug 2025 14:47:02 -0500 Subject: [PATCH 02/23] feat(test): add tor test suite functional runtime tests --- .../testing/test_suites/tor_test_suite.dart | 247 ++++++++++++++++-- lib/services/testing/testing_models.dart | 2 - 2 files changed, 229 insertions(+), 20 deletions(-) diff --git a/lib/services/testing/test_suites/tor_test_suite.dart b/lib/services/testing/test_suites/tor_test_suite.dart index 10f1835c4..ede27da64 100644 --- a/lib/services/testing/test_suites/tor_test_suite.dart +++ b/lib/services/testing/test_suites/tor_test_suite.dart @@ -9,11 +9,20 @@ */ 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 { final StreamController _statusController = StreamController.broadcast(); @@ -34,34 +43,26 @@ class TorTestSuite implements TestSuiteInterface { @override Future runTests() async { final stopwatch = Stopwatch()..start(); - final logs = []; try { _updateStatus(TestSuiteStatus.running); - logs.add("Starting Tor service test suite..."); - - logs.add("Testing Tor connection establishment..."); + Logging.instance.log(Level.info, ("Starting Tor service test suite...")); + await _testTorConnection(); - logs.add("✓ Tor connection test passed"); - - logs.add("Testing proxy functionality verification..."); + await _testProxyFunctionality(); - logs.add("✓ Proxy functionality test passed"); - - logs.add("Testing node access through Tor..."); + await _testNodeAccessThroughTor(); - logs.add("✓ Node access through Tor test passed"); stopwatch.stop(); _updateStatus(TestSuiteStatus.passed); - logs.add("All Tor service tests completed successfully!"); + Logging.instance.log(Level.info, ("👍👍👍 All Tor service tests completed successfully!")); return TestResult( success: true, message: "All Tor service tests passed", - logs: logs, executionTime: stopwatch.elapsed, ); @@ -69,27 +70,228 @@ class TorTestSuite implements TestSuiteInterface { stopwatch.stop(); _updateStatus(TestSuiteStatus.failed); - logs.add("✗ Test failed: ${e.toString()}"); + Logging.instance.log(Level.info, ("✗ Test failed: ${e.toString()}")); return TestResult( success: false, message: "Tor service test failed: ${e.toString()}", - logs: logs, 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 { - await Future.delayed(const Duration(seconds: 1)); + 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 { - await Future.delayed(const Duration(milliseconds: 800)); + 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 { - await Future.delayed(const Duration(milliseconds: 900)); + 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) { @@ -100,6 +302,15 @@ class TorTestSuite implements TestSuiteInterface { @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/testing_models.dart b/lib/services/testing/testing_models.dart index 3a6626f08..982e47c31 100644 --- a/lib/services/testing/testing_models.dart +++ b/lib/services/testing/testing_models.dart @@ -18,13 +18,11 @@ enum TestSuiteType { class TestResult { final bool success; final String message; - final List logs; final Duration executionTime; const TestResult({ required this.success, required this.message, - required this.logs, required this.executionTime, }); } From 13ea4c4b2379c1e0b63701d11d0fc5f9320ba1b1 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 14 Aug 2025 15:22:51 -0500 Subject: [PATCH 03/23] feat(test): add monero wallet test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP, polyseed restoration test throws: ``` flutter: ERROR ┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── flutter: ERROR │ 2025-08-14 20:21:20.986 flutter: ERROR ├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ flutter: ERROR │ Monero wallet test suite failed: Exception: file already exists "~/.stackwallet/wallets/monero/polyseed_test_2529" flutter: ERROR │ #0 checkWalletStatus (package:cs_monero/src/ffi_bindings/monero_wallet_bindings.dart:18:3) flutter: ERROR │ #1 MoneroWallet.restoreWalletFromSeed (package:cs_monero/src/wallets/monero_wallet.dart:241:13) flutter: ERROR │ flutter: ERROR │ #2 MoneroWalletTestSuite._testPolyseedRestoration (package:stackwallet/services/testing/test_suites/monero_wallet_test_suite.dart:194:16) flutter: ERROR │ flutter: ERROR │ #3 MoneroWalletTestSuite.runTests (package:stackwallet/services/testing/test_suites/monero_wallet_test_suite.dart:52:7) flutter: ERROR │ flutter: ERROR │ #4 Future.timeout. (dart:async/future_impl.dart:1043:7) flutter: ERROR │ flutter: ERROR │ #5 TestingService.runTestSuite (package:stackwallet/services/testing/testing_service.dart:88:22) flutter: ERROR │ flutter: ERROR │ #6 TestingService.runAllTests (package:stackwallet/services/testing/testing_service.dart:65:9) flutter: ERROR │ flutter: ERROR │ flutter: ERROR └─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ``` --- .../test_suites/monero_wallet_test_suite.dart | 290 ++++++++++++++++++ .../test_data/polyseed_vectors.dart | 39 +++ lib/services/testing/testing_models.dart | 3 +- lib/services/testing/testing_service.dart | 5 +- 4 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 lib/services/testing/test_suites/monero_wallet_test_suite.dart create mode 100644 lib/services/testing/test_suites/test_data/polyseed_vectors.dart diff --git a/lib/services/testing/test_suites/monero_wallet_test_suite.dart b/lib/services/testing/test_suites/monero_wallet_test_suite.dart new file mode 100644 index 000000000..14812c61c --- /dev/null +++ b/lib/services/testing/test_suites/monero_wallet_test_suite.dart @@ -0,0 +1,290 @@ +/* + * 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 '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 '../../../utilities/logger.dart'; +import '../../../utilities/stack_file_system.dart'; +import '../test_suite_interface.dart'; +import '../testing_models.dart'; +import 'test_data/polyseed_vectors.dart'; + +class MoneroWalletTestSuite implements TestSuiteInterface { + final 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 => _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(); + + // 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"); + } + } + } + + void _updateStatus(TestSuiteStatus newStatus) { + _status = newStatus; + _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/testing_models.dart b/lib/services/testing/testing_models.dart index 982e47c31..c7aa0877e 100644 --- a/lib/services/testing/testing_models.dart +++ b/lib/services/testing/testing_models.dart @@ -12,7 +12,8 @@ enum TestSuiteStatus { waiting, running, passed, failed } enum TestSuiteType { - tor + tor, + moneroWallet } class TestResult { diff --git a/lib/services/testing/testing_service.dart b/lib/services/testing/testing_service.dart index e197c0018..bb3bbaba2 100644 --- a/lib/services/testing/testing_service.dart +++ b/lib/services/testing/testing_service.dart @@ -16,6 +16,7 @@ import '../../utilities/logger.dart'; import 'testing_models.dart'; import 'test_suite_interface.dart'; import 'test_suites/tor_test_suite.dart'; +import 'test_suites/monero_wallet_test_suite.dart'; final testingServiceProvider = StateNotifierProvider((ref) { return TestingService(); @@ -39,6 +40,7 @@ class TestingService extends StateNotifier { void _initializeTestSuites() { _testSuites[TestSuiteType.tor] = TorTestSuite(); + _testSuites[TestSuiteType.moneroWallet] = MoneroWalletTestSuite(); } Future runAllTests() async { @@ -88,7 +90,6 @@ class TestingService extends StateNotifier { onTimeout: () => const TestResult( success: false, message: "Test suite timed out", - logs: ["Test execution exceeded 30 second timeout"], executionTime: Duration(seconds: 30), ), ); @@ -157,6 +158,8 @@ class TestingService extends StateNotifier { switch (type) { case TestSuiteType.tor: return "Tor Service"; + case TestSuiteType.moneroWallet: + return "Monero Wallet FFI"; } } From bb2a199d6f7449b410beab755c7aef4b47eb7820 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 14 Aug 2025 16:30:38 -0500 Subject: [PATCH 04/23] fix(test): make individual test cards clickable --- lib/services/testing/testing_service.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/services/testing/testing_service.dart b/lib/services/testing/testing_service.dart index bb3bbaba2..ba6875663 100644 --- a/lib/services/testing/testing_service.dart +++ b/lib/services/testing/testing_service.dart @@ -75,6 +75,10 @@ class TestingService extends StateNotifier { Future runTestSuite(TestSuiteType type) async { if (_cancelled) return; + if (_testSuites.isEmpty) { + _initializeTestSuites(); + } + final suite = _testSuites[type]; if (suite == null) return; From 9cc89d6a007c35fe21ee7dfeb3485d53d87a1b14 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 14 Aug 2025 16:36:37 -0500 Subject: [PATCH 05/23] fix(test): remove empty logo space inherited from SWB UI elements, can be re-added later as needed. --- lib/pages/testing/sub_widgets/test_suite_card.dart | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/pages/testing/sub_widgets/test_suite_card.dart b/lib/pages/testing/sub_widgets/test_suite_card.dart index 8bf1de3fc..fea831408 100644 --- a/lib/pages/testing/sub_widgets/test_suite_card.dart +++ b/lib/pages/testing/sub_widgets/test_suite_card.dart @@ -45,11 +45,6 @@ class TestSuiteCard extends ConsumerWidget { padding: const EdgeInsets.all(10), child: Row( children: [ - const SizedBox( - width: 32, - height: 32, - ), - const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, From 34aa5d12b8feebe269df7ab67ca90554955481e3 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 14 Aug 2025 18:51:00 -0500 Subject: [PATCH 06/23] feat(test): add swb option it's WIP, just opens the SWB as a POC the intention is to be able to test, for example, making a self-send transaction. --- .../testing/sub_widgets/test_suite_card.dart | 8 +- lib/pages/testing/testing_view.dart | 457 +++++++++++++++--- ...art => monero_integration_test_suite.dart} | 0 lib/services/testing/testing_models.dart | 154 +++++- lib/services/testing/testing_service.dart | 265 ++++++++-- 5 files changed, 768 insertions(+), 116 deletions(-) rename lib/services/testing/test_suites/{monero_wallet_test_suite.dart => monero_integration_test_suite.dart} (100%) diff --git a/lib/pages/testing/sub_widgets/test_suite_card.dart b/lib/pages/testing/sub_widgets/test_suite_card.dart index fea831408..bcd2e782f 100644 --- a/lib/pages/testing/sub_widgets/test_suite_card.dart +++ b/lib/pages/testing/sub_widgets/test_suite_card.dart @@ -21,12 +21,12 @@ import '../../../widgets/rounded_white_container.dart'; class TestSuiteCard extends ConsumerWidget { const TestSuiteCard({ super.key, - required this.testSuiteType, + required this.testType, required this.status, this.onTap, }); - final TestSuiteType testSuiteType; + final TestType testType; final TestSuiteStatus status; final VoidCallback? onTap; @@ -51,7 +51,7 @@ class TestSuiteCard extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - testingService.getDisplayNameForTestSuite(testSuiteType), + testingService.getDisplayNameForTest(testType), style: STextStyles.titleBold12(context), ), const SizedBox(height: 2), @@ -99,7 +99,7 @@ class TestSuiteCard extends ConsumerWidget { color: colors.textSubtitle1, ); case TestSuiteStatus.running: - return SizedBox( + return const SizedBox( width: 20, height: 20, child: LoadingIndicator( diff --git a/lib/pages/testing/testing_view.dart b/lib/pages/testing/testing_view.dart index 75feb88df..656fd8ce5 100644 --- a/lib/pages/testing/testing_view.dart +++ b/lib/pages/testing/testing_view.dart @@ -9,12 +9,16 @@ */ 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'; @@ -24,6 +28,9 @@ 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 { @@ -37,11 +44,17 @@ class TestingView extends ConsumerStatefulWidget { 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 @@ -140,90 +153,194 @@ class _TestingViewState extends ConsumerState { height: 24, ), - // Control buttons - Row( - children: [ - Expanded( - child: ConditionalParent( - condition: isDesktop, - builder: (child) => SecondaryButton( - label: testingState.isRunning ? "Cancel" : "Start All Tests", + // 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. + ...IntegrationTestType.values.map((type) { + 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 - ? () => testingService.cancelTesting() - : () => testingService.runAllTests(), + ? null + : () => _selectSwbFile(), ), - child: TextButton( - style: Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context), - onPressed: testingState.isRunning - ? () => testingService.cancelTesting() - : () => testingService.runAllTests(), - child: Text( - testingState.isRunning ? "Cancel" : "Start All Tests", + 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: Theme.of(context) + color: testingState.isRunning + ? Theme.of(context) + .extension()! + .buttonTextPrimaryDisabled + : Theme.of(context) .extension()! .buttonTextPrimary, ), ), - ), + ], ), ), - const SizedBox(width: 16), - Expanded( - child: ConditionalParent( - condition: isDesktop, - builder: (child) => PrimaryButton( - label: "Reset", - enabled: !testingState.isRunning, + ), + if (swbLoaded) + ConditionalParent( + condition: isDesktop, + builder: (child) => + SecondaryButton( + label: "Run extended SWB tests", onPressed: testingState.isRunning ? null - : () => testingService.resetTestResults(), + : () => _showPasswordDialog(), ), - 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", + 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 + .extension()! + .buttonTextPrimaryDisabled : Theme.of(context) - .extension()! - .buttonTextSecondary, + .extension()! + .buttonTextPrimary, ), ), - ), + ], ), ), - ], - ), - const SizedBox(height: 32), + ), + + const SizedBox(height: 16), - // Test suite cards - ...TestSuiteType.values.map((type) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: TestSuiteCard( - testSuiteType: type, - status: testingState.suiteStatuses[type] ?? TestSuiteStatus.waiting, - onTap: testingState.isRunning - ? null - : () => testingService.runTestSuite(type), + // 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, + ), ), - ); - }), + ), + ), ], ), ), @@ -243,4 +360,220 @@ class _TestingViewState extends ConsumerState { ), ); } + + 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/services/testing/test_suites/monero_wallet_test_suite.dart b/lib/services/testing/test_suites/monero_integration_test_suite.dart similarity index 100% rename from lib/services/testing/test_suites/monero_wallet_test_suite.dart rename to lib/services/testing/test_suites/monero_integration_test_suite.dart diff --git a/lib/services/testing/testing_models.dart b/lib/services/testing/testing_models.dart index c7aa0877e..09c65c2cd 100644 --- a/lib/services/testing/testing_models.dart +++ b/lib/services/testing/testing_models.dart @@ -8,50 +8,192 @@ * */ +// 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 } -enum TestSuiteType { +/// 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, - moneroWallet + moneroIntegration; + + @override + String get displayName { + switch (this) { + case IntegrationTestType.tor: + return "Tor Integration"; + case IntegrationTestType.moneroIntegration: + return "Monero 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"; + } + } +} + +/// 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 suiteStatuses; + final Map testStatuses; + final Map integrationTestStatuses; + final Map walletTestStatuses; final bool isRunning; final int completed; final int total; const TestingSessionState({ - required this.suiteStatuses, + required this.testStatuses, + required this.integrationTestStatuses, + required this.walletTestStatuses, required this.isRunning, required this.completed, required this.total, }); TestingSessionState copyWith({ - Map? suiteStatuses, + Map? suiteStatuses, + Map? integrationTestStatuses, + Map? walletTestStatuses, bool? isRunning, int? completed, int? total, }) { return TestingSessionState( - suiteStatuses: suiteStatuses ?? this.suiteStatuses, + 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 index ba6875663..acbb5d353 100644 --- a/lib/services/testing/testing_service.dart +++ b/lib/services/testing/testing_service.dart @@ -16,7 +16,7 @@ import '../../utilities/logger.dart'; import 'testing_models.dart'; import 'test_suite_interface.dart'; import 'test_suites/tor_test_suite.dart'; -import 'test_suites/monero_wallet_test_suite.dart'; +import 'test_suites/monero_integration_test_suite.dart'; final testingServiceProvider = StateNotifierProvider((ref) { return TestingService(); @@ -24,68 +24,122 @@ final testingServiceProvider = StateNotifierProvider { TestingService() : super(TestingSessionState( - suiteStatuses: { - for (var type in TestSuiteType.values) type: TestSuiteStatus.waiting + 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: TestSuiteType.values.length, + total: IntegrationTestType.values.length + WalletTestType.values.length, )); - final Map _testSuites = {}; + final Map _integrationTestSuites = {}; + final Map _walletTestSuites = {}; final StreamController _statusController = StreamController.broadcast(); + WalletTestConfig? _walletTestConfig; bool _cancelled = false; Stream get statusStream => _statusController.stream; - void _initializeTestSuites() { - _testSuites[TestSuiteType.tor] = TorTestSuite(); - _testSuites[TestSuiteType.moneroWallet] = MoneroWalletTestSuite(); + void _initializeIntegrationTestSuites() { + _integrationTestSuites[IntegrationTestType.tor] = TorTestSuite(); + _integrationTestSuites[IntegrationTestType.moneroIntegration] = MoneroWalletTestSuite(); + } + + void _initializeWalletTestSuites() { + _walletTestSuites[WalletTestType.moneroWallet] = MoneroWalletTestSuite(); } - Future runAllTests() async { + Future runAllIntegrationTests() async { if (state.isRunning) return; _cancelled = false; - _initializeTestSuites(); + _initializeIntegrationTestSuites(); state = state.copyWith( isRunning: true, completed: 0, - suiteStatuses: { - for (var type in TestSuiteType.values) type: TestSuiteStatus.waiting + integrationTestStatuses: { + for (var type in IntegrationTestType.values) type: TestSuiteStatus.waiting }, ); _statusController.add(state); try { - for (final type in TestSuiteType.values) { + 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); + _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 + }, + ); + _statusController.add(state); - await runTestSuite(type); + try { + for (final type in WalletTestType.values) { + if (_cancelled) break; + await runWalletTestSuite(type); } } catch (e) { - Logging.instance.log(Level.error, "Error running test suites: $e"); + Logging.instance.log(Level.error, "Error running wallet test suites: $e"); } finally { state = state.copyWith(isRunning: false); _statusController.add(state); } } + + @Deprecated('Use runAllIntegrationTests() instead') + Future runAllTests() async { + await runAllIntegrationTests(); + } - Future runTestSuite(TestSuiteType type) async { + Future runIntegrationTestSuite(IntegrationTestType type) async { if (_cancelled) return; - if (_testSuites.isEmpty) { - _initializeTestSuites(); + if (_integrationTestSuites.isEmpty) { + _initializeIntegrationTestSuites(); } - final suite = _testSuites[type]; + final suite = _integrationTestSuites[type]; if (suite == null) return; - final updatedStatuses = Map.from(state.suiteStatuses); + final updatedStatuses = Map.from(state.integrationTestStatuses); + final updatedAllStatuses = Map.from(state.testStatuses); updatedStatuses[type] = TestSuiteStatus.running; + updatedAllStatuses[type] = TestSuiteStatus.running; - state = state.copyWith(suiteStatuses: updatedStatuses); + state = state.copyWith( + integrationTestStatuses: updatedStatuses, + suiteStatuses: updatedAllStatuses, + ); _statusController.add(state); try { @@ -100,14 +154,15 @@ class TestingService extends StateNotifier { if (_cancelled) return; - updatedStatuses[type] = result.success ? TestSuiteStatus.passed : TestSuiteStatus.failed; + final status = result.success ? TestSuiteStatus.passed : TestSuiteStatus.failed; + updatedStatuses[type] = status; + updatedAllStatuses[type] = status; - final completed = updatedStatuses.values - .where((status) => status == TestSuiteStatus.passed || status == TestSuiteStatus.failed) - .length; + final completed = _calculateCompletedTests(); state = state.copyWith( - suiteStatuses: updatedStatuses, + integrationTestStatuses: updatedStatuses, + suiteStatuses: updatedAllStatuses, completed: completed, ); _statusController.add(state); @@ -117,18 +172,98 @@ class TestingService extends StateNotifier { if (_cancelled) return; updatedStatuses[type] = TestSuiteStatus.failed; + updatedAllStatuses[type] = TestSuiteStatus.failed; - final completed = updatedStatuses.values - .where((status) => status == TestSuiteStatus.passed || status == TestSuiteStatus.failed) - .length; + final completed = _calculateCompletedTests(); state = state.copyWith( - suiteStatuses: updatedStatuses, + integrationTestStatuses: updatedStatuses, + suiteStatuses: updatedAllStatuses, completed: completed, ); _statusController.add(state); - Logging.instance.log(Level.error, "Error running $type test suite: $e"); + 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, + ); + _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, + ); + _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, + ); + _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); } } @@ -137,34 +272,67 @@ class TestingService extends StateNotifier { state = state.copyWith(isRunning: false); _statusController.add(state); - for (final suite in _testSuites.values) { + for (final suite in _integrationTestSuites.values) { + await suite.cleanup(); + } + for (final suite in _walletTestSuites.values) { await suite.cleanup(); } } Future resetTestResults() async { state = TestingSessionState( - suiteStatuses: { - for (var type in TestSuiteType.values) type: TestSuiteStatus.waiting + 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: TestSuiteType.values.length, + total: IntegrationTestType.values.length + WalletTestType.values.length, ); _statusController.add(state); } - TestSuiteInterface? getTestSuite(TestSuiteType type) { - return _testSuites[type]; + // Wallet test configuration methods + void setWalletTestConfig(WalletTestConfig config) { + _walletTestConfig = config; } - - String getDisplayNameForTestSuite(TestSuiteType type) { - switch (type) { - case TestSuiteType.tor: - return "Tor Service"; - case TestSuiteType.moneroWallet: - return "Monero Wallet FFI"; + + 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 @@ -172,4 +340,13 @@ class TestingService extends StateNotifier { _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 From e0b9377d8e7dd4744b45a99cdeb9d80514f61041 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 14 Aug 2025 19:25:38 -0500 Subject: [PATCH 07/23] feat(test): add wownero integration test --- .../wownero_integration_test_suite.dart | 210 ++++++++++++++++++ lib/services/testing/testing_models.dart | 7 +- lib/services/testing/testing_service.dart | 2 + 3 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 lib/services/testing/test_suites/wownero_integration_test_suite.dart 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..553b00109 --- /dev/null +++ b/lib/services/testing/test_suites/wownero_integration_test_suite.dart @@ -0,0 +1,210 @@ +/* + * 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 '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 '../../../utilities/logger.dart'; +import '../../../utilities/stack_file_system.dart'; +import '../test_suite_interface.dart'; +import '../testing_models.dart'; + +class WowneroIntegrationTestSuite implements TestSuiteInterface { + final 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 => _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(); + + 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"); + } + } + } + + void _updateStatus(TestSuiteStatus newStatus) { + _status = newStatus; + _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 index 09c65c2cd..674d26b90 100644 --- a/lib/services/testing/testing_models.dart +++ b/lib/services/testing/testing_models.dart @@ -27,7 +27,8 @@ abstract class TestType { /// Integration tests verify FFI plugins are correctly integrated. enum IntegrationTestType implements TestType { tor, - moneroIntegration; + moneroIntegration, + wowneroIntegration; @override String get displayName { @@ -36,6 +37,8 @@ enum IntegrationTestType implements TestType { return "Tor Integration"; case IntegrationTestType.moneroIntegration: return "Monero Integration"; + case IntegrationTestType.wowneroIntegration: + return "Wownero Integration"; } } @@ -46,6 +49,8 @@ enum IntegrationTestType implements TestType { 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"; } } } diff --git a/lib/services/testing/testing_service.dart b/lib/services/testing/testing_service.dart index acbb5d353..3e42356ae 100644 --- a/lib/services/testing/testing_service.dart +++ b/lib/services/testing/testing_service.dart @@ -17,6 +17,7 @@ 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'; final testingServiceProvider = StateNotifierProvider((ref) { return TestingService(); @@ -50,6 +51,7 @@ class TestingService extends StateNotifier { void _initializeIntegrationTestSuites() { _integrationTestSuites[IntegrationTestType.tor] = TorTestSuite(); _integrationTestSuites[IntegrationTestType.moneroIntegration] = MoneroWalletTestSuite(); + _integrationTestSuites[IntegrationTestType.wowneroIntegration] = WowneroIntegrationTestSuite(); } void _initializeWalletTestSuites() { From 6e10d52e2d8e2d7fc73c3252175d9bbd3c08ecc6 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 14 Aug 2025 19:34:57 -0500 Subject: [PATCH 08/23] feat(test): add salvium integration test --- .../salvium_integration_test_suite.dart | 200 ++++++++++++++++++ lib/services/testing/testing_models.dart | 7 +- lib/services/testing/testing_service.dart | 2 + 3 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 lib/services/testing/test_suites/salvium_integration_test_suite.dart 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..b9e0bf26d --- /dev/null +++ b/lib/services/testing/test_suites/salvium_integration_test_suite.dart @@ -0,0 +1,200 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:logger/logger.dart'; +import 'package:cs_salvium/cs_salvium.dart' as lib_salvium; +import '../../../utilities/logger.dart'; +import '../../../utilities/stack_file_system.dart'; +import '../test_suite_interface.dart'; +import '../testing_models.dart'; + +class SalviumIntegrationTestSuite implements TestSuiteInterface { + final 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 => _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(); + + 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"); + } + } + } + + void _updateStatus(TestSuiteStatus newStatus) { + _status = newStatus; + _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 index 674d26b90..6011299d5 100644 --- a/lib/services/testing/testing_models.dart +++ b/lib/services/testing/testing_models.dart @@ -28,7 +28,8 @@ abstract class TestType { enum IntegrationTestType implements TestType { tor, moneroIntegration, - wowneroIntegration; + wowneroIntegration, + salviumIntegration; @override String get displayName { @@ -39,6 +40,8 @@ enum IntegrationTestType implements TestType { return "Monero Integration"; case IntegrationTestType.wowneroIntegration: return "Wownero Integration"; + case IntegrationTestType.salviumIntegration: + return "Salvium Integration"; } } @@ -51,6 +54,8 @@ enum IntegrationTestType implements TestType { 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"; } } } diff --git a/lib/services/testing/testing_service.dart b/lib/services/testing/testing_service.dart index 3e42356ae..c0164e9aa 100644 --- a/lib/services/testing/testing_service.dart +++ b/lib/services/testing/testing_service.dart @@ -18,6 +18,7 @@ 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'; final testingServiceProvider = StateNotifierProvider((ref) { return TestingService(); @@ -52,6 +53,7 @@ class TestingService extends StateNotifier { _integrationTestSuites[IntegrationTestType.tor] = TorTestSuite(); _integrationTestSuites[IntegrationTestType.moneroIntegration] = MoneroWalletTestSuite(); _integrationTestSuites[IntegrationTestType.wowneroIntegration] = WowneroIntegrationTestSuite(); + _integrationTestSuites[IntegrationTestType.salviumIntegration] = SalviumIntegrationTestSuite(); } void _initializeWalletTestSuites() { From 2e7c0eeab9d03a193034e00548a96d4ee9d5942d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 14 Aug 2025 19:50:07 -0500 Subject: [PATCH 09/23] feat(test): add epic cash integration test --- .../epiccash_integration_test_suite.dart | 167 ++++++++++++++++++ lib/services/testing/testing_models.dart | 7 +- lib/services/testing/testing_service.dart | 2 + 3 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 lib/services/testing/test_suites/epiccash_integration_test_suite.dart 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..35e94fafd --- /dev/null +++ b/lib/services/testing/test_suites/epiccash_integration_test_suite.dart @@ -0,0 +1,167 @@ +/* + * 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 { + final 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 => _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; + _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 index 6011299d5..a26d62e7e 100644 --- a/lib/services/testing/testing_models.dart +++ b/lib/services/testing/testing_models.dart @@ -29,7 +29,8 @@ enum IntegrationTestType implements TestType { tor, moneroIntegration, wowneroIntegration, - salviumIntegration; + salviumIntegration, + epiccashIntegration; @override String get displayName { @@ -42,6 +43,8 @@ enum IntegrationTestType implements TestType { return "Wownero Integration"; case IntegrationTestType.salviumIntegration: return "Salvium Integration"; + case IntegrationTestType.epiccashIntegration: + return "Epic Cash Integration"; } } @@ -56,6 +59,8 @@ enum IntegrationTestType implements TestType { 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"; } } } diff --git a/lib/services/testing/testing_service.dart b/lib/services/testing/testing_service.dart index c0164e9aa..7308f3d8e 100644 --- a/lib/services/testing/testing_service.dart +++ b/lib/services/testing/testing_service.dart @@ -19,6 +19,7 @@ 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'; final testingServiceProvider = StateNotifierProvider((ref) { return TestingService(); @@ -54,6 +55,7 @@ class TestingService extends StateNotifier { _integrationTestSuites[IntegrationTestType.moneroIntegration] = MoneroWalletTestSuite(); _integrationTestSuites[IntegrationTestType.wowneroIntegration] = WowneroIntegrationTestSuite(); _integrationTestSuites[IntegrationTestType.salviumIntegration] = SalviumIntegrationTestSuite(); + _integrationTestSuites[IntegrationTestType.epiccashIntegration] = EpiccashIntegrationTestSuite(); } void _initializeWalletTestSuites() { From 8b6adba73dc554622024cb3689423a537b50df5f Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 14 Aug 2025 22:52:48 -0500 Subject: [PATCH 10/23] feat(test): add firo integration test --- .../firo_integration_test_suite.dart | 220 ++++++++++++++++++ lib/services/testing/testing_models.dart | 7 +- lib/services/testing/testing_service.dart | 2 + 3 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 lib/services/testing/test_suites/firo_integration_test_suite.dart 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..7795dc3bc --- /dev/null +++ b/lib/services/testing/test_suites/firo_integration_test_suite.dart @@ -0,0 +1,220 @@ +/* + * 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 { + final 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 => _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; + _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 index a26d62e7e..321400400 100644 --- a/lib/services/testing/testing_models.dart +++ b/lib/services/testing/testing_models.dart @@ -30,7 +30,8 @@ enum IntegrationTestType implements TestType { moneroIntegration, wowneroIntegration, salviumIntegration, - epiccashIntegration; + epiccashIntegration, + firoIntegration; @override String get displayName { @@ -45,6 +46,8 @@ enum IntegrationTestType implements TestType { return "Salvium Integration"; case IntegrationTestType.epiccashIntegration: return "Epic Cash Integration"; + case IntegrationTestType.firoIntegration: + return "Firo Integration"; } } @@ -61,6 +64,8 @@ enum IntegrationTestType implements TestType { 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"; } } } diff --git a/lib/services/testing/testing_service.dart b/lib/services/testing/testing_service.dart index 7308f3d8e..54a6c3f8b 100644 --- a/lib/services/testing/testing_service.dart +++ b/lib/services/testing/testing_service.dart @@ -20,6 +20,7 @@ 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'; final testingServiceProvider = StateNotifierProvider((ref) { return TestingService(); @@ -56,6 +57,7 @@ class TestingService extends StateNotifier { _integrationTestSuites[IntegrationTestType.wowneroIntegration] = WowneroIntegrationTestSuite(); _integrationTestSuites[IntegrationTestType.salviumIntegration] = SalviumIntegrationTestSuite(); _integrationTestSuites[IntegrationTestType.epiccashIntegration] = EpiccashIntegrationTestSuite(); + _integrationTestSuites[IntegrationTestType.firoIntegration] = FiroIntegrationTestSuite(); } void _initializeWalletTestSuites() { From e7e796484d17b99dcecea17d31619ea970c4a2a7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 14 Aug 2025 23:08:32 -0500 Subject: [PATCH 11/23] feat(test): ltc mweb integration test --- .../litecoin_mweb_integration_test_suite.dart | 302 ++++++++++++++++++ lib/services/testing/testing_models.dart | 7 +- lib/services/testing/testing_service.dart | 2 + 3 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 lib/services/testing/test_suites/litecoin_mweb_integration_test_suite.dart 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..053a3a2d7 --- /dev/null +++ b/lib/services/testing/test_suites/litecoin_mweb_integration_test_suite.dart @@ -0,0 +1,302 @@ +/* + * 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 { + final 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 => _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(); + 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; + _statusController.add(newStatus); + } + + @override + Future cleanup() async { + await _statusController.close(); + } +} + +// Helper function to get a random unused port. +Future _getRandomUnusedPort({Set excluded = const {}}) async { + 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/testing_models.dart b/lib/services/testing/testing_models.dart index 321400400..2461df4ed 100644 --- a/lib/services/testing/testing_models.dart +++ b/lib/services/testing/testing_models.dart @@ -31,7 +31,8 @@ enum IntegrationTestType implements TestType { wowneroIntegration, salviumIntegration, epiccashIntegration, - firoIntegration; + firoIntegration, + litecoinMwebIntegration; @override String get displayName { @@ -48,6 +49,8 @@ enum IntegrationTestType implements TestType { return "Epic Cash Integration"; case IntegrationTestType.firoIntegration: return "Firo Integration"; + case IntegrationTestType.litecoinMwebIntegration: + return "Litecoin MWEB Integration"; } } @@ -66,6 +69,8 @@ enum IntegrationTestType implements TestType { 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"; } } } diff --git a/lib/services/testing/testing_service.dart b/lib/services/testing/testing_service.dart index 54a6c3f8b..8d09e9568 100644 --- a/lib/services/testing/testing_service.dart +++ b/lib/services/testing/testing_service.dart @@ -21,6 +21,7 @@ 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(); @@ -58,6 +59,7 @@ class TestingService extends StateNotifier { _integrationTestSuites[IntegrationTestType.salviumIntegration] = SalviumIntegrationTestSuite(); _integrationTestSuites[IntegrationTestType.epiccashIntegration] = EpiccashIntegrationTestSuite(); _integrationTestSuites[IntegrationTestType.firoIntegration] = FiroIntegrationTestSuite(); + _integrationTestSuites[IntegrationTestType.litecoinMwebIntegration] = LitecoinMwebIntegrationTestSuite(); } void _initializeWalletTestSuites() { From 92d44a9643bc5fd00a2afaf540abadd6b691deb7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 14 Aug 2025 23:16:04 -0500 Subject: [PATCH 12/23] feat(test): put test suite cards in a scrollable list view --- lib/pages/testing/testing_view.dart | 31 ++++++++++++++++++----------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/lib/pages/testing/testing_view.dart b/lib/pages/testing/testing_view.dart index 656fd8ce5..c08e38ad4 100644 --- a/lib/pages/testing/testing_view.dart +++ b/lib/pages/testing/testing_view.dart @@ -182,18 +182,25 @@ class _TestingViewState extends ConsumerState { const SizedBox(height: 16), // Integration test suite cards. - ...IntegrationTestType.values.map((type) { - 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), - ), - ); - }), + 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. From 854a4019e55bf2663f78675d9ee94dbebd985d1f Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 14 Aug 2025 23:23:51 -0500 Subject: [PATCH 13/23] fix(test): fix the reset button --- lib/services/testing/testing_service.dart | 71 +++++++++++++++++------ 1 file changed, 52 insertions(+), 19 deletions(-) diff --git a/lib/services/testing/testing_service.dart b/lib/services/testing/testing_service.dart index 8d09e9568..df2b59c91 100644 --- a/lib/services/testing/testing_service.dart +++ b/lib/services/testing/testing_service.dart @@ -79,7 +79,9 @@ class TestingService extends StateNotifier { for (var type in IntegrationTestType.values) type: TestSuiteStatus.waiting }, ); - _statusController.add(state); + if (!_statusController.isClosed) { + _statusController.add(state); + } try { for (final type in IntegrationTestType.values) { @@ -90,7 +92,9 @@ class TestingService extends StateNotifier { Logging.instance.log(Level.error, "Error running integration test suites: $e"); } finally { state = state.copyWith(isRunning: false); - _statusController.add(state); + if (!_statusController.isClosed) { + _statusController.add(state); + } } } @@ -111,7 +115,9 @@ class TestingService extends StateNotifier { for (var type in WalletTestType.values) type: TestSuiteStatus.waiting }, ); - _statusController.add(state); + if (!_statusController.isClosed) { + _statusController.add(state); + } try { for (final type in WalletTestType.values) { @@ -122,7 +128,9 @@ class TestingService extends StateNotifier { Logging.instance.log(Level.error, "Error running wallet test suites: $e"); } finally { state = state.copyWith(isRunning: false); - _statusController.add(state); + if (!_statusController.isClosed) { + _statusController.add(state); + } } } @@ -150,7 +158,9 @@ class TestingService extends StateNotifier { integrationTestStatuses: updatedStatuses, suiteStatuses: updatedAllStatuses, ); - _statusController.add(state); + if (!_statusController.isClosed) { + _statusController.add(state); + } try { final result = await suite.runTests().timeout( @@ -175,7 +185,9 @@ class TestingService extends StateNotifier { suiteStatuses: updatedAllStatuses, completed: completed, ); - _statusController.add(state); + if (!_statusController.isClosed) { + _statusController.add(state); + } await suite.cleanup(); } catch (e) { @@ -191,7 +203,9 @@ class TestingService extends StateNotifier { suiteStatuses: updatedAllStatuses, completed: completed, ); - _statusController.add(state); + if (!_statusController.isClosed) { + _statusController.add(state); + } Logging.instance.log(Level.error, "Error running $type integration test suite: $e"); } @@ -220,7 +234,9 @@ class TestingService extends StateNotifier { walletTestStatuses: updatedStatuses, suiteStatuses: updatedAllStatuses, ); - _statusController.add(state); + if (!_statusController.isClosed) { + _statusController.add(state); + } try { // TODO: Pass wallet config to suite when implementing actual wallet tests. @@ -246,7 +262,9 @@ class TestingService extends StateNotifier { suiteStatuses: updatedAllStatuses, completed: completed, ); - _statusController.add(state); + if (!_statusController.isClosed) { + _statusController.add(state); + } await suite.cleanup(); } catch (e) { @@ -262,7 +280,9 @@ class TestingService extends StateNotifier { suiteStatuses: updatedAllStatuses, completed: completed, ); - _statusController.add(state); + if (!_statusController.isClosed) { + _statusController.add(state); + } Logging.instance.log(Level.error, "Error running $type wallet test suite: $e"); } @@ -280,7 +300,11 @@ class TestingService extends StateNotifier { Future cancelTesting() async { _cancelled = true; state = state.copyWith(isRunning: false); - _statusController.add(state); + + // 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(); @@ -291,32 +315,41 @@ class TestingService extends StateNotifier { } Future resetTestResults() async { + // Clear test suites to ensure fresh state. + _integrationTestSuites.clear(); + _walletTestSuites.clear(); + _cancelled = false; + state = TestingSessionState( testStatuses: { - for (var type in IntegrationTestType.values) type: TestSuiteStatus.waiting, - for (var type in WalletTestType.values) type: TestSuiteStatus.waiting, + for (final type in IntegrationTestType.values) type: TestSuiteStatus.waiting, + for (final type in WalletTestType.values) type: TestSuiteStatus.waiting, }, integrationTestStatuses: { - for (var type in IntegrationTestType.values) type: TestSuiteStatus.waiting + for (final type in IntegrationTestType.values) type: TestSuiteStatus.waiting }, walletTestStatuses: { - for (var type in WalletTestType.values) type: TestSuiteStatus.waiting + for (final type in WalletTestType.values) type: TestSuiteStatus.waiting }, isRunning: false, completed: 0, total: IntegrationTestType.values.length + WalletTestType.values.length, ); - _statusController.add(state); + + // Only add to stream controller if it's not closed. + if (!_statusController.isClosed) { + _statusController.add(state); + } } - // Wallet test configuration methods + // Wallet test configuration methods. void setWalletTestConfig(WalletTestConfig config) { _walletTestConfig = config; } WalletTestConfig? get walletTestConfig => _walletTestConfig; - // Helper method to calculate completed tests across both types + // Helper method to calculate completed tests across both types. int _calculateCompletedTests() { final integrationCompleted = state.integrationTestStatuses.values .where((status) => status == TestSuiteStatus.passed || status == TestSuiteStatus.failed) @@ -351,7 +384,7 @@ class TestingService extends StateNotifier { super.dispose(); } - // Convenience methods for getting display names + // Convenience methods for getting display names. String getDisplayNameForTest(TestType type) { return type.displayName; } From a7c414eaf3ec45718d52e98dc4a9b0674d66cd08 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 14 Aug 2025 23:24:14 -0500 Subject: [PATCH 14/23] fix(test): fix the mweb test create then start then (attempt to) stop the server --- .../litecoin_mweb_integration_test_suite.dart | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 index 053a3a2d7..cbe665fc9 100644 --- a/lib/services/testing/test_suites/litecoin_mweb_integration_test_suite.dart +++ b/lib/services/testing/test_suites/litecoin_mweb_integration_test_suite.dart @@ -101,12 +101,23 @@ class LitecoinMwebIntegrationTestSuite implements TestSuiteInterface { // 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 { From 21e0cf7bf9469ea1bdf90652f8d653a4c3f819bd Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 15 Aug 2025 16:42:44 -0500 Subject: [PATCH 15/23] fix(test): fix litecoin mweb test --- .../test_suites/litecoin_mweb_integration_test_suite.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index cbe665fc9..58aa0aace 100644 --- a/lib/services/testing/test_suites/litecoin_mweb_integration_test_suite.dart +++ b/lib/services/testing/test_suites/litecoin_mweb_integration_test_suite.dart @@ -282,7 +282,8 @@ class LitecoinMwebIntegrationTestSuite implements TestSuiteInterface { } // Helper function to get a random unused port. -Future _getRandomUnusedPort({Set excluded = const {}}) async { +Future _getRandomUnusedPort({Set? excluded}) async { + excluded ??= {}; const int minPort = 1024; const int maxPort = 65535; const int maxAttempts = 100; From 0d2ee6ff4b2404d89126a7adcd0a96b26e7dd209 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 17 Sep 2025 12:29:27 -0500 Subject: [PATCH 16/23] test(monero): add Monero SWB round trip test --- .../monero_integration_test_suite.dart | 260 ++++++++++++++++++ pubspec.lock | 23 +- 2 files changed, 271 insertions(+), 12 deletions(-) diff --git a/lib/services/testing/test_suites/monero_integration_test_suite.dart b/lib/services/testing/test_suites/monero_integration_test_suite.dart index 14812c61c..886d87509 100644 --- a/lib/services/testing/test_suites/monero_integration_test_suite.dart +++ b/lib/services/testing/test_suites/monero_integration_test_suite.dart @@ -9,14 +9,17 @@ */ 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'; @@ -49,6 +52,8 @@ class MoneroWalletTestSuite implements TestSuiteInterface { await _testMnemonicGeneration(); + await _testStackWalletBackupRoundTrip(); + // TODO: FIXME. // await _testPolyseedRestoration(); @@ -278,6 +283,261 @@ class MoneroWalletTestSuite implements TestSuiteInterface { } } + /// 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; _statusController.add(newStatus); 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" From de7d53f0c83310db906d6bdeaaac76a82704f447 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 17 Sep 2025 12:43:06 -0500 Subject: [PATCH 17/23] test(wownero): add Wownero SWB round trip test --- .../wownero_integration_test_suite.dart | 262 +++++++++++++++++- 1 file changed, 261 insertions(+), 1 deletion(-) diff --git a/lib/services/testing/test_suites/wownero_integration_test_suite.dart b/lib/services/testing/test_suites/wownero_integration_test_suite.dart index 553b00109..358e0ab0f 100644 --- a/lib/services/testing/test_suites/wownero_integration_test_suite.dart +++ b/lib/services/testing/test_suites/wownero_integration_test_suite.dart @@ -9,14 +9,17 @@ */ 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'; @@ -47,7 +50,9 @@ class WowneroIntegrationTestSuite implements TestSuiteInterface { Logging.instance.log(Level.info, "Starting Wownero integration test suite..."); await _testWowneroMnemonicGeneration(); - + + await _testWowneroStackWalletBackupRoundTrip(); + stopwatch.stop(); _updateStatus(TestSuiteStatus.passed); @@ -198,6 +203,261 @@ class WowneroIntegrationTestSuite implements TestSuiteInterface { } } + /// 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; _statusController.add(newStatus); From 413010040c692764fc87dad51482813dc5524d7a Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 17 Sep 2025 12:49:46 -0500 Subject: [PATCH 18/23] test(salvium): add Salvium SWB round trip test --- .../salvium_integration_test_suite.dart | 253 +++++++++++++++++- 1 file changed, 252 insertions(+), 1 deletion(-) diff --git a/lib/services/testing/test_suites/salvium_integration_test_suite.dart b/lib/services/testing/test_suites/salvium_integration_test_suite.dart index b9e0bf26d..cffc07214 100644 --- a/lib/services/testing/test_suites/salvium_integration_test_suite.dart +++ b/lib/services/testing/test_suites/salvium_integration_test_suite.dart @@ -9,13 +9,16 @@ */ 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'; @@ -46,7 +49,9 @@ class SalviumIntegrationTestSuite implements TestSuiteInterface { Logging.instance.log(Level.info, "Starting Salvium integration test suite..."); await _testSalviumMnemonicGeneration(); - + + await _testSalviumStackWalletBackupRoundTrip(); + stopwatch.stop(); _updateStatus(TestSuiteStatus.passed); @@ -188,6 +193,252 @@ class SalviumIntegrationTestSuite implements TestSuiteInterface { } } + /// 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; _statusController.add(newStatus); From a6be31c22c2d80df74bb7651b90ec09e0df4c8c1 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 17 Sep 2025 13:01:09 -0500 Subject: [PATCH 19/23] fix(test): allow iui e2e tests to re-run --- .../test_suites/epiccash_integration_test_suite.dart | 12 ++++++++++-- .../test_suites/firo_integration_test_suite.dart | 12 ++++++++++-- .../litecoin_mweb_integration_test_suite.dart | 12 ++++++++++-- .../test_suites/monero_integration_test_suite.dart | 12 ++++++++++-- .../test_suites/salvium_integration_test_suite.dart | 12 ++++++++++-- lib/services/testing/test_suites/tor_test_suite.dart | 12 ++++++++++-- .../test_suites/wownero_integration_test_suite.dart | 12 ++++++++++-- 7 files changed, 70 insertions(+), 14 deletions(-) diff --git a/lib/services/testing/test_suites/epiccash_integration_test_suite.dart b/lib/services/testing/test_suites/epiccash_integration_test_suite.dart index 35e94fafd..af5c47ccd 100644 --- a/lib/services/testing/test_suites/epiccash_integration_test_suite.dart +++ b/lib/services/testing/test_suites/epiccash_integration_test_suite.dart @@ -17,7 +17,7 @@ import '../test_suite_interface.dart'; import '../testing_models.dart'; class EpiccashIntegrationTestSuite implements TestSuiteInterface { - final StreamController _statusController = + StreamController _statusController = StreamController.broadcast(); TestSuiteStatus _status = TestSuiteStatus.waiting; @@ -31,7 +31,12 @@ class EpiccashIntegrationTestSuite implements TestSuiteInterface { TestSuiteStatus get status => _status; @override - Stream get statusStream => _statusController.stream; + Stream get statusStream { + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + return _statusController.stream; + } @override Future runTests() async { @@ -157,6 +162,9 @@ class EpiccashIntegrationTestSuite implements TestSuiteInterface { void _updateStatus(TestSuiteStatus newStatus) { _status = newStatus; + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } _statusController.add(newStatus); } diff --git a/lib/services/testing/test_suites/firo_integration_test_suite.dart b/lib/services/testing/test_suites/firo_integration_test_suite.dart index 7795dc3bc..020ef6627 100644 --- a/lib/services/testing/test_suites/firo_integration_test_suite.dart +++ b/lib/services/testing/test_suites/firo_integration_test_suite.dart @@ -18,7 +18,7 @@ import '../test_suite_interface.dart'; import '../testing_models.dart'; class FiroIntegrationTestSuite implements TestSuiteInterface { - final StreamController _statusController = + StreamController _statusController = StreamController.broadcast(); TestSuiteStatus _status = TestSuiteStatus.waiting; @@ -32,7 +32,12 @@ class FiroIntegrationTestSuite implements TestSuiteInterface { TestSuiteStatus get status => _status; @override - Stream get statusStream => _statusController.stream; + Stream get statusStream { + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + return _statusController.stream; + } @override Future runTests() async { @@ -210,6 +215,9 @@ class FiroIntegrationTestSuite implements TestSuiteInterface { void _updateStatus(TestSuiteStatus newStatus) { _status = newStatus; + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } _statusController.add(newStatus); } 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 index 58aa0aace..95c463fcf 100644 --- a/lib/services/testing/test_suites/litecoin_mweb_integration_test_suite.dart +++ b/lib/services/testing/test_suites/litecoin_mweb_integration_test_suite.dart @@ -22,7 +22,7 @@ import '../test_suite_interface.dart'; import '../testing_models.dart'; class LitecoinMwebIntegrationTestSuite implements TestSuiteInterface { - final StreamController _statusController = + StreamController _statusController = StreamController.broadcast(); TestSuiteStatus _status = TestSuiteStatus.waiting; @@ -36,7 +36,12 @@ class LitecoinMwebIntegrationTestSuite implements TestSuiteInterface { TestSuiteStatus get status => _status; @override - Stream get statusStream => _statusController.stream; + Stream get statusStream { + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + return _statusController.stream; + } @override Future runTests() async { @@ -272,6 +277,9 @@ class LitecoinMwebIntegrationTestSuite implements TestSuiteInterface { void _updateStatus(TestSuiteStatus newStatus) { _status = newStatus; + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } _statusController.add(newStatus); } diff --git a/lib/services/testing/test_suites/monero_integration_test_suite.dart b/lib/services/testing/test_suites/monero_integration_test_suite.dart index 886d87509..8f8e80928 100644 --- a/lib/services/testing/test_suites/monero_integration_test_suite.dart +++ b/lib/services/testing/test_suites/monero_integration_test_suite.dart @@ -25,7 +25,7 @@ import '../testing_models.dart'; import 'test_data/polyseed_vectors.dart'; class MoneroWalletTestSuite implements TestSuiteInterface { - final StreamController _statusController = + StreamController _statusController = StreamController.broadcast(); TestSuiteStatus _status = TestSuiteStatus.waiting; @@ -39,7 +39,12 @@ class MoneroWalletTestSuite implements TestSuiteInterface { TestSuiteStatus get status => _status; @override - Stream get statusStream => _statusController.stream; + Stream get statusStream { + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + return _statusController.stream; + } @override Future runTests() async { @@ -540,6 +545,9 @@ class MoneroWalletTestSuite implements TestSuiteInterface { void _updateStatus(TestSuiteStatus newStatus) { _status = newStatus; + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } _statusController.add(newStatus); } diff --git a/lib/services/testing/test_suites/salvium_integration_test_suite.dart b/lib/services/testing/test_suites/salvium_integration_test_suite.dart index cffc07214..546a02ed2 100644 --- a/lib/services/testing/test_suites/salvium_integration_test_suite.dart +++ b/lib/services/testing/test_suites/salvium_integration_test_suite.dart @@ -23,7 +23,7 @@ import '../test_suite_interface.dart'; import '../testing_models.dart'; class SalviumIntegrationTestSuite implements TestSuiteInterface { - final StreamController _statusController = + StreamController _statusController = StreamController.broadcast(); TestSuiteStatus _status = TestSuiteStatus.waiting; @@ -37,7 +37,12 @@ class SalviumIntegrationTestSuite implements TestSuiteInterface { TestSuiteStatus get status => _status; @override - Stream get statusStream => _statusController.stream; + Stream get statusStream { + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + return _statusController.stream; + } @override Future runTests() async { @@ -441,6 +446,9 @@ class SalviumIntegrationTestSuite implements TestSuiteInterface { void _updateStatus(TestSuiteStatus newStatus) { _status = newStatus; + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } _statusController.add(newStatus); } diff --git a/lib/services/testing/test_suites/tor_test_suite.dart b/lib/services/testing/test_suites/tor_test_suite.dart index ede27da64..a6174f91b 100644 --- a/lib/services/testing/test_suites/tor_test_suite.dart +++ b/lib/services/testing/test_suites/tor_test_suite.dart @@ -25,7 +25,7 @@ import '../../../electrumx_rpc/electrumx_client.dart'; import '../../../utilities/tor_plain_net_option_enum.dart'; class TorTestSuite implements TestSuiteInterface { - final StreamController _statusController = StreamController.broadcast(); + StreamController _statusController = StreamController.broadcast(); TestSuiteStatus _status = TestSuiteStatus.waiting; @override @@ -38,7 +38,12 @@ class TorTestSuite implements TestSuiteInterface { TestSuiteStatus get status => _status; @override - Stream get statusStream => _statusController.stream; + Stream get statusStream { + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + return _statusController.stream; + } @override Future runTests() async { @@ -296,6 +301,9 @@ class TorTestSuite implements TestSuiteInterface { void _updateStatus(TestSuiteStatus newStatus) { _status = newStatus; + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } _statusController.add(_status); } diff --git a/lib/services/testing/test_suites/wownero_integration_test_suite.dart b/lib/services/testing/test_suites/wownero_integration_test_suite.dart index 358e0ab0f..9d37abbf5 100644 --- a/lib/services/testing/test_suites/wownero_integration_test_suite.dart +++ b/lib/services/testing/test_suites/wownero_integration_test_suite.dart @@ -24,7 +24,7 @@ import '../test_suite_interface.dart'; import '../testing_models.dart'; class WowneroIntegrationTestSuite implements TestSuiteInterface { - final StreamController _statusController = + StreamController _statusController = StreamController.broadcast(); TestSuiteStatus _status = TestSuiteStatus.waiting; @@ -38,7 +38,12 @@ class WowneroIntegrationTestSuite implements TestSuiteInterface { TestSuiteStatus get status => _status; @override - Stream get statusStream => _statusController.stream; + Stream get statusStream { + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } + return _statusController.stream; + } @override Future runTests() async { @@ -460,6 +465,9 @@ class WowneroIntegrationTestSuite implements TestSuiteInterface { void _updateStatus(TestSuiteStatus newStatus) { _status = newStatus; + if (_statusController.isClosed) { + _statusController = StreamController.broadcast(); + } _statusController.add(newStatus); } From 874854cc1d84175553878a812bcea76a282afee3 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 11 Dec 2025 17:17:39 -0600 Subject: [PATCH 20/23] fix(test): cs_monero, cs_wownero updates --- .../monero_integration_test_suite.dart | 628 ++++++++-------- .../salvium_integration_test_suite.dart | 642 +++++++++-------- .../wownero_integration_test_suite.dart | 676 ++++++++++-------- 3 files changed, 1046 insertions(+), 900 deletions(-) diff --git a/lib/services/testing/test_suites/monero_integration_test_suite.dart b/lib/services/testing/test_suites/monero_integration_test_suite.dart index 8f8e80928..952253330 100644 --- a/lib/services/testing/test_suites/monero_integration_test_suite.dart +++ b/lib/services/testing/test_suites/monero_integration_test_suite.dart @@ -9,17 +9,16 @@ */ 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:cs_monero/cs_monero.dart' as lib_monero; 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'; @@ -49,36 +48,35 @@ class MoneroWalletTestSuite implements TestSuiteInterface { @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 _testStackWalletBackupRoundTrip(); // 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" + + Logging.instance.log( + Level.error, + "Monero wallet test suite failed: $e\n$stackTrace", ); - + return TestResult( success: false, message: "Monero wallet FFI tests failed: $e", @@ -88,22 +86,25 @@ class MoneroWalletTestSuite implements TestSuiteInterface { } Future _testMnemonicGeneration() async { - Logging.instance.log(Level.info, "Testing mnemonic generation and wallet creation..."); - + 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", + walletPath: "${walletPath}_16", password: walletPassword, seedType: lib_monero.MoneroSeedType.sixteen, expectedWordCount: 16, ); - + // Test 25-word mnemonic generation. await _testWalletCreation( walletPath: "${walletPath}_25", @@ -111,15 +112,11 @@ class MoneroWalletTestSuite implements TestSuiteInterface { 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", - ]); + await _cleanupTestWallets(["${walletPath}_16", "${walletPath}_25"]); } } @@ -133,7 +130,7 @@ class MoneroWalletTestSuite implements TestSuiteInterface { required int expectedWordCount, }) async { lib_monero.Wallet? wallet; - + try { // Create new wallet with specified seed type. wallet = await lib_monero.MoneroWallet.create( @@ -141,36 +138,37 @@ class MoneroWalletTestSuite implements TestSuiteInterface { password: password, seedType: seedType, seedOffset: "", + networkType: lib_monero.Network.mainnet, ); - + // 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" + "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(); - + final secretSpendKey = await wallet.getPrivateSpendKey(); + final secretViewKey = await 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" + + Logging.instance.log( + Level.info, + "Successfully created $expectedWordCount-word wallet: $address", ); - } finally { await wallet?.close(); } @@ -191,12 +189,12 @@ class MoneroWalletTestSuite implements TestSuiteInterface { 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, @@ -204,53 +202,56 @@ class MoneroWalletTestSuite implements TestSuiteInterface { seed: testVector.mnemonic, restoreHeight: 0, // Polyseed vectors don't require a restore height. seedOffset: "", + networkType: lib_monero.Network.mainnet, ); - + // 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'" + "Restored mnemonic doesn't match: expected '${testVector.mnemonic}', got '$restoredMnemonic'", ); } - final address = wallet.getAddress().value; + final address = (await wallet.getAddress()).value; if (address != testVector.expectedMainAddress.toString()) { throw Exception( - "Address mismatch: expected '${testVector.expectedMainAddress}', got '$address'" + "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'" + "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'" + "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'" + "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'" + "Public view key mismatch: expected '${testVector.expectedPublicViewKey}', got '$publicViewKey'", ); } - - Logging.instance.log(Level.info, "👍 Polyseed restoration test passed successfully"); - + + Logging.instance.log( + Level.info, + "👍 Polyseed restoration test passed successfully", + ); } finally { await wallet?.close(); await _cleanupTestWallets([walletPath]); @@ -264,7 +265,7 @@ class MoneroWalletTestSuite implements TestSuiteInterface { final walletFile = File(walletPath); final keysFile = File("$walletPath.keys"); final addressFile = File("$walletPath.address.txt"); - + if (await walletFile.exists()) { await walletFile.delete(); } @@ -280,269 +281,272 @@ class MoneroWalletTestSuite implements TestSuiteInterface { 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}" + Logging.instance.log( + Level.warning, + "Failed to cleanup wallet $walletPath: $e", ); } - - // 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"); - } } } + // /// 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: "", + // networkType: lib_monero.Network.mainnet, + // ); + // + // // 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, + // ); + // + // 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.decryptStackWalletStringWithPassphrase( + // 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) { @@ -555,4 +559,4 @@ class MoneroWalletTestSuite implements TestSuiteInterface { 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 index 546a02ed2..ee88278e9 100644 --- a/lib/services/testing/test_suites/salvium_integration_test_suite.dart +++ b/lib/services/testing/test_suites/salvium_integration_test_suite.dart @@ -9,16 +9,15 @@ */ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'dart:math'; + +import 'package:cs_salvium/cs_salvium.dart' as lib_salvium; 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'; @@ -47,33 +46,37 @@ class SalviumIntegrationTestSuite implements TestSuiteInterface { @override Future runTests() async { final stopwatch = Stopwatch()..start(); - + try { _updateStatus(TestSuiteStatus.running); - - Logging.instance.log(Level.info, "Starting Salvium integration test suite..."); + + Logging.instance.log( + Level.info, + "Starting Salvium integration test suite...", + ); await _testSalviumMnemonicGeneration(); - await _testSalviumStackWalletBackupRoundTrip(); + // TODO: FIXME. + // 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" + + Logging.instance.log( + Level.error, + "Salvium integration test suite failed: $e\n$stackTrace", ); - + return TestResult( success: false, message: "Salvium integration tests failed: $e", @@ -83,13 +86,16 @@ class SalviumIntegrationTestSuite implements TestSuiteInterface { } Future _testSalviumMnemonicGeneration() async { - Logging.instance.log(Level.info, "Testing Salvium mnemonic generation and wallet creation..."); - + 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( @@ -98,14 +104,14 @@ class SalviumIntegrationTestSuite implements TestSuiteInterface { seedType: lib_salvium.SalviumSeedType.twentyFive, expectedWordCount: 25, ); - - Logging.instance.log(Level.info, "👍 Salvium mnemonic generation tests passed"); - + + Logging.instance.log( + Level.info, + "👍 Salvium mnemonic generation tests passed", + ); } finally { // Cleanup test wallet files. - await _cleanupTestWallets([ - "${walletPath}_25", - ]); + await _cleanupTestWallets(["${walletPath}_25"]); } } @@ -119,7 +125,7 @@ class SalviumIntegrationTestSuite implements TestSuiteInterface { required int expectedWordCount, }) async { lib_salvium.Wallet? wallet; - + try { // Create new Salvium wallet with specified seed type. wallet = await lib_salvium.SalviumWallet.create( @@ -128,40 +134,42 @@ class SalviumIntegrationTestSuite implements TestSuiteInterface { 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" + "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}"); + 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" + + Logging.instance.log( + Level.info, + "Successfully created $expectedWordCount-word Salvium wallet: $address", ); - } finally { await wallet?.close(); } @@ -174,7 +182,7 @@ class SalviumIntegrationTestSuite implements TestSuiteInterface { final walletFile = File(walletPath); final keysFile = File("$walletPath.keys"); final addressFile = File("$walletPath.address.txt"); - + if (await walletFile.exists()) { await walletFile.delete(); } @@ -190,260 +198,324 @@ class SalviumIntegrationTestSuite implements TestSuiteInterface { 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" + Logging.instance.log( + Level.info, + "Cleaned up test Salvium wallet: $walletPath", ); - } - - // 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"); + 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) { @@ -456,4 +528,4 @@ class SalviumIntegrationTestSuite implements TestSuiteInterface { Future cleanup() async { 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 index 9d37abbf5..860b46988 100644 --- a/lib/services/testing/test_suites/wownero_integration_test_suite.dart +++ b/lib/services/testing/test_suites/wownero_integration_test_suite.dart @@ -9,17 +9,15 @@ */ 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:cs_wownero/cs_wownero.dart' as lib_wownero; 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'; @@ -48,33 +46,37 @@ class WowneroIntegrationTestSuite implements TestSuiteInterface { @override Future runTests() async { final stopwatch = Stopwatch()..start(); - + try { _updateStatus(TestSuiteStatus.running); - - Logging.instance.log(Level.info, "Starting Wownero integration test suite..."); + + Logging.instance.log( + Level.info, + "Starting Wownero integration test suite...", + ); await _testWowneroMnemonicGeneration(); - await _testWowneroStackWalletBackupRoundTrip(); + // TODO: FIXME. + // 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" + + Logging.instance.log( + Level.error, + "Wownero integration test suite failed: $e\n$stackTrace", ); - + return TestResult( success: false, message: "Wownero integration tests failed: $e", @@ -84,38 +86,40 @@ class WowneroIntegrationTestSuite implements TestSuiteInterface { } Future _testWowneroMnemonicGeneration() async { - Logging.instance.log(Level.info, "Testing Wownero mnemonic generation and wallet creation..."); - + 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", + walletPath: "${walletPath}_16", password: walletPassword, - seedType: lib_monero.WowneroSeedType.sixteen, + seedType: lib_wownero.WowneroSeedType.sixteen, expectedWordCount: 16, ); - + // Test 25-word mnemonic generation for Wownero. await _testWowneroWalletCreation( walletPath: "${walletPath}_25", password: walletPassword, - seedType: lib_monero.WowneroSeedType.twentyFive, + seedType: lib_wownero.WowneroSeedType.twentyFive, expectedWordCount: 25, ); - - Logging.instance.log(Level.info, "👍 Wownero mnemonic generation tests passed"); - + + Logging.instance.log( + Level.info, + "👍 Wownero mnemonic generation tests passed", + ); } finally { // Cleanup test wallet files. - await _cleanupTestWallets([ - "${walletPath}_16", - "${walletPath}_25", - ]); + await _cleanupTestWallets(["${walletPath}_16", "${walletPath}_25"]); } } @@ -125,53 +129,55 @@ class WowneroIntegrationTestSuite implements TestSuiteInterface { Future _testWowneroWalletCreation({ required String walletPath, required String password, - required lib_monero.WowneroSeedType seedType, + required lib_wownero.WowneroSeedType seedType, required int expectedWordCount, }) async { - lib_monero.Wallet? wallet; - + lib_wownero.Wallet? wallet; + try { // Create new Wownero wallet with specified seed type. - wallet = await lib_monero.WowneroWallet.create( + wallet = await lib_wownero.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" + "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}"); + 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" + + Logging.instance.log( + Level.info, + "Successfully created $expectedWordCount-word Wownero wallet: $address", ); - } finally { await wallet?.close(); } @@ -184,7 +190,7 @@ class WowneroIntegrationTestSuite implements TestSuiteInterface { final walletFile = File(walletPath); final keysFile = File("$walletPath.keys"); final addressFile = File("$walletPath.address.txt"); - + if (await walletFile.exists()) { await walletFile.delete(); } @@ -200,269 +206,333 @@ class WowneroIntegrationTestSuite implements TestSuiteInterface { 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" + Logging.instance.log( + Level.info, + "Cleaned up test Wownero wallet: $walletPath", ); - } - - // 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"); + 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_wownero.WowneroSeedType.sixteen, + // expectedWordCount: 16, + // suffix: "16", + // ); + // + // // Test 25-word mnemonic backup. + // await _testWowneroBackupWithSeedType( + // tempDir: tempDir, + // testId: testId, + // seedType: lib_wownero.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_wownero.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_wownero.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_wownero.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) { @@ -475,4 +545,4 @@ class WowneroIntegrationTestSuite implements TestSuiteInterface { Future cleanup() async { await _statusController.close(); } -} \ No newline at end of file +} From 069734586b5a5f8cff2855512cdfc7a75d9e5f52 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 11 Dec 2025 17:30:26 -0600 Subject: [PATCH 21/23] fix(test): fix swb handling and unrelated dart format changes thruout --- lib/pages/testing/testing_view.dart | 282 +++++++++++++--------------- 1 file changed, 126 insertions(+), 156 deletions(-) diff --git a/lib/pages/testing/testing_view.dart b/lib/pages/testing/testing_view.dart index c08e38ad4..9633a8aac 100644 --- a/lib/pages/testing/testing_view.dart +++ b/lib/pages/testing/testing_view.dart @@ -10,27 +10,28 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; + 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 '../../services/testing/testing_service.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; +import '../../widgets/background.dart'; import '../../widgets/conditional_parent.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_app_bar.dart'; +import '../../widgets/desktop/desktop_scaffold.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 '../settings_views/global_settings_view/stack_backup_views/helpers/swb_file_system.dart'; import 'sub_widgets/test_suite_card.dart'; class TestingView extends ConsumerStatefulWidget { @@ -79,16 +80,15 @@ class _TestingViewState extends ConsumerState { isCompactHeight: false, leading: AppBarBackButton(), ), - body: SizedBox( - width: 480, - child: child, - ), + body: SizedBox(width: 480, child: child), ), child: ConditionalParent( condition: !isDesktop, builder: (child) => Background( child: Scaffold( - backgroundColor: Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( leading: AppBarBackButton( onPressed: () async { @@ -97,10 +97,7 @@ class _TestingViewState extends ConsumerState { } }, ), - title: Text( - "Testing", - style: STextStyles.navBarTitle(context), - ), + title: Text("Testing", style: STextStyles.navBarTitle(context)), ), body: LayoutBuilder( builder: (context, constraints) { @@ -109,9 +106,7 @@ class _TestingViewState extends ConsumerState { constraints: BoxConstraints( minHeight: constraints.maxHeight, ), - child: IntrinsicHeight( - child: child, - ), + child: IntrinsicHeight(child: child), ), ); }, @@ -123,41 +118,29 @@ class _TestingViewState extends ConsumerState { ConditionalParent( condition: isDesktop, builder: (child) => Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - top: 16, - ), + 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, - ), + 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, - ), - + 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", + label: testingState.isRunning + ? "Cancel" + : "Run integration tests", onPressed: testingState.isRunning ? () => testingService.cancelTesting() : () => testingService.runAllTests(), @@ -170,20 +153,23 @@ class _TestingViewState extends ConsumerState { ? () => testingService.cancelTesting() : () => testingService.runAllTests(), child: Text( - testingState.isRunning ? "Cancel" : "Run integration tests", + testingState.isRunning + ? "Cancel" + : "Run integration tests", style: STextStyles.button(context).copyWith( - color: Theme.of(context) - .extension()! - .buttonTextPrimary, + 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. + height: + 300, // Set a fixed height for the scrollable area. child: ListView.builder( itemCount: IntegrationTestType.values.length, itemBuilder: (context, index) { @@ -192,9 +178,11 @@ class _TestingViewState extends ConsumerState { padding: const EdgeInsets.only(bottom: 8), child: TestSuiteCard( testType: type, - status: testingState.testStatuses[type] ?? TestSuiteStatus.waiting, - onTap: testingState.isRunning - ? null + status: + testingState.testStatuses[type] ?? + TestSuiteStatus.waiting, + onTap: testingState.isRunning + ? null : () => testingService.runTestSuite(type), ), ); @@ -207,21 +195,20 @@ class _TestingViewState extends ConsumerState { if (!swbLoaded) ConditionalParent( condition: isDesktop, - builder: (child) => - SecondaryButton( - label: "Load SWB for extended tests", - onPressed: testingState.isRunning - ? null - : () => _selectSwbFile(), - ), + 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) + .extension()! + .getPrimaryDisabledButtonStyle(context) : Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context), + .extension()! + .getPrimaryEnabledButtonStyle(context), onPressed: testingState.isRunning ? null : () => _selectSwbFile(), @@ -232,11 +219,11 @@ class _TestingViewState extends ConsumerState { Assets.svg.backupRestore, color: testingState.isRunning ? Theme.of(context) - .extension()! - .buttonTextPrimaryDisabled + .extension()! + .buttonTextPrimaryDisabled : Theme.of(context) - .extension()! - .buttonTextPrimary, + .extension()! + .buttonTextPrimary, width: 16, height: 16, ), @@ -246,11 +233,11 @@ class _TestingViewState extends ConsumerState { style: STextStyles.button(context).copyWith( color: testingState.isRunning ? Theme.of(context) - .extension()! - .buttonTextPrimaryDisabled + .extension()! + .buttonTextPrimaryDisabled : Theme.of(context) - .extension()! - .buttonTextPrimary, + .extension()! + .buttonTextPrimary, ), ), ], @@ -260,21 +247,20 @@ class _TestingViewState extends ConsumerState { if (swbLoaded) ConditionalParent( condition: isDesktop, - builder: (child) => - SecondaryButton( - label: "Run extended SWB tests", - onPressed: testingState.isRunning - ? null - : () => _showPasswordDialog(), - ), + builder: (child) => SecondaryButton( + label: "Run extended SWB tests", + onPressed: testingState.isRunning + ? null + : () => _showPasswordDialog(), + ), child: TextButton( style: testingState.isRunning ? Theme.of(context) - .extension()! - .getPrimaryDisabledButtonStyle(context) + .extension()! + .getPrimaryDisabledButtonStyle(context) : Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context), + .extension()! + .getPrimaryEnabledButtonStyle(context), onPressed: testingState.isRunning ? null : () => _selectSwbFile(), @@ -285,11 +271,11 @@ class _TestingViewState extends ConsumerState { Assets.svg.backupRestore, color: testingState.isRunning ? Theme.of(context) - .extension()! - .buttonTextPrimaryDisabled + .extension()! + .buttonTextPrimaryDisabled : Theme.of(context) - .extension()! - .buttonTextPrimary, + .extension()! + .buttonTextPrimary, width: 16, height: 16, ), @@ -299,11 +285,11 @@ class _TestingViewState extends ConsumerState { style: STextStyles.button(context).copyWith( color: testingState.isRunning ? Theme.of(context) - .extension()! - .buttonTextPrimaryDisabled + .extension()! + .buttonTextPrimaryDisabled : Theme.of(context) - .extension()! - .buttonTextPrimary, + .extension()! + .buttonTextPrimary, ), ), ], @@ -312,7 +298,7 @@ class _TestingViewState extends ConsumerState { ), const SizedBox(height: 16), - + // Reset button ConditionalParent( condition: isDesktop, @@ -326,11 +312,11 @@ class _TestingViewState extends ConsumerState { child: TextButton( style: testingState.isRunning ? Theme.of(context) - .extension()! - .getPrimaryDisabledButtonStyle(context) + .extension()! + .getPrimaryDisabledButtonStyle(context) : Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context), + .extension()! + .getSecondaryEnabledButtonStyle(context), onPressed: testingState.isRunning ? null : () => testingService.resetTestResults(), @@ -339,11 +325,11 @@ class _TestingViewState extends ConsumerState { style: STextStyles.button(context).copyWith( color: testingState.isRunning ? Theme.of(context) - .extension()! - .buttonTextPrimaryDisabled + .extension()! + .buttonTextPrimaryDisabled : Theme.of(context) - .extension()! - .buttonTextSecondary, + .extension()! + .buttonTextSecondary, ), ), ), @@ -355,12 +341,8 @@ class _TestingViewState extends ConsumerState { const Spacer(), ConditionalParent( condition: isDesktop, - builder: (child) => const SizedBox( - height: 64, - ), - child: const SizedBox( - height: 32, - ), + builder: (child) => const SizedBox(height: 64), + child: const SizedBox(height: 32), ), ], ), @@ -371,15 +353,16 @@ class _TestingViewState extends ConsumerState { Future _selectSwbFile() async { try { await _swbFileSystem.prepareStorage(); + String? filePath; if (mounted) { - await _swbFileSystem.openFile(context); + filePath = await _swbFileSystem.openFile(); } - - if (_swbFileSystem.filePath != null) { + + if (filePath != null) { setState(() { - _selectedSwbFile = _swbFileSystem.filePath; + _selectedSwbFile = filePath; }); - + if (mounted) { swbLoaded = true; // await _showPasswordDialog(); @@ -394,10 +377,7 @@ class _TestingViewState extends ConsumerState { message: "Failed to open SWB file: $e", rightButton: TextButton( onPressed: () => Navigator.of(context).pop(), - child: Text( - "OK", - style: STextStyles.button(context), - ), + child: Text("OK", style: STextStyles.button(context)), ), ), ); @@ -408,7 +388,7 @@ class _TestingViewState extends ConsumerState { Future _showPasswordDialog() async { final passwordController = TextEditingController(); bool hidePassword = true; - + await showDialog( context: context, builder: (context) => StatefulBuilder( @@ -451,10 +431,7 @@ class _TestingViewState extends ConsumerState { Expanded( child: TextButton( onPressed: () => Navigator.of(context).pop(), - child: Text( - "Cancel", - style: STextStyles.button(context), - ), + child: Text("Cancel", style: STextStyles.button(context)), ), ), const SizedBox(width: 8), @@ -464,10 +441,7 @@ class _TestingViewState extends ConsumerState { Navigator.of(context).pop(); await _loadWalletsFromSwb(passwordController.text); }, - child: Text( - "OK", - style: STextStyles.button(context), - ), + child: Text("OK", style: STextStyles.button(context)), ), ), ], @@ -477,7 +451,7 @@ class _TestingViewState extends ConsumerState { ), ), ); - + passwordController.dispose(); } @@ -486,29 +460,35 @@ class _TestingViewState extends ConsumerState { 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), - ); - + + final encryptedText = await File(_selectedSwbFile!).readAsString(); + + final String? jsonString = await SWB + .decryptStackWalletStringWithPassphrase(( + encryptedText: encryptedText, + passphrase: password, + )); + if (jsonString == null) { swbLoaded = false; - throw Exception("Failed to decrypt SWB file. Please check your password."); + 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 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(); } @@ -521,10 +501,7 @@ class _TestingViewState extends ConsumerState { message: "Failed to decrypt SWB file: $e", rightButton: TextButton( onPressed: () => Navigator.of(context).pop(), - child: Text( - "OK", - style: STextStyles.button(context), - ), + child: Text("OK", style: STextStyles.button(context)), ), ), ); @@ -539,10 +516,7 @@ class _TestingViewState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - "Wallets in SWB", - style: STextStyles.pageTitleH2(context), - ), + Text("Wallets in SWB", style: STextStyles.pageTitleH2(context)), const SizedBox(height: 8), Text( "The following wallets were found in the backup file:", @@ -550,19 +524,18 @@ class _TestingViewState extends ConsumerState { ), 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), - ), - ], + ..._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: [ @@ -570,10 +543,7 @@ class _TestingViewState extends ConsumerState { Expanded( child: TextButton( onPressed: () => Navigator.of(context).pop(), - child: Text( - "OK", - style: STextStyles.button(context), - ), + child: Text("OK", style: STextStyles.button(context)), ), ), ], @@ -583,4 +553,4 @@ class _TestingViewState extends ConsumerState { ), ); } -} \ No newline at end of file +} From db3d349c50fa5493987a5704596c954c3b26eccb Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 11 Dec 2025 17:59:58 -0600 Subject: [PATCH 22/23] test: reproduce epic v4 crash on wallet creation/restoration TODO FIXME --- .../epiccash_integration_test_suite.dart | 224 +++++++++++++++++- 1 file changed, 217 insertions(+), 7 deletions(-) diff --git a/lib/services/testing/test_suites/epiccash_integration_test_suite.dart b/lib/services/testing/test_suites/epiccash_integration_test_suite.dart index af5c47ccd..947416be3 100644 --- a/lib/services/testing/test_suites/epiccash_integration_test_suite.dart +++ b/lib/services/testing/test_suites/epiccash_integration_test_suite.dart @@ -9,9 +9,13 @@ */ import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:logger/logger.dart'; import 'package:flutter_libepiccash/lib.dart' as lib_epic; +import 'package:path_provider/path_provider.dart'; +import 'package:stack_wallet_backup/generate_password.dart'; import '../../../utilities/logger.dart'; import '../test_suite_interface.dart'; import '../testing_models.dart'; @@ -49,6 +53,8 @@ class EpiccashIntegrationTestSuite implements TestSuiteInterface { await _testEpicCashMnemonicGeneration(); await _testEpicCashAddressValidation(); + await _testEpicCashWalletCreation(); + await _testEpicCashWalletRestore(); stopwatch.stop(); _updateStatus(TestSuiteStatus.passed); @@ -114,7 +120,7 @@ class EpiccashIntegrationTestSuite implements TestSuiteInterface { Future _testEpicCashAddressValidation() async { Logging.instance.log(Level.info, "Testing Epic Cash address validation..."); - + try { // Test valid Epic Cash addresses (different formats). final validAddresses = [ @@ -122,7 +128,7 @@ class EpiccashIntegrationTestSuite implements TestSuiteInterface { "esXrtQYZzs7DveZV4pxmXr8nntSjEkmxLddCF4hoEjVUh9nQYP7j@epicbox.stackwallet.com", "epicbox://esXrtQYZzs7DveZV4pxmXr8nntSjEkmxLddCF4hoEjVUh9nQYP7j@epicbox.fastepic.eu", ]; - + final invalidAddresses = [ "", "invalid_address", @@ -134,7 +140,7 @@ class EpiccashIntegrationTestSuite implements TestSuiteInterface { "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); @@ -142,7 +148,7 @@ class EpiccashIntegrationTestSuite implements TestSuiteInterface { 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); @@ -150,16 +156,220 @@ class EpiccashIntegrationTestSuite implements TestSuiteInterface { throw Exception("Invalid Epic Cash address marked as valid: $address"); } } - - Logging.instance.log(Level.info, + + Logging.instance.log(Level.info, "👍 Epic Cash address validation test passed" ); - + } catch (e) { throw Exception("Epic Cash address validation test failed: $e"); } } + Future _testEpicCashWalletCreation() async { + Logging.instance.log( + Level.info, + "Testing Epic Cash wallet creation (init)..." + ); + + Directory? testWalletDir; + + try { + // Create a temporary directory for the test wallet. + final tempDir = await getTemporaryDirectory(); + testWalletDir = Directory('${tempDir.path}/epic_test_wallet_${DateTime.now().millisecondsSinceEpoch}'); + await testWalletDir.create(recursive: true); + + Logging.instance.log( + Level.info, + "Created test wallet directory: ${testWalletDir.path}" + ); + + // Generate a test mnemonic. + final mnemonic = lib_epic.LibEpiccash.getMnemonic(); + + // Generate a password. + final password = generatePassword(); + + // Create config similar to what epiccash_wallet.dart creates. + final config = { + "wallet_dir": testWalletDir.path, + "check_node_api_http_addr": "http://epiccash.stackwallet.com:3413", + "chain": "mainnet", + "account": "default", + "api_listen_port": 3413, + "api_listen_interface": "://epiccash.stackwallet.com:3413", + }; + final configString = jsonEncode(config); + + final walletName = "test_wallet_${DateTime.now().millisecondsSinceEpoch}"; + + Logging.instance.log( + Level.info, + "Attempting to initialize new Epic Cash wallet..." + ); + + // This should crash with the database error. + final initResult = await lib_epic.LibEpiccash.initializeNewWallet( + config: configString, + mnemonic: mnemonic, + password: password, + name: walletName, + ); + + Logging.instance.log( + Level.info, + "Initialize wallet result: $initResult" + ); + + // Try to open the wallet. + Logging.instance.log( + Level.info, + "Attempting to open Epic Cash wallet..." + ); + + final openResult = await lib_epic.LibEpiccash.openWallet( + config: configString, + password: password, + ); + + Logging.instance.log( + Level.info, + "Open wallet result: ${openResult.substring(0, 50)}..." + ); + + Logging.instance.log( + Level.info, + "👍 Epic Cash wallet creation test passed" + ); + + } catch (e, stackTrace) { + Logging.instance.log( + Level.error, + "Epic Cash wallet creation test failed: $e\n$stackTrace" + ); + throw Exception("Epic Cash wallet creation test failed: $e"); + } finally { + // Clean up test wallet directory. + if (testWalletDir != null && await testWalletDir.exists()) { + try { + await testWalletDir.delete(recursive: true); + Logging.instance.log( + Level.info, + "Cleaned up test wallet directory" + ); + } catch (e) { + Logging.instance.log( + Level.warning, + "Failed to clean up test wallet directory: $e" + ); + } + } + } + } + + Future _testEpicCashWalletRestore() async { + Logging.instance.log( + Level.info, + "Testing Epic Cash wallet restoration (recover)..." + ); + + Directory? testWalletDir; + + try { + // Create a temporary directory for the test wallet. + final tempDir = await getTemporaryDirectory(); + testWalletDir = Directory('${tempDir.path}/epic_test_restore_${DateTime.now().millisecondsSinceEpoch}'); + await testWalletDir.create(recursive: true); + + Logging.instance.log( + Level.info, + "Created test wallet directory: ${testWalletDir.path}" + ); + + // Generate a test mnemonic. + final mnemonic = lib_epic.LibEpiccash.getMnemonic(); + + // Generate a password. + final password = generatePassword(); + + // Create config similar to what epiccash_wallet.dart creates. + final config = { + "wallet_dir": testWalletDir.path, + "check_node_api_http_addr": "http://epiccash.stackwallet.com:3413", + "chain": "mainnet", + "account": "default", + "api_listen_port": 3413, + "api_listen_interface": "://epiccash.stackwallet.com:3413", + }; + final configString = jsonEncode(config); + + final walletName = "test_restore_${DateTime.now().millisecondsSinceEpoch}"; + + Logging.instance.log( + Level.info, + "Attempting to recover Epic Cash wallet..." + ); + + // This should crash with the database error. + await lib_epic.LibEpiccash.recoverWallet( + config: configString, + password: password, + mnemonic: mnemonic, + name: walletName, + ); + + Logging.instance.log( + Level.info, + "Recover wallet completed" + ); + + // Try to open the wallet. + Logging.instance.log( + Level.info, + "Attempting to open recovered Epic Cash wallet..." + ); + + final openResult = await lib_epic.LibEpiccash.openWallet( + config: configString, + password: password, + ); + + Logging.instance.log( + Level.info, + "Open wallet result: ${openResult.substring(0, 50)}..." + ); + + Logging.instance.log( + Level.info, + "👍 Epic Cash wallet restoration test passed" + ); + + } catch (e, stackTrace) { + Logging.instance.log( + Level.error, + "Epic Cash wallet restoration test failed: $e\n$stackTrace" + ); + throw Exception("Epic Cash wallet restoration test failed: $e"); + } finally { + // Clean up test wallet directory. + if (testWalletDir != null && await testWalletDir.exists()) { + try { + await testWalletDir.delete(recursive: true); + Logging.instance.log( + Level.info, + "Cleaned up test wallet directory" + ); + } catch (e) { + Logging.instance.log( + Level.warning, + "Failed to clean up test wallet directory: $e" + ); + } + } + } + } + void _updateStatus(TestSuiteStatus newStatus) { _status = newStatus; if (_statusController.isClosed) { From c8a974692cf47a8467384c01fd4f5887f70f6436 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 15 Dec 2025 15:21:22 -0600 Subject: [PATCH 23/23] feat(epic): use epic & epic-wallet v4, but with a patched epic-wallet --- crypto_plugins/flutter_libepiccash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crypto_plugins/flutter_libepiccash b/crypto_plugins/flutter_libepiccash index 7af247de8..5f3b09f0b 160000 --- a/crypto_plugins/flutter_libepiccash +++ b/crypto_plugins/flutter_libepiccash @@ -1 +1 @@ -Subproject commit 7af247de8f404206c79452e2286fc119bd1b7bee +Subproject commit 5f3b09f0b66f2d7c79e010db80141b836dfc700f