|
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 | import 'test_data/polyseed_vectors.dart'; |
@@ -49,6 +52,8 @@ class MoneroWalletTestSuite implements TestSuiteInterface { |
49 | 52 |
|
50 | 53 | await _testMnemonicGeneration(); |
51 | 54 |
|
| 55 | + await _testStackWalletBackupRoundTrip(); |
| 56 | + |
52 | 57 | // TODO: FIXME. |
53 | 58 | // await _testPolyseedRestoration(); |
54 | 59 |
|
@@ -278,6 +283,261 @@ class MoneroWalletTestSuite implements TestSuiteInterface { |
278 | 283 | } |
279 | 284 | } |
280 | 285 |
|
| 286 | + /// Tests Stack Wallet Backup round-trip functionality. |
| 287 | + /// |
| 288 | + /// Creates Monero wallets with both 16-word and 25-word mnemonics, saves the mnemonics, |
| 289 | + /// creates backups, restores the backups, and verifies the restored mnemonics match the originals. |
| 290 | + Future<void> _testStackWalletBackupRoundTrip() async { |
| 291 | + Logging.instance.log(Level.info, "Testing Stack Wallet Backup round-trip for Monero..."); |
| 292 | + |
| 293 | + final tempDir = await StackFileSystem.applicationRootDirectory(); |
| 294 | + final testId = Random().nextInt(10000); |
| 295 | + |
| 296 | + try { |
| 297 | + // Test 16-word mnemonic backup. |
| 298 | + await _testBackupWithSeedType( |
| 299 | + tempDir: tempDir, |
| 300 | + testId: testId, |
| 301 | + seedType: lib_monero.MoneroSeedType.sixteen, |
| 302 | + expectedWordCount: 16, |
| 303 | + suffix: "16", |
| 304 | + ); |
| 305 | + |
| 306 | + // Test 25-word mnemonic backup. |
| 307 | + await _testBackupWithSeedType( |
| 308 | + tempDir: tempDir, |
| 309 | + testId: testId, |
| 310 | + seedType: lib_monero.MoneroSeedType.twentyFive, |
| 311 | + expectedWordCount: 25, |
| 312 | + suffix: "25", |
| 313 | + ); |
| 314 | + |
| 315 | + Logging.instance.log(Level.info, "✓ All Stack Wallet Backup round-trip tests passed successfully!"); |
| 316 | + } catch (e) { |
| 317 | + Logging.instance.log(Level.error, "Stack Wallet Backup round-trip test failed: $e"); |
| 318 | + rethrow; |
| 319 | + } |
| 320 | + } |
| 321 | + |
| 322 | + /// Tests Stack Wallet Backup round-trip functionality for a specific seed type. |
| 323 | + Future<void> _testBackupWithSeedType({ |
| 324 | + required Directory tempDir, |
| 325 | + required int testId, |
| 326 | + required lib_monero.MoneroSeedType seedType, |
| 327 | + required int expectedWordCount, |
| 328 | + required String suffix, |
| 329 | + }) async { |
| 330 | + Logging.instance.log(Level.info, "Testing ${expectedWordCount}-word mnemonic backup..."); |
| 331 | + |
| 332 | + final walletName = "test_monero_backup_${testId}_$suffix"; |
| 333 | + final walletPath = "${tempDir.path}/$walletName"; |
| 334 | + final backupPath = "${tempDir.path}/${walletName}_backup.swb"; |
| 335 | + const walletPassword = "testpass123"; |
| 336 | + const backupPassword = "backuppass456"; |
| 337 | + |
| 338 | + lib_monero.Wallet? originalWallet; |
| 339 | + String? originalMnemonic; |
| 340 | + |
| 341 | + try { |
| 342 | + // Step 1: Create a new Monero wallet using lib_monero directly. |
| 343 | + Logging.instance.log(Level.info, "Step 1: Creating new ${expectedWordCount}-word Monero wallet..."); |
| 344 | + |
| 345 | + originalWallet = await lib_monero.MoneroWallet.create( |
| 346 | + path: walletPath, |
| 347 | + password: walletPassword, |
| 348 | + seedType: seedType, |
| 349 | + seedOffset: "", |
| 350 | + ); |
| 351 | + |
| 352 | + // Step 2: Save the original mnemonic out-of-band. |
| 353 | + Logging.instance.log(Level.info, "Step 2: Saving original mnemonic..."); |
| 354 | + originalMnemonic = await originalWallet.getSeed(); |
| 355 | + |
| 356 | + if (originalMnemonic.isEmpty) { |
| 357 | + throw Exception("Failed to retrieve mnemonic from created wallet"); |
| 358 | + } |
| 359 | + |
| 360 | + final originalWords = originalMnemonic.split(' '); |
| 361 | + Logging.instance.log(Level.info, "Original mnemonic has ${originalWords.length} words"); |
| 362 | + |
| 363 | + // Validate the mnemonic format. |
| 364 | + if (originalWords.length != expectedWordCount) { |
| 365 | + throw Exception("Expected ${expectedWordCount}-word mnemonic, got ${originalWords.length} words"); |
| 366 | + } |
| 367 | + |
| 368 | + // Step 3: Create a Stack Wallet Backup. |
| 369 | + Logging.instance.log(Level.info, "Step 3: Creating Stack Wallet Backup..."); |
| 370 | + |
| 371 | + // Create a minimal backup JSON with just our test wallet. |
| 372 | + final backupJson = { |
| 373 | + "wallets": [ |
| 374 | + { |
| 375 | + "name": walletName, |
| 376 | + "id": "test_wallet_${testId}_$suffix", |
| 377 | + "mnemonic": originalMnemonic, |
| 378 | + "mnemonicPassphrase": "", |
| 379 | + "coinName": "monero", |
| 380 | + "storedChainHeight": 0, |
| 381 | + "restoreHeight": 0, |
| 382 | + "notes": {}, |
| 383 | + "isFavorite": false, |
| 384 | + "otherDataJsonString": null, |
| 385 | + } |
| 386 | + ], |
| 387 | + "prefs": { |
| 388 | + "currency": "USD", |
| 389 | + "useBiometrics": false, |
| 390 | + "hasPin": false, |
| 391 | + "language": "en", |
| 392 | + "showFavoriteWallets": true, |
| 393 | + "wifiOnly": false, |
| 394 | + "syncType": "allWalletsOnStartup", |
| 395 | + "walletIdsSyncOnStartup": [], |
| 396 | + "showTestNetCoins": false, |
| 397 | + "isAutoBackupEnabled": false, |
| 398 | + "autoBackupLocation": null, |
| 399 | + "backupFrequencyType": "BackupFrequencyType.everyAppStart", |
| 400 | + "lastAutoBackup": DateTime.now().toString(), |
| 401 | + }, |
| 402 | + "nodes": [], |
| 403 | + "addressBookEntries": [], |
| 404 | + "tradeHistory": [], |
| 405 | + "tradeTxidLookupData": [], |
| 406 | + "tradeNotes": {}, |
| 407 | + }; |
| 408 | + |
| 409 | + final jsonString = jsonEncode(backupJson); |
| 410 | + |
| 411 | + // Encrypt and save the backup. |
| 412 | + final success = await SWB.encryptStackWalletWithPassphrase( |
| 413 | + backupPath, |
| 414 | + backupPassword, |
| 415 | + jsonString, |
| 416 | + ); |
| 417 | + |
| 418 | + if (!success) { |
| 419 | + throw Exception("Failed to create Stack Wallet Backup"); |
| 420 | + } |
| 421 | + |
| 422 | + Logging.instance.log(Level.info, "Backup created successfully at: $backupPath"); |
| 423 | + |
| 424 | + // Step 4: Restore the Stack Wallet Backup. |
| 425 | + Logging.instance.log(Level.info, "Step 4: Restoring Stack Wallet Backup..."); |
| 426 | + |
| 427 | + final restoredJsonString = await SWB.decryptStackWalletWithPassphrase( |
| 428 | + Tuple2(backupPath, backupPassword), |
| 429 | + ); |
| 430 | + |
| 431 | + if (restoredJsonString == null) { |
| 432 | + throw Exception("Failed to decrypt Stack Wallet Backup"); |
| 433 | + } |
| 434 | + |
| 435 | + final restoredJson = jsonDecode(restoredJsonString) as Map<String, dynamic>; |
| 436 | + final restoredWallets = restoredJson["wallets"] as List<dynamic>; |
| 437 | + |
| 438 | + if (restoredWallets.isEmpty) { |
| 439 | + throw Exception("No wallets found in restored backup"); |
| 440 | + } |
| 441 | + |
| 442 | + final restoredWalletData = restoredWallets.first as Map<String, dynamic>; |
| 443 | + final restoredMnemonic = restoredWalletData["mnemonic"] as String; |
| 444 | + |
| 445 | + // Step 5: Verify that the restored mnemonic matches the original. |
| 446 | + Logging.instance.log(Level.info, "Step 5: Verifying mnemonic integrity..."); |
| 447 | + |
| 448 | + if (restoredMnemonic != originalMnemonic) { |
| 449 | + throw Exception( |
| 450 | + "Mnemonic mismatch!\n" |
| 451 | + "Original: $originalMnemonic\n" |
| 452 | + "Restored: $restoredMnemonic" |
| 453 | + ); |
| 454 | + } |
| 455 | + |
| 456 | + // Additional verification: check word count. |
| 457 | + final restoredWords = restoredMnemonic.split(' '); |
| 458 | + |
| 459 | + if (originalWords.length != restoredWords.length) { |
| 460 | + throw Exception( |
| 461 | + "Word count mismatch: original ${originalWords.length}, restored ${restoredWords.length}" |
| 462 | + ); |
| 463 | + } |
| 464 | + |
| 465 | + // Verify each word matches. |
| 466 | + for (int i = 0; i < originalWords.length; i++) { |
| 467 | + if (originalWords[i] != restoredWords[i]) { |
| 468 | + throw Exception( |
| 469 | + "Word mismatch at position $i: '${originalWords[i]}' != '${restoredWords[i]}'" |
| 470 | + ); |
| 471 | + } |
| 472 | + } |
| 473 | + |
| 474 | + // Step 6: Additional test - verify we can recreate the wallet from the restored mnemonic. |
| 475 | + Logging.instance.log(Level.info, "Step 6: Testing wallet restoration with recovered mnemonic..."); |
| 476 | + |
| 477 | + final testWalletPath = "${tempDir.path}/test_restore_${testId}_$suffix"; |
| 478 | + lib_monero.Wallet? restoredWallet; |
| 479 | + |
| 480 | + try { |
| 481 | + restoredWallet = await lib_monero.MoneroWallet.restoreWalletFromSeed( |
| 482 | + path: testWalletPath, |
| 483 | + password: walletPassword, |
| 484 | + seed: restoredMnemonic, |
| 485 | + restoreHeight: 0, |
| 486 | + seedOffset: "", |
| 487 | + ); |
| 488 | + |
| 489 | + final restoredMnemonicFromWallet = await restoredWallet.getSeed(); |
| 490 | + |
| 491 | + if (restoredMnemonicFromWallet != originalMnemonic) { |
| 492 | + throw Exception( |
| 493 | + "Restored wallet mnemonic doesn't match original!\n" |
| 494 | + "Original: $originalMnemonic\n" |
| 495 | + "From restored wallet: $restoredMnemonicFromWallet" |
| 496 | + ); |
| 497 | + } |
| 498 | + |
| 499 | + Logging.instance.log(Level.info, "✓ Successfully restored ${expectedWordCount}-word wallet from backup mnemonic"); |
| 500 | + |
| 501 | + } finally { |
| 502 | + await restoredWallet?.close(); |
| 503 | + // Clean up restored wallet files. |
| 504 | + final testWalletFile = File(testWalletPath); |
| 505 | + final testKeysFile = File("$testWalletPath.keys"); |
| 506 | + final testAddressFile = File("$testWalletPath.address.txt"); |
| 507 | + |
| 508 | + if (await testWalletFile.exists()) await testWalletFile.delete(); |
| 509 | + if (await testKeysFile.exists()) await testKeysFile.delete(); |
| 510 | + if (await testAddressFile.exists()) await testAddressFile.delete(); |
| 511 | + } |
| 512 | + |
| 513 | + Logging.instance.log(Level.info, "✓ ${expectedWordCount}-word Stack Wallet Backup round-trip test passed!"); |
| 514 | + Logging.instance.log(Level.info, "✓ Original and restored mnemonics match perfectly"); |
| 515 | + Logging.instance.log(Level.info, "✓ Verified ${originalWords.length}-word mnemonic integrity"); |
| 516 | + Logging.instance.log(Level.info, "✓ Confirmed ${expectedWordCount}-word wallet can be restored from backup mnemonic"); |
| 517 | + |
| 518 | + } finally { |
| 519 | + // Cleanup. |
| 520 | + try { |
| 521 | + await originalWallet?.close(); |
| 522 | + |
| 523 | + // Clean up test files. |
| 524 | + final walletFile = File(walletPath); |
| 525 | + final keysFile = File("$walletPath.keys"); |
| 526 | + final addressFile = File("$walletPath.address.txt"); |
| 527 | + final backupFile = File(backupPath); |
| 528 | + |
| 529 | + if (await walletFile.exists()) await walletFile.delete(); |
| 530 | + if (await keysFile.exists()) await keysFile.delete(); |
| 531 | + if (await addressFile.exists()) await addressFile.delete(); |
| 532 | + if (await backupFile.exists()) await backupFile.delete(); |
| 533 | + |
| 534 | + Logging.instance.log(Level.info, "Cleaned up test files for ${expectedWordCount}-word Monero backup test"); |
| 535 | + } catch (e) { |
| 536 | + Logging.instance.log(Level.warning, "Cleanup error for ${expectedWordCount}-word test: $e"); |
| 537 | + } |
| 538 | + } |
| 539 | + } |
| 540 | + |
281 | 541 | void _updateStatus(TestSuiteStatus newStatus) { |
282 | 542 | _status = newStatus; |
283 | 543 | _statusController.add(newStatus); |
|
0 commit comments