|
9 | 9 | */ |
10 | 10 |
|
11 | 11 | import 'dart:async'; |
| 12 | +import 'dart:convert'; |
12 | 13 | import 'dart:io'; |
13 | 14 | import 'dart:math'; |
14 | 15 | import 'package:compat/old_cw_core/path_for_wallet.dart' as lib_monero_compat; |
15 | 16 | import 'package:flutter/material.dart'; |
16 | 17 | import 'package:logger/logger.dart'; |
17 | 18 | import 'package:cs_monero/cs_monero.dart' as lib_monero; |
| 19 | +import 'package:tuple/tuple.dart'; |
18 | 20 | import '../../../utilities/logger.dart'; |
19 | 21 | import '../../../utilities/stack_file_system.dart'; |
| 22 | +import '../../../pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart'; |
20 | 23 | import '../test_suite_interface.dart'; |
21 | 24 | import '../testing_models.dart'; |
22 | 25 |
|
@@ -47,7 +50,9 @@ class WowneroIntegrationTestSuite implements TestSuiteInterface { |
47 | 50 | Logging.instance.log(Level.info, "Starting Wownero integration test suite..."); |
48 | 51 |
|
49 | 52 | await _testWowneroMnemonicGeneration(); |
50 | | - |
| 53 | + |
| 54 | + await _testWowneroStackWalletBackupRoundTrip(); |
| 55 | + |
51 | 56 | stopwatch.stop(); |
52 | 57 | _updateStatus(TestSuiteStatus.passed); |
53 | 58 |
|
@@ -198,6 +203,261 @@ class WowneroIntegrationTestSuite implements TestSuiteInterface { |
198 | 203 | } |
199 | 204 | } |
200 | 205 |
|
| 206 | + /// Tests Stack Wallet Backup round-trip functionality. |
| 207 | + /// |
| 208 | + /// Creates Wownero wallets with both 16-word and 25-word mnemonics, saves the mnemonics, |
| 209 | + /// creates backups, restores the backups, and verifies the restored mnemonics match the originals. |
| 210 | + Future<void> _testWowneroStackWalletBackupRoundTrip() async { |
| 211 | + Logging.instance.log(Level.info, "Testing Stack Wallet Backup round-trip for Wownero..."); |
| 212 | + |
| 213 | + final tempDir = await StackFileSystem.applicationRootDirectory(); |
| 214 | + final testId = Random().nextInt(10000); |
| 215 | + |
| 216 | + try { |
| 217 | + // Test 16-word mnemonic backup. |
| 218 | + await _testWowneroBackupWithSeedType( |
| 219 | + tempDir: tempDir, |
| 220 | + testId: testId, |
| 221 | + seedType: lib_monero.WowneroSeedType.sixteen, |
| 222 | + expectedWordCount: 16, |
| 223 | + suffix: "16", |
| 224 | + ); |
| 225 | + |
| 226 | + // Test 25-word mnemonic backup. |
| 227 | + await _testWowneroBackupWithSeedType( |
| 228 | + tempDir: tempDir, |
| 229 | + testId: testId, |
| 230 | + seedType: lib_monero.WowneroSeedType.twentyFive, |
| 231 | + expectedWordCount: 25, |
| 232 | + suffix: "25", |
| 233 | + ); |
| 234 | + |
| 235 | + Logging.instance.log(Level.info, "✓ All Wownero Stack Wallet Backup round-trip tests passed successfully!"); |
| 236 | + } catch (e) { |
| 237 | + Logging.instance.log(Level.error, "Wownero Stack Wallet Backup round-trip test failed: $e"); |
| 238 | + rethrow; |
| 239 | + } |
| 240 | + } |
| 241 | + |
| 242 | + /// Tests Stack Wallet Backup round-trip functionality for a specific Wownero seed type. |
| 243 | + Future<void> _testWowneroBackupWithSeedType({ |
| 244 | + required Directory tempDir, |
| 245 | + required int testId, |
| 246 | + required lib_monero.WowneroSeedType seedType, |
| 247 | + required int expectedWordCount, |
| 248 | + required String suffix, |
| 249 | + }) async { |
| 250 | + Logging.instance.log(Level.info, "Testing ${expectedWordCount}-word Wownero mnemonic backup..."); |
| 251 | + |
| 252 | + final walletName = "test_wownero_backup_${testId}_$suffix"; |
| 253 | + final walletPath = "${tempDir.path}/$walletName"; |
| 254 | + final backupPath = "${tempDir.path}/${walletName}_backup.swb"; |
| 255 | + const walletPassword = "testpass123"; |
| 256 | + const backupPassword = "backuppass456"; |
| 257 | + |
| 258 | + lib_monero.Wallet? originalWallet; |
| 259 | + String? originalMnemonic; |
| 260 | + |
| 261 | + try { |
| 262 | + // Step 1: Create a new Wownero wallet using lib_monero directly. |
| 263 | + Logging.instance.log(Level.info, "Step 1: Creating new ${expectedWordCount}-word Wownero wallet..."); |
| 264 | + |
| 265 | + originalWallet = await lib_monero.WowneroWallet.create( |
| 266 | + path: walletPath, |
| 267 | + password: walletPassword, |
| 268 | + seedType: seedType, |
| 269 | + seedOffset: "", |
| 270 | + ); |
| 271 | + |
| 272 | + // Step 2: Save the original mnemonic out-of-band. |
| 273 | + Logging.instance.log(Level.info, "Step 2: Saving original mnemonic..."); |
| 274 | + originalMnemonic = originalWallet.getSeed(); |
| 275 | + |
| 276 | + if (originalMnemonic.isEmpty) { |
| 277 | + throw Exception("Failed to retrieve mnemonic from created Wownero wallet"); |
| 278 | + } |
| 279 | + |
| 280 | + final originalWords = originalMnemonic.split(' '); |
| 281 | + Logging.instance.log(Level.info, "Original Wownero mnemonic has ${originalWords.length} words"); |
| 282 | + |
| 283 | + // Validate the mnemonic format. |
| 284 | + if (originalWords.length != expectedWordCount) { |
| 285 | + throw Exception("Expected ${expectedWordCount}-word mnemonic, got ${originalWords.length} words"); |
| 286 | + } |
| 287 | + |
| 288 | + // Step 3: Create a Stack Wallet Backup. |
| 289 | + Logging.instance.log(Level.info, "Step 3: Creating Stack Wallet Backup..."); |
| 290 | + |
| 291 | + // Create a minimal backup JSON with just our test wallet. |
| 292 | + final backupJson = { |
| 293 | + "wallets": [ |
| 294 | + { |
| 295 | + "name": walletName, |
| 296 | + "id": "test_wownero_wallet_${testId}_$suffix", |
| 297 | + "mnemonic": originalMnemonic, |
| 298 | + "mnemonicPassphrase": "", |
| 299 | + "coinName": "wownero", |
| 300 | + "storedChainHeight": 0, |
| 301 | + "restoreHeight": 0, |
| 302 | + "notes": {}, |
| 303 | + "isFavorite": false, |
| 304 | + "otherDataJsonString": null, |
| 305 | + } |
| 306 | + ], |
| 307 | + "prefs": { |
| 308 | + "currency": "USD", |
| 309 | + "useBiometrics": false, |
| 310 | + "hasPin": false, |
| 311 | + "language": "en", |
| 312 | + "showFavoriteWallets": true, |
| 313 | + "wifiOnly": false, |
| 314 | + "syncType": "allWalletsOnStartup", |
| 315 | + "walletIdsSyncOnStartup": [], |
| 316 | + "showTestNetCoins": false, |
| 317 | + "isAutoBackupEnabled": false, |
| 318 | + "autoBackupLocation": null, |
| 319 | + "backupFrequencyType": "BackupFrequencyType.everyAppStart", |
| 320 | + "lastAutoBackup": DateTime.now().toString(), |
| 321 | + }, |
| 322 | + "nodes": [], |
| 323 | + "addressBookEntries": [], |
| 324 | + "tradeHistory": [], |
| 325 | + "tradeTxidLookupData": [], |
| 326 | + "tradeNotes": {}, |
| 327 | + }; |
| 328 | + |
| 329 | + final jsonString = jsonEncode(backupJson); |
| 330 | + |
| 331 | + // Encrypt and save the backup. |
| 332 | + final success = await SWB.encryptStackWalletWithPassphrase( |
| 333 | + backupPath, |
| 334 | + backupPassword, |
| 335 | + jsonString, |
| 336 | + ); |
| 337 | + |
| 338 | + if (!success) { |
| 339 | + throw Exception("Failed to create Stack Wallet Backup for Wownero"); |
| 340 | + } |
| 341 | + |
| 342 | + Logging.instance.log(Level.info, "Backup created successfully at: $backupPath"); |
| 343 | + |
| 344 | + // Step 4: Restore the Stack Wallet Backup. |
| 345 | + Logging.instance.log(Level.info, "Step 4: Restoring Stack Wallet Backup..."); |
| 346 | + |
| 347 | + final restoredJsonString = await SWB.decryptStackWalletWithPassphrase( |
| 348 | + Tuple2(backupPath, backupPassword), |
| 349 | + ); |
| 350 | + |
| 351 | + if (restoredJsonString == null) { |
| 352 | + throw Exception("Failed to decrypt Stack Wallet Backup for Wownero"); |
| 353 | + } |
| 354 | + |
| 355 | + final restoredJson = jsonDecode(restoredJsonString) as Map<String, dynamic>; |
| 356 | + final restoredWallets = restoredJson["wallets"] as List<dynamic>; |
| 357 | + |
| 358 | + if (restoredWallets.isEmpty) { |
| 359 | + throw Exception("No wallets found in restored Wownero backup"); |
| 360 | + } |
| 361 | + |
| 362 | + final restoredWalletData = restoredWallets.first as Map<String, dynamic>; |
| 363 | + final restoredMnemonic = restoredWalletData["mnemonic"] as String; |
| 364 | + |
| 365 | + // Step 5: Verify that the restored mnemonic matches the original. |
| 366 | + Logging.instance.log(Level.info, "Step 5: Verifying Wownero mnemonic integrity..."); |
| 367 | + |
| 368 | + if (restoredMnemonic != originalMnemonic) { |
| 369 | + throw Exception( |
| 370 | + "Wownero mnemonic mismatch!\n" |
| 371 | + "Original: $originalMnemonic\n" |
| 372 | + "Restored: $restoredMnemonic" |
| 373 | + ); |
| 374 | + } |
| 375 | + |
| 376 | + // Additional verification: check word count. |
| 377 | + final restoredWords = restoredMnemonic.split(' '); |
| 378 | + |
| 379 | + if (originalWords.length != restoredWords.length) { |
| 380 | + throw Exception( |
| 381 | + "Word count mismatch: original ${originalWords.length}, restored ${restoredWords.length}" |
| 382 | + ); |
| 383 | + } |
| 384 | + |
| 385 | + // Verify each word matches. |
| 386 | + for (int i = 0; i < originalWords.length; i++) { |
| 387 | + if (originalWords[i] != restoredWords[i]) { |
| 388 | + throw Exception( |
| 389 | + "Word mismatch at position $i: '${originalWords[i]}' != '${restoredWords[i]}'" |
| 390 | + ); |
| 391 | + } |
| 392 | + } |
| 393 | + |
| 394 | + // Step 6: Additional test - verify we can recreate the wallet from the restored mnemonic. |
| 395 | + Logging.instance.log(Level.info, "Step 6: Testing Wownero wallet restoration with recovered mnemonic..."); |
| 396 | + |
| 397 | + final testWalletPath = "${tempDir.path}/test_wownero_restore_${testId}_$suffix"; |
| 398 | + lib_monero.Wallet? restoredWallet; |
| 399 | + |
| 400 | + try { |
| 401 | + restoredWallet = await lib_monero.WowneroWallet.restoreWalletFromSeed( |
| 402 | + path: testWalletPath, |
| 403 | + password: walletPassword, |
| 404 | + seed: restoredMnemonic, |
| 405 | + restoreHeight: 0, |
| 406 | + seedOffset: "", |
| 407 | + ); |
| 408 | + |
| 409 | + final restoredMnemonicFromWallet = restoredWallet.getSeed(); |
| 410 | + |
| 411 | + if (restoredMnemonicFromWallet != originalMnemonic) { |
| 412 | + throw Exception( |
| 413 | + "Restored Wownero wallet mnemonic doesn't match original!\n" |
| 414 | + "Original: $originalMnemonic\n" |
| 415 | + "From restored wallet: $restoredMnemonicFromWallet" |
| 416 | + ); |
| 417 | + } |
| 418 | + |
| 419 | + Logging.instance.log(Level.info, "✓ Successfully restored ${expectedWordCount}-word Wownero wallet from backup mnemonic"); |
| 420 | + |
| 421 | + } finally { |
| 422 | + await restoredWallet?.close(); |
| 423 | + // Clean up restored wallet files. |
| 424 | + final testWalletFile = File(testWalletPath); |
| 425 | + final testKeysFile = File("$testWalletPath.keys"); |
| 426 | + final testAddressFile = File("$testWalletPath.address.txt"); |
| 427 | + |
| 428 | + if (await testWalletFile.exists()) await testWalletFile.delete(); |
| 429 | + if (await testKeysFile.exists()) await testKeysFile.delete(); |
| 430 | + if (await testAddressFile.exists()) await testAddressFile.delete(); |
| 431 | + } |
| 432 | + |
| 433 | + Logging.instance.log(Level.info, "✓ ${expectedWordCount}-word Wownero Stack Wallet Backup round-trip test passed!"); |
| 434 | + Logging.instance.log(Level.info, "✓ Original and restored Wownero mnemonics match perfectly"); |
| 435 | + Logging.instance.log(Level.info, "✓ Verified ${originalWords.length}-word Wownero mnemonic integrity"); |
| 436 | + Logging.instance.log(Level.info, "✓ Confirmed ${expectedWordCount}-word Wownero wallet can be restored from backup mnemonic"); |
| 437 | + |
| 438 | + } finally { |
| 439 | + // Cleanup. |
| 440 | + try { |
| 441 | + await originalWallet?.close(); |
| 442 | + |
| 443 | + // Clean up test files. |
| 444 | + final walletFile = File(walletPath); |
| 445 | + final keysFile = File("$walletPath.keys"); |
| 446 | + final addressFile = File("$walletPath.address.txt"); |
| 447 | + final backupFile = File(backupPath); |
| 448 | + |
| 449 | + if (await walletFile.exists()) await walletFile.delete(); |
| 450 | + if (await keysFile.exists()) await keysFile.delete(); |
| 451 | + if (await addressFile.exists()) await addressFile.delete(); |
| 452 | + if (await backupFile.exists()) await backupFile.delete(); |
| 453 | + |
| 454 | + Logging.instance.log(Level.info, "Cleaned up test files for ${expectedWordCount}-word Wownero backup test"); |
| 455 | + } catch (e) { |
| 456 | + Logging.instance.log(Level.warning, "Cleanup error for ${expectedWordCount}-word Wownero test: $e"); |
| 457 | + } |
| 458 | + } |
| 459 | + } |
| 460 | + |
201 | 461 | void _updateStatus(TestSuiteStatus newStatus) { |
202 | 462 | _status = newStatus; |
203 | 463 | _statusController.add(newStatus); |
|
0 commit comments