diff --git a/packages/komodo_defi_framework/app_build/build_config.json b/packages/komodo_defi_framework/app_build/build_config.json index 3612b9282..ebd156fc8 100644 --- a/packages/komodo_defi_framework/app_build/build_config.json +++ b/packages/komodo_defi_framework/app_build/build_config.json @@ -65,7 +65,7 @@ "coins": { "fetch_at_build_enabled": true, "update_commit_on_build": true, - "bundled_coins_repo_commit": "1d2a5c9c4d23416df2fa1c5e2f263a244a09704d", + "bundled_coins_repo_commit": "46587568ac5ed542544dc9bd68bab35d7d818cf2", "coins_repo_api_url": "https://api.github.com/repos/GLEECBTC/coins", "coins_repo_content_url": "https://raw.githubusercontent.com/GLEECBTC/coins", "coins_repo_branch": "master", diff --git a/packages/komodo_defi_framework/lib/komodo_defi_framework.dart b/packages/komodo_defi_framework/lib/komodo_defi_framework.dart index 0946cae84..d75b3cf23 100644 --- a/packages/komodo_defi_framework/lib/komodo_defi_framework.dart +++ b/packages/komodo_defi_framework/lib/komodo_defi_framework.dart @@ -23,6 +23,10 @@ export 'package:komodo_defi_framework/src/streaming/events/kdf_event.dart'; export 'src/operations/kdf_operations_interface.dart'; class KomodoDefiFramework implements ApiClient { + static const Duration _versionProbeTimeout = Duration(seconds: 2); + static const Duration _stopPollInterval = Duration(milliseconds: 250); + static const Duration _stopSettleDelay = Duration(milliseconds: 250); + factory KomodoDefiFramework.create({ required IKdfHostConfig hostConfig, void Function(String)? externalLogger, @@ -162,24 +166,44 @@ class KomodoDefiFramework implements ApiClient { _log('Stopping KDF...'); final result = await _kdfOperations.kdfStop(); _log('KDF stop result: $result'); - // Await a max of 5 seconds for KDF to stop. Check every 500ms. - for (var i = 0; i < 10; i++) { - await Future.delayed(const Duration(milliseconds: 500)); - if (!await isRunning()) { - break; - } - if (i == 9) { - throw Exception('Error stopping KDF: KDF did not stop in time.'); + + // Drop any stale keep-alive socket before verifying shutdown. Otherwise, + // the post-stop version() fallback can hang on Android while the native + // thread is already tearing down. + resetHttpClient(); + + // Wait for native status to settle without probing RPC over HTTP. + for (var i = 0; i < 20; i++) { + await Future.delayed(_stopPollInterval); + final stillRunning = await isRunning(allowVersionFallback: false); + if (!stillRunning) { + await Future.delayed(_stopSettleDelay); + if (!await isRunning(allowVersionFallback: false)) { + return result; + } } } - return result; + throw Exception('Error stopping KDF: KDF did not stop in time.'); } - Future isRunning() async { + Future isRunning({bool allowVersionFallback = true}) async { + final nativeRunning = await _kdfOperations.isRunning(); + if (nativeRunning) { + return true; + } + + if (!allowVersionFallback) { + _log('KDF is not running.'); + return false; + } + final running = - await _kdfOperations.isRunning() || - await _kdfOperations.version() != null; + await _kdfOperations.version().timeout( + _versionProbeTimeout, + onTimeout: () => null, + ) != + null; if (!running) { _log('KDF is not running.'); } @@ -188,15 +212,23 @@ class KomodoDefiFramework implements ApiClient { Future version() async { final stopwatch = Stopwatch()..start(); - _log('version(): Starting version RPC call via ${_kdfOperations.operationsName}'); + _log( + 'version(): Starting version RPC call via ${_kdfOperations.operationsName}', + ); try { - final version = await _kdfOperations.version(); + final version = await _kdfOperations.version().timeout( + _versionProbeTimeout, + ); stopwatch.stop(); - _log('version(): Completed in ${stopwatch.elapsedMilliseconds}ms, result=$version'); + _log( + 'version(): Completed in ${stopwatch.elapsedMilliseconds}ms, result=$version', + ); return version; } catch (e) { stopwatch.stop(); - _log('version(): Failed after ${stopwatch.elapsedMilliseconds}ms with error: $e'); + _log( + 'version(): Failed after ${stopwatch.elapsedMilliseconds}ms with error: $e', + ); rethrow; } } @@ -205,7 +237,7 @@ class KomodoDefiFramework implements ApiClient { /// Returns true if KDF is running and responsive, false otherwise. /// This is useful for detecting when KDF has become unavailable, especially /// on mobile platforms after app backgrounding. - /// + /// /// IMPORTANT: This method ONLY relies on actual RPC verification (version() call) /// to avoid false positives where native status reports "running" but HTTP listener /// is not accepting connections (common after iOS backgrounding). @@ -217,7 +249,7 @@ class KomodoDefiFramework implements ApiClient { _log('KDF health check failed: version call returned null'); return false; } - + _log('KDF health check passed: version=$versionCheck'); return true; } catch (e) { @@ -279,7 +311,7 @@ class KomodoDefiFramework implements ApiClient { return response; } catch (e) { stopwatch.stop(); - + // Detect transport-fatal SocketExceptions that indicate KDF is down/dying // errno 32 (EPIPE): Broken pipe - writing to socket whose peer closed // errno 54 (ECONNRESET): Connection reset by peer @@ -287,18 +319,29 @@ class KomodoDefiFramework implements ApiClient { // errno 61 (ECONNREFUSED): Connection refused - no listener on port final errorString = e.toString().toLowerCase(); final isSocketException = errorString.contains('socketexception'); - final isFatalTransportError = isSocketException && ( - errorString.contains('broken pipe') || errorString.contains('errno = 32') || - errorString.contains('connection reset') || errorString.contains('errno = 54') || - errorString.contains('operation timed out') || errorString.contains('errno = 60') || - errorString.contains('connection refused') || errorString.contains('errno = 61') - ); + final isFatalTransportError = + isSocketException && + (errorString.contains('broken pipe') || + errorString.contains('errno = 32') || + errorString.contains('connection reset') || + errorString.contains('errno = 54') || + errorString.contains('operation timed out') || + errorString.contains('errno = 60') || + errorString.contains('connection refused') || + errorString.contains('errno = 61')); if (isFatalTransportError) { - final errorType = errorString.contains('errno = 32') || errorString.contains('broken pipe') ? 'EPIPE (32)' : - errorString.contains('errno = 54') || errorString.contains('connection reset') ? 'ECONNRESET (54)' : - errorString.contains('errno = 60') || errorString.contains('operation timed out') ? 'ETIMEDOUT (60)' : - 'ECONNREFUSED (61)'; + final errorType = + errorString.contains('errno = 32') || + errorString.contains('broken pipe') + ? 'EPIPE (32)' + : errorString.contains('errno = 54') || + errorString.contains('connection reset') + ? 'ECONNRESET (54)' + : errorString.contains('errno = 60') || + errorString.contains('operation timed out') + ? 'ETIMEDOUT (60)' + : 'ECONNREFUSED (61)'; _logger.severe( '[RPC] ${method ?? 'unknown'} failed: KDF transport error $errorType. ' 'Resetting HTTP client to drop stale connections.', diff --git a/packages/komodo_defi_local_auth/lib/src/auth/auth_service.dart b/packages/komodo_defi_local_auth/lib/src/auth/auth_service.dart index bef499912..5595fd179 100644 --- a/packages/komodo_defi_local_auth/lib/src/auth/auth_service.dart +++ b/packages/komodo_defi_local_auth/lib/src/auth/auth_service.dart @@ -151,6 +151,10 @@ class KdfAuthService implements IAuthService { List? _usersCache; DateTime? _usersCacheTimestamp; final Duration _usersCacheTtl = const Duration(minutes: 5); + static const Duration _kdfRpcReadyTimeout = Duration(seconds: 15); + static const Duration _kdfRpcProbeTimeout = Duration(seconds: 2); + static const Duration _kdfRpcPollInterval = Duration(milliseconds: 250); + static const Duration _startupSensitiveRpcTimeout = Duration(seconds: 10); ApiClient get _client => _kdfFramework.client; late final methods = KomodoDefiRpcMethods(_client); @@ -254,42 +258,85 @@ class KdfAuthService implements IAuthService { ), Mnemonic? mnemonic, }) async { - await _ensureKdfRunning(); + _logger.info( + '[$_sessionId] register: Starting registration for wallet: $walletName', + ); + final registerStopwatch = Stopwatch()..start(); - await _runReadOperation(() async { - final walletExists = await _walletExists(walletName); - if (walletExists) { - throw AuthException( - 'Wallet already exists', - type: AuthExceptionType.generalAuthError, + try { + final ensureStartStopwatch = Stopwatch()..start(); + await _ensureKdfRunning(); + ensureStartStopwatch.stop(); + _logger.info( + '[$_sessionId] register: ensure no-auth start completed in ' + '${ensureStartStopwatch.elapsedMilliseconds}ms', + ); + + final walletExistsStopwatch = Stopwatch()..start(); + await _runReadOperation(() async { + final walletExists = await _walletExists(walletName); + if (walletExists) { + throw AuthException( + 'Wallet already exists', + type: AuthExceptionType.generalAuthError, + ); + } + }); + walletExistsStopwatch.stop(); + _logger.info( + '[$_sessionId] register: wallet existence read completed in ' + '${walletExistsStopwatch.elapsedMilliseconds}ms', + ); + + // replaces the __assertWalletOrStop method - wait for read/write locks to + // be released here. + // can be used outside of a lock, since both functions are public-facing + // and manage their own read/write locks + final stopStopwatch = Stopwatch()..start(); + if (await isSignedIn()) { + await signOut(); + stopStopwatch.stop(); + _logger.info( + '[$_sessionId] register: stop phase completed in ' + '${stopStopwatch.elapsedMilliseconds}ms', + ); + } else { + stopStopwatch.stop(); + _logger.info( + '[$_sessionId] register: no active session to stop ' + '(${stopStopwatch.elapsedMilliseconds}ms)', ); } - }); - // replaces the __assertWalletOrStop method - wait for read/write locks to - // be released here. - // can be used outside of a lock, since both functions are public-facing - // and manage their own read/write locks - if (await isSignedIn()) { - await signOut(); - } - - final config = await _generateStartupConfig( - walletName: walletName, - walletPassword: password, - allowRegistrations: true, - plaintextMnemonic: mnemonic?.plaintextMnemonic, - hdEnabled: options.derivationMethod == DerivationMethod.hdWallet, - allowWeakPassword: options.allowWeakPassword, - ); + final config = await _generateStartupConfig( + walletName: walletName, + walletPassword: password, + allowRegistrations: true, + plaintextMnemonic: mnemonic?.plaintextMnemonic, + hdEnabled: options.derivationMethod == DerivationMethod.hdWallet, + allowWeakPassword: options.allowWeakPassword, + ); - return _lockWriteOperation(() async { - final isImported = mnemonic != null; - final currentUser = await _registerNewUser(config, options, isImported); - _emitAuthStateChange(currentUser); - _invalidateUsersCache(); - return currentUser; - }); + return _lockWriteOperation(() async { + final writePathStopwatch = Stopwatch()..start(); + final isImported = mnemonic != null; + final currentUser = await _registerNewUser(config, options, isImported); + writePathStopwatch.stop(); + _logger.info( + '[$_sessionId] register: registration write path completed in ' + '${writePathStopwatch.elapsedMilliseconds}ms', + ); + _emitAuthStateChange(currentUser); + _invalidateUsersCache(); + return currentUser; + }); + } finally { + registerStopwatch.stop(); + _logger.info( + '[$_sessionId] register: Finished in ' + '${registerStopwatch.elapsedMilliseconds}ms', + ); + } } @override @@ -304,7 +351,10 @@ class KdfAuthService implements IAuthService { return _usersCache!; } - final walletNames = await _client.rpc.wallet.getWalletNames(); + final walletNames = await _runStartupSensitiveRpc( + phase: 'get_wallet_names', + operation: () => _client.rpc.wallet.getWalletNames(), + ); final users = await Future.wait( walletNames.walletNames.map((name) async { @@ -1006,12 +1056,14 @@ class KdfAuthService implements IAuthService { throw KdfExtensions._mapStartupErrorToAuthException(result); } - _logger.info('[$_sessionId] _forceStartKdf: Waiting for RPC to be up'); + _kdfFramework.resetHttpClient(); + _logger.info('[$_sessionId] _forceStartKdf: Waiting for RPC to be ready'); final waitStopwatch = Stopwatch()..start(); - await _waitUntilKdfRpcIsUp(); + await _waitUntilKdfRpcReady(); waitStopwatch.stop(); _logger.info( - '[$_sessionId] _forceStartKdf: RPC is up after ${waitStopwatch.elapsedMilliseconds}ms', + '[$_sessionId] _forceStartKdf: RPC ready after ' + '${waitStopwatch.elapsedMilliseconds}ms', ); }); } diff --git a/packages/komodo_defi_local_auth/lib/src/auth/auth_service_auth_extension.dart b/packages/komodo_defi_local_auth/lib/src/auth/auth_service_auth_extension.dart index 91dbd0554..6a169df59 100644 --- a/packages/komodo_defi_local_auth/lib/src/auth/auth_service_auth_extension.dart +++ b/packages/komodo_defi_local_auth/lib/src/auth/auth_service_auth_extension.dart @@ -2,7 +2,17 @@ part of 'auth_service.dart'; extension KdfAuthServiceAuthExtension on KdfAuthService { Future _authenticateUser(KdfStartupConfig config) async { + _logger.info( + '[$_sessionId] _authenticateUser: Restarting KDF for ' + '${config.walletName}', + ); + final restartStopwatch = Stopwatch()..start(); await _restartKdf(config); + restartStopwatch.stop(); + _logger.info( + '[$_sessionId] _authenticateUser: auth start + readiness verify ' + 'completed in ${restartStopwatch.elapsedMilliseconds}ms', + ); final status = await _kdfFramework.kdfMainStatus(); if (status != MainStatus.rpcIsUp) { throw AuthException( @@ -13,7 +23,13 @@ extension KdfAuthServiceAuthExtension on KdfAuthService { // use the internal function here, which isn't read-protected, to avoid // deadlocks if used within a write-lock + final activeUserStopwatch = Stopwatch()..start(); var currentUser = await _getActiveUser(); + activeUserStopwatch.stop(); + _logger.info( + '[$_sessionId] _authenticateUser: first authenticated RPC completed in ' + '${activeUserStopwatch.elapsedMilliseconds}ms', + ); if (currentUser == null) { throw AuthException( 'No user signed in', @@ -46,7 +62,17 @@ extension KdfAuthServiceAuthExtension on KdfAuthService { AuthOptions authOptions, bool isImported, ) async { + _logger.info( + '[$_sessionId] _registerNewUser: Restarting KDF for ' + '${config.walletName}', + ); + final restartStopwatch = Stopwatch()..start(); await _restartKdf(config); + restartStopwatch.stop(); + _logger.info( + '[$_sessionId] _registerNewUser: auth start + readiness verify ' + 'completed in ${restartStopwatch.elapsedMilliseconds}ms', + ); final status = await _kdfFramework.kdfMainStatus(); if (status != MainStatus.rpcIsUp) { throw AuthException( @@ -56,13 +82,25 @@ extension KdfAuthServiceAuthExtension on KdfAuthService { } final walletId = WalletId.fromName(config.walletName!, authOptions); + final seedValidationStopwatch = Stopwatch()..start(); final isBip39Seed = await _isSeedBip39Compatible(config); + seedValidationStopwatch.stop(); + _logger.info( + '[$_sessionId] _registerNewUser: seed validation pipeline completed ' + 'in ${seedValidationStopwatch.elapsedMilliseconds}ms', + ); final currentUser = KdfUser( walletId: walletId, isBip39Seed: isBip39Seed, metadata: {'isImported': isImported}, ); + final secureStorageStopwatch = Stopwatch()..start(); await _secureStorage.saveUser(currentUser); + secureStorageStopwatch.stop(); + _logger.info( + '[$_sessionId] _registerNewUser: secure-storage save completed in ' + '${secureStorageStopwatch.elapsedMilliseconds}ms', + ); // Do not allow authentication to proceed for HD wallets if the seed is not // BIP39 compatible. @@ -79,10 +117,16 @@ extension KdfAuthServiceAuthExtension on KdfAuthService { /// Checks if the seed is a valid BIP39 seed phrase. /// Throws [AuthException] if the seed could not be obtained from KDF. Future _isSeedBip39Compatible(KdfStartupConfig config) async { + final mnemonicStopwatch = Stopwatch()..start(); final plaintext = await _getMnemonic( encrypted: false, walletPassword: config.walletPassword, ); + mnemonicStopwatch.stop(); + _logger.info( + '[$_sessionId] _registerNewUser: first authenticated RPC ' + '(get_mnemonic) completed in ${mnemonicStopwatch.elapsedMilliseconds}ms', + ); if (plaintext.plaintextMnemonic == null) { throw AuthException( @@ -91,9 +135,15 @@ extension KdfAuthServiceAuthExtension on KdfAuthService { ); } + final validationStopwatch = Stopwatch()..start(); final validator = MnemonicValidator(); await validator.init(); final isBip39 = validator.validateBip39(plaintext.plaintextMnemonic!); + validationStopwatch.stop(); + _logger.info( + '[$_sessionId] _registerNewUser: seed validation completed in ' + '${validationStopwatch.elapsedMilliseconds}ms', + ); return isBip39; } diff --git a/packages/komodo_defi_local_auth/lib/src/auth/auth_service_kdf_extension.dart b/packages/komodo_defi_local_auth/lib/src/auth/auth_service_kdf_extension.dart index a26d42229..b3d4809bd 100644 --- a/packages/komodo_defi_local_auth/lib/src/auth/auth_service_kdf_extension.dart +++ b/packages/komodo_defi_local_auth/lib/src/auth/auth_service_kdf_extension.dart @@ -13,8 +13,10 @@ extension KdfExtensions on KdfAuthService { return null; } - final activeWallet = - (await _client.rpc.wallet.getWalletNames()).activatedWallet; + final activeWallet = (await _runStartupSensitiveRpc( + phase: 'active wallet read', + operation: () => _client.rpc.wallet.getWalletNames(), + )).activatedWallet; if (activeWallet == null) { return null; } @@ -39,14 +41,19 @@ extension KdfExtensions on KdfAuthService { ); } - final response = await _kdfFramework.client.executeRpc({ - 'mmrpc': '2.0', - 'method': 'get_mnemonic', - 'params': { - 'format': encrypted ? 'encrypted' : 'plaintext', - if (!encrypted) 'password': walletPassword, + final response = await _runStartupSensitiveRpc( + phase: 'get_mnemonic', + operation: () async { + return _kdfFramework.client.executeRpc({ + 'mmrpc': '2.0', + 'method': 'get_mnemonic', + 'params': { + 'format': encrypted ? 'encrypted' : 'plaintext', + if (!encrypted) 'password': walletPassword, + }, + }); }, - }); + ); if (response is JsonRpcErrorResponse) { throw AuthException( @@ -60,6 +67,7 @@ extension KdfExtensions on KdfAuthService { Future _stopKdf() async { await _kdfFramework.kdfStop(); + _kdfFramework.resetHttpClient(); _authStateController.add(null); } @@ -68,22 +76,54 @@ extension KdfExtensions on KdfAuthService { Future _ensureKdfRunning() async { if (!await _kdfFramework.isRunning()) { await _lockWriteOperation(() async { - await _kdfFramework.startKdf(await _noAuthConfig); - await _waitUntilKdfRpcIsUp(); + final startStopwatch = Stopwatch()..start(); + final kdfResult = await _kdfFramework.startKdf(await _noAuthConfig); + startStopwatch.stop(); + _logger.info( + '[$_sessionId] _ensureKdfRunning: startKdf(no-auth) returned ' + '${kdfResult.name} in ${startStopwatch.elapsedMilliseconds}ms', + ); + + if (!kdfResult.isStartingOrAlreadyRunning()) { + throw _mapStartupErrorToAuthException(kdfResult); + } + + _kdfFramework.resetHttpClient(); + await _waitUntilKdfRpcReady(); }); } } // consider moving to kdf api Future _restartKdf(KdfStartupConfig config) async { + final stopStopwatch = Stopwatch()..start(); await _stopKdf(); + stopStopwatch.stop(); + _logger.info( + '[$_sessionId] _restartKdf: stop phase completed in ' + '${stopStopwatch.elapsedMilliseconds}ms', + ); + + final startStopwatch = Stopwatch()..start(); final kdfResult = await _kdfFramework.startKdf(config); + startStopwatch.stop(); + _logger.info( + '[$_sessionId] _restartKdf: auth start returned ${kdfResult.name} in ' + '${startStopwatch.elapsedMilliseconds}ms', + ); if (!kdfResult.isStartingOrAlreadyRunning()) { throw _mapStartupErrorToAuthException(kdfResult); } - await _waitUntilKdfRpcIsUp(); + _kdfFramework.resetHttpClient(); + final readyStopwatch = Stopwatch()..start(); + await _waitUntilKdfRpcReady(); + readyStopwatch.stop(); + _logger.info( + '[$_sessionId] _restartKdf: readiness verify completed in ' + '${readyStopwatch.elapsedMilliseconds}ms', + ); } static AuthException _mapStartupErrorToAuthException( @@ -140,28 +180,107 @@ extension KdfExtensions on KdfAuthService { } } - Future _waitUntilKdfRpcIsUp({ - Duration timeout = const Duration(seconds: 5), - bool throwOnTimeout = false, + Future _waitUntilKdfRpcReady({ + Duration timeout = KdfAuthService._kdfRpcReadyTimeout, }) async { final stopwatch = Stopwatch()..start(); while (stopwatch.elapsed < timeout) { - final status = await _kdfFramework.kdfMainStatus(); + final status = await _kdfFramework.kdfMainStatus().timeout( + KdfAuthService._kdfRpcProbeTimeout, + onTimeout: () => MainStatus.notRunning, + ); if (status == MainStatus.rpcIsUp) { - return; + try { + final version = await _kdfFramework.version().timeout( + KdfAuthService._kdfRpcProbeTimeout, + onTimeout: () => null, + ); + if (version != null) { + _logger.info( + '[$_sessionId] _waitUntilKdfRpcReady: RPC ready in ' + '${stopwatch.elapsedMilliseconds}ms', + ); + return; + } + } on SocketException catch (e) { + _logger.fine( + '[$_sessionId] _waitUntilKdfRpcReady: version probe transport ' + 'error (will retry): $e', + ); + } on HttpException catch (e) { + _logger.fine( + '[$_sessionId] _waitUntilKdfRpcReady: version probe transport ' + 'error (will retry): $e', + ); + } on HandshakeException catch (e) { + _logger.fine( + '[$_sessionId] _waitUntilKdfRpcReady: version probe transport ' + 'error (will retry): $e', + ); + } } - await Future.delayed(const Duration(milliseconds: 100)); + + await Future.delayed(KdfAuthService._kdfRpcPollInterval); } - if (throwOnTimeout) { - throw AuthException( - 'Timeout waiting for KDF RPC to start', - type: AuthExceptionType.generalAuthError, + throw AuthException( + 'KDF RPC did not become ready within ${timeout.inSeconds} seconds', + type: AuthExceptionType.apiConnectionError, + ); + } + + Future _runStartupSensitiveRpc({ + required String phase, + required Future Function() operation, + }) async { + Future runAttempt() => + operation().timeout(KdfAuthService._startupSensitiveRpcTimeout); + + try { + return await runAttempt(); + } catch (error, stackTrace) { + if (!_shouldRecoverStartupSensitiveRpc(error)) { + rethrow; + } + + _logger.warning( + '[$_sessionId] _runStartupSensitiveRpc: $phase failed on first ' + 'attempt, resetting HTTP client and retrying', + error, + stackTrace, ); + _kdfFramework.resetHttpClient(); + await _waitUntilKdfRpcReady(); + + try { + return await runAttempt(); + } catch (retryError, retryStackTrace) { + if (!_shouldRecoverStartupSensitiveRpc(retryError)) { + rethrow; + } + + _logger.severe( + '[$_sessionId] _runStartupSensitiveRpc: $phase failed after retry', + retryError, + retryStackTrace, + ); + throw AuthException( + 'KDF RPC unavailable during $phase', + type: AuthExceptionType.apiConnectionError, + details: {'phase': phase, 'cause': retryError.toString()}, + ); + } } } + bool _shouldRecoverStartupSensitiveRpc(Object error) { + return error is TimeoutException || + error is SocketException || + error is HttpException || + error is HandshakeException; + } + Future _generateStartupConfig({ required String walletName, required String walletPassword, diff --git a/packages/komodo_defi_rpc_methods/lib/src/common_structures/activation/activation_params/activation_params.dart b/packages/komodo_defi_rpc_methods/lib/src/common_structures/activation/activation_params/activation_params.dart index 1df318c44..0d4dd3dc4 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/common_structures/activation/activation_params/activation_params.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/common_structures/activation/activation_params/activation_params.dart @@ -411,8 +411,11 @@ class ActivationRpcData { /// Maximum number of electrum servers to keep connected. Defaults to 1. final int? maxConnected; - /// ZHTLC coins only. Optional, defaults to two days ago. Defines where to start - /// scanning blockchain data upon initial activation. + /// ZHTLC coins only. Optional. Defines where to start scanning blockchain + /// data on activation when provided by the client. + /// + /// If omitted, backend-side behavior applies (for example resuming from + /// persisted sync state or using backend defaults). /// /// Supported values: /// - Earliest: start from the coin's `sapling_activation_height` diff --git a/packages/komodo_defi_sdk/lib/komodo_defi_sdk.dart b/packages/komodo_defi_sdk/lib/komodo_defi_sdk.dart index b612cae13..b705bbfda 100644 --- a/packages/komodo_defi_sdk/lib/komodo_defi_sdk.dart +++ b/packages/komodo_defi_sdk/lib/komodo_defi_sdk.dart @@ -45,6 +45,8 @@ export 'src/activation_config/activation_config_service.dart' InMemoryKeyValueStore, JsonActivationConfigRepository, WalletIdResolver, + ZhtlcRecurringSyncMode, + ZhtlcRecurringSyncPolicy, ZhtlcUserConfig; export 'src/activation_config/hive_activation_config_repository.dart' show HiveActivationConfigRepository; diff --git a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/zhtlc_activation_strategy.dart b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/zhtlc_activation_strategy.dart index f6fb25bbc..a96427b17 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/zhtlc_activation_strategy.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/zhtlc_activation_strategy.dart @@ -4,8 +4,8 @@ import 'dart:convert'; import 'dart:developer' show log; -import 'package:komodo_defi_framework/komodo_defi_framework.dart'; +import 'package:komodo_defi_framework/komodo_defi_framework.dart'; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; import 'package:komodo_defi_sdk/src/activation/_activation.dart'; import 'package:komodo_defi_sdk/src/activation/protocol_strategies/zhtlc_activation_progress.dart'; @@ -81,8 +81,9 @@ class ZhtlcActivationStrategy extends ProtocolActivationStrategy { privKeyPolicy: privKeyPolicy, ); - // Apply one-shot sync_params only when explicitly provided via config form - // right before activation. This avoids caching and unintended rewinds. + // Apply sync params only when explicitly requested as a one-shot + // override. If absent, omit sync_params so backend resume/default sync + // behavior is used. if (params.mode?.rpcData != null) { final oneShotSync = await configService.takeOneShotSyncParams(asset.id); if (oneShotSync != null) { @@ -106,15 +107,25 @@ class ZhtlcActivationStrategy extends ProtocolActivationStrategy { // Debug logging for ZHTLC activation if (KdfLoggingConfig.verboseLogging) { log( - '[RPC] Activating ZHTLC coin: ${asset.id.id}', - name: 'ZhtlcActivationStrategy', - ); + '[RPC] Activating ZHTLC coin: ${asset.id.id}', + name: 'ZhtlcActivationStrategy', + ); } if (KdfLoggingConfig.verboseLogging) { + final activationLogPayload = { + 'ticker': asset.id.id, + 'protocol': asset.protocol.subClass.formatted, + 'activation_params': params.toRpcParams(), + 'zcash_params_path': userConfig.zcashParamsPath, + 'scan_blocks_per_iteration': userConfig.scanBlocksPerIteration, + 'scan_interval_ms': userConfig.scanIntervalMs, + 'polling_interval_ms': effectivePollingInterval.inMilliseconds, + 'priv_key_policy': privKeyPolicy.toJson(), + }; log( - '[RPC] Activation parameters: ${jsonEncode({'ticker': asset.id.id, 'protocol': asset.protocol.subClass.formatted, 'activation_params': params.toRpcParams(), 'zcash_params_path': userConfig.zcashParamsPath, 'scan_blocks_per_iteration': userConfig.scanBlocksPerIteration, 'scan_interval_ms': userConfig.scanIntervalMs, 'polling_interval_ms': effectivePollingInterval.inMilliseconds, 'priv_key_policy': privKeyPolicy.toJson()})}', - name: 'ZhtlcActivationStrategy', - ); + '[RPC] Activation parameters: ${jsonEncode(activationLogPayload)}', + name: 'ZhtlcActivationStrategy', + ); } // Initialize task and watch via TaskShepherd @@ -167,7 +178,7 @@ class ZhtlcActivationStrategy extends ProtocolActivationStrategy { detail: detail, ); } - } catch (e, stack) { + } on Object catch (e, stack) { yield ActivationProgressZhtlc.failure(e, stack); } } diff --git a/packages/komodo_defi_sdk/lib/src/activation_config/activation_config_service.dart b/packages/komodo_defi_sdk/lib/src/activation_config/activation_config_service.dart index f783a1035..c00678996 100644 --- a/packages/komodo_defi_sdk/lib/src/activation_config/activation_config_service.dart +++ b/packages/komodo_defi_sdk/lib/src/activation_config/activation_config_service.dart @@ -1,14 +1,145 @@ import 'dart:async'; import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:hive_ce/hive.dart'; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; -import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; typedef JsonMap = Map; +enum ZhtlcRecurringSyncMode { + recentTransactions, + earliest, + height, + date; + + static ZhtlcRecurringSyncMode? tryParse(String? value) { + switch (value) { + case 'recent_transactions': + return ZhtlcRecurringSyncMode.recentTransactions; + case 'earliest': + return ZhtlcRecurringSyncMode.earliest; + case 'height': + return ZhtlcRecurringSyncMode.height; + case 'date': + return ZhtlcRecurringSyncMode.date; + default: + return null; + } + } + + String get jsonValue => switch (this) { + ZhtlcRecurringSyncMode.recentTransactions => 'recent_transactions', + ZhtlcRecurringSyncMode.earliest => 'earliest', + ZhtlcRecurringSyncMode.height => 'height', + ZhtlcRecurringSyncMode.date => 'date', + }; +} + +/// Persisted recurring sync policy for ZHTLC wallet activations. +class ZhtlcRecurringSyncPolicy { + ZhtlcRecurringSyncPolicy._({ + required this.mode, + this.height, + this.unixTimestamp, + }) : assert(switch (mode) { + ZhtlcRecurringSyncMode.recentTransactions || + ZhtlcRecurringSyncMode.earliest => + height == null && unixTimestamp == null, + ZhtlcRecurringSyncMode.height => height != null, + ZhtlcRecurringSyncMode.date => unixTimestamp != null, + }, 'Recurring sync policy data does not match its mode.'); + + factory ZhtlcRecurringSyncPolicy.recentTransactions() => + ZhtlcRecurringSyncPolicy._( + mode: ZhtlcRecurringSyncMode.recentTransactions, + ); + + factory ZhtlcRecurringSyncPolicy.earliest() => + ZhtlcRecurringSyncPolicy._(mode: ZhtlcRecurringSyncMode.earliest); + + factory ZhtlcRecurringSyncPolicy.height(int height) => + ZhtlcRecurringSyncPolicy._( + mode: ZhtlcRecurringSyncMode.height, + height: height, + ); + + factory ZhtlcRecurringSyncPolicy.date(int unixTimestamp) => + ZhtlcRecurringSyncPolicy._( + mode: ZhtlcRecurringSyncMode.date, + unixTimestamp: unixTimestamp, + ); + + factory ZhtlcRecurringSyncPolicy.fromJson(JsonMap json) { + final mode = ZhtlcRecurringSyncMode.tryParse( + json.valueOrNull('mode'), + ); + if (mode == null) { + throw ArgumentError.value( + json['mode'], + 'json.mode', + 'Unsupported recurring ZHTLC sync policy mode', + ); + } + + return switch (mode) { + ZhtlcRecurringSyncMode.recentTransactions => + ZhtlcRecurringSyncPolicy.recentTransactions(), + ZhtlcRecurringSyncMode.earliest => ZhtlcRecurringSyncPolicy.earliest(), + ZhtlcRecurringSyncMode.height => ZhtlcRecurringSyncPolicy.height( + json.value('height'), + ), + ZhtlcRecurringSyncMode.date => ZhtlcRecurringSyncPolicy.date( + json.value('unixTimestamp'), + ), + }; + } + + factory ZhtlcRecurringSyncPolicy.fromSyncParams(ZhtlcSyncParams syncParams) { + if (syncParams.isEarliest) { + return ZhtlcRecurringSyncPolicy.earliest(); + } + if (syncParams.height != null) { + return ZhtlcRecurringSyncPolicy.height(syncParams.height!); + } + if (syncParams.date != null) { + return ZhtlcRecurringSyncPolicy.date(syncParams.date!); + } + throw ArgumentError.value( + syncParams, + 'syncParams', + 'Unsupported ZHTLC sync params payload', + ); + } + + final ZhtlcRecurringSyncMode mode; + final int? height; + final int? unixTimestamp; + + JsonMap toJson() => { + 'mode': mode.jsonValue, + if (height != null) 'height': height, + if (unixTimestamp != null) 'unixTimestamp': unixTimestamp, + }; + + ZhtlcSyncParams toSyncParams({DateTime? now}) { + return switch (mode) { + ZhtlcRecurringSyncMode.recentTransactions => ZhtlcSyncParams.date( + (now ?? DateTime.now()) + .toUtc() + .subtract(const Duration(days: 2)) + .millisecondsSinceEpoch ~/ + 1000, + ), + ZhtlcRecurringSyncMode.earliest => ZhtlcSyncParams.earliest(), + ZhtlcRecurringSyncMode.height => ZhtlcSyncParams.height(height!), + ZhtlcRecurringSyncMode.date => ZhtlcSyncParams.date(unixTimestamp!), + }; + } +} + /// Simple key-value store abstraction for persisting activation configs. abstract class KeyValueStore { Future get(String key); @@ -45,6 +176,7 @@ class ZhtlcUserConfig { this.scanBlocksPerIteration = 1000, this.scanIntervalMs = 0, this.taskStatusPollingIntervalMs, + this.recurringSyncPolicy, this.syncParams, }); @@ -52,13 +184,39 @@ class ZhtlcUserConfig { final int scanBlocksPerIteration; final int scanIntervalMs; final int? taskStatusPollingIntervalMs; + final ZhtlcRecurringSyncPolicy? recurringSyncPolicy; + /// Optional, accepted for backward compatibility. Not persisted. /// If provided to saveZhtlcConfig, it will be applied as a one-shot /// sync override for the next activation and then discarded. final ZhtlcSyncParams? syncParams; - // Sync params are no longer persisted here; they are supplied one-shot - // via ActivationConfigService at activation time when the user requests - // an intentional resync. + // Sync params are supplied one-shot via ActivationConfigService when the + // user requests an immediate resync. Recurring sync behavior is persisted + // separately via [recurringSyncPolicy]. + + ZhtlcUserConfig copyWith({ + String? zcashParamsPath, + int? scanBlocksPerIteration, + int? scanIntervalMs, + int? taskStatusPollingIntervalMs, + ZhtlcRecurringSyncPolicy? recurringSyncPolicy, + bool clearRecurringSyncPolicy = false, + ZhtlcSyncParams? syncParams, + bool clearSyncParams = false, + }) { + return ZhtlcUserConfig( + zcashParamsPath: zcashParamsPath ?? this.zcashParamsPath, + scanBlocksPerIteration: + scanBlocksPerIteration ?? this.scanBlocksPerIteration, + scanIntervalMs: scanIntervalMs ?? this.scanIntervalMs, + taskStatusPollingIntervalMs: + taskStatusPollingIntervalMs ?? this.taskStatusPollingIntervalMs, + recurringSyncPolicy: clearRecurringSyncPolicy + ? null + : recurringSyncPolicy ?? this.recurringSyncPolicy, + syncParams: clearSyncParams ? null : syncParams ?? this.syncParams, + ); + } JsonMap toJson() => { 'zcashParamsPath': zcashParamsPath, @@ -66,6 +224,8 @@ class ZhtlcUserConfig { 'scanIntervalMs': scanIntervalMs, if (taskStatusPollingIntervalMs != null) 'taskStatusPollingIntervalMs': taskStatusPollingIntervalMs, + if (recurringSyncPolicy != null) + 'recurringSyncPolicy': recurringSyncPolicy!.toJson(), }; static ZhtlcUserConfig fromJson(JsonMap json) => ZhtlcUserConfig( @@ -76,6 +236,12 @@ class ZhtlcUserConfig { taskStatusPollingIntervalMs: json.valueOrNull( 'taskStatusPollingIntervalMs', ), + recurringSyncPolicy: + json.valueOrNull('recurringSyncPolicy') == null + ? null + : ZhtlcRecurringSyncPolicy.fromJson( + json.value('recurringSyncPolicy'), + ), ); } @@ -249,8 +415,12 @@ class ActivationConfigService { onTimeout: () => null, ); if (result == null) return null; - await repo.saveConfig(walletId, id, result); - return result; + if (result.syncParams != null) { + _oneShotSyncParams[key] = result.syncParams; + } + final normalizedConfig = _normalizeConfigForPersistence(result); + await repo.saveConfig(walletId, id, normalizedConfig); + return normalizedConfig; } finally { _awaitingControllers.remove(key); } @@ -258,12 +428,12 @@ class ActivationConfigService { Future saveZhtlcConfig(AssetId id, ZhtlcUserConfig config) async { final walletId = await _requireActiveWallet(); - // If legacy callers provide syncParams in the config, convert it to - // a one-shot sync override and do not persist it. - if (config.syncParams != null) { - _oneShotSyncParams[_WalletAssetKey(walletId, id)] = config.syncParams; + final oneShotSyncParams = config.syncParams; + final normalizedConfig = _normalizeConfigForPersistence(config); + if (oneShotSyncParams != null) { + _oneShotSyncParams[_WalletAssetKey(walletId, id)] = oneShotSyncParams; } - await repo.saveConfig(walletId, id, config); + await repo.saveConfig(walletId, id, normalizedConfig); } Future submitZhtlc(AssetId id, ZhtlcUserConfig config) async { @@ -290,6 +460,19 @@ class ActivationConfigService { return value; } + ZhtlcUserConfig _normalizeConfigForPersistence(ZhtlcUserConfig config) { + final recurringSyncPolicy = + config.recurringSyncPolicy ?? + (config.syncParams == null + ? null + : ZhtlcRecurringSyncPolicy.fromSyncParams(config.syncParams!)); + + return config.copyWith( + recurringSyncPolicy: recurringSyncPolicy, + clearSyncParams: true, + ); + } + /// Clears all one-shot sync params for the specified wallet. /// This should be called when a user signs out to prevent stale one-shot /// params from being applied on the next activation after re-login. @@ -307,8 +490,9 @@ class ActivationConfigService { {}; } +@immutable class _WalletAssetKey { - _WalletAssetKey(this.walletId, this.assetId); + const _WalletAssetKey(this.walletId, this.assetId); final WalletId walletId; final AssetId assetId; diff --git a/packages/komodo_defi_sdk/lib/src/errors/sdk_error_mapper.dart b/packages/komodo_defi_sdk/lib/src/errors/sdk_error_mapper.dart index dd7cede59..decbda903 100644 --- a/packages/komodo_defi_sdk/lib/src/errors/sdk_error_mapper.dart +++ b/packages/komodo_defi_sdk/lib/src/errors/sdk_error_mapper.dart @@ -229,6 +229,7 @@ class _AuthExceptionHandler extends SdkErrorHandler { case AuthExceptionType.alreadySignedIn: case AuthExceptionType.registrationNotAllowed: case AuthExceptionType.internalError: + case AuthExceptionType.legacyWalletAlreadyMigrated: return _build( code: SdkErrorCode.general, category: SdkErrorCategory.auth, diff --git a/packages/komodo_defi_sdk/test/activation/zhtlc_activation_strategy_test.dart b/packages/komodo_defi_sdk/test/activation/zhtlc_activation_strategy_test.dart new file mode 100644 index 000000000..141dfad74 --- /dev/null +++ b/packages/komodo_defi_sdk/test/activation/zhtlc_activation_strategy_test.dart @@ -0,0 +1,155 @@ +import 'dart:collection'; + +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; +import 'package:komodo_defi_sdk/src/activation/protocol_strategies/zhtlc_activation_strategy.dart'; +import 'package:komodo_defi_sdk/src/activation_config/activation_config_service.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:test/test.dart'; + +class _QueueApiClient implements ApiClient { + _QueueApiClient({required Map> responsesByMethod}) + : _responsesByMethod = { + for (final entry in responsesByMethod.entries) + entry.key: Queue.from(entry.value), + }; + + final Map> _responsesByMethod; + final List requests = []; + + @override + Future executeRpc(JsonMap request) async { + requests.add(Map.from(request)); + final method = request['method'] as String?; + if (method == null || method.isEmpty) { + throw StateError('Missing RPC method in request: $request'); + } + + final queue = _responsesByMethod[method]; + if (queue == null || queue.isEmpty) { + throw StateError('No queued response for method $method'); + } + + return queue.removeFirst(); + } +} + +Asset _createZhtlcAsset() { + final protocol = ZhtlcProtocol.fromJson(const { + 'type': 'ZHTLC', + 'light_wallet_d_servers': ['https://lightd.example'], + 'electrum_servers': [ + {'url': 'electrum.example:50002', 'protocol': 'SSL'}, + ], + }); + + return Asset( + id: AssetId( + id: 'ARRR', + name: 'Pirate Chain', + symbol: AssetSymbol(assetConfigId: 'ARRR'), + chainId: AssetChainId(chainId: 1), + derivationPath: null, + subClass: CoinSubClass.zhtlc, + ), + protocol: protocol, + isWalletOnly: false, + signMessagePrefix: null, + ); +} + +void main() { + group('ZhtlcActivationStrategy', () { + test('applies one-shot sync params once and omits sync_params ' + 'on subsequent activations', () async { + final walletId = WalletId.fromName( + 'Test Wallet', + const AuthOptions(derivationMethod: DerivationMethod.iguana), + ); + final configService = ActivationConfigService( + JsonActivationConfigRepository(InMemoryKeyValueStore()), + walletIdResolver: () async => walletId, + ); + final asset = _createZhtlcAsset(); + + await configService.saveZhtlcConfig( + asset.id, + ZhtlcUserConfig( + zcashParamsPath: '/zcash-params', + recurringSyncPolicy: ZhtlcRecurringSyncPolicy.recentTransactions(), + syncParams: ZhtlcSyncParams.height(123456), + ), + ); + + final client = _QueueApiClient( + responsesByMethod: { + 'task::enable_z_coin::init': [ + { + 'mmrpc': '2.0', + 'result': {'task_id': 1}, + }, + { + 'mmrpc': '2.0', + 'result': {'task_id': 2}, + }, + ], + 'task::enable_z_coin::status': [ + { + 'mmrpc': '2.0', + 'result': {'status': 'Ok', 'details': 'done'}, + }, + { + 'mmrpc': '2.0', + 'result': {'status': 'Ok', 'details': 'done'}, + }, + ], + }, + ); + final strategy = ZhtlcActivationStrategy( + client, + const PrivateKeyPolicy.contextPrivKey(), + configService, + pollingInterval: const Duration(milliseconds: 1), + ); + + await strategy.activate(asset).toList(); + await strategy.activate(asset).toList(); + + final initRequests = client.requests + .where((request) => request['method'] == 'task::enable_z_coin::init') + .toList(growable: false); + expect(initRequests, hasLength(2)); + + final firstRpcData = + ((initRequests.first['params'] + as Map)['activation_params'] + as Map)['mode'] + as Map; + final firstModeRpcData = firstRpcData['rpc_data'] as Map; + expect(firstModeRpcData['sync_params'], { + 'height': 123456, + }); + + final secondRpcData = + ((initRequests.last['params'] + as Map)['activation_params'] + as Map)['mode'] + as Map; + final secondModeRpcData = + secondRpcData['rpc_data'] as Map; + expect(secondModeRpcData.containsKey('sync_params'), isFalse); + + final savedConfig = await configService.getSavedZhtlc(asset.id); + expect(savedConfig, isNotNull); + expect(savedConfig?.syncParams, isNull); + expect( + savedConfig?.recurringSyncPolicy?.mode, + ZhtlcRecurringSyncMode.recentTransactions, + ); + + final remainingOneShot = await configService.takeOneShotSyncParams( + asset.id, + ); + expect(remainingOneShot, isNull); + }); + }); +} diff --git a/packages/komodo_defi_sdk/test/src/activation_config/activation_config_service_test.dart b/packages/komodo_defi_sdk/test/src/activation_config/activation_config_service_test.dart new file mode 100644 index 000000000..3f51501e5 --- /dev/null +++ b/packages/komodo_defi_sdk/test/src/activation_config/activation_config_service_test.dart @@ -0,0 +1,75 @@ +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:test/test.dart'; + +void main() { + group('ZhtlcRecurringSyncPolicy', () { + test('recentTransactions resolves to a runtime date sync param', () { + final policy = ZhtlcRecurringSyncPolicy.recentTransactions(); + final now = DateTime.utc(2026, 4, 10, 12); + final syncParams = policy.toSyncParams(now: now); + + expect(syncParams.isEarliest, isFalse); + expect(syncParams.height, isNull); + expect( + syncParams.date, + now.subtract(const Duration(days: 2)).millisecondsSinceEpoch ~/ 1000, + ); + }); + + test('serializes and deserializes date policies', () { + final policy = ZhtlcRecurringSyncPolicy.date(1775659200); + + final decoded = ZhtlcRecurringSyncPolicy.fromJson(policy.toJson()); + + expect(decoded.mode, ZhtlcRecurringSyncMode.date); + expect(decoded.unixTimestamp, 1775659200); + }); + }); + + group('ActivationConfigService', () { + test('saveZhtlcConfig persists recurring policy and stores one-shot ' + 'sync params', () async { + final walletId = WalletId.fromName( + 'Test Wallet', + const AuthOptions(derivationMethod: DerivationMethod.iguana), + ); + final assetId = AssetId( + id: 'ARRR', + name: 'Pirate Chain', + symbol: AssetSymbol(assetConfigId: 'ARRR'), + chainId: AssetChainId(chainId: 777, decimalsValue: 8), + derivationPath: null, + subClass: CoinSubClass.zhtlc, + ); + final service = ActivationConfigService( + JsonActivationConfigRepository(InMemoryKeyValueStore()), + walletIdResolver: () async => walletId, + ); + + await service.saveZhtlcConfig( + assetId, + ZhtlcUserConfig( + zcashParamsPath: '/zcash-params', + syncParams: ZhtlcSyncParams.height(123456), + ), + ); + + final savedConfig = await service.getSavedZhtlc(assetId); + final oneShotSync = await service.takeOneShotSyncParams(assetId); + final consumedSync = await service.takeOneShotSyncParams(assetId); + + expect(savedConfig, isNotNull); + expect(savedConfig?.syncParams, isNull); + expect(savedConfig?.zcashParamsPath, '/zcash-params'); + expect( + savedConfig?.recurringSyncPolicy?.mode, + ZhtlcRecurringSyncMode.height, + ); + expect(savedConfig?.recurringSyncPolicy?.height, 123456); + expect(oneShotSync?.height, 123456); + expect(consumedSync, isNull); + }); + }); +} diff --git a/packages/komodo_defi_types/lib/src/auth/exceptions/auth_exception.dart b/packages/komodo_defi_types/lib/src/auth/exceptions/auth_exception.dart index 5acd85651..b2e33a37b 100644 --- a/packages/komodo_defi_types/lib/src/auth/exceptions/auth_exception.dart +++ b/packages/komodo_defi_types/lib/src/auth/exceptions/auth_exception.dart @@ -14,20 +14,26 @@ enum AuthExceptionType { registrationNotAllowed, internalError, apiConnectionError, + + /// Legacy wallet already has a migrated KDF counterpart. + legacyWalletAlreadyMigrated, } class AuthException implements Exception { - AuthException( - this.message, { - required this.type, - this.details = const {}, - }); + AuthException(this.message, {required this.type, this.details = const {}}); // Common exception constructors convenience methods AuthException.notSignedIn() - : this('Not signed in', type: AuthExceptionType.unauthorized); + : this('Not signed in', type: AuthExceptionType.unauthorized); AuthException.notFound() - : this('Not found', type: AuthExceptionType.walletNotFound); + : this('Not found', type: AuthExceptionType.walletNotFound); + + AuthException.legacyWalletAlreadyMigrated(String migratedWalletName) + : this( + 'Legacy wallet already migrated', + type: AuthExceptionType.legacyWalletAlreadyMigrated, + details: {'migratedWalletName': migratedWalletName}, + ); /// The error message. final String message; @@ -95,49 +101,46 @@ class AuthException implements Exception { return matchingPatterns[AuthExceptionType.registrationNotAllowed]!; case AuthExceptionType.apiConnectionError: return matchingPatterns[AuthExceptionType.apiConnectionError]!; - // The following types don't originate from the API, so we return empty arrays + // The following types don't originate from the API, so we return empty + // arrays. case AuthExceptionType.generalAuthError: case AuthExceptionType.unauthorized: case AuthExceptionType.alreadySignedIn: case AuthExceptionType.internalError: case AuthExceptionType.invalidBip39Mnemonic: + case AuthExceptionType.legacyWalletAlreadyMigrated: return []; } } static Map> get matchingPatterns => { - AuthExceptionType.incorrectPassword: [ - 'Incorrect wallet password', - 'Error generating or decrypting mnemonic', - 'HMAC', - 'Error decrypting mnemonic: HMAC error: MAC tag mismatch', - 'MAC tag mismatch', - 'Error decrypting mnemonic', - ], - AuthExceptionType.walletAlreadyRunning: [ - 'Wallet is already running', - ], - AuthExceptionType.walletStartFailed: [ - 'Failed to start KDF', - ], - AuthExceptionType.walletNotFound: [ - 'Wallet does not exist', - 'No wallet found with the given name', - ], - AuthExceptionType.walletAlreadyExists: [ - 'Wallet already exists', - 'A wallet with this name already exists', - ], - AuthExceptionType.registrationNotAllowed: [ - 'wallet creation is disabled', - ], - AuthExceptionType.apiConnectionError: [ - 'Connection refused', - 'Connection timed out', - ], - // We don't include patterns for the following types as they don't originate from the API - // AuthExceptionType.generalAuthError - // AuthExceptionType.unauthorized - // AuthExceptionType.alreadySignedIn - }; + AuthExceptionType.incorrectPassword: [ + 'Incorrect wallet password', + 'Error generating or decrypting mnemonic', + 'HMAC', + 'Error decrypting mnemonic: HMAC error: MAC tag mismatch', + 'MAC tag mismatch', + 'Error decrypting mnemonic', + ], + AuthExceptionType.walletAlreadyRunning: ['Wallet is already running'], + AuthExceptionType.walletStartFailed: ['Failed to start KDF'], + AuthExceptionType.walletNotFound: [ + 'Wallet does not exist', + 'No wallet found with the given name', + ], + AuthExceptionType.walletAlreadyExists: [ + 'Wallet already exists', + 'A wallet with this name already exists', + ], + AuthExceptionType.registrationNotAllowed: ['wallet creation is disabled'], + AuthExceptionType.apiConnectionError: [ + 'Connection refused', + 'Connection timed out', + ], + // We don't include patterns for the following types as they don't + // originate from the API. + // AuthExceptionType.generalAuthError + // AuthExceptionType.unauthorized + // AuthExceptionType.alreadySignedIn + }; } diff --git a/packages/komodo_legacy_wallet_migration/.gitignore b/packages/komodo_legacy_wallet_migration/.gitignore new file mode 100644 index 000000000..06ef8e610 --- /dev/null +++ b/packages/komodo_legacy_wallet_migration/.gitignore @@ -0,0 +1,44 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# VSCode related +.vscode/* + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +pubspec.lock + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Test related +coverage \ No newline at end of file diff --git a/packages/komodo_legacy_wallet_migration/LICENSE b/packages/komodo_legacy_wallet_migration/LICENSE new file mode 100644 index 000000000..8d45e062a --- /dev/null +++ b/packages/komodo_legacy_wallet_migration/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/komodo_legacy_wallet_migration/README.md b/packages/komodo_legacy_wallet_migration/README.md new file mode 100644 index 000000000..d965dfdce --- /dev/null +++ b/packages/komodo_legacy_wallet_migration/README.md @@ -0,0 +1,67 @@ +# Komodo Legacy Wallet Migration + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![Powered by Mason](https://img.shields.io/endpoint?url=https%3A%2F%2Ftinyurl.com%2Fmason-badge)](https://github.com/felangel/mason) +[![License: MIT][license_badge]][license_link] + +Legacy wallet migration utilities for Komodo/Gleec SDK apps. + +## Installation ๐Ÿ’ป + +**โ— In order to start using Komodo Legacy Wallet Migration you must have the [Flutter SDK][flutter_install_link] installed on your machine.** + +Install via `flutter pub add`: + +```sh +dart pub add komodo_legacy_wallet_migration +``` + +--- + +## Continuous Integration ๐Ÿค– + +Komodo Legacy Wallet Migration comes with a built-in [GitHub Actions workflow][github_actions_link] powered by [Very Good Workflows][very_good_workflows_link] but you can also add your preferred CI/CD solution. + +Out of the box, on each pull request and push, the CI `formats`, `lints`, and `tests` the code. This ensures the code remains consistent and behaves correctly as you add functionality or make changes. The project uses [Very Good Analysis][very_good_analysis_link] for a strict set of analysis options used by our team. Code coverage is enforced using the [Very Good Workflows][very_good_coverage_link]. + +--- + +## Running Tests ๐Ÿงช + +For first time users, install the [very_good_cli][very_good_cli_link]: + +```sh +dart pub global activate very_good_cli +``` + +To run all unit tests: + +```sh +very_good test --coverage +``` + +To view the generated coverage report you can use [lcov](https://github.com/linux-test-project/lcov). + +```sh +# Generate Coverage Report +genhtml coverage/lcov.info -o coverage/ + +# Open Coverage Report +open coverage/index.html +``` + +[flutter_install_link]: https://docs.flutter.dev/get-started/install +[github_actions_link]: https://docs.github.com/en/actions/learn-github-actions +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[logo_black]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_black.png#gh-light-mode-only +[logo_white]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_white.png#gh-dark-mode-only +[mason_link]: https://github.com/felangel/mason +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[very_good_cli_link]: https://pub.dev/packages/very_good_cli +[very_good_coverage_link]: https://github.com/marketplace/actions/very-good-coverage +[very_good_ventures_link]: https://verygood.ventures +[very_good_ventures_link_light]: https://verygood.ventures#gh-light-mode-only +[very_good_ventures_link_dark]: https://verygood.ventures#gh-dark-mode-only +[very_good_workflows_link]: https://github.com/VeryGoodOpenSource/very_good_workflows diff --git a/packages/komodo_legacy_wallet_migration/analysis_options.yaml b/packages/komodo_legacy_wallet_migration/analysis_options.yaml new file mode 100644 index 000000000..9df80aa49 --- /dev/null +++ b/packages/komodo_legacy_wallet_migration/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.yaml diff --git a/packages/komodo_legacy_wallet_migration/lib/komodo_legacy_wallet_migration.dart b/packages/komodo_legacy_wallet_migration/lib/komodo_legacy_wallet_migration.dart new file mode 100644 index 000000000..a2916cb2d --- /dev/null +++ b/packages/komodo_legacy_wallet_migration/lib/komodo_legacy_wallet_migration.dart @@ -0,0 +1,6 @@ +// Legacy wallet migration utilities for Komodo/Gleec SDK apps. +export 'src/komodo_legacy_wallet_migration.dart'; +export 'src/models/legacy_wallet_cleanup_result.dart'; +export 'src/models/legacy_wallet_migration_exception.dart'; +export 'src/models/legacy_wallet_record.dart'; +export 'src/models/legacy_wallet_secrets.dart'; diff --git a/packages/komodo_legacy_wallet_migration/lib/src/adapters/legacy_password_verifier.dart b/packages/komodo_legacy_wallet_migration/lib/src/adapters/legacy_password_verifier.dart new file mode 100644 index 000000000..65ec84ff4 --- /dev/null +++ b/packages/komodo_legacy_wallet_migration/lib/src/adapters/legacy_password_verifier.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:pointycastle/key_derivators/api.dart'; +import 'package:pointycastle/key_derivators/argon2.dart'; + +/// Verifies a legacy native wallet password against the stored seed hash. +// ignore: one_member_abstracts +abstract interface class LegacyPasswordVerifier { + /// Returns `true` when [password] matches the legacy [encodedHash]. + Future verifySeedPassword({ + required String password, + required String encodedHash, + }); +} + +/// Argon2id-based verifier for legacy native wallet seed passwords. +/// +/// Uses pointycastle's pure-Dart Argon2 implementation, which is compatible +/// with all Dart platforms including Flutter Web WASM. +class Argon2LegacyPasswordVerifier implements LegacyPasswordVerifier { + /// Creates an Argon2-based verifier. + const Argon2LegacyPasswordVerifier(); + + @override + Future verifySeedPassword({ + required String password, + required String encodedHash, + }) async { + try { + final parsed = _parsePhcEncodedHash(encodedHash); + if (parsed == null) return false; + + final params = Argon2Parameters( + parsed.type, + parsed.salt, + desiredKeyLength: parsed.hash.length, + iterations: parsed.timeCost, + memory: parsed.memoryCost, + lanes: parsed.parallelism, + version: parsed.version, + ); + + final generator = Argon2BytesGenerator()..init(params); + final derived = generator.process( + Uint8List.fromList(utf8.encode(password)), + ); + + return _constantTimeEquals(derived, parsed.hash); + } on Object { + return false; + } + } + + /// Parses the PHC string format used by Argon2 reference implementations. + /// + /// Format: + /// `$argon2$v=$m=,t=