diff --git a/packages/komodo_defi_framework/lib/src/js/js_error_utils.dart b/packages/komodo_defi_framework/lib/src/js/js_error_utils.dart new file mode 100644 index 00000000..ce037b4c --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/js/js_error_utils.dart @@ -0,0 +1,62 @@ +/// Utilities for extracting error codes and messages from dartified JS values. +/// +/// Provides functions to extract numeric error codes and human-readable messages +/// from dartified JavaScript error objects, as well as heuristics for common +/// error patterns. +library; + +bool _isFiniteNum(num value) => value.isFinite; + +/// Attempts to extract a numeric error code from a dartified JS error/value. +/// +/// Supported shapes: +/// - int or num (finite) +/// - String containing an integer +/// - Map with `code` or `result` as int/num/stringified-int +int? extractNumericCodeFromDartError(dynamic value) { + if (value is int) return value; + if (value is num) return _isFiniteNum(value) ? value.toInt() : null; + + if (value is String) { + final parsed = int.tryParse(value); + if (parsed != null) return parsed; + } + + if (value is Map) { + final dynamic code = value['code'] ?? value['result']; + if (code is int) return code; + if (code is num) return _isFiniteNum(code) ? code.toInt() : null; + if (code is String) { + final parsed = int.tryParse(code); + if (parsed != null) return parsed; + } + } + + return null; +} + +/// Attempts to extract a human-readable message from a dartified JS error/value. +/// +/// Supported shapes: +/// - String +/// - Map with `message` or `error` as String +String? extractMessageFromDartError(dynamic value) { + if (value is String) return value; + if (value is Map) { + final dynamic message = value['message'] ?? value['error']; + if (message is String && message.isNotEmpty) return message; + } + return null; +} + +const List _alreadyRunningPatterns = [ + 'already running', + 'already_running', +]; + +// TODO: generalise to a log/string-based watcher for other KDF errors +/// Heuristic matcher for common "already running" messages. +bool messageIndicatesAlreadyRunning(String message) { + final lower = message.toLowerCase(); + return _alreadyRunningPatterns.any(lower.contains); +} diff --git a/packages/komodo_defi_framework/lib/src/js/js_interop_utils.dart b/packages/komodo_defi_framework/lib/src/js/js_interop_utils.dart new file mode 100644 index 00000000..aaf63f3e --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/js/js_interop_utils.dart @@ -0,0 +1,146 @@ +// ignore_for_file: avoid_dynamic_calls + +import 'dart:convert'; +import 'dart:js_interop' as js_interop; + +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; +import 'package:logging/logging.dart'; + +final Logger _jsInteropLogger = Logger('JsInteropUtils'); + +/// Parses a JS interop response into a JsonMap. +/// +/// Accepts: +/// - JSAny/JSObject (will be dartified) +/// - Map (with non-string keys will be normalized) +/// - String (JSON encoded) +/// +/// Throws a [FormatException] if the response cannot be parsed into a JSON map. +JsonMap parseJsInteropJson(dynamic jsResponse) { + try { + dynamic value = jsResponse; + + // If we received a JS value, convert to Dart first + if (value is js_interop.JSAny?) { + value = value?.dartify(); + } + + if (value is String) { + final decoded = jsonDecode(value); + if (decoded is Map) { + return _deepConvertMap(decoded); + } + throw const FormatException('Expected JSON object string'); + } + + if (value is Map) { + return _deepConvertMap(value); + } + + throw FormatException('Unexpected JS response type: ${value.runtimeType}'); + } catch (e, s) { + _jsInteropLogger.severe('Error parsing JS interop response', e, s); + rethrow; + } +} + +/// Generic helper that parses a JS response and maps it to a Dart model. +T parseJsInteropCall(dynamic jsResponse, T Function(JsonMap) fromJson) { + final map = parseJsInteropJson(jsResponse); + return fromJson(map); +} + +// Recursively converts the provided map to JsonMap by stringifying keys and +// converting nested maps/lists to JSON-friendly structures. +JsonMap _deepConvertMap(Map map) { + return map.map((key, value) { + if (value is Map) return MapEntry(key.toString(), _deepConvertMap(value)); + if (value is List) { + return MapEntry(key.toString(), _deepConvertList(value)); + } + return MapEntry(key.toString(), value); + }); +} + +List _deepConvertList(List list) { + return list.map((value) { + if (value is Map) return _deepConvertMap(value); + if (value is List) return _deepConvertList(value); + return value; + }).toList(); +} + +/// Resolves a JS interop value that might be a Promise into a Dart value. +/// +/// - If [jsValue] is a JSPromise, it awaits the promise, then dartifies it +/// - If [jsValue] is not a JSPromise, it is dartified directly +/// - Returns the dartified dynamic value +Future resolveJsAnyMaybePromise(js_interop.JSAny? jsValue) async { + if (jsValue is js_interop.JSPromise) { + final resolved = await jsValue.toDart; + return resolved?.dartify(); + } + return jsValue?.dartify(); +} + +/// Generic helper to resolve a JS interop value (maybe a Promise) and map it. +/// +/// After resolution and dartification, the provided [mapper] is used to convert +/// the dynamic result into type [T]. +Future parseJsInteropMaybePromise( + js_interop.JSAny? jsValue, [ + T Function(dynamic dartValue)? mapper, +]) async { + final dartValue = await resolveJsAnyMaybePromise(jsValue); + + // If a mapper was provided, use it + if (mapper != null) { + return mapper(dartValue); + } + + // Allow common primitive/collection types without a mapper + if (T == dynamic || T == Object) { + return dartValue as T; + } + if (T == int) { + if (dartValue is int) return dartValue as T; + if (dartValue is num) return dartValue.toInt() as T; + if (dartValue is String) { + final parsed = int.tryParse(dartValue); + if (parsed != null) return parsed as T; + } + } + if (T == double || T == num) { + if (dartValue is num) { + if (T == double) return dartValue.toDouble() as T; + return dartValue as T; // T == num + } + if (dartValue is String) { + final parsed = double.tryParse(dartValue); + if (parsed != null) { + if (T == num) { + final num n = parsed; + return n as T; + } else { + final double d = parsed; + return d as T; + } + } + } + } + if (T == String) { + if (dartValue is String) return dartValue as T; + } + if (T == bool) { + if (dartValue is bool) return dartValue as T; + } + if (T == Map || T == Map) { + if (dartValue is Map) return dartValue as T; + } + if (T == List || T == List) { + if (dartValue is List) return dartValue as T; + } + + // Fallback: attempt a direct cast; this will surface a clear type error + return dartValue as T; +} diff --git a/packages/komodo_defi_framework/lib/src/js/js_result_mappers.dart b/packages/komodo_defi_framework/lib/src/js/js_result_mappers.dart new file mode 100644 index 00000000..a3adf7a6 --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/js/js_result_mappers.dart @@ -0,0 +1,56 @@ +import 'package:komodo_defi_framework/src/operations/kdf_operations_interface.dart'; +import 'package:logging/logging.dart'; + +final _logger = Logger('JsResultMappers'); + +/// Maps the various possible JS return shapes from `mm2_stop` into [StopStatus]. +/// +/// Accepts: +/// - `null` (treated as OK for backward-compatibility with legacy behavior) +/// - Numeric codes (int/num) +/// - String responses like "success", "ok", "already_stopped", or a stringified +/// integer code +/// - Objects/Maps that may contain `error`, `result`, or `code` fields +StopStatus mapJsStopResult(dynamic result) { + if (result == null) return StopStatus.ok; + + if (result is int) return StopStatus.fromDefaultInt(result); + if (result is num) return StopStatus.fromDefaultInt(result.toInt()); + + if (result is String) { + final normalized = result.trim().toLowerCase(); + if (normalized == 'success' || normalized == 'ok') { + return StopStatus.ok; + } + if (normalized == 'already_stopped' || normalized.contains('already')) { + return StopStatus.stoppingAlready; + } + final maybeCode = int.tryParse(result); + if (maybeCode != null) return StopStatus.fromDefaultInt(maybeCode); + return StopStatus.ok; + } + + if (result is Map) { + final map = result; + if (map.containsKey('error') && map['error'] != null) { + return StopStatus.errorStopping; + } + final inner = map['result']; + if (inner is String) return mapJsStopResult(inner); + if (inner is num) return StopStatus.fromDefaultInt(inner.toInt()); + + final code = map['code']; + if (code is num) return StopStatus.fromDefaultInt(code.toInt()); + + // Log unexpected map structure for debugging + _logger.fine( + 'Unexpected map structure in stop result, defaulting to ok: $map', + ); + return StopStatus.ok; + } + + _logger.fine( + 'Unrecognized stop result type ${result.runtimeType}, defaulting to ok', + ); + return StopStatus.ok; +} 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 78dd7a6f..5c73fb0f 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 @@ -7,6 +7,9 @@ import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'package:http/http.dart'; import 'package:komodo_defi_framework/komodo_defi_framework.dart'; import 'package:komodo_defi_framework/src/config/kdf_logging_config.dart'; +import 'package:komodo_defi_framework/src/js/js_error_utils.dart'; +import 'package:komodo_defi_framework/src/js/js_interop_utils.dart'; +import 'package:komodo_defi_framework/src/js/js_result_mappers.dart' as js_maps; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'package:mutex/mutex.dart'; @@ -42,6 +45,12 @@ class KdfOperationsWasm implements IKdfOperations { void _log(String message) => (_logger ?? print).call(message); + void _debugLog(String message) { + if (KdfLoggingConfig.debugLogging) { + _log(message); + } + } + @override Future isAvailable(IKdfHostConfig hostConfig) async { try { @@ -96,79 +105,66 @@ class KdfOperationsWasm implements IKdfOperations { Future _executeKdfMain( js_interop.JSObject? jsConfig, ) async { - final future = _kdfModule! - .callMethod( - 'mm2_main'.toJS, - jsConfig, - (int level, String message) { - _log('[$level] KDF: $message'); - }.toJS, - ) - .dartify() as Future?; - - final result = await future; - _log('mm2_main called: $result'); + final jsMethod = _kdfModule!.callMethod( + 'mm2_main'.toJS, + jsConfig, + (int level, String message) { + _log('[$level] KDF: $message'); + }.toJS, + ); - if (result is int) { - return KdfStartupResult.fromDefaultInt(result); - } + final result = await parseJsInteropMaybePromise(jsMethod); + _log('mm2_main called: $result'); - throw Exception( - 'KDF main returned unexpected type: ${result.runtimeType}', - ); + return KdfStartupResult.fromDefaultInt(result); } KdfStartupResult _handleStartupJsError(js_interop.JSAny jsError) { try { - _log('Handling JSAny error: [${jsError.runtimeType}] $jsError'); + _debugLog('Handling JSAny error: [${jsError.runtimeType}] $jsError'); - // Try to extract error code from JSNumber + // Direct JSNumber error if (isInstance(jsError, 'JSNumber')) { - final errorCode = (jsError as js_interop.JSNumber).toDartInt; - _log('KdfOperationsWasm: Resolved as JSNumber error code: $errorCode'); - return KdfStartupResult.fromDefaultInt(errorCode); + final dynamic dartNumber = (jsError as js_interop.JSNumber).dartify(); + final code = extractNumericCodeFromDartError(dartNumber); + if (code != null) { + _debugLog('KdfOperationsWasm: Resolved as JSNumber code: $code'); + return KdfStartupResult.fromDefaultInt(code); + } } - // Try to extract error code from JSObject + // JSObject with useful fields if (isInstance(jsError, 'JSObject')) { final jsObj = jsError as js_interop.JSObject; - // Check for code property - if (jsObj.hasProperty('code'.toJS).toDart) { - final code = jsObj.getProperty('code'.toJS); - // Check if the property is a JSNumber - if (code != null && - isInstance(code, 'JSNumber')) { - final errorCode = code.toDartInt; - _log( - 'KdfOperationsWasm: Resolved as JSObject->JSNumber error code: $errorCode', - ); - return KdfStartupResult.fromDefaultInt(errorCode); - } + // Prefer robust dartify and then inspect + final dynamic dartified = jsObj.dartify(); + final code = extractNumericCodeFromDartError(dartified); + if (code != null) return KdfStartupResult.fromDefaultInt(code); + + final msg = extractMessageFromDartError(dartified); + if (msg != null && messageIndicatesAlreadyRunning(msg)) { + return KdfStartupResult.alreadyRunning; } - // Try toNumber method - final asNumber = - jsObj.callMethod('toNumber'.toJS); - if (asNumber?.isDefinedAndNotNull ?? false) { - final errorCode = asNumber!.toDartInt; - _log( - 'KdfOperationsWasm: Resolved as JSNumber error code: $errorCode', - ); - return KdfStartupResult.fromDefaultInt(errorCode); + // Fallback for 'code' property directly on JS object if not covered above + if (jsObj.hasProperty('code'.toJS).toDart) { + final jsAnyCode = jsObj.getProperty('code'.toJS); + final code2 = extractNumericCodeFromDartError(jsAnyCode?.dartify()); + if (code2 != null) return KdfStartupResult.fromDefaultInt(code2); } } // Try dartify as last resort final dynamic error = jsError.dartify(); - _log('Dartified error type: ${error.runtimeType}, value: $error'); - - if (error is int) { - return KdfStartupResult.fromDefaultInt(error); - } else if (error is num) { - return KdfStartupResult.fromDefaultInt(error.toInt()); - } else if (error is String && int.tryParse(error) != null) { - return KdfStartupResult.fromDefaultInt(int.parse(error)); + _debugLog('Dartified error type: ${error.runtimeType}, value: $error'); + + final code = extractNumericCodeFromDartError(error); + if (code != null) return KdfStartupResult.fromDefaultInt(code); + + final msg = extractMessageFromDartError(error); + if (msg != null && messageIndicatesAlreadyRunning(msg)) { + return KdfStartupResult.alreadyRunning; } _log('Could not extract error code from JSAny: $error'); @@ -183,8 +179,7 @@ class KdfOperationsWasm implements IKdfOperations { js_interop.JSAny? obj, [ String? typeString, ]) { - return obj is T || - obj.instanceOfString(typeString ?? T.runtimeType.toString()); + return obj.instanceOfString(typeString ?? T.runtimeType.toString()); } @override @@ -201,35 +196,32 @@ class KdfOperationsWasm implements IKdfOperations { await _ensureLoaded(); try { - final errorOrNull = await (_kdfModule! - .callMethod('mm2_stop'.toJS) - .dartify()! as Future); - - if (errorOrNull is int) { - return StopStatus.fromDefaultInt(errorOrNull); + // Call mm2_stop which may return a Promise or a direct value + final jsAny = _kdfModule!.callMethod('mm2_stop'.toJS); + final status = + await parseJsInteropMaybePromise(jsAny, js_maps.mapJsStopResult); + + // Ensure the node actually stops when we expect success or already stopped + if (status == StopStatus.ok || status == StopStatus.stoppingAlready) { + await Future.doWhile(() async { + final isStopped = (await kdfMainStatus()) == MainStatus.notRunning; + if (!isStopped) { + await Future.delayed(const Duration(milliseconds: 300)); + } + return !isStopped; + }).timeout( + const Duration(seconds: 10), + onTimeout: () => throw TimeoutException('KDF stop timed out'), + ); } - _log('KDF stop result: $errorOrNull'); - - await Future.doWhile(() async { - final isStopped = (await kdfMainStatus()) == MainStatus.notRunning; - - if (!isStopped) { - await Future.delayed(const Duration(milliseconds: 300)); - } - return !isStopped; - }).timeout( - const Duration(seconds: 10), - onTimeout: () => throw TimeoutException('KDF stop timed out'), - ); + return status; } on int catch (e) { return StopStatus.fromDefaultInt(e); } catch (e) { _log('Error stopping KDF: $e'); return StopStatus.errorStopping; } - - return StopStatus.ok; } @override @@ -237,7 +229,7 @@ class KdfOperationsWasm implements IKdfOperations { await _ensureLoaded(); final jsResponse = await _makeJsCall(request); - final dartResponse = _parseDartResponse(jsResponse, request); + final dartResponse = parseJsInteropJson(jsResponse); _validateResponse(dartResponse, request, jsResponse); return JsonMap.from(dartResponse); @@ -245,9 +237,7 @@ class KdfOperationsWasm implements IKdfOperations { /// Makes the JavaScript RPC call and returns the raw JS response Future _makeJsCall(JsonMap request) async { - if (KdfLoggingConfig.debugLogging) { - _log('mm2Rpc request: ${request.censored()}'); - } + _debugLog('mm2Rpc request: ${request.censored()}'); request['userpass'] = _config.rpcPassword; final jsRequest = request.jsify() as js_interop.JSObject?; @@ -284,33 +274,12 @@ class KdfOperationsWasm implements IKdfOperations { ); } - if (KdfLoggingConfig.debugLogging) { - try { - final stringified = jsResponse.dartify().toString(); - _log('Raw JS response: $stringified'); - } catch (e) { - _log('Raw JS response: $jsResponse (stringify failed: $e)'); - } - } - return jsResponse as js_interop.JSObject; - } - - /// Converts JS response to Dart Map - JsonMap _parseDartResponse( - js_interop.JSObject jsResponse, - JsonMap request, - ) { try { - final dynamic converted = jsResponse.dartify(); - if (converted is! JsonMap) { - return _deepConvertMap(converted as Map); - } - return converted; + _debugLog('Raw JS response: ${jsResponse.dartify()}'); } catch (e) { - _log('Response parsing error for method ${request['method']}:\n' - 'Request: $request'); - rethrow; + _debugLog('Raw JS response: $jsResponse (stringify failed: $e)'); } + return jsResponse as js_interop.JSObject; } /// Validates the response structure @@ -330,30 +299,7 @@ class KdfOperationsWasm implements IKdfOperations { ); } - if (KdfLoggingConfig.debugLogging) { - _log('JS response validated: $dartResponse'); - } - } - - /// Recursively converts the provided map to JsonMap. This is required, as - /// many of the responses received from the sdk are - /// LinkedHashMap - Map _deepConvertMap(Map map) { - return map.map((key, value) { - if (value is Map) return MapEntry(key.toString(), _deepConvertMap(value)); - if (value is List) { - return MapEntry(key.toString(), _deepConvertList(value)); - } - return MapEntry(key.toString(), value); - }); - } - - List _deepConvertList(List list) { - return list.map((value) { - if (value is Map) return _deepConvertMap(value); - if (value is List) return _deepConvertList(value); - return value; - }).toList(); + _debugLog('JS response validated: $dartResponse'); } @override diff --git a/packages/komodo_defi_framework/test/js/js_result_mappers_test.dart b/packages/komodo_defi_framework/test/js/js_result_mappers_test.dart new file mode 100644 index 00000000..e6eb1e56 --- /dev/null +++ b/packages/komodo_defi_framework/test/js/js_result_mappers_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_defi_framework/src/js/js_result_mappers.dart'; +import 'package:komodo_defi_framework/src/operations/kdf_operations_interface.dart'; + +void main() { + group('mapJsStopResult', () { + test('numeric codes', () { + expect(mapJsStopResult(0), StopStatus.ok); + expect(mapJsStopResult(1), StopStatus.notRunning); + expect(mapJsStopResult(2), StopStatus.errorStopping); + expect(mapJsStopResult(3), StopStatus.stoppingAlready); + expect(mapJsStopResult(3.0), StopStatus.stoppingAlready); + }); + + test('string responses', () { + expect(mapJsStopResult('success'), StopStatus.ok); + expect(mapJsStopResult('ok'), StopStatus.ok); + expect(mapJsStopResult('already_stopped'), StopStatus.stoppingAlready); + expect(mapJsStopResult('Already stopped'), StopStatus.stoppingAlready); + expect(mapJsStopResult('2'), StopStatus.errorStopping); + expect(mapJsStopResult('unexpected'), StopStatus.ok); + }); + + test('map responses', () { + expect(mapJsStopResult({'error': 'Something'}), StopStatus.errorStopping); + expect(mapJsStopResult({'result': 'success'}), StopStatus.ok); + expect(mapJsStopResult({'result': 0}), StopStatus.ok); + expect(mapJsStopResult({'code': 3}), StopStatus.stoppingAlready); + expect(mapJsStopResult({'unexpected': true}), StopStatus.ok); + }); + + test('null treated as ok', () { + expect(mapJsStopResult(null), StopStatus.ok); + }); + }); +} diff --git a/packages/komodo_defi_types/lib/src/utils/json_type_utils.dart b/packages/komodo_defi_types/lib/src/utils/json_type_utils.dart index efbf1b29..ac206f93 100644 --- a/packages/komodo_defi_types/lib/src/utils/json_type_utils.dart +++ b/packages/komodo_defi_types/lib/src/utils/json_type_utils.dart @@ -120,8 +120,9 @@ T? _traverseJson( return jsonFromString(value) as T; } catch (e) { throw ArgumentError( - 'Expected a JSON string to parse, but got an invalid type: ' - '${value.runtimeType}'); + 'Failed to parse string as JsonMap. Expected valid JSON string, ' + 'but parsing failed for value of type: ${value.runtimeType}', + ); } } @@ -129,14 +130,14 @@ T? _traverseJson( return jsonToString(value) as T; } -// In the list handling section: + // In the list handling section: if (T == JsonList && value is String) { try { return jsonListFromString(value) as T; } catch (e) { throw ArgumentError( - 'Expected a JSON string representing a List, ' - 'but got an invalid type: ${value.runtimeType}', + 'Failed to parse string as JsonList. Expected valid JSON array string, ' + 'but parsing failed for value of type: ${value.runtimeType}', ); } } @@ -154,6 +155,14 @@ T? _traverseJson( return (value == 1) as T; } + // Normalize numeric types between int/double for WASM interop + if (T == int && value is num) { + return value.toInt() as T; + } + if (T == double && value is num) { + return value.toDouble() as T; + } + // Final type check if (value is! T) { throw ArgumentError( @@ -214,9 +223,7 @@ T _convertMap(Map sourceMap) { try { return sanitizedMap as T; } catch (e) { - throw ArgumentError( - 'Failed to convert map to expected type $T: $e', - ); + throw ArgumentError('Failed to convert map to expected type $T: $e'); } } @@ -373,9 +380,7 @@ extension MapCensoring on Map { } final censoredMap = {}; - final stack = <_CensorTask>[ - _CensorTask(targetMap, censoredMap), - ]; + final stack = <_CensorTask>[_CensorTask(targetMap, censoredMap)]; while (stack.isNotEmpty) { final currentTask = stack.removeLast();