diff --git a/packages/komodo_defi_framework/lib/komodo_defi_framework.dart b/packages/komodo_defi_framework/lib/komodo_defi_framework.dart index 7964da35..a5ad0e96 100644 --- a/packages/komodo_defi_framework/lib/komodo_defi_framework.dart +++ b/packages/komodo_defi_framework/lib/komodo_defi_framework.dart @@ -173,31 +173,38 @@ class KomodoDefiFramework implements ApiClient { } Future version() async { - final version = await _kdfOperations.version(); - _log('KDF version: $version'); - return version; + final stopwatch = Stopwatch()..start(); + _log('version(): Starting version RPC call via ${_kdfOperations.operationsName}'); + try { + final version = await _kdfOperations.version(); + stopwatch.stop(); + _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'); + rethrow; + } } /// Checks if KDF is healthy and responsive by attempting a version RPC call. /// 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). Future isHealthy() async { try { - final isRunningCheck = await isRunning(); - if (!isRunningCheck) { - _log('KDF health check failed: not running'); - return false; - } - - // Additional check: try to get version to verify RPC is responsive + // Only rely on actual RPC verification - don't trust native status alone final versionCheck = await version(); if (versionCheck == null) { _log('KDF health check failed: version call returned null'); return false; } - _log('KDF health check passed'); + _log('KDF health check passed: version=$versionCheck'); return true; } catch (e) { _log('KDF health check failed with exception: $e'); @@ -205,6 +212,14 @@ class KomodoDefiFramework implements ApiClient { } } + /// Resets the HTTP client to drop stale keep-alive connections. + /// This is useful after KDF has been killed and restarted to ensure + /// we don't try to reuse dead connections. + void resetHttpClient() { + _log('Resetting HTTP client to drop stale connections'); + _kdfOperations.resetHttpClient(); + } + @override Future executeRpc(JsonMap request) async { if (!enableDebugLogging) { @@ -248,9 +263,37 @@ class KomodoDefiFramework implements ApiClient { return response; } catch (e) { stopwatch.stop(); - _logger.warning( - '[RPC] ${method ?? 'unknown'} failed after ${stopwatch.elapsedMilliseconds}ms: $e', + + // 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 + // errno 60 (ETIMEDOUT): Operation timed out + // 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') ); + + 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)'; + _logger.severe( + '[RPC] ${method ?? 'unknown'} failed: KDF transport error $errorType. ' + 'Resetting HTTP client to drop stale connections.', + ); + // Reset HTTP client immediately to drop stale keep-alive connections + resetHttpClient(); + } else { + _logger.warning( + '[RPC] ${method ?? 'unknown'} failed after ${stopwatch.elapsedMilliseconds}ms: $e', + ); + } rethrow; } } diff --git a/packages/komodo_defi_framework/lib/src/operations/kdf_operations_interface.dart b/packages/komodo_defi_framework/lib/src/operations/kdf_operations_interface.dart index 98db7446..097e3404 100644 --- a/packages/komodo_defi_framework/lib/src/operations/kdf_operations_interface.dart +++ b/packages/komodo_defi_framework/lib/src/operations/kdf_operations_interface.dart @@ -132,6 +132,11 @@ abstract interface class IKdfOperations { /// concerns. Future isAvailable(IKdfHostConfig hostConfig); + /// Resets the HTTP client to drop stale keep-alive connections. + /// This is useful after KDF has been killed and restarted to ensure + /// we don't try to reuse dead connections. + void resetHttpClient(); + /// Dispose of any resources used by this operations implementation void dispose(); } diff --git a/packages/komodo_defi_framework/lib/src/operations/kdf_operations_local_executable.dart b/packages/komodo_defi_framework/lib/src/operations/kdf_operations_local_executable.dart index d8d63835..a159a09d 100644 --- a/packages/komodo_defi_framework/lib/src/operations/kdf_operations_local_executable.dart +++ b/packages/komodo_defi_framework/lib/src/operations/kdf_operations_local_executable.dart @@ -298,6 +298,12 @@ class KdfOperationsLocalExecutable implements IKdfOperations { } } + @override + void resetHttpClient() { + // Delegate to remote operations + _kdfRemote.resetHttpClient(); + } + @override void dispose() { // Cancel and clean up subscriptions diff --git a/packages/komodo_defi_framework/lib/src/operations/kdf_operations_native.dart b/packages/komodo_defi_framework/lib/src/operations/kdf_operations_native.dart index 8997e261..3bc5ddf1 100644 --- a/packages/komodo_defi_framework/lib/src/operations/kdf_operations_native.dart +++ b/packages/komodo_defi_framework/lib/src/operations/kdf_operations_native.dart @@ -259,7 +259,7 @@ class KdfOperationsNativeLibrary implements IKdfOperations { // platforms, especially after app backgrounding. See: // https://github.com/KomodoPlatform/komodo-wallet/issues/3213 final Uri _url = Uri.parse('http://127.0.0.1:7783'); - final Client _client = Client(); + Client _client = Client(); @override Future> mm2Rpc(Map request) async { @@ -297,6 +297,13 @@ class KdfOperationsNativeLibrary implements IKdfOperations { } } + @override + void resetHttpClient() { + _log('Resetting HTTP client to drop stale keep-alive connections'); + _client.close(); + _client = Client(); + } + static int _kdfMainIsolate(_KdfMainParams params) { final dylib = _library; assert( diff --git a/packages/komodo_defi_framework/lib/src/operations/kdf_operations_native_stub.dart b/packages/komodo_defi_framework/lib/src/operations/kdf_operations_native_stub.dart index d585c5c6..7c05683b 100644 --- a/packages/komodo_defi_framework/lib/src/operations/kdf_operations_native_stub.dart +++ b/packages/komodo_defi_framework/lib/src/operations/kdf_operations_native_stub.dart @@ -45,6 +45,11 @@ class KdfOperationsNativeLibrary implements IKdfOperations { throw UnsupportedError('Native operations not available on this platform'); } + @override + void resetHttpClient() { + // No-op for stub + } + @override void dispose() { // No-op for stub diff --git a/packages/komodo_defi_framework/lib/src/operations/kdf_operations_remote.dart b/packages/komodo_defi_framework/lib/src/operations/kdf_operations_remote.dart index 40de7124..8d934ce2 100644 --- a/packages/komodo_defi_framework/lib/src/operations/kdf_operations_remote.dart +++ b/packages/komodo_defi_framework/lib/src/operations/kdf_operations_remote.dart @@ -198,6 +198,12 @@ class KdfOperationsRemote implements IKdfOperations { } } + @override + void resetHttpClient() { + // No-op for remote operations - HTTP client is managed by http package + _log('resetHttpClient called on remote operations (no-op)'); + } + @override void dispose() { // No-op for remote operations - HTTP client is managed externally diff --git a/packages/komodo_defi_framework/lib/src/operations/kdf_operations_wasm.dart b/packages/komodo_defi_framework/lib/src/operations/kdf_operations_wasm.dart index 1876274c..46472288 100644 --- a/packages/komodo_defi_framework/lib/src/operations/kdf_operations_wasm.dart +++ b/packages/komodo_defi_framework/lib/src/operations/kdf_operations_wasm.dart @@ -397,6 +397,12 @@ class KdfOperationsWasm implements IKdfOperations { } } + @override + void resetHttpClient() { + // No-op for WASM operations - HTTP client is managed by browser + _log('resetHttpClient called on WASM operations (no-op)'); + } + @override void dispose() { // Clean up any resources used by the WASM operations 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 d015bca8..0bd81162 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 @@ -5,7 +5,9 @@ import 'package:komodo_defi_local_auth/src/auth/storage/secure_storage.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:logging/logging.dart'; import 'package:mutex/mutex.dart'; +import 'package:uuid/uuid.dart'; part 'auth_service_auth_extension.dart'; part 'auth_service_kdf_extension.dart'; @@ -99,7 +101,8 @@ abstract interface class IAuthService { } class KdfAuthService implements IAuthService { - KdfAuthService(this._kdfFramework, this._hostConfig) { + KdfAuthService(this._kdfFramework, this._hostConfig) : _sessionId = const Uuid().v4() { + _logger.info('[$_sessionId] KdfAuthService initialized'); _startHealthCheck(); } @@ -109,10 +112,17 @@ class KdfAuthService implements IAuthService { StreamController.broadcast(); final SecureLocalStorage _secureStorage = SecureLocalStorage(); final ReadWriteMutex _authMutex = ReadWriteMutex(); + final Logger _logger = Logger('KdfAuthService'); + final String _sessionId; KdfUser? _lastEmittedUser; Timer? _healthCheckTimer; + // Single-flight guard for ensureKdfHealthy to prevent concurrent restarts + Future? _ongoingHealthCheck; + DateTime? _lastHealthCheckAttempt; + DateTime? _lastHealthCheckCompleted; + ApiClient get _client => _kdfFramework.client; late final methods = KomodoDefiRpcMethods(_client); @@ -122,6 +132,37 @@ class KdfAuthService implements IAuthService { required String password, required AuthOptions options, }) async { + _logger.info('[$_sessionId] signIn: Starting login for wallet: $walletName'); + + // Proactively ensure KDF is healthy before attempting login + // This prevents login attempts while KDF is down or restarting + final isHealthy = await ensureKdfHealthy().timeout( + const Duration(seconds: 3), + onTimeout: () { + _logger.warning('[$_sessionId] signIn: Health check timed out after 3s'); + return false; + }, + ); + + if (!isHealthy) { + _logger.warning('[$_sessionId] signIn: KDF not healthy, retrying after 1s'); + // Wait and retry once + await Future.delayed(const Duration(milliseconds: 1000)); + final retryHealthy = await ensureKdfHealthy().timeout( + const Duration(seconds: 3), + onTimeout: () => false, + ); + if (!retryHealthy) { + _logger.severe('[$_sessionId] signIn: KDF still not healthy after retry'); + throw AuthException( + 'KDF is not available. Please try again.', + type: AuthExceptionType.apiConnectionError, + ); + } + } + + _logger.info('[$_sessionId] signIn: KDF healthy, proceeding with login'); + // [getActiveUser] performs a read lock, which should happen outside of // the write lock to prevent deadlocks. If kdf is not running, null is // returned, so we can safely call it here without any checks. @@ -494,27 +535,186 @@ class KdfAuthService implements IAuthService { @override Future ensureKdfHealthy() async { + // Single-flight guard: if a health check is already in progress, return that future + if (_ongoingHealthCheck != null) { + _logger.info('[$_sessionId] ensureKdfHealthy: Health check already in progress, awaiting result'); + return _ongoingHealthCheck!; + } + + // Cooldown mechanism: prevent rapid successive health checks + // Only apply cooldown if a previous check has completed + final now = DateTime.now(); + if (_lastHealthCheckCompleted != null) { + final timeSinceLastCheck = now.difference(_lastHealthCheckCompleted!); + if (timeSinceLastCheck.inSeconds < 2) { + _logger.info('[$_sessionId] ensureKdfHealthy: In cooldown period (${timeSinceLastCheck.inSeconds}s since last check)'); + return false; + } + } + + // Start the health check and store the future + _lastHealthCheckAttempt = now; + _ongoingHealthCheck = _performHealthCheck(); + + try { + final result = await _ongoingHealthCheck!; + _lastHealthCheckCompleted = DateTime.now(); + final elapsed = _lastHealthCheckCompleted!.difference(_lastHealthCheckAttempt!); + _logger.info('[$_sessionId] ensureKdfHealthy: Completed in ${elapsed.inMilliseconds}ms, result=$result'); + return result; + } finally { + // Clear the ongoing check flag when done + _ongoingHealthCheck = null; + } + } + + Future _performHealthCheck() async { + _logger.info('[$_sessionId] _performHealthCheck: Starting health check'); + final stopwatch = Stopwatch()..start(); + try { - // First check if KDF is healthy - if (await _kdfFramework.isHealthy()) { - return true; + // First check if KDF is healthy with a short timeout + final isHealthy = await _kdfFramework.isHealthy().timeout( + const Duration(seconds: 2), + onTimeout: () { + _logger.warning('[$_sessionId] _performHealthCheck: isHealthy() timed out after 2s'); + return false; + }, + ); + + if (isHealthy) { + // Double verification: even if isHealthy() returns true, verify with version() RPC + // This prevents false positives where native status reports "running" but HTTP is down + _logger.info('[$_sessionId] _performHealthCheck: Initial check passed, performing double verification'); + final doubleCheck = await _verifyKdfHealthy().timeout( + const Duration(seconds: 2), + onTimeout: () { + _logger.warning('[$_sessionId] _performHealthCheck: Double verification timed out'); + return false; + }, + ); + + if (doubleCheck) { + stopwatch.stop(); + _logger.info('[$_sessionId] _performHealthCheck: KDF is healthy (double verified) in ${stopwatch.elapsedMilliseconds}ms'); + return true; + } + + _logger.warning('[$_sessionId] _performHealthCheck: Double verification failed, KDF not actually healthy'); } - // KDF is not healthy, try to get the current active user - final currentUser = await _getActiveUser(); - if (currentUser == null) { - // No current user, just ensure KDF is running in no-auth mode - await _ensureKdfRunning(); - return await _kdfFramework.isHealthy(); + _logger.warning('[$_sessionId] _performHealthCheck: KDF is not healthy, forcing full restart'); + + // Use _lastEmittedUser instead of calling _getActiveUser() RPC when KDF is down + // This avoids blocking on a dead KDF + final hadAuthenticatedUser = _lastEmittedUser != null; + _logger.info('[$_sessionId] _performHealthCheck: hadAuthenticatedUser=$hadAuthenticatedUser'); + + // FORCE a full stop->start cycle when we've determined KDF is unhealthy + // Don't trust isRunning() as it can be stale after iOS backgrounding + _logger.info('[$_sessionId] _performHealthCheck: Forcing clean shutdown (ignoring isRunning status)'); + try { + await _stopKdf().timeout( + const Duration(seconds: 2), + onTimeout: () { + _logger.warning('[$_sessionId] _performHealthCheck: kdfStop() timed out'); + }, + ); + } catch (e) { + _logger.warning('[$_sessionId] _performHealthCheck: Error during shutdown: $e (continuing with restart)'); + // KDF might already be dead, continue with restart + } + + // Reset HTTP client unconditionally to drop stale keep-alive connections + _logger.info('[$_sessionId] _performHealthCheck: Resetting HTTP client'); + _kdfFramework.resetHttpClient(); + + // Force restart KDF in no-auth mode (we don't have the password) + // Use _forceStartKdf instead of _ensureKdfRunning to bypass isRunning check + _logger.info('[$_sessionId] _performHealthCheck: Force starting KDF'); + final restartStopwatch = Stopwatch()..start(); + await _forceStartKdf(); + restartStopwatch.stop(); + _logger.info('[$_sessionId] _performHealthCheck: KDF force start completed in ${restartStopwatch.elapsedMilliseconds}ms'); + + // Reset HTTP client again after restart to ensure no stale sockets + _logger.info('[$_sessionId] _performHealthCheck: Resetting HTTP client again after restart'); + _kdfFramework.resetHttpClient(); + + // Add 200ms delay after restart before verification to avoid race where + // native status reports "up" but HTTP listener hasn't bound yet + _logger.info('[$_sessionId] _performHealthCheck: Waiting 200ms for HTTP listener to bind'); + await Future.delayed(const Duration(milliseconds: 200)); + + // Check if restart was successful with a strong health check (version RPC) + _logger.info('[$_sessionId] _performHealthCheck: Verifying KDF health with version check'); + final verifyStopwatch = Stopwatch()..start(); + final isHealthyAfterRestart = await _verifyKdfHealthy().timeout( + const Duration(seconds: 2), + onTimeout: () { + _logger.warning('[$_sessionId] _performHealthCheck: Health verification timed out'); + return false; + }, + ); + verifyStopwatch.stop(); + _logger.info('[$_sessionId] _performHealthCheck: Health verification took ${verifyStopwatch.elapsedMilliseconds}ms, result=$isHealthyAfterRestart'); + + // If we had an authenticated user, emit logged-out state + // This will trigger the UI to show re-authentication prompt + if (hadAuthenticatedUser && _lastEmittedUser != null) { + _logger.info('[$_sessionId] _performHealthCheck: Emitting logged-out state'); + _emitAuthStateChange(null); } - // We have a current user but KDF is not healthy - // Try to restart KDF in no-auth mode first as we don't have the password - await _ensureKdfRunning(); - return await _kdfFramework.isHealthy(); + stopwatch.stop(); + _logger.info('[$_sessionId] _performHealthCheck: Health check completed in ${stopwatch.elapsedMilliseconds}ms, result=$isHealthyAfterRestart'); + return isHealthyAfterRestart; } catch (e) { + stopwatch.stop(); + _logger.severe('[$_sessionId] _performHealthCheck: Error during health check after ${stopwatch.elapsedMilliseconds}ms: $e'); + // If we can't restart KDF and had an authenticated user, emit logged-out state + if (_lastEmittedUser != null) { + _logger.info('[$_sessionId] _performHealthCheck: Emitting logged-out state due to error'); + _emitAuthStateChange(null); + } // Log the error but don't throw - return false to indicate failure return false; } } + + /// Force starts KDF without checking isRunning() status + /// This is needed when we've determined KDF is unhealthy but isRunning() returns stale true + Future _forceStartKdf() async { + _logger.info('[$_sessionId] _forceStartKdf: Starting KDF (bypassing isRunning check)'); + await _lockWriteOperation(() async { + final startStopwatch = Stopwatch()..start(); + final result = await _kdfFramework.startKdf(await _noAuthConfig); + startStopwatch.stop(); + _logger.info('[$_sessionId] _forceStartKdf: startKdf() returned ${result.name} in ${startStopwatch.elapsedMilliseconds}ms'); + + if (!result.isStartingOrAlreadyRunning()) { + _logger.severe('[$_sessionId] _forceStartKdf: Failed to start KDF: ${result.name}'); + throw KdfExtensions._mapStartupErrorToAuthException(result); + } + + _logger.info('[$_sessionId] _forceStartKdf: Waiting for RPC to be up'); + final waitStopwatch = Stopwatch()..start(); + await _waitUntilKdfRpcIsUp(); + waitStopwatch.stop(); + _logger.info('[$_sessionId] _forceStartKdf: RPC is up after ${waitStopwatch.elapsedMilliseconds}ms'); + }); + } + + /// Verifies KDF is healthy by checking if it responds to a version RPC + /// This is a stronger check than just checking if the socket is open + Future _verifyKdfHealthy() async { + try { + // Try to get KDF version - this confirms KDF is actually responding to RPCs + await _kdfFramework.version(); + return true; + } catch (e) { + _logger.warning('[$_sessionId] _verifyKdfHealthy: Version check failed: $e'); + return false; + } + } } diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/withdrawal/withdraw_request.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/withdrawal/withdraw_request.dart index e89adea2..ef3a0070 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/withdrawal/withdraw_request.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/withdrawal/withdraw_request.dart @@ -3,6 +3,19 @@ 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'; +/// Default amount value for KMD rewards when claiming +const String _kDefaultKmdRewardsAmount = '0'; + +/// Returns KMD-specific parameters for withdrawal requests +/// +/// KDF requires kmd_rewards object with claimed_by_me flag for KMD withdrawals +Map _kmdRewardsParams() => { + 'kmd_rewards': { + 'amount': _kDefaultKmdRewardsAmount, + 'claimed_by_me': true, + }, + }; + /// Request for standard withdrawal (non-task API) /// /// After the bug with the task-based withdrawal API was fixed, this request @@ -51,6 +64,7 @@ class WithdrawRequest if (fee != null) 'fee': fee!.toJson(), if (from != null) 'from': from!.toRpcParams(), if (memo != null) 'memo': memo, + if (coin.toUpperCase() == 'KMD') ..._kmdRewardsParams(), if (ibcSourceChannel != null) 'ibc_source_channel': ibcSourceChannel, }, }; @@ -112,6 +126,7 @@ class WithdrawInitRequest if (from != null) 'from': from!.toRpcParams(), if (memo != null) 'memo': memo, if (max) 'max': max, + if (coin.toUpperCase() == 'KMD') ..._kmdRewardsParams(), }, }; diff --git a/packages/komodo_defi_types/lib/src/withdrawal/withdrawal_types.dart b/packages/komodo_defi_types/lib/src/withdrawal/withdrawal_types.dart index 0c32b34d..9d0cd8f3 100644 --- a/packages/komodo_defi_types/lib/src/withdrawal/withdrawal_types.dart +++ b/packages/komodo_defi_types/lib/src/withdrawal/withdrawal_types.dart @@ -280,21 +280,21 @@ class WithdrawalSource extends Equatable implements RpcRequestParams { class KmdRewards { KmdRewards({ required this.amount, - required this.claimedByMe, + this.claimedByMe, }); factory KmdRewards.fromJson(JsonMap json) { return KmdRewards( amount: json.value('amount'), - claimedByMe: json.value('claimed_by_me'), + claimedByMe: json.valueOrNull('claimed_by_me'), ); } final String amount; - final bool claimedByMe; + final bool? claimedByMe; JsonMap toJson() => { 'amount': amount, - 'claimed_by_me': claimedByMe, + if (claimedByMe != null) 'claimed_by_me': claimedByMe, }; }