From 2afcb6e49064ddce9ee727e062f8d2a5b76e12a3 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Sun, 26 Oct 2025 15:21:04 +0100 Subject: [PATCH 01/35] feat(pubkeys): persist AssetPubkeys across sessions using Hive TypeAdapters; hydrate on cold start\n\n- Add Hive adapters for stored pubkeys\n- Persist on fetch, hydrate before first RPC\n- Align balance polling to 60s and integrate with tx watcher\n\nBREAKING CHANGE: none --- .../lib/src/balances/balance_manager.dart | 2 +- .../komodo_defi_sdk/lib/src/bootstrap.dart | 9 +- .../src/pubkeys/hive_pubkeys_adapters.dart | 143 ++++++++++++++++++ .../lib/src/pubkeys/pubkey_manager.dart | 74 ++++++++- .../lib/src/pubkeys/pubkeys_storage.dart | 74 +++++++++ .../transaction_history_manager.dart | 124 ++++++++------- 6 files changed, 368 insertions(+), 58 deletions(-) create mode 100644 packages/komodo_defi_sdk/lib/src/pubkeys/hive_pubkeys_adapters.dart create mode 100644 packages/komodo_defi_sdk/lib/src/pubkeys/pubkeys_storage.dart diff --git a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart index 599c4180..3593d756 100644 --- a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart @@ -75,7 +75,7 @@ class BalanceManager implements IBalanceManager { final IAssetLookup _assetLookup; final KomodoDefiLocalAuth _auth; StreamSubscription? _authSubscription; - final Duration _defaultPollingInterval = const Duration(seconds: 30); + final Duration _defaultPollingInterval = const Duration(minutes: 1); /// Enable debug logging for balance polling static bool enableDebugLogging = true; diff --git a/packages/komodo_defi_sdk/lib/src/bootstrap.dart b/packages/komodo_defi_sdk/lib/src/bootstrap.dart index 617aa917..4bcf286f 100644 --- a/packages/komodo_defi_sdk/lib/src/bootstrap.dart +++ b/packages/komodo_defi_sdk/lib/src/bootstrap.dart @@ -179,7 +179,11 @@ Future bootstrap({ final auth = await container.getAsync(); final activationCoordinator = await container .getAsync(); - final pubkeyManager = PubkeyManager(client, auth, activationCoordinator); + final pubkeyManager = PubkeyManager( + client, + auth, + activationCoordinator, + ); // Set the PubkeyManager on BalanceManager now that it's available final balanceManager = await container.getAsync(); @@ -240,6 +244,7 @@ Future bootstrap({ final auth = await container.getAsync(); final assetProvider = await container.getAsync(); final pubkeys = await container.getAsync(); + final balances = await container.getAsync(); final activationCoordinator = await container .getAsync(); return TransactionHistoryManager( @@ -248,6 +253,7 @@ Future bootstrap({ assetProvider, activationCoordinator, pubkeyManager: pubkeys, + balanceManager: balances, ); }, dependsOn: [ @@ -255,6 +261,7 @@ Future bootstrap({ KomodoDefiLocalAuth, AssetManager, PubkeyManager, + BalanceManager, SharedActivationCoordinator, ], ); diff --git a/packages/komodo_defi_sdk/lib/src/pubkeys/hive_pubkeys_adapters.dart b/packages/komodo_defi_sdk/lib/src/pubkeys/hive_pubkeys_adapters.dart new file mode 100644 index 00000000..58ba825b --- /dev/null +++ b/packages/komodo_defi_sdk/lib/src/pubkeys/hive_pubkeys_adapters.dart @@ -0,0 +1,143 @@ +import 'package:decimal/decimal.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +// Reserve unique typeIds (avoid collisions with other adapters) +const int _hiveStoredPubkeyTypeId = 310; +const int _hiveAssetPubkeysRecordTypeId = 311; + +class HiveStoredPubkey { + HiveStoredPubkey({ + required this.address, + required this.derivationPath, + required this.chain, + required this.spendable, + required this.unspendable, + }); + + factory HiveStoredPubkey.fromDomain(PubkeyInfo info) => HiveStoredPubkey( + address: info.address, + derivationPath: info.derivationPath, + chain: info.chain, + spendable: info.balance.spendable.toString(), + unspendable: info.balance.unspendable.toString(), + ); + + final String address; + final String? derivationPath; + final String? chain; + final String spendable; + final String unspendable; + + PubkeyInfo toDomain(String coinTicker) => PubkeyInfo( + address: address, + derivationPath: derivationPath, + chain: chain, + balance: BalanceInfo( + total: null, + spendable: Decimal.parse(spendable), + unspendable: Decimal.parse(unspendable), + ), + coinTicker: coinTicker, + ); +} + +class HiveStoredPubkeyAdapter extends TypeAdapter { + @override + final int typeId = _hiveStoredPubkeyTypeId; + + @override + HiveStoredPubkey read(BinaryReader reader) { + final address = reader.readString(); + final hasDerivation = reader.readBool(); + final derivation = hasDerivation ? reader.readString() : null; + final hasChain = reader.readBool(); + final chain = hasChain ? reader.readString() : null; + final spendable = reader.readString(); + final unspendable = reader.readString(); + return HiveStoredPubkey( + address: address, + derivationPath: derivation, + chain: chain, + spendable: spendable, + unspendable: unspendable, + ); + } + + @override + void write(BinaryWriter writer, HiveStoredPubkey obj) { + writer + ..writeString(obj.address) + ..writeBool(obj.derivationPath != null); + if (obj.derivationPath != null) writer.writeString(obj.derivationPath!); + writer.writeBool(obj.chain != null); + if (obj.chain != null) writer.writeString(obj.chain!); + writer + ..writeString(obj.spendable) + ..writeString(obj.unspendable); + } +} + +class HiveAssetPubkeysRecord { + HiveAssetPubkeysRecord({ + required this.available, + required this.sync, + required this.keys, + }); + + factory HiveAssetPubkeysRecord.fromDomain(AssetPubkeys pubkeys) => + HiveAssetPubkeysRecord( + available: pubkeys.availableAddressesCount, + sync: pubkeys.syncStatus.toString(), + keys: pubkeys.keys.map(HiveStoredPubkey.fromDomain).toList(), + ); + + final int available; + final String sync; + final List keys; + + AssetPubkeys toDomain(AssetId assetId) => AssetPubkeys( + assetId: assetId, + keys: keys.map((k) => k.toDomain(assetId.id)).toList(), + availableAddressesCount: available, + syncStatus: SyncStatusEnum.tryParse(sync) ?? SyncStatusEnum.success, + ); +} + +class HiveAssetPubkeysRecordAdapter + extends TypeAdapter { + @override + final int typeId = _hiveAssetPubkeysRecordTypeId; + + @override + HiveAssetPubkeysRecord read(BinaryReader reader) { + final available = reader.readInt(); + final sync = reader.readString(); + final length = reader.readInt(); + final keys = []; + for (var i = 0; i < length; i++) { + keys.add(reader.read() as HiveStoredPubkey); + } + return HiveAssetPubkeysRecord(available: available, sync: sync, keys: keys); + } + + @override + void write(BinaryWriter writer, HiveAssetPubkeysRecord obj) { + writer + ..writeInt(obj.available) + ..writeString(obj.sync) + ..writeInt(obj.keys.length); + for (final k in obj.keys) { + writer.write(k); + } + } +} + +void registerPubkeysAdapters() { + if (!Hive.isAdapterRegistered(_hiveStoredPubkeyTypeId)) { + Hive.registerAdapter(HiveStoredPubkeyAdapter()); + } + if (!Hive.isAdapterRegistered(_hiveAssetPubkeysRecordTypeId)) { + Hive.registerAdapter(HiveAssetPubkeysRecordAdapter()); + } +} diff --git a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart index 5892972a..fb8fe702 100644 --- a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart @@ -5,6 +5,7 @@ import 'package:komodo_defi_sdk/src/_internal_exports.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:komodo_defi_sdk/src/pubkeys/pubkeys_storage.dart'; /// Interface defining the contract for pubkey management operations abstract class IPubkeyManager { @@ -40,7 +41,12 @@ abstract class IPubkeyManager { /// Manager responsible for handling pubkey operations across different assets class PubkeyManager implements IPubkeyManager { - PubkeyManager(this._client, this._auth, this._activationCoordinator) { + PubkeyManager( + this._client, + this._auth, + this._activationCoordinator, { + PubkeysStorage? storage, + }) : _storage = storage ?? HivePubkeysStorage() { _authSubscription = _auth.authStateChanges.listen(_handleAuthStateChanged); _logger.fine('Initialized'); } @@ -49,6 +55,7 @@ class PubkeyManager implements IPubkeyManager { final ApiClient _client; final KomodoDefiLocalAuth _auth; final SharedActivationCoordinator _activationCoordinator; + final PubkeysStorage _storage; // Internal state for watching pubkeys per asset final Map _pubkeysCache = {}; @@ -68,7 +75,10 @@ class PubkeyManager implements IPubkeyManager { Future getPubkeys(Asset asset) async { await retry(() => _activationCoordinator.activateAsset(asset)); final strategy = await _resolvePubkeyStrategy(asset); - return strategy.getPubkeys(asset.id, _client); + final pubkeys = await strategy.getPubkeys(asset.id, _client); + // Persist asynchronously; do not block the call + unawaited(_persistPubkeys(asset, pubkeys)); + return pubkeys; } /// Create a new pubkey for an asset if supported @@ -160,6 +170,59 @@ class PubkeyManager implements IPubkeyManager { return _pubkeysCache[assetId]; } + Future _persistPubkeys(Asset asset, AssetPubkeys pubkeys) async { + try { + final user = await _auth.currentUser; + if (user == null) return; + await _storage.savePubkeys(user.walletId, asset.id.id, pubkeys); + } catch (_) { + // best-effort persistence + } + } + + Future _hydrateFromStorage(Asset asset) async { + try { + final user = await _auth.currentUser; + if (user == null) return null; + final map = await _storage.listForWallet(user.walletId); + final raw = map[asset.id.id]; + if (raw == null) return null; + + final addresses = + (raw['addresses'] as List?)?.cast>() ?? + const >[]; + final keys = []; + for (final addr in addresses) { + final bal = BalanceInfo.fromJson( + (addr['balance'] as Map).cast(), + ); + keys.add( + PubkeyInfo( + address: addr['address'] as String, + derivationPath: addr['derivation_path'] as String?, + chain: addr['chain'] as String?, + balance: bal, + coinTicker: asset.id.id, + ), + ); + } + + final available = (raw['available'] as num?)?.toInt() ?? keys.length; + final syncString = raw['sync'] as String?; + final sync = + SyncStatusEnum.tryParse(syncString) ?? SyncStatusEnum.success; + + return AssetPubkeys( + assetId: asset.id, + keys: keys, + availableAddressesCount: available, + syncStatus: sync, + ); + } catch (_) { + return null; + } + } + Future _startWatchingPubkeys(Asset asset, bool activateIfNeeded) async { final controller = _pubkeysControllers[asset.id]; if (controller == null || _isDisposed) return; @@ -197,6 +260,13 @@ class PubkeyManager implements IPubkeyManager { } if (isActive) { + // Try hydrate from persisted cache first for faster cold start + final hydrated = await _hydrateFromStorage(asset); + if (hydrated != null) { + _pubkeysCache[asset.id] = hydrated; + if (!controller.isClosed) controller.add(hydrated); + } + final first = await getPubkeys(asset); _pubkeysCache[asset.id] = first; if (!controller.isClosed) controller.add(first); diff --git a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkeys_storage.dart b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkeys_storage.dart new file mode 100644 index 00000000..0277da53 --- /dev/null +++ b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkeys_storage.dart @@ -0,0 +1,74 @@ +import 'package:hive_ce/hive.dart'; +import 'package:komodo_defi_sdk/src/pubkeys/hive_pubkeys_adapters.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +/// Storage interface for persisting pubkeys between sessions +abstract class PubkeysStorage { + Future savePubkeys( + WalletId walletId, + String assetTicker, + AssetPubkeys pubkeys, + ); + + /// Returns a map of assetTicker -> stored pubkeys JSON for the wallet + Future>> listForWallet(WalletId walletId); +} + +class HivePubkeysStorage implements PubkeysStorage { + static const _boxName = 'pubkeys_cache_v1'; + Box? _box; + Future> _openBox() async { + registerPubkeysAdapters(); + if (_box != null) return _box!; + _box = await Hive.openBox(_boxName); + return _box!; + } + + String _keyFor(WalletId walletId, String assetTicker) => + '${walletId.compoundId}|$assetTicker'; + + @override + Future savePubkeys( + WalletId walletId, + String assetTicker, + AssetPubkeys pubkeys, + ) async { + final box = await _openBox(); + final record = HiveAssetPubkeysRecord.fromDomain(pubkeys); + await box.put(_keyFor(walletId, assetTicker), record); + } + + @override + Future>> listForWallet( + WalletId walletId, + ) async { + final box = await _openBox(); + final prefix = '${walletId.compoundId}|'; + final result = >{}; + for (final dynamicKey in box.keys) { + final key = dynamicKey as String; + if (!key.startsWith(prefix)) continue; + final record = box.get(key); + if (record == null) continue; + // Expose as generic map for caller hydration convenience + result[key.substring(prefix.length)] = { + 'available': record.available, + 'sync': record.sync, + 'addresses': record.keys + .map( + (k) => { + 'address': k.address, + 'derivation_path': k.derivationPath, + 'chain': k.chain, + 'balance': { + 'spendable': k.spendable, + 'unspendable': k.unspendable, + }, + }, + ) + .toList(), + }; + } + return result; + } +} diff --git a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart index d80639a5..ccd5d465 100644 --- a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math' as math; import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; +import 'package:komodo_defi_sdk/src/balances/balance_manager.dart'; import 'package:komodo_defi_sdk/src/_internal_exports.dart'; import 'package:komodo_defi_sdk/src/pubkeys/pubkey_manager.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; @@ -36,12 +37,14 @@ class TransactionHistoryManager implements _TransactionHistoryManager { this._assetProvider, this._activationCoordinator, { required PubkeyManager pubkeyManager, + required BalanceManager balanceManager, TransactionStorage? storage, }) : _storage = storage ?? TransactionStorage.defaultForPlatform(), _strategyFactory = TransactionHistoryStrategyFactory( pubkeyManager, _auth, - ) { + ), + _balanceManager = balanceManager { // Subscribe to auth changes directly in constructor _authSubscription = _auth.authStateChanges.listen((user) { if (user == null) { @@ -55,13 +58,16 @@ class TransactionHistoryManager implements _TransactionHistoryManager { final IAssetProvider _assetProvider; final SharedActivationCoordinator _activationCoordinator; final TransactionStorage _storage; + final BalanceManager _balanceManager; final _streamControllers = >{}; - final _pollingTimers = {}; + final _balanceSubscriptions = >{}; + final _lastObservedBalance = {}; final _syncInProgress = {}; final _rateLimiter = _RateLimiter(const Duration(milliseconds: 500)); - static const _defaultPollingInterval = Duration(seconds: 30); + // Legacy interval retained for backwards compatibility and potential feature flags. + // Currently unused since polling is driven by balance changes. static const _maxPollingRetries = 3; static const _maxBatchSize = 50; @@ -73,11 +79,12 @@ class TransactionHistoryManager implements _TransactionHistoryManager { void _stopAllPolling() { if (_isDisposed) return; - // Cancel all polling timers - for (final timer in _pollingTimers.values) { - timer.cancel(); + // Cancel all balance subscriptions + for (final sub in _balanceSubscriptions.values) { + sub.cancel(); } - _pollingTimers.clear(); + _balanceSubscriptions.clear(); + _lastObservedBalance.clear(); // Close controllers in a separate iteration to avoid modification during iteration final controllers = _streamControllers.values.toList(); @@ -107,8 +114,9 @@ class TransactionHistoryManager implements _TransactionHistoryManager { final localPage = await _storage.getTransactions( asset.id, await _getCurrentWalletId(), - fromId: - pagination is TransactionBasedPagination ? pagination.fromId : null, + fromId: pagination is TransactionBasedPagination + ? pagination.fromId + : null, pageNumber: pagination is PagePagination ? pagination.pageNumber : null, limit: pagination.limit ?? _maxBatchSize, ); @@ -136,10 +144,9 @@ class TransactionHistoryManager implements _TransactionHistoryManager { ); // Convert API response to domain model - final transactions = - response.transactions - .map((tx) => tx.asTransaction(asset.id)) - .toList(); + final transactions = response.transactions + .map((tx) => tx.asTransaction(asset.id)) + .toList(); // Store in local storage efficiently await _batchStoreTransactions(transactions); @@ -197,13 +204,13 @@ class TransactionHistoryManager implements _TransactionHistoryManager { asset, fromId != null ? TransactionBasedPagination( - fromId: fromId, - itemCount: _maxBatchSize, - ) + fromId: fromId, + itemCount: _maxBatchSize, + ) : const PagePagination( - pageNumber: 1, - itemsPerPage: _maxBatchSize, - ), + pageNumber: 1, + itemsPerPage: _maxBatchSize, + ), ); if (response.transactions.isEmpty) { @@ -211,10 +218,9 @@ class TransactionHistoryManager implements _TransactionHistoryManager { continue; } - final transactions = - response.transactions - .map((tx) => tx.asTransaction(asset.id)) - .toList(); + final transactions = response.transactions + .map((tx) => tx.asTransaction(asset.id)) + .toList(); await _batchStoreTransactions(transactions); yield transactions; @@ -249,7 +255,8 @@ class TransactionHistoryManager implements _TransactionHistoryManager { asset.id, () => StreamController.broadcast( onListen: () { - if (!_pollingTimers.containsKey(asset.id)) { + // Start balance-driven polling only once per asset + if (!_balanceSubscriptions.containsKey(asset.id)) { _startPolling(asset); } }, @@ -287,13 +294,13 @@ class TransactionHistoryManager implements _TransactionHistoryManager { asset, fromId != null ? TransactionBasedPagination( - fromId: fromId, - itemCount: _maxBatchSize, - ) + fromId: fromId, + itemCount: _maxBatchSize, + ) : const PagePagination( - pageNumber: 1, - itemsPerPage: _maxBatchSize, - ), + pageNumber: 1, + itemsPerPage: _maxBatchSize, + ), ); if (response.transactions.isEmpty) { @@ -301,10 +308,9 @@ class TransactionHistoryManager implements _TransactionHistoryManager { continue; } - final transactions = - response.transactions - .map((tx) => tx.asTransaction(asset.id)) - .toList(); + final transactions = response.transactions + .map((tx) => tx.asTransaction(asset.id)) + .toList(); await _batchStoreTransactions(transactions); fromId = response.fromId; @@ -345,19 +351,19 @@ class TransactionHistoryManager implements _TransactionHistoryManager { asset, lastTx != null ? TransactionBasedPagination( - fromId: lastTx, - itemCount: _maxBatchSize, - ) + fromId: lastTx, + itemCount: _maxBatchSize, + ) : const PagePagination(pageNumber: 1, itemsPerPage: _maxBatchSize), ); - if (!_pollingTimers.containsKey(asset.id)) return; + // If asset is no longer being watched, stop + if (!_streamControllers.containsKey(asset.id)) return; if (response.transactions.isNotEmpty) { - final newTransactions = - response.transactions - .map((tx) => tx.asTransaction(asset.id)) - .toList(); + final newTransactions = response.transactions + .map((tx) => tx.asTransaction(asset.id)) + .toList(); await _batchStoreTransactions(newTransactions); @@ -410,17 +416,33 @@ class TransactionHistoryManager implements _TransactionHistoryManager { } void _startPolling(Asset asset) { + // Ensure we don't duplicate subscriptions _stopPolling(asset.id); - _pollingTimers[asset.id] = Timer.periodic( - _defaultPollingInterval, - (_) => _pollNewTransactions(asset), - ); + + // Initial sync: poll once on activation _pollNewTransactions(asset); + + // Subscribe to balance changes and trigger history fetch only when balance changes + _balanceSubscriptions[asset.id] = _balanceManager + .watchBalance(asset.id) + .listen((BalanceInfo balance) { + final last = _lastObservedBalance[asset.id]; + final changed = + last == null || + balance.total != last.total || + balance.spendable != last.spendable; + _lastObservedBalance[asset.id] = balance; + if (changed) { + _pollNewTransactions(asset); + } + }); } void _stopPolling(AssetId assetId) { - _pollingTimers[assetId]?.cancel(); - _pollingTimers.remove(assetId); + // Cancel balance subscription + _balanceSubscriptions[assetId]?.cancel(); + _balanceSubscriptions.remove(assetId); + _lastObservedBalance.remove(assetId); } Future dispose() async { @@ -429,12 +451,6 @@ class TransactionHistoryManager implements _TransactionHistoryManager { await _authSubscription?.cancel(); - final timers = _pollingTimers.values.toList(); - _pollingTimers.clear(); - for (final timer in timers) { - timer.cancel(); - } - final controllers = _streamControllers.values.toList(); _streamControllers.clear(); for (final controller in controllers) { From f11c5f51609941b826fb1fca87753c0166688601 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Sun, 26 Oct 2025 15:25:46 +0100 Subject: [PATCH 02/35] chore(format): run dart format on pubkey persistence and balance manager files --- .../komodo_defi_sdk/lib/src/balances/balance_manager.dart | 6 ++++-- packages/komodo_defi_sdk/lib/src/bootstrap.dart | 6 +----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart index 3593d756..cd2b2b44 100644 --- a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart @@ -76,7 +76,7 @@ class BalanceManager implements IBalanceManager { final KomodoDefiLocalAuth _auth; StreamSubscription? _authSubscription; final Duration _defaultPollingInterval = const Duration(minutes: 1); - + /// Enable debug logging for balance polling static bool enableDebugLogging = true; @@ -381,7 +381,9 @@ class BalanceManager implements IBalanceManager { // Just log the error and continue with the last known balance // This prevents the stream from terminating on transient errors if (enableDebugLogging) { - _logger.warning('[POLLING] Balance fetch failed for ${assetId.name}: $e'); + _logger.warning( + '[POLLING] Balance fetch failed for ${assetId.name}: $e', + ); } } diff --git a/packages/komodo_defi_sdk/lib/src/bootstrap.dart b/packages/komodo_defi_sdk/lib/src/bootstrap.dart index 4bcf286f..6e220970 100644 --- a/packages/komodo_defi_sdk/lib/src/bootstrap.dart +++ b/packages/komodo_defi_sdk/lib/src/bootstrap.dart @@ -179,11 +179,7 @@ Future bootstrap({ final auth = await container.getAsync(); final activationCoordinator = await container .getAsync(); - final pubkeyManager = PubkeyManager( - client, - auth, - activationCoordinator, - ); + final pubkeyManager = PubkeyManager(client, auth, activationCoordinator); // Set the PubkeyManager on BalanceManager now that it's available final balanceManager = await container.getAsync(); From 77ca946a16cd5793e4c323cb5ae64fe6e7f103a2 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Sun, 26 Oct 2025 16:46:06 +0100 Subject: [PATCH 03/35] perf(sdk): dedupe pubkeys/address fetch, cache-first hydrate; throttle health checks; cache wallet names (#3238) --- .../lib/src/auth/auth_service.dart | 33 +++++++++++++- .../auth_service_operations_extension.dart | 4 +- .../lib/src/pubkeys/pubkey_manager.dart | 44 ++++++++++++++++--- 3 files changed, 72 insertions(+), 9 deletions(-) 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..00983db5 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 @@ -113,6 +113,11 @@ class KdfAuthService implements IAuthService { KdfUser? _lastEmittedUser; Timer? _healthCheckTimer; + // Cache for wallet users list to avoid spamming get_wallet_names + List? _usersCache; + DateTime? _usersCacheTimestamp; + final Duration _usersCacheTtl = const Duration(minutes: 5); + ApiClient get _client => _kdfFramework.client; late final methods = KomodoDefiRpcMethods(_client); @@ -208,6 +213,7 @@ class KdfAuthService implements IAuthService { return _lockWriteOperation(() async { final currentUser = await _registerNewUser(config, options); _emitAuthStateChange(currentUser); + _invalidateUsersCache(); return currentUser; }); } @@ -217,9 +223,16 @@ class KdfAuthService implements IAuthService { await _ensureKdfRunning(); return _runReadOperation(() async { + // Serve from cache if fresh + if (_usersCache != null && + _usersCacheTimestamp != null && + DateTime.now().difference(_usersCacheTimestamp!) < _usersCacheTtl) { + return _usersCache!; + } + final walletNames = await _client.rpc.wallet.getWalletNames(); - return Future.wait( + final users = await Future.wait( walletNames.walletNames.map((name) async { final user = await _secureStorage.getUser(name); if (user != null) return user; @@ -233,6 +246,10 @@ class KdfAuthService implements IAuthService { return newUser; }), ); + + _usersCache = users; + _usersCacheTimestamp = DateTime.now(); + return users; }); } @@ -267,7 +284,13 @@ class KdfAuthService implements IAuthService { @override Future getActiveUser() async { - return _runReadOperation(_getActiveUser); + return _runReadOperation(() async { + // Prefer last known user emitted by health checks to avoid extra RPCs + if (_lastEmittedUser != null) { + return _lastEmittedUser; + } + return _getActiveUser(); + }); } AuthOptions get _fallbackAuthOptions => @@ -348,6 +371,7 @@ class KdfAuthService implements IAuthService { password: password, ); await _secureStorage.deleteUser(walletName); + _invalidateUsersCache(); } on DeleteWalletInvalidPasswordErrorResponse catch (e) { throw AuthException( e.error ?? 'Invalid password', @@ -390,6 +414,11 @@ class KdfAuthService implements IAuthService { }); } + void _invalidateUsersCache() { + _usersCache = null; + _usersCacheTimestamp = null; + } + @override Stream get authStateChanges => _authStateController.stream; diff --git a/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart b/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart index 4c7c2976..c742a6b7 100644 --- a/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart +++ b/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart @@ -11,8 +11,10 @@ extension KdfAuthServiceOperationsExtension on KdfAuthService { void _startHealthCheck() { _healthCheckTimer?.cancel(); + // Reduce frequency to prevent excessive wallet name checks. + // Health checks do not need sub-second responsiveness; 5 minutes is ample. _healthCheckTimer = Timer.periodic( - const Duration(seconds: 5), + const Duration(minutes: 5), (_) => _checkKdfHealth(), ); } diff --git a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart index fb8fe702..75c15f1c 100644 --- a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart @@ -64,6 +64,8 @@ class PubkeyManager implements IPubkeyManager { // Track the Asset for each AssetId that has an associated controller so that // we can restart watchers after auth changes without requiring new listeners final Map _watchedAssets = {}; + // Deduplicate concurrent getPubkeys requests per asset + final Map> _inFlightPubkeyRequests = {}; StreamSubscription? _authSubscription; WalletId? _currentWalletId; @@ -73,12 +75,42 @@ class PubkeyManager implements IPubkeyManager { /// Get pubkeys for a given asset, handling HD/non-HD differences internally @override Future getPubkeys(Asset asset) async { - await retry(() => _activationCoordinator.activateAsset(asset)); - final strategy = await _resolvePubkeyStrategy(asset); - final pubkeys = await strategy.getPubkeys(asset.id, _client); - // Persist asynchronously; do not block the call - unawaited(_persistPubkeys(asset, pubkeys)); - return pubkeys; + // Serve from in-memory cache if available + final cached = _pubkeysCache[asset.id]; + if (cached != null) { + return cached; + } + + // If a request for this asset is already in flight, await it + final existing = _inFlightPubkeyRequests[asset.id]; + if (existing != null) { + return existing; + } + + // Otherwise, start a new request and dedupe concurrent callers + final future = () async { + // Try to hydrate from persisted storage first for instant response + final hydrated = await _hydrateFromStorage(asset); + if (hydrated != null) { + _pubkeysCache[asset.id] = hydrated; + return hydrated; + } + + await retry(() => _activationCoordinator.activateAsset(asset)); + final strategy = await _resolvePubkeyStrategy(asset); + final pubkeys = await strategy.getPubkeys(asset.id, _client); + _pubkeysCache[asset.id] = pubkeys; + // Persist asynchronously; do not block the call + unawaited(_persistPubkeys(asset, pubkeys)); + return pubkeys; + }(); + + _inFlightPubkeyRequests[asset.id] = future; + try { + return await future; + } finally { + _inFlightPubkeyRequests.remove(asset.id); + } } /// Create a new pubkey for an asset if supported From 0db04b262e32b40c1cbe97c3c16672cb5d6609d2 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Sun, 26 Oct 2025 17:19:48 +0100 Subject: [PATCH 04/35] test(local-auth): add ensureKdfHealthy to FakeAuthService for Trezor tests --- .../src/trezor/trezor_auth_service_test.dart | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/komodo_defi_local_auth/test/src/trezor/trezor_auth_service_test.dart b/packages/komodo_defi_local_auth/test/src/trezor/trezor_auth_service_test.dart index af0cbd67..95c14814 100644 --- a/packages/komodo_defi_local_auth/test/src/trezor/trezor_auth_service_test.dart +++ b/packages/komodo_defi_local_auth/test/src/trezor/trezor_auth_service_test.dart @@ -135,6 +135,9 @@ class _FakeAuthService implements IAuthService { @override Future isSignedIn() async => activeUser != null; + @override + Future ensureKdfHealthy() async => true; + @override Future restoreSession(KdfUser user) async { activeUser = user; @@ -259,10 +262,9 @@ void main() { test( 'signIn success: registers new wallet, sends passphrase, starts monitor', () async { - final auth = - _FakeAuthService() - // No existing users => new user => register branch - ..users = []; + final auth = _FakeAuthService() + // No existing users => new user => register branch + ..users = []; final repo = _FakeTrezorRepository(); final monitor = _FakeConnectionMonitor(); @@ -442,21 +444,20 @@ void main() { }); test('existing user without stored password throws before auth', () async { - final auth = - _FakeAuthService() - // Pre-existing Trezor user - ..users = [ - KdfUser( - walletId: WalletId.fromName( - TrezorAuthService.trezorWalletName, - const AuthOptions( - derivationMethod: DerivationMethod.hdWallet, - privKeyPolicy: PrivateKeyPolicy.trezor(), - ), - ), - isBip39Seed: true, + final auth = _FakeAuthService() + // Pre-existing Trezor user + ..users = [ + KdfUser( + walletId: WalletId.fromName( + TrezorAuthService.trezorWalletName, + const AuthOptions( + derivationMethod: DerivationMethod.hdWallet, + privKeyPolicy: PrivateKeyPolicy.trezor(), ), - ]; + ), + isBip39Seed: true, + ), + ]; final repo = _FakeTrezorRepository(); final monitor = _FakeConnectionMonitor(); @@ -516,8 +517,8 @@ void main() { () async { final auth = _FakeAuthService(); final repo = _FakeTrezorRepository(); - final monitor = - _FakeConnectionMonitor()..started = true; // simulate active + final monitor = _FakeConnectionMonitor() + ..started = true; // simulate active final service = TrezorAuthService( auth, From 301ddb217c0663e42d397aa73eee48d74b03756c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Oct 2025 22:21:46 +0000 Subject: [PATCH 05/35] Refactor: Wallet-aware pubkey persistence and retrieval This change ensures that pubkey data is correctly associated with the active wallet, preventing cross-wallet contamination. It also improves the accuracy of KDF health checks by bypassing cached user data. Co-authored-by: charl --- .../auth_service_operations_extension.dart | 3 +- .../lib/src/pubkeys/pubkey_manager.dart | 82 ++++++++++++++++--- .../lib/src/pubkeys/pubkeys_storage.dart | 3 +- .../transaction_history_manager.dart | 3 +- 4 files changed, 77 insertions(+), 14 deletions(-) diff --git a/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart b/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart index c742a6b7..e33fa0c9 100644 --- a/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart +++ b/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart @@ -22,7 +22,8 @@ extension KdfAuthServiceOperationsExtension on KdfAuthService { Future _checkKdfHealth() async { try { final isRunning = await _kdfFramework.isRunning(); - final currentUser = await getActiveUser(); + // Bypass cached user to detect external changes accurately + final currentUser = await _getActiveUser(); // If KDF is not running or we're in no-auth mode but previously had a user, // emit signed out state diff --git a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart index 75c15f1c..687f3e67 100644 --- a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart @@ -89,8 +89,15 @@ class PubkeyManager implements IPubkeyManager { // Otherwise, start a new request and dedupe concurrent callers final future = () async { + // Capture wallet id at start to avoid cross-wallet persistence + final currentUser = await _auth.currentUser; + if (currentUser == null) { + throw AuthException.notSignedIn(); + } + final WalletId walletId = currentUser.walletId; + // Try to hydrate from persisted storage first for instant response - final hydrated = await _hydrateFromStorage(asset); + final hydrated = await _hydrateFromStorageForWallet(walletId, asset); if (hydrated != null) { _pubkeysCache[asset.id] = hydrated; return hydrated; @@ -101,7 +108,7 @@ class PubkeyManager implements IPubkeyManager { final pubkeys = await strategy.getPubkeys(asset.id, _client); _pubkeysCache[asset.id] = pubkeys; // Persist asynchronously; do not block the call - unawaited(_persistPubkeys(asset, pubkeys)); + unawaited(_persistPubkeysForWallet(walletId, asset, pubkeys)); return pubkeys; }(); @@ -255,6 +262,63 @@ class PubkeyManager implements IPubkeyManager { } } + // Wallet-stable variants to avoid cross-wallet contamination during async ops + Future _persistPubkeysForWallet( + WalletId walletId, + Asset asset, + AssetPubkeys pubkeys, + ) async { + try { + await _storage.savePubkeys(walletId, asset.id.id, pubkeys); + } catch (_) { + // best-effort persistence + } + } + + Future _hydrateFromStorageForWallet( + WalletId walletId, + Asset asset, + ) async { + try { + final map = await _storage.listForWallet(walletId); + final raw = map[asset.id.id]; + if (raw == null) return null; + + final addresses = + (raw['addresses'] as List?)?.cast>() ?? + const >[]; + final keys = []; + for (final addr in addresses) { + final bal = BalanceInfo.fromJson( + (addr['balance'] as Map).cast(), + ); + keys.add( + PubkeyInfo( + address: addr['address'] as String, + derivationPath: addr['derivation_path'] as String?, + chain: addr['chain'] as String?, + balance: bal, + coinTicker: asset.id.id, + ), + ); + } + + final available = (raw['available'] as num?)?.toInt() ?? keys.length; + final syncString = raw['sync'] as String?; + final sync = + SyncStatusEnum.tryParse(syncString) ?? SyncStatusEnum.success; + + return AssetPubkeys( + assetId: asset.id, + keys: keys, + availableAddressesCount: available, + syncStatus: sync, + ); + } catch (_) { + return null; + } + } + Future _startWatchingPubkeys(Asset asset, bool activateIfNeeded) async { final controller = _pubkeysControllers[asset.id]; if (controller == null || _isDisposed) return; @@ -275,12 +339,6 @@ class PubkeyManager implements IPubkeyManager { _currentWalletId = user.walletId; _logger.fine('Starting watcher for ${asset.id.name}'); - // Emit last known immediately if available - final maybeKnown = _pubkeysCache[asset.id]; - if (maybeKnown != null && !controller.isClosed) { - controller.add(maybeKnown); - } - try { // Ensure activation if requested, otherwise only proceed if already active bool isActive = await _activationCoordinator.isAssetActive(asset.id); @@ -293,7 +351,8 @@ class PubkeyManager implements IPubkeyManager { if (isActive) { // Try hydrate from persisted cache first for faster cold start - final hydrated = await _hydrateFromStorage(asset); + final walletId = _currentWalletId!; + final hydrated = await _hydrateFromStorageForWallet(walletId, asset); if (hydrated != null) { _pubkeysCache[asset.id] = hydrated; if (!controller.isClosed) controller.add(hydrated); @@ -301,7 +360,9 @@ class PubkeyManager implements IPubkeyManager { final first = await getPubkeys(asset); _pubkeysCache[asset.id] = first; - if (!controller.isClosed) controller.add(first); + if (!controller.isClosed && (hydrated == null || first != hydrated)) { + controller.add(first); + } _logger.fine('Emitted initial pubkeys for ${asset.id.name}'); } @@ -454,6 +515,7 @@ class PubkeyManager implements IPubkeyManager { // Clear caches _pubkeysCache.clear(); + _inFlightPubkeyRequests.clear(); stopwatch.stop(); _logger.fine( diff --git a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkeys_storage.dart b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkeys_storage.dart index 0277da53..e511f8e5 100644 --- a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkeys_storage.dart +++ b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkeys_storage.dart @@ -50,7 +50,8 @@ class HivePubkeysStorage implements PubkeysStorage { if (!key.startsWith(prefix)) continue; final record = box.get(key); if (record == null) continue; - // Expose as generic map for caller hydration convenience + // Build map structure to mirror the expected hydration format + // used by PubkeyManager._hydrateFromStorage* for fast hydration result[key.substring(prefix.length)] = { 'available': record.available, 'sync': record.sync, diff --git a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart index ccd5d465..5aa8a2ce 100644 --- a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart @@ -66,8 +66,7 @@ class TransactionHistoryManager implements _TransactionHistoryManager { final _syncInProgress = {}; final _rateLimiter = _RateLimiter(const Duration(milliseconds: 500)); - // Legacy interval retained for backwards compatibility and potential feature flags. - // Currently unused since polling is driven by balance changes. + // Maximum consecutive retry attempts when polling fails transiently static const _maxPollingRetries = 3; static const _maxBatchSize = 50; From eb8572a2ae6244fe13fc9abe74c913aa2cac9207 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Oct 2025 22:45:24 +0000 Subject: [PATCH 06/35] Refactor: Improve pubkey and balance fetching logic Co-authored-by: charl --- .../lib/src/balances/balance_manager.dart | 8 +- .../lib/src/pubkeys/pubkey_manager.dart | 144 +++++++----------- .../transaction_history_manager.dart | 27 ++-- 3 files changed, 80 insertions(+), 99 deletions(-) diff --git a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart index cd2b2b44..cd971a3b 100644 --- a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart @@ -75,10 +75,10 @@ class BalanceManager implements IBalanceManager { final IAssetLookup _assetLookup; final KomodoDefiLocalAuth _auth; StreamSubscription? _authSubscription; - final Duration _defaultPollingInterval = const Duration(minutes: 1); + final Duration _defaultPollingInterval = const Duration(minutes: 1); // consider DI/config override /// Enable debug logging for balance polling - static bool enableDebugLogging = true; + static bool enableDebugLogging = false; /// Cache of the latest known balances for each asset final Map _balanceCache = {}; @@ -377,12 +377,14 @@ class BalanceManager implements IBalanceManager { } return balance; } - } catch (e) { + } catch (e, s) { // Just log the error and continue with the last known balance // This prevents the stream from terminating on transient errors if (enableDebugLogging) { _logger.warning( '[POLLING] Balance fetch failed for ${assetId.name}: $e', + e, + s, ); } } diff --git a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart index 687f3e67..57d8cc38 100644 --- a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart @@ -81,43 +81,40 @@ class PubkeyManager implements IPubkeyManager { return cached; } - // If a request for this asset is already in flight, await it + // If a network fetch for this asset is already in flight, await it final existing = _inFlightPubkeyRequests[asset.id]; if (existing != null) { return existing; } - // Otherwise, start a new request and dedupe concurrent callers - final future = () async { - // Capture wallet id at start to avoid cross-wallet persistence - final currentUser = await _auth.currentUser; - if (currentUser == null) { - throw AuthException.notSignedIn(); - } - final WalletId walletId = currentUser.walletId; - - // Try to hydrate from persisted storage first for instant response - final hydrated = await _hydrateFromStorageForWallet(walletId, asset); - if (hydrated != null) { - _pubkeysCache[asset.id] = hydrated; - return hydrated; - } - - await retry(() => _activationCoordinator.activateAsset(asset)); - final strategy = await _resolvePubkeyStrategy(asset); - final pubkeys = await strategy.getPubkeys(asset.id, _client); - _pubkeysCache[asset.id] = pubkeys; - // Persist asynchronously; do not block the call - unawaited(_persistPubkeysForWallet(walletId, asset, pubkeys)); - return pubkeys; - }(); - - _inFlightPubkeyRequests[asset.id] = future; - try { - return await future; - } finally { - _inFlightPubkeyRequests.remove(asset.id); + // Capture wallet id at start to avoid cross-wallet persistence + final currentUser = await _auth.currentUser; + if (currentUser == null) { + throw AuthException.notSignedIn(); + } + final WalletId walletId = currentUser.walletId; + + // Try to hydrate from persisted storage first for instant response + final hydrated = await _hydrateFromStorageForWallet(walletId, asset); + if (hydrated != null) { + _pubkeysCache[asset.id] = hydrated; + // Fire-and-forget fresh refresh; deduped if one is already running + unawaited(() async { + try { + final fresh = await _fetchFreshPubkeys(asset, walletId); + final controller = _pubkeysControllers[asset.id]; + if (controller != null && !controller.isClosed && fresh != hydrated) { + controller.add(fresh); + } + } catch (_) { + // best-effort background refresh + } + }()); + return hydrated; } + + // No hydration available, fetch fresh + return _fetchFreshPubkeys(asset, walletId); } /// Create a new pubkey for an asset if supported @@ -162,6 +159,31 @@ class PubkeyManager implements IPubkeyManager { return asset.pubkeyStrategy(kdfUser: currentUser); } + // Perform a fresh network fetch for pubkeys, deduplicated per asset + Future _fetchFreshPubkeys( + Asset asset, + WalletId walletId, + ) async { + final existing = _inFlightPubkeyRequests[asset.id]; + if (existing != null) return existing; + + final future = () async { + await retry(() => _activationCoordinator.activateAsset(asset)); + final strategy = await _resolvePubkeyStrategy(asset); + final pubkeys = await strategy.getPubkeys(asset.id, _client); + _pubkeysCache[asset.id] = pubkeys; + unawaited(_persistPubkeysForWallet(walletId, asset, pubkeys)); + return pubkeys; + }(); + + _inFlightPubkeyRequests[asset.id] = future; + try { + return await future; + } finally { + _inFlightPubkeyRequests.remove(asset.id); + } + } + /// Stream of pubkeys per asset. Polls pubkeys (not balances) and emits updates. /// Emits the initial known state if available. @override @@ -209,58 +231,7 @@ class PubkeyManager implements IPubkeyManager { return _pubkeysCache[assetId]; } - Future _persistPubkeys(Asset asset, AssetPubkeys pubkeys) async { - try { - final user = await _auth.currentUser; - if (user == null) return; - await _storage.savePubkeys(user.walletId, asset.id.id, pubkeys); - } catch (_) { - // best-effort persistence - } - } - - Future _hydrateFromStorage(Asset asset) async { - try { - final user = await _auth.currentUser; - if (user == null) return null; - final map = await _storage.listForWallet(user.walletId); - final raw = map[asset.id.id]; - if (raw == null) return null; - - final addresses = - (raw['addresses'] as List?)?.cast>() ?? - const >[]; - final keys = []; - for (final addr in addresses) { - final bal = BalanceInfo.fromJson( - (addr['balance'] as Map).cast(), - ); - keys.add( - PubkeyInfo( - address: addr['address'] as String, - derivationPath: addr['derivation_path'] as String?, - chain: addr['chain'] as String?, - balance: bal, - coinTicker: asset.id.id, - ), - ); - } - - final available = (raw['available'] as num?)?.toInt() ?? keys.length; - final syncString = raw['sync'] as String?; - final sync = - SyncStatusEnum.tryParse(syncString) ?? SyncStatusEnum.success; - - return AssetPubkeys( - assetId: asset.id, - keys: keys, - availableAddressesCount: available, - syncStatus: sync, - ); - } catch (_) { - return null; - } - } + // Removed unused non-wallet-stable helpers to avoid confusion // Wallet-stable variants to avoid cross-wallet contamination during async ops Future _persistPubkeysForWallet( @@ -358,7 +329,7 @@ class PubkeyManager implements IPubkeyManager { if (!controller.isClosed) controller.add(hydrated); } - final first = await getPubkeys(asset); + final first = await _fetchFreshPubkeys(asset, walletId); _pubkeysCache[asset.id] = first; if (!controller.isClosed && (hydrated == null || first != hydrated)) { controller.add(first); @@ -389,7 +360,10 @@ class PubkeyManager implements IPubkeyManager { active = activationResult.isSuccess; } if (active) { - final pubkeys = await getPubkeys(asset); + final pubkeys = await _fetchFreshPubkeys( + asset, + currentUser.walletId, + ); _pubkeysCache[asset.id] = pubkeys; return pubkeys; } diff --git a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart index 5aa8a2ce..b2b8412a 100644 --- a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart @@ -424,17 +424,22 @@ class TransactionHistoryManager implements _TransactionHistoryManager { // Subscribe to balance changes and trigger history fetch only when balance changes _balanceSubscriptions[asset.id] = _balanceManager .watchBalance(asset.id) - .listen((BalanceInfo balance) { - final last = _lastObservedBalance[asset.id]; - final changed = - last == null || - balance.total != last.total || - balance.spendable != last.spendable; - _lastObservedBalance[asset.id] = balance; - if (changed) { - _pollNewTransactions(asset); - } - }); + .listen( + (BalanceInfo balance) { + final last = _lastObservedBalance[asset.id]; + final changed = + last == null || + balance.total != last.total || + balance.spendable != last.spendable; + _lastObservedBalance[asset.id] = balance; + if (changed) { + _pollNewTransactions(asset); + } + }, + onError: (Object error) { + // Keep subscription alive; BalanceManager should recover + }, + ); } void _stopPolling(AssetId assetId) { From f2da6f18adcb6162604cdc41307dfde52b0a77de Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Mon, 27 Oct 2025 13:20:01 +0100 Subject: [PATCH 07/35] fix: market data resource improvements --- .../src/bootstrap/market_data_bootstrap.dart | 2 ++ .../lib/src/cex_repository.dart | 6 ++++++ .../data/coinpaprika_cex_provider.dart | 14 +++++++++++++- .../data/coinpaprika_repository.dart | 12 +++++++++++- .../lib/src/sparkline_repository.dart | 19 ++++++++++++++++++- .../src/operations/kdf_operations_native.dart | 1 + 6 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/komodo_cex_market_data/lib/src/bootstrap/market_data_bootstrap.dart b/packages/komodo_cex_market_data/lib/src/bootstrap/market_data_bootstrap.dart index 4a52ad45..5cc92e3d 100644 --- a/packages/komodo_cex_market_data/lib/src/bootstrap/market_data_bootstrap.dart +++ b/packages/komodo_cex_market_data/lib/src/bootstrap/market_data_bootstrap.dart @@ -128,6 +128,7 @@ class MarketDataBootstrap { if (config.enableCoinPaprika) { container.registerSingletonAsync( () async => config.coinPaprikaProvider ?? CoinPaprikaProvider(), + dispose: (provider) => provider.dispose(), ); } @@ -166,6 +167,7 @@ class MarketDataBootstrap { coinPaprikaProvider: await container.getAsync(), ), dependsOn: [ICoinPaprikaProvider], + dispose: (repository) => repository.dispose(), ); } diff --git a/packages/komodo_cex_market_data/lib/src/cex_repository.dart b/packages/komodo_cex_market_data/lib/src/cex_repository.dart index 2571295e..82af337e 100644 --- a/packages/komodo_cex_market_data/lib/src/cex_repository.dart +++ b/packages/komodo_cex_market_data/lib/src/cex_repository.dart @@ -202,4 +202,10 @@ abstract class CexRepository { QuoteCurrency fiatCurrency, PriceRequestType requestType, ); + + /// Releases any resources held by the repository. + /// + /// Repositories that allocate resources such as HTTP clients or file handles + /// should override this method to dispose them when no longer needed. + void dispose() {} } diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/data/coinpaprika_cex_provider.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/data/coinpaprika_cex_provider.dart index b15f2b72..42744179 100644 --- a/packages/komodo_cex_market_data/lib/src/coinpaprika/data/coinpaprika_cex_provider.dart +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/data/coinpaprika_cex_provider.dart @@ -68,6 +68,9 @@ abstract class ICoinPaprikaProvider { /// The current API plan with its limitations and features. CoinPaprikaApiPlan get apiPlan; + + /// Releases any resources held by the provider. + void dispose(); } /// Implementation of CoinPaprika data provider using HTTP requests. @@ -80,7 +83,8 @@ class CoinPaprikaProvider implements ICoinPaprikaProvider { this.apiPlan = const CoinPaprikaApiPlan.free(), http.Client? httpClient, }) : _apiKey = apiKey, - _httpClient = httpClient ?? http.Client(); + _httpClient = httpClient ?? http.Client(), + _ownsHttpClient = httpClient == null; /// The base URL for the CoinPaprika API. final String baseUrl; @@ -97,6 +101,7 @@ class CoinPaprikaProvider implements ICoinPaprikaProvider { /// The HTTP client for the CoinPaprika API. final http.Client _httpClient; + final bool _ownsHttpClient; static final Logger _logger = Logger('CoinPaprikaProvider'); @@ -514,4 +519,11 @@ class CoinPaprikaProvider implements ICoinPaprikaProvider { : null, ); } + + @override + void dispose() { + if (_ownsHttpClient) { + _httpClient.close(); + } + } } diff --git a/packages/komodo_cex_market_data/lib/src/coinpaprika/data/coinpaprika_repository.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/data/coinpaprika_repository.dart index 8135d55c..293c14a6 100644 --- a/packages/komodo_cex_market_data/lib/src/coinpaprika/data/coinpaprika_repository.dart +++ b/packages/komodo_cex_market_data/lib/src/coinpaprika/data/coinpaprika_repository.dart @@ -27,13 +27,16 @@ class CoinPaprikaRepository implements CexRepository { CoinPaprikaRepository({ required this.coinPaprikaProvider, bool enableMemoization = true, + bool ownsProvider = false, }) : _idResolutionStrategy = CoinPaprikaIdResolutionStrategy(), - _enableMemoization = enableMemoization; + _enableMemoization = enableMemoization, + _ownsProvider = ownsProvider; /// The CoinPaprika provider to use for fetching data. final ICoinPaprikaProvider coinPaprikaProvider; final IdResolutionStrategy _idResolutionStrategy; final bool _enableMemoization; + final bool _ownsProvider; final AsyncMemoizer> _coinListMemoizer = AsyncMemoizer(); Set? _cachedQuoteCurrencies; @@ -437,4 +440,11 @@ class CoinPaprikaRepository implements CexRepository { return false; } } + + @override + void dispose() { + if (_ownsProvider) { + coinPaprikaProvider.dispose(); + } + } } diff --git a/packages/komodo_cex_market_data/lib/src/sparkline_repository.dart b/packages/komodo_cex_market_data/lib/src/sparkline_repository.dart index 1d1b8c00..9dd11a43 100644 --- a/packages/komodo_cex_market_data/lib/src/sparkline_repository.dart +++ b/packages/komodo_cex_market_data/lib/src/sparkline_repository.dart @@ -23,7 +23,10 @@ class SparklineRepository with RepositoryFallbackMixin { factory SparklineRepository.defaultInstance() { return SparklineRepository([ BinanceRepository(binanceProvider: const BinanceProvider()), - CoinPaprikaRepository(coinPaprikaProvider: CoinPaprikaProvider()), + CoinPaprikaRepository( + coinPaprikaProvider: CoinPaprikaProvider(), + ownsProvider: true, + ), CoinGeckoRepository(coinGeckoProvider: CoinGeckoCexProvider()), ], selectionStrategy: DefaultRepositorySelectionStrategy()); } @@ -174,6 +177,20 @@ class SparklineRepository with RepositoryFallbackMixin { return future; } + /// Releases held resources such as HTTP clients and Hive boxes. + Future dispose() async { + for (final repository in _repositories) { + repository.dispose(); + } + + final box = _box; + if (box != null && box.isOpen) { + await box.close(); + } + _box = null; + isInitialized = false; + } + /// Internal method to perform the actual sparkline fetch /// /// This is separated from fetchSparkline to enable proper request 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..281f6a15 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 @@ -325,6 +325,7 @@ class KdfOperationsNativeLibrary implements IKdfOperations { } void dispose() { + _client.close(); _logCallback.close(); // Ensure the NativeCallable is properly closed } } From df63a72322cd3f21ce5fdc62b37765185af1e5fa Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Mon, 27 Oct 2025 14:19:43 +0100 Subject: [PATCH 08/35] perf(assets): cache activated assets and coalesce activation checks - Wire SDK `ActivatedAssetsCache` into activation/coins flows: updates across `CoinsBloc`, `AssetOverviewBloc`, custom token import, and `sdk_auth_activation_extension` to reuse activation state instead of re-querying. - Debounce/polish polling in `portfolio_growth_bloc` and `profit_loss_bloc` to prevent overlapping requests. - Remove duplicate activation/balance checks in maker/taker validators and forms. - Consolidate repeated calls in `mm2_api`/`mm2_api_nft`/`rpc_native`; prefer cached values. - Reduce startup RPCs in `app_bootstrapper`; stop background timers in `window_close_handler` on app close to avoid trailing calls. - Add shared intervals in `shared/constants`; introduce `lib/shared/utils/activated_assets_cache.dart` for app-specific helpers. - No UI changes; measurable reduction in RPC volume and improved responsiveness. Refs #3238 --- .../src/coins_config/_coins_config_index.dart | 2 +- .../komodo_defi_sdk/lib/komodo_defi_sdk.dart | 4 +- .../lib/src/activation/_activation_index.dart | 1 + .../src/activation/activation_manager.dart | 12 +- .../activation/nft_activation_service.dart | 100 ++++++++++++++ .../lib/src/assets/_assets_index.dart | 1 + .../src/assets/activated_assets_cache.dart | 122 ++++++++++++++++++ .../lib/src/assets/asset_manager.dart | 29 +++-- .../komodo_defi_sdk/lib/src/bootstrap.dart | 29 ++++- .../lib/src/komodo_defi_sdk.dart | 88 +++++++++++++ .../lib/src/sdk/komodo_defi_sdk_config.dart | 8 ++ 11 files changed, 370 insertions(+), 26 deletions(-) create mode 100644 packages/komodo_defi_sdk/lib/src/activation/nft_activation_service.dart create mode 100644 packages/komodo_defi_sdk/lib/src/assets/activated_assets_cache.dart diff --git a/packages/komodo_coin_updates/lib/src/coins_config/_coins_config_index.dart b/packages/komodo_coin_updates/lib/src/coins_config/_coins_config_index.dart index c9428992..59a914b0 100644 --- a/packages/komodo_coin_updates/lib/src/coins_config/_coins_config_index.dart +++ b/packages/komodo_coin_updates/lib/src/coins_config/_coins_config_index.dart @@ -1,6 +1,6 @@ // Generated by the `index_generator` package with the `index_generator.yaml` configuration file. -library; +library _coins_config; export 'asset_parser.dart'; export 'coin_config_provider.dart'; diff --git a/packages/komodo_defi_sdk/lib/komodo_defi_sdk.dart b/packages/komodo_defi_sdk/lib/komodo_defi_sdk.dart index cfa7cc81..054afc07 100644 --- a/packages/komodo_defi_sdk/lib/komodo_defi_sdk.dart +++ b/packages/komodo_defi_sdk/lib/komodo_defi_sdk.dart @@ -34,7 +34,9 @@ export 'src/activation_config/activation_config_service.dart' ZhtlcUserConfig; export 'src/activation_config/hive_activation_config_repository.dart' show HiveActivationConfigRepository; -export 'src/assets/_assets_index.dart' show AssetHdWalletAddressesExtension; +export 'src/activation/nft_activation_service.dart' show NftActivationService; +export 'src/assets/_assets_index.dart' + show AssetHdWalletAddressesExtension, ActivatedAssetsCache; export 'src/assets/asset_extensions.dart' show AssetFaucetExtension, diff --git a/packages/komodo_defi_sdk/lib/src/activation/_activation_index.dart b/packages/komodo_defi_sdk/lib/src/activation/_activation_index.dart index 6ab3e568..76d945de 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/_activation_index.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/_activation_index.dart @@ -6,6 +6,7 @@ library _activation; export 'activation_manager.dart'; export 'base_strategies/activation_strategy_base.dart'; export 'base_strategies/activation_strategy_factory.dart'; +export 'nft_activation_service.dart'; export 'progress_reporting.dart'; export 'protocol_strategies/bch_activation_strategy.dart'; export 'protocol_strategies/bch_with_tokens_batch_strategy.dart'; diff --git a/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart b/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart index 625adda7..68c26702 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart @@ -22,6 +22,7 @@ class ActivationManager { this._balanceManager, this._configService, this._assetsUpdateManager, + this._activatedAssetsCache, ); final ApiClient _client; @@ -31,6 +32,7 @@ class ActivationManager { final IBalanceManager _balanceManager; final ActivationConfigService _configService; final KomodoAssetsUpdateManager _assetsUpdateManager; + final ActivatedAssetsCache _activatedAssetsCache; final _activationMutex = Mutex(); static const _operationTimeout = Duration(seconds: 30); @@ -215,6 +217,8 @@ class ActivationManager { // Pre-cache balance for the activated asset await _balanceManager.precacheBalance(asset); } + + _activatedAssetsCache.invalidate(); } if (!completer.isCompleted) { @@ -241,13 +245,7 @@ class ActivationManager { } try { - final enabledCoins = await _client.rpc.generalActivation - .getEnabledCoins(); - return enabledCoins.result - .map((coin) => _assetLookup.findAssetsByConfigId(coin.ticker)) - .expand((assets) => assets) - .map((asset) => asset.id) - .toSet(); + return await _activatedAssetsCache.getActivatedAssetIds(); } catch (e) { debugPrint('Failed to get active assets: $e'); return {}; diff --git a/packages/komodo_defi_sdk/lib/src/activation/nft_activation_service.dart b/packages/komodo_defi_sdk/lib/src/activation/nft_activation_service.dart new file mode 100644 index 00000000..c347a7eb --- /dev/null +++ b/packages/komodo_defi_sdk/lib/src/activation/nft_activation_service.dart @@ -0,0 +1,100 @@ +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; +import 'package:komodo_defi_sdk/src/_internal_exports.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'; + +/// Utilities for managing NFT chain activation lifecycle. +class NftActivationService { + /// Creates a new service instance. + NftActivationService( + this._client, + this._assetManager, + this._activatedAssetsCache, + ); + + final ApiClient _client; + final AssetManager _assetManager; + final ActivatedAssetsCache _activatedAssetsCache; + final Logger _logger = Logger('NftActivationService'); + + /// Returns the subset of [nftTickers] that are currently active. + Future> getActiveNftChains(Iterable nftTickers) async { + final activeIds = await _activatedAssetsCache.getActivatedAssetIds(); + if (activeIds.isEmpty) return const []; + + final activeTickers = activeIds.map((id) => id.id).toSet(); + final result = []; + final seen = {}; + + for (final ticker in nftTickers) { + if (activeTickers.contains(ticker) && seen.add(ticker)) { + result.add(ticker); + } + } + + return result; + } + + /// Activates a single NFT asset if it's not already active. + Future enableNft( + Asset asset, { + NftActivationParams? activationParams, + int maxAttempts = 3, + Duration initialBackoff = const Duration(seconds: 1), + }) async { + final active = await _activatedAssetsCache.getActivatedAssetIds(); + if (active.contains(asset.id)) { + return; + } + + final params = + activationParams ?? + NftActivationParams(provider: NftProvider.moralis()); + + await retry( + () async { + await _client.rpc.nft.enableNft( + ticker: asset.id.symbol.assetConfigId, + activationParams: params, + ); + }, + maxAttempts: maxAttempts, + backoffStrategy: ExponentialBackoff(initialDelay: initialBackoff), + ); + + _activatedAssetsCache.invalidate(); + } + + /// Ensures all [nftTickers] are activated. Any failures are logged and the + /// last encountered exception is rethrown after attempting all activations. + Future enableNftChains( + Iterable nftTickers, { + NftActivationParams? activationParams, + }) async { + final assetsById = {}; + for (final ticker in nftTickers) { + for (final asset in _assetManager.findAssetsByConfigId(ticker)) { + assetsById[asset.id] = asset; + } + } + + if (assetsById.isEmpty) { + return; + } + + Exception? lastError; + for (final asset in assetsById.values) { + try { + await enableNft(asset, activationParams: activationParams); + } on Object catch (e, s) { + _logger.severe('Failed to enable NFT asset ${asset.id.id}', e, s); + lastError = e is Exception ? e : Exception(e.toString()); + } + } + + if (lastError != null) { + throw lastError; + } + } +} diff --git a/packages/komodo_defi_sdk/lib/src/assets/_assets_index.dart b/packages/komodo_defi_sdk/lib/src/assets/_assets_index.dart index c2d9f81b..ec8c9d3e 100644 --- a/packages/komodo_defi_sdk/lib/src/assets/_assets_index.dart +++ b/packages/komodo_defi_sdk/lib/src/assets/_assets_index.dart @@ -3,6 +3,7 @@ /// Internal/private classes related to the assets of the Komodo DeFi Framework ecosystem. library _assets; +export 'activated_assets_cache.dart'; export 'asset_extensions.dart'; export 'asset_history_storage.dart'; export 'asset_lookup.dart'; diff --git a/packages/komodo_defi_sdk/lib/src/assets/activated_assets_cache.dart b/packages/komodo_defi_sdk/lib/src/assets/activated_assets_cache.dart new file mode 100644 index 00000000..1ca48da7 --- /dev/null +++ b/packages/komodo_defi_sdk/lib/src/assets/activated_assets_cache.dart @@ -0,0 +1,122 @@ +import 'dart:async'; + +import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; +import 'package:komodo_defi_sdk/src/assets/asset_lookup.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +/// Cache for the activated assets list with a configurable TTL. +/// +/// This cache reduces repeated `get_enabled_coins` RPC calls by memoizing the +/// activated assets for a short duration. It automatically invalidates when +/// the signed-in wallet changes or when explicitly cleared. +class ActivatedAssetsCache { + /// Creates a new cache instance. + ActivatedAssetsCache({ + required ApiClient client, + required KomodoDefiLocalAuth auth, + required IAssetLookup assetLookup, + Duration ttl = const Duration(seconds: 2), + DateTime Function() clock = DateTime.now, + }) : _client = client, + _auth = auth, + _assetLookup = assetLookup, + _ttl = ttl, + _clock = clock { + _authSubscription = _auth.authStateChanges.listen((_) => invalidate()); + } + + final ApiClient _client; + final KomodoDefiLocalAuth _auth; + final IAssetLookup _assetLookup; + final Duration _ttl; + final DateTime Function() _clock; + + List? _cache; + DateTime? _lastFetchAt; + Future>? _pendingFetch; + StreamSubscription? _authSubscription; + bool _isDisposed = false; + + /// Returns the cached activated assets, refreshing when the TTL has expired + /// or when [forceRefresh] is true. + Future> getActivatedAssets({bool forceRefresh = false}) async { + _assertNotDisposed(); + + if (forceRefresh) { + invalidate(); + } + + if (_hasValidCache) { + return _cache!; + } + + final inflight = _pendingFetch; + if (inflight != null) { + return inflight; + } + + final future = _fetchActivatedAssets(); + _pendingFetch = future; + try { + final assets = await future; + _cache = assets; + _lastFetchAt = _clock(); + return assets; + } finally { + if (identical(_pendingFetch, future)) { + _pendingFetch = null; + } + } + } + + /// Returns the activated [AssetId] set, refreshing as needed. + Future> getActivatedAssetIds({bool forceRefresh = false}) async { + final assets = await getActivatedAssets(forceRefresh: forceRefresh); + return assets.map((asset) => asset.id).toSet(); + } + + /// Clears the current cache forcing the next lookup to hit the network. + void invalidate() { + _cache = null; + _lastFetchAt = null; + _pendingFetch = null; + } + + /// Disposes the cache, cancelling auth subscriptions and clearing state. + Future dispose() async { + if (_isDisposed) return; + _isDisposed = true; + await _authSubscription?.cancel(); + invalidate(); + } + + Future> _fetchActivatedAssets() async { + if (!await _auth.isSignedIn()) return const []; + + final response = await _client.rpc.generalActivation.getEnabledCoins(); + + final assets = []; + final seen = {}; + for (final coin in response.result) { + for (final asset in _assetLookup.findAssetsByConfigId(coin.ticker)) { + if (seen.add(asset.id)) { + assets.add(asset); + } + } + } + + return assets; + } + + bool get _hasValidCache { + if (_ttl == Duration.zero) return false; + if (_cache == null || _lastFetchAt == null) return false; + return _clock().difference(_lastFetchAt!) <= _ttl; + } + + void _assertNotDisposed() { + if (_isDisposed) { + throw StateError('ActivatedAssetsCache has been disposed'); + } + } +} diff --git a/packages/komodo_defi_sdk/lib/src/assets/asset_manager.dart b/packages/komodo_defi_sdk/lib/src/assets/asset_manager.dart index 642bd9f3..ea19febf 100644 --- a/packages/komodo_defi_sdk/lib/src/assets/asset_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/assets/asset_manager.dart @@ -43,28 +43,30 @@ class AssetManager implements IAssetProvider { /// This is typically created by the SDK and shouldn't need to be instantiated /// directly. AssetManager( - this._client, this._auth, this._config, - this._activationManager, + ValueGetter activationManager, this._coins, - ) { + ValueGetter activatedAssetsCache, + ) : _activationManager = activationManager, + _activatedAssetsCache = activatedAssetsCache { _authSubscription = _auth.authStateChanges.listen(_handleAuthStateChange); } - - final ApiClient _client; final KomodoDefiLocalAuth _auth; final KomodoDefiSdkConfig _config; final AssetsUpdateManager _coins; - StreamSubscription? _authSubscription; - bool _isDisposed = false; - AssetFilterStrategy _currentFilterStrategy = const NoAssetFilterStrategy(); /// NB: This cannot be used during initialization. This is a workaround /// to publicly expose the activation manager's activation methods. /// See [activateAsset] and [activateAssets] for more details. final ValueGetter _activationManager; + /// Activated assets cache shared across SDK consumers. + final ValueGetter _activatedAssetsCache; + StreamSubscription? _authSubscription; + bool _isDisposed = false; + AssetFilterStrategy _currentFilterStrategy = const NoAssetFilterStrategy(); + /// Initializes the asset manager. /// /// This is called automatically by the SDK and shouldn't need to be called @@ -114,6 +116,7 @@ class AssetManager implements IAssetProvider { : const NoAssetFilterStrategy(); setFilterStrategy(strategy); + _activatedAssetsCache().invalidate(); } /// Returns an asset by its [AssetId], if available. @@ -141,8 +144,7 @@ class AssetManager implements IAssetProvider { /// Returns an empty list if no user is signed in. @override Future> getActivatedAssets() async { - final enabled = await getEnabledCoins(); - return enabled.expand(findAssetsByConfigId).toList(); + return _activatedAssetsCache().getActivatedAssets(); } /// Returns the set of enabled coin tickers for the current user. @@ -150,10 +152,8 @@ class AssetManager implements IAssetProvider { /// Returns an empty set if no user is signed in. @override Future> getEnabledCoins() async { - if (!await _auth.isSignedIn()) return {}; - - final enabled = await _client.rpc.generalActivation.getEnabledCoins(); - return enabled.result.map((e) => e.ticker).toSet(); + final activated = await _activatedAssetsCache().getActivatedAssets(); + return activated.map((asset) => asset.id.id).toSet(); } /// Finds all assets matching the given ID String (as is in the coins config). @@ -220,5 +220,6 @@ class AssetManager implements IAssetProvider { Future dispose() async { _isDisposed = true; await _authSubscription?.cancel(); + _activatedAssetsCache().invalidate(); } } diff --git a/packages/komodo_defi_sdk/lib/src/bootstrap.dart b/packages/komodo_defi_sdk/lib/src/bootstrap.dart index 6e220970..fff16e45 100644 --- a/packages/komodo_defi_sdk/lib/src/bootstrap.dart +++ b/packages/komodo_defi_sdk/lib/src/bootstrap.dart @@ -93,20 +93,31 @@ Future bootstrap({ // Register asset manager first since it's a core dependency container.registerSingletonAsync(() async { - final client = await container.getAsync(); final auth = await container.getAsync(); final assetManager = AssetManager( - client, auth, config, () => container(), container(), + () => container(), ); await assetManager.init(); // Will be removed in near future after KW is fully migrated to KDF await assetManager.initTickerIndex(); return assetManager; - }, dependsOn: [ApiClient, KomodoDefiLocalAuth]); + }, dependsOn: [KomodoDefiLocalAuth]); + + container.registerSingletonAsync(() async { + final client = await container.getAsync(); + final auth = await container.getAsync(); + final assets = await container.getAsync(); + return ActivatedAssetsCache( + client: client, + auth: auth, + assetLookup: assets, + ttl: config.activatedAssetsCacheTtl, + ); + }, dependsOn: [ApiClient, KomodoDefiLocalAuth, AssetManager]); // Register BalanceManager BEFORE ActivationManager to avoid circular dependency container.registerSingletonAsync(() async { @@ -131,6 +142,8 @@ Future bootstrap({ final assetManager = await container.getAsync(); final balanceManager = await container.getAsync(); final configService = await container.getAsync(); + final activatedAssetsCache = await container + .getAsync(); final activationManager = ActivationManager( client, @@ -142,6 +155,7 @@ Future bootstrap({ // Needed here to add custom tokens to the same instance // as the asset manager container(), + activatedAssetsCache, ); return activationManager; @@ -153,9 +167,18 @@ Future bootstrap({ BalanceManager, ActivationConfigService, KomodoAssetsUpdateManager, + ActivatedAssetsCache, ], ); + container.registerSingletonAsync(() async { + final client = await container.getAsync(); + final assetManager = await container.getAsync(); + final activatedAssetsCache = await container + .getAsync(); + return NftActivationService(client, assetManager, activatedAssetsCache); + }, dependsOn: [ApiClient, AssetManager, ActivatedAssetsCache]); + // Register shared activation coordinator container.registerSingletonAsync(() async { final activationManager = await container.getAsync(); diff --git a/packages/komodo_defi_sdk/lib/src/komodo_defi_sdk.dart b/packages/komodo_defi_sdk/lib/src/komodo_defi_sdk.dart index db979820..da18ce4c 100644 --- a/packages/komodo_defi_sdk/lib/src/komodo_defi_sdk.dart +++ b/packages/komodo_defi_sdk/lib/src/komodo_defi_sdk.dart @@ -206,6 +206,16 @@ class KomodoDefiSdk with SecureRpcPasswordMixin { /// Throws [StateError] if accessed before initialization. AssetManager get assets => _assertSdkInitialized(_container()); + /// Cache of activated assets with per-instance TTL. + /// + /// Useful for avoiding repeated activation RPC calls across features. + ActivatedAssetsCache get activatedAssetsCache => + _assertSdkInitialized(_container()); + + /// NFT-specific activation helpers. + NftActivationService get nftActivation => + _assertSdkInitialized(_container()); + /// The transaction history manager instance. /// /// Manages transaction history and monitoring. @@ -290,6 +300,83 @@ class KomodoDefiSdk with SecureRpcPasswordMixin { Stream get logStream => _assertSdkInitialized(_container().logStream); + /// Waits until the percentage of enabled assets among [assetIds] meets or + /// exceeds [threshold], polling at [pollInterval] until [timeout]. + /// + /// Returns `true` when the threshold is reached, or `false` if the timeout + /// elapses first. + Future waitForEnabledAssetsToPassThreshold( + Iterable assetIds, { + double threshold = 0.5, + Duration timeout = const Duration(seconds: 30), + Duration pollInterval = const Duration(seconds: 2), + }) async { + _assertSdkInitialized(activatedAssetsCache); + + final targets = assetIds.toSet(); + if (targets.isEmpty) { + throw ArgumentError.value(assetIds, 'assetIds', 'is empty'); + } + if (threshold <= 0 || threshold > 1) { + throw ArgumentError.value(threshold, 'threshold', 'must be (0, 1]'); + } + if (timeout <= Duration.zero) { + throw ArgumentError.value(timeout, 'timeout', 'must be positive'); + } + if (pollInterval <= Duration.zero) { + throw ArgumentError.value( + pollInterval, + 'pollInterval', + 'must be positive', + ); + } + + final stopwatch = Stopwatch()..start(); + var forceRefresh = true; + + while (true) { + final enabled = await activatedAssetsCache.getActivatedAssetIds( + forceRefresh: forceRefresh, + ); + forceRefresh = false; + + final matched = enabled.intersection(targets).length; + final coverage = matched / targets.length; + if (coverage >= threshold) { + return true; + } + + if (stopwatch.elapsed >= timeout) { + return false; + } + + final remaining = timeout - stopwatch.elapsed; + await Future.delayed( + remaining < pollInterval ? remaining : pollInterval, + ); + } + } + + /// Convenience helper that accepts asset tickers instead of [AssetId]s. + /// Matches assets by config ID (`asset.id.id`) before delegating to + /// [waitForEnabledAssetsToPassThreshold]. + Future waitForEnabledTickersToPassThreshold( + Iterable tickers, { + double threshold = 0.5, + Duration timeout = const Duration(seconds: 30), + Duration pollInterval = const Duration(seconds: 2), + }) { + final ids = tickers + .expand((ticker) => assets.findAssetsByConfigId(ticker)) + .map((asset) => asset.id); + return waitForEnabledAssetsToPassThreshold( + ids, + threshold: threshold, + timeout: timeout, + pollInterval: pollInterval, + ); + } + /// Initializes the SDK instance. /// /// This must be called before using any SDK functionality. The initialization @@ -407,6 +494,7 @@ class KomodoDefiSdk with SecureRpcPasswordMixin { await Future.wait([ _disposeIfRegistered((m) => m.dispose()), _disposeIfRegistered((m) => m.dispose()), + _disposeIfRegistered((m) => m.dispose()), _disposeIfRegistered((m) => m.dispose()), _disposeIfRegistered((m) => m.dispose()), _disposeIfRegistered((m) => m.dispose()), diff --git a/packages/komodo_defi_sdk/lib/src/sdk/komodo_defi_sdk_config.dart b/packages/komodo_defi_sdk/lib/src/sdk/komodo_defi_sdk_config.dart index 3944ee57..5792aeaa 100644 --- a/packages/komodo_defi_sdk/lib/src/sdk/komodo_defi_sdk_config.dart +++ b/packages/komodo_defi_sdk/lib/src/sdk/komodo_defi_sdk_config.dart @@ -9,6 +9,7 @@ class KomodoDefiSdkConfig { this.preActivateCustomTokenAssets = true, this.maxPreActivationAttempts = 3, this.activationRetryDelay = const Duration(seconds: 2), + this.activatedAssetsCacheTtl = const Duration(seconds: 2), this.marketDataConfig = const MarketDataConfig(), }); @@ -30,6 +31,10 @@ class KomodoDefiSdkConfig { /// Delay between retry attempts final Duration activationRetryDelay; + /// Time-to-live for the activated assets cache. + /// Set to [Duration.zero] to disable caching. + final Duration activatedAssetsCacheTtl; + /// Configuration for market data repositories final MarketDataConfig marketDataConfig; @@ -40,6 +45,7 @@ class KomodoDefiSdkConfig { bool? preActivateCustomTokenAssets, int? maxPreActivationAttempts, Duration? activationRetryDelay, + Duration? activatedAssetsCacheTtl, MarketDataConfig? marketDataConfig, }) { return KomodoDefiSdkConfig( @@ -53,6 +59,8 @@ class KomodoDefiSdkConfig { maxPreActivationAttempts: maxPreActivationAttempts ?? this.maxPreActivationAttempts, activationRetryDelay: activationRetryDelay ?? this.activationRetryDelay, + activatedAssetsCacheTtl: + activatedAssetsCacheTtl ?? this.activatedAssetsCacheTtl, marketDataConfig: marketDataConfig ?? this.marketDataConfig, ); } From 1c791aaec4326c3c9446f358bc59712b2c0eba48 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Mon, 27 Oct 2025 16:50:18 +0100 Subject: [PATCH 09/35] feat(streaming): add typed stream RPCs and web SharedWorker integration; expose streaming API in framework and rpc methods --- .../lib/komodo_defi_framework.dart | 37 +++-- .../src/streaming/event_streaming_models.dart | 6 + .../event_streaming_platform_stub.dart | 10 ++ .../event_streaming_platform_web.dart | 49 +++++++ .../streaming/event_streaming_service.dart | 48 +++++++ .../lib/src/rpc_methods/rpc_methods.dart | 10 ++ .../streaming/streaming_balance_enable.dart | 37 +++++ .../streaming/streaming_common.dart | 59 ++++++++ .../streaming/streaming_disable.dart | 32 +++++ .../streaming/streaming_heartbeat_enable.dart | 37 +++++ .../streaming/streaming_network_enable.dart | 37 +++++ .../streaming_order_status_enable.dart | 27 ++++ .../streaming/streaming_orderbook_enable.dart | 37 +++++ .../streaming/streaming_rpc_namespace.dart | 134 ++++++++++++++++++ .../streaming_swap_status_enable.dart | 27 ++++ .../streaming_tx_history_enable.dart | 31 ++++ .../lib/src/rpc_methods_library.dart | 5 + 17 files changed, 610 insertions(+), 13 deletions(-) create mode 100644 packages/komodo_defi_framework/lib/src/streaming/event_streaming_models.dart create mode 100644 packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_stub.dart create mode 100644 packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart create mode 100644 packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart create mode 100644 packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_balance_enable.dart create mode 100644 packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_common.dart create mode 100644 packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_disable.dart create mode 100644 packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_heartbeat_enable.dart create mode 100644 packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_network_enable.dart create mode 100644 packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_order_status_enable.dart create mode 100644 packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_orderbook_enable.dart create mode 100644 packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_rpc_namespace.dart create mode 100644 packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_swap_status_enable.dart create mode 100644 packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_tx_history_enable.dart diff --git a/packages/komodo_defi_framework/lib/komodo_defi_framework.dart b/packages/komodo_defi_framework/lib/komodo_defi_framework.dart index 3bf496e0..c983d068 100644 --- a/packages/komodo_defi_framework/lib/komodo_defi_framework.dart +++ b/packages/komodo_defi_framework/lib/komodo_defi_framework.dart @@ -9,11 +9,14 @@ import 'package:komodo_defi_framework/src/operations/kdf_operations_interface.da 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:komodo_defi_framework/src/streaming/event_streaming_service.dart'; export 'package:komodo_defi_framework/src/client/kdf_api_client.dart'; export 'package:komodo_defi_framework/src/config/kdf_config.dart'; export 'package:komodo_defi_framework/src/config/kdf_startup_config.dart'; export 'package:komodo_defi_framework/src/services/seed_node_service.dart'; +export 'package:komodo_defi_framework/src/streaming/event_streaming_service.dart'; +export 'package:komodo_defi_framework/src/streaming/event_streaming_models.dart'; export 'src/operations/kdf_operations_interface.dart'; @@ -36,7 +39,7 @@ class KomodoDefiFramework implements ApiClient { /// Enable debug logging for RPC calls (method names, durations, success/failure) /// This can be controlled via app configuration static bool enableDebugLogging = true; - + final Logger _logger = Logger('KomodoDefiFramework'); factory KomodoDefiFramework.create({ @@ -104,6 +107,12 @@ class KomodoDefiFramework implements ApiClient { } } + // Streaming service (web: SharedWorker integration) + KdfEventStreamingService? _streamingService; + KdfEventStreamingService get streaming { + return _streamingService ??= KdfEventStreamingService()..initialize(); + } + //TODO! Figure out best way to handle overlap between startup and host //TODO! Handle common KDF operations startup log scanning here or in a //shared class. This is important to ensure consistent startup error handling @@ -189,14 +198,14 @@ class KomodoDefiFramework implements ApiClient { _log('KDF health check failed: not running'); return false; } - + // Additional check: try to get version to verify RPC is responsive final versionCheck = await version(); if (versionCheck == null) { _log('KDF health check failed: version call returned null'); return false; } - + _log('KDF health check passed'); return true; } catch (e) { @@ -216,27 +225,29 @@ class KomodoDefiFramework implements ApiClient { } return response; } - + // Extract method name for logging final method = request['method'] as String?; final stopwatch = Stopwatch()..start(); - + try { final response = (await _kdfOperations.mm2Rpc( request..setIfAbsentOrEmpty('userpass', _hostConfig.rpcPassword), )).ensureJson(); stopwatch.stop(); - + _logger.info( '[RPC] ${method ?? 'unknown'} completed in ${stopwatch.elapsedMilliseconds}ms', ); - + // Log electrum-related methods with more detail if (method != null && _isElectrumRelatedMethod(method)) { - _logger.info('[ELECTRUM] Method: $method, Duration: ${stopwatch.elapsedMilliseconds}ms'); + _logger.info( + '[ELECTRUM] Method: $method, Duration: ${stopwatch.elapsedMilliseconds}ms', + ); _logElectrumConnectionInfo(method, response); } - + if (KdfLoggingConfig.verboseLogging) { _log('RPC response: ${response.toJsonString()}'); } @@ -249,7 +260,7 @@ class KomodoDefiFramework implements ApiClient { rethrow; } } - + bool _isElectrumRelatedMethod(String method) { return method.contains('electrum') || method.contains('enable') || @@ -257,7 +268,7 @@ class KomodoDefiFramework implements ApiClient { method == 'get_enabled_coins' || method == 'my_balance'; } - + void _logElectrumConnectionInfo(String method, JsonMap response) { try { // Log connection information from enable responses @@ -269,7 +280,7 @@ class KomodoDefiFramework implements ApiClient { _logger.info( '[ELECTRUM] Coin enabled - Address: ${address ?? 'N/A'}, Balance: ${balance ?? 'N/A'}', ); - + // Log server information if available if (result['servers'] != null) { final servers = result['servers']; @@ -277,7 +288,7 @@ class KomodoDefiFramework implements ApiClient { } } } - + // Log balance information if (method == 'my_balance' && response['result'] != null) { final result = response['result'] as Map?; diff --git a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_models.dart b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_models.dart new file mode 100644 index 00000000..42d5860e --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_models.dart @@ -0,0 +1,6 @@ +class KdfEvent { + KdfEvent({required this.type, required this.message}); + + final String type; + final Map message; +} diff --git a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_stub.dart b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_stub.dart new file mode 100644 index 00000000..fce8b8b8 --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_stub.dart @@ -0,0 +1,10 @@ +typedef SharedWorkerUnsubscribe = void Function(); + +SharedWorkerUnsubscribe connectSharedWorker( + void Function(Object? data) onMessage, +) { + // No-op on non-web platforms + return () {}; +} + + diff --git a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart new file mode 100644 index 00000000..1fb4c507 --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart @@ -0,0 +1,49 @@ +// Web implementation: connect to SharedWorker('event_streaming_worker.js') +// and forward messages to Dart via the provided callback. + +// ignore: avoid_web_libraries_in_flutter +import 'dart:html' as html show Event; +import 'package:js/js_util.dart' as jsu; + +typedef SharedWorkerUnsubscribe = void Function(); + +Object _getGlobalProperty(String name) => jsu.getProperty(jsu.globalThis, name); + +Object? _getProperty(Object o, String name) => jsu.getProperty(o, name); + +void _setProperty(Object o, String name, Object? value) => jsu.setProperty(o, name, value); + +T _callConstructor(Object ctor, List args) => jsu.callConstructor(ctor, args) as T; + +T _callMethod(Object o, String name, List args) => jsu.callMethod(o, name, args) as T; + +SharedWorkerUnsubscribe connectSharedWorker( + void Function(Object? data) onMessage, +) { + try { + final Object sharedWorkerCtor = _getGlobalProperty('SharedWorker'); + final Object worker = _callConstructor(sharedWorkerCtor, ['event_streaming_worker.js']); + final Object? portMaybe = _getProperty(worker, 'port'); + if (portMaybe == null) return () {}; + final Object port = portMaybe; + _callMethod(port, 'start', const []); + + void handler(html.Event e) { + final Object? data = _getProperty(e, 'data'); + onMessage(data); + } + + _setProperty(port, 'onmessage', handler); + + return () { + try { + _setProperty(port, 'onmessage', null); + _callMethod(port, 'close', const []); + } catch (_) {} + }; + } catch (_) { + return () {}; + } +} + + diff --git a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart new file mode 100644 index 00000000..19d3b70a --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart @@ -0,0 +1,48 @@ +// Minimal streaming service facade; on Web, relies on a SharedWorker posting +// messages from the WASM layer using `mm2_net::handle_worker_stream`. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:komodo_defi_framework/src/streaming/event_streaming_models.dart'; +import 'package:komodo_defi_framework/src/streaming/event_streaming_platform_stub.dart' + if (dart.library.html) 'package:komodo_defi_framework/src/streaming/event_streaming_platform_web.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; + +typedef EventPredicate = bool Function(KdfEvent event); + +class KdfEventStreamingService { + KdfEventStreamingService(); + + final StreamController _events = StreamController.broadcast(); + + Stream get events => _events.stream; + + /// Start listening to WASM SharedWorker forwarded messages (web only). + /// No-op on non-web platforms. + void initialize() { + if (!kIsWeb) return; + _unsubscribe ??= connectSharedWorker((data) { + try { + final map = JsonMap.from(data! as Map); + final type = map.value('_type'); + final message = map.value('message'); + _events.add(KdfEvent(type: type, message: message)); + } catch (_) { + // ignore + } + }); + } + + /// Convenience filter function to get a stream of a specific event type + Stream whereType(String type) => + events.where((e) => e.type == type); + + /// Cleanup + Future dispose() async { + _unsubscribe?.call(); + await _events.close(); + } + + SharedWorkerUnsubscribe? _unsubscribe; +} diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/rpc_methods.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/rpc_methods.dart index 9319dd79..824f740e 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/rpc_methods.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/rpc_methods.dart @@ -56,6 +56,16 @@ export 'orderbook/orderbook_rpc_namespace.dart'; export 'orderbook/set_order.dart'; export 'qtum/enable_qtum.dart'; export 'qtum/qtum_rpc_namespace.dart'; +export 'streaming/streaming_common.dart'; +export 'streaming/streaming_heartbeat_enable.dart'; +export 'streaming/streaming_network_enable.dart'; +export 'streaming/streaming_balance_enable.dart'; +export 'streaming/streaming_orderbook_enable.dart'; +export 'streaming/streaming_order_status_enable.dart'; +export 'streaming/streaming_swap_status_enable.dart'; +export 'streaming/streaming_tx_history_enable.dart'; +export 'streaming/streaming_disable.dart'; +export 'streaming/streaming_rpc_namespace.dart'; export 'tendermint/enable_tendermint_token.dart'; export 'tendermint/enable_tendermint_with_assets.dart'; export 'tendermint/task_enable_tendermint_init.dart'; diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_balance_enable.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_balance_enable.dart new file mode 100644 index 00000000..3cbf4e0f --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_balance_enable.dart @@ -0,0 +1,37 @@ +import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; + +import 'streaming_common.dart'; + +/// stream::balance::enable +class StreamBalanceEnableRequest + extends BaseRequest { + StreamBalanceEnableRequest({ + required String rpcPass, + required this.coin, + this.clientId, + this.config, + }) : super( + method: 'stream::balance::enable', + rpcPass: rpcPass, + mmrpc: RpcVersion.v2_0, + ); + + final String coin; + final int? clientId; + final StreamConfig? config; + + @override + JsonMap toJson() => super.toJson().deepMerge({ + 'params': { + 'coin': coin, + if (clientId != null) 'client_id': clientId, + if (config != null) 'config': config!.toRpcParams(), + }, + }); + + @override + StreamEnableResponse parse(JsonMap json) => StreamEnableResponse.parse(json); +} + + diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_common.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_common.dart new file mode 100644 index 00000000..10d62a8b --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_common.dart @@ -0,0 +1,59 @@ +import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; + +/// Generic response for stream enable methods returning a streamer identifier +class StreamEnableResponse extends BaseResponse { + StreamEnableResponse({required super.mmrpc, required this.streamerId}); + + factory StreamEnableResponse.parse(JsonMap json) { + final result = json.value('result'); + return StreamEnableResponse( + mmrpc: json.value('mmrpc'), + streamerId: result.value('streamer_id'), + ); + } + + final String streamerId; + + @override + JsonMap toJson() => { + 'mmrpc': mmrpc, + 'result': {'streamer_id': streamerId}, + }; +} + +/// Generic response for stream::disable (typically returns { result: { result: "Success" } }) +class StreamDisableResponse extends BaseResponse { + StreamDisableResponse({required super.mmrpc, required this.result}); + + factory StreamDisableResponse.parse(JsonMap json) { + final result = json.value('result'); + return StreamDisableResponse( + mmrpc: json.value('mmrpc'), + result: result.value('result'), + ); + } + + final String result; // e.g. "Success" + + @override + JsonMap toJson() => { + 'mmrpc': mmrpc, + 'result': {'result': result}, + }; +} + +/// Optional stream configuration shared by some stream enable methods +class StreamConfig implements RpcRequestParams { + const StreamConfig({this.streamIntervalSeconds}); + + final int? streamIntervalSeconds; + + @override + JsonMap toRpcParams() => { + if (streamIntervalSeconds != null) + 'stream_interval_seconds': streamIntervalSeconds, + }; +} + + diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_disable.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_disable.dart new file mode 100644 index 00000000..76febf9b --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_disable.dart @@ -0,0 +1,32 @@ +import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; + +import 'streaming_common.dart'; + +/// stream::disable +class StreamDisableRequest + extends BaseRequest { + StreamDisableRequest({ + required String rpcPass, + required this.clientId, + required this.streamerId, + }) : super( + method: 'stream::disable', + rpcPass: rpcPass, + mmrpc: RpcVersion.v2_0, + ); + + final int clientId; + final String streamerId; + + @override + JsonMap toJson() => super.toJson().deepMerge({ + 'params': {'client_id': clientId, 'streamer_id': streamerId}, + }); + + @override + StreamDisableResponse parse(JsonMap json) => + StreamDisableResponse.parse(json); +} + + diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_heartbeat_enable.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_heartbeat_enable.dart new file mode 100644 index 00000000..c871f4b5 --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_heartbeat_enable.dart @@ -0,0 +1,37 @@ +import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; + +import 'streaming_common.dart'; + +/// stream::heartbeat::enable +class StreamHeartbeatEnableRequest + extends BaseRequest { + StreamHeartbeatEnableRequest({ + required String rpcPass, + this.clientId, + this.config, + this.alwaysSend, + }) : super( + method: 'stream::heartbeat::enable', + rpcPass: rpcPass, + mmrpc: RpcVersion.v2_0, + ); + + final int? clientId; + final StreamConfig? config; + final bool? alwaysSend; + + @override + JsonMap toJson() => super.toJson().deepMerge({ + 'params': { + if (clientId != null) 'client_id': clientId, + if (config != null) 'config': config!.toRpcParams(), + if (alwaysSend != null) 'always_send': alwaysSend, + }, + }); + + @override + StreamEnableResponse parse(JsonMap json) => StreamEnableResponse.parse(json); +} + + diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_network_enable.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_network_enable.dart new file mode 100644 index 00000000..d70df277 --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_network_enable.dart @@ -0,0 +1,37 @@ +import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; + +import 'streaming_common.dart'; + +/// stream::network::enable +class StreamNetworkEnableRequest + extends BaseRequest { + StreamNetworkEnableRequest({ + required String rpcPass, + this.clientId, + this.config, + this.alwaysSend, + }) : super( + method: 'stream::network::enable', + rpcPass: rpcPass, + mmrpc: RpcVersion.v2_0, + ); + + final int? clientId; + final StreamConfig? config; + final bool? alwaysSend; + + @override + JsonMap toJson() => super.toJson().deepMerge({ + 'params': { + if (clientId != null) 'client_id': clientId, + if (config != null) 'config': config!.toRpcParams(), + if (alwaysSend != null) 'always_send': alwaysSend, + }, + }); + + @override + StreamEnableResponse parse(JsonMap json) => StreamEnableResponse.parse(json); +} + + diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_order_status_enable.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_order_status_enable.dart new file mode 100644 index 00000000..da1dbacc --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_order_status_enable.dart @@ -0,0 +1,27 @@ +import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; + +import 'streaming_common.dart'; + +/// stream::order_status::enable +class StreamOrderStatusEnableRequest + extends BaseRequest { + StreamOrderStatusEnableRequest({required String rpcPass, this.clientId}) + : super( + method: 'stream::order_status::enable', + rpcPass: rpcPass, + mmrpc: RpcVersion.v2_0, + ); + + final int? clientId; + + @override + JsonMap toJson() => super.toJson().deepMerge({ + 'params': {if (clientId != null) 'client_id': clientId}, + }); + + @override + StreamEnableResponse parse(JsonMap json) => StreamEnableResponse.parse(json); +} + + diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_orderbook_enable.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_orderbook_enable.dart new file mode 100644 index 00000000..9f260f3c --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_orderbook_enable.dart @@ -0,0 +1,37 @@ +import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; + +import 'streaming_common.dart'; + +/// stream::orderbook::enable +class StreamOrderbookEnableRequest + extends BaseRequest { + StreamOrderbookEnableRequest({ + required String rpcPass, + required this.base, + required this.rel, + this.clientId, + }) : super( + method: 'stream::orderbook::enable', + rpcPass: rpcPass, + mmrpc: RpcVersion.v2_0, + ); + + final String base; + final String rel; + final int? clientId; + + @override + JsonMap toJson() => super.toJson().deepMerge({ + 'params': { + 'base': base, + 'rel': rel, + if (clientId != null) 'client_id': clientId, + }, + }); + + @override + StreamEnableResponse parse(JsonMap json) => StreamEnableResponse.parse(json); +} + + diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_rpc_namespace.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_rpc_namespace.dart new file mode 100644 index 00000000..ac6f6576 --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_rpc_namespace.dart @@ -0,0 +1,134 @@ +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; + +/// RPC namespace for streaming methods. +/// +/// Provides enable/disable methods for different streaming topics such as +/// heartbeat, network, balances, orderbook, order status, swap status, and +/// transaction history. +class StreamingMethodsNamespace extends BaseRpcMethodNamespace { + StreamingMethodsNamespace(super.client); + + /// Enable heartbeat stream + Future enableHeartbeat({ + int? clientId, + StreamConfig? config, + bool? alwaysSend, + String? rpcPass, + }) { + return execute( + StreamHeartbeatEnableRequest( + rpcPass: rpcPass ?? this.rpcPass ?? '', + clientId: clientId, + config: config, + alwaysSend: alwaysSend, + ), + ); + } + + /// Enable network stream + Future enableNetwork({ + int? clientId, + StreamConfig? config, + bool? alwaysSend, + String? rpcPass, + }) { + return execute( + StreamNetworkEnableRequest( + rpcPass: rpcPass ?? this.rpcPass ?? '', + clientId: clientId, + config: config, + alwaysSend: alwaysSend, + ), + ); + } + + /// Enable balance stream for coin + Future enableBalance({ + required String coin, + int? clientId, + StreamConfig? config, + String? rpcPass, + }) { + return execute( + StreamBalanceEnableRequest( + rpcPass: rpcPass ?? this.rpcPass ?? '', + coin: coin, + clientId: clientId, + config: config, + ), + ); + } + + /// Enable orderbook stream for pair + Future enableOrderbook({ + required String base, + required String rel, + int? clientId, + String? rpcPass, + }) { + return execute( + StreamOrderbookEnableRequest( + rpcPass: rpcPass ?? this.rpcPass ?? '', + base: base, + rel: rel, + clientId: clientId, + ), + ); + } + + /// Enable order status stream + Future enableOrderStatus({ + int? clientId, + String? rpcPass, + }) { + return execute( + StreamOrderStatusEnableRequest( + rpcPass: rpcPass ?? this.rpcPass ?? '', + clientId: clientId, + ), + ); + } + + /// Enable swap status stream + Future enableSwapStatus({ + int? clientId, + String? rpcPass, + }) { + return execute( + StreamSwapStatusEnableRequest( + rpcPass: rpcPass ?? this.rpcPass ?? '', + clientId: clientId, + ), + ); + } + + /// Enable transaction history stream for coin + Future enableTxHistory({ + required String coin, + int? clientId, + String? rpcPass, + }) { + return execute( + StreamTxHistoryEnableRequest( + rpcPass: rpcPass ?? this.rpcPass ?? '', + coin: coin, + clientId: clientId, + ), + ); + } + + /// Disable a previously enabled stream + Future disable({ + required int clientId, + required String streamerId, + String? rpcPass, + }) { + return execute( + StreamDisableRequest( + rpcPass: rpcPass ?? this.rpcPass ?? '', + clientId: clientId, + streamerId: streamerId, + ), + ); + } +} diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_swap_status_enable.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_swap_status_enable.dart new file mode 100644 index 00000000..84612157 --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_swap_status_enable.dart @@ -0,0 +1,27 @@ +import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; + +import 'streaming_common.dart'; + +/// stream::swap_status::enable +class StreamSwapStatusEnableRequest + extends BaseRequest { + StreamSwapStatusEnableRequest({required String rpcPass, this.clientId}) + : super( + method: 'stream::swap_status::enable', + rpcPass: rpcPass, + mmrpc: RpcVersion.v2_0, + ); + + final int? clientId; + + @override + JsonMap toJson() => super.toJson().deepMerge({ + 'params': {if (clientId != null) 'client_id': clientId}, + }); + + @override + StreamEnableResponse parse(JsonMap json) => StreamEnableResponse.parse(json); +} + + diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_tx_history_enable.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_tx_history_enable.dart new file mode 100644 index 00000000..2fbef8bc --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_tx_history_enable.dart @@ -0,0 +1,31 @@ +import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; + +import 'streaming_common.dart'; + +/// stream::tx_history::enable +class StreamTxHistoryEnableRequest + extends BaseRequest { + StreamTxHistoryEnableRequest({ + required String rpcPass, + required this.coin, + this.clientId, + }) : super( + method: 'stream::tx_history::enable', + rpcPass: rpcPass, + mmrpc: RpcVersion.v2_0, + ); + + final String coin; + final int? clientId; + + @override + JsonMap toJson() => super.toJson().deepMerge({ + 'params': {'coin': coin, if (clientId != null) 'client_id': clientId}, + }); + + @override + StreamEnableResponse parse(JsonMap json) => StreamEnableResponse.parse(json); +} + + diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods_library.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods_library.dart index c21e3437..118df22f 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods_library.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods_library.dart @@ -55,6 +55,9 @@ class KomodoDefiRpcMethods { UtilityMethods get utility => UtilityMethods(_client); FeeManagementMethodsNamespace get feeManagement => FeeManagementMethodsNamespace(_client); + + // Streaming namespaces + StreamingMethodsNamespace get streaming => StreamingMethodsNamespace(_client); } class TaskMethods extends BaseRpcMethodNamespace { @@ -64,6 +67,8 @@ class TaskMethods extends BaseRpcMethodNamespace { // execute(TaskStatusRequest(taskId: taskId, rpcPass: rpcPass)); } +// StreamingMethodsNamespace moved to streaming/streaming_rpc_namespace.dart + class WalletMethods extends BaseRpcMethodNamespace { WalletMethods(super.client); From 3ad67663288e1f404d8fe47a7d49a5494f4e75d4 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Mon, 27 Oct 2025 17:27:11 +0100 Subject: [PATCH 10/35] feat(web): package event_streaming_worker.js in framework assets and load via package path --- .../assets/web/event_streaming_worker.js | 21 +++++++++++++++++++ .../event_streaming_platform_web.dart | 5 ++++- packages/komodo_defi_framework/pubspec.yaml | 1 + 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 packages/komodo_defi_framework/assets/web/event_streaming_worker.js diff --git a/packages/komodo_defi_framework/assets/web/event_streaming_worker.js b/packages/komodo_defi_framework/assets/web/event_streaming_worker.js new file mode 100644 index 00000000..1897a52f --- /dev/null +++ b/packages/komodo_defi_framework/assets/web/event_streaming_worker.js @@ -0,0 +1,21 @@ +// SharedWorker script that forwards messages to all connected ports. +/* eslint-disable no-restricted-globals */ + +const connections = []; + +onconnect = function (e) { + const port = e.ports[0]; + connections.push(port); + port.start(); + + port.onmessage = function (msgEvent) { + try { + const data = msgEvent.data; + for (const p of connections) { + try { p.postMessage(data); } catch (_) {} + } + } catch (_) {} + }; +}; + + diff --git a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart index 1fb4c507..498c0d8f 100644 --- a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart @@ -22,7 +22,10 @@ SharedWorkerUnsubscribe connectSharedWorker( ) { try { final Object sharedWorkerCtor = _getGlobalProperty('SharedWorker'); - final Object worker = _callConstructor(sharedWorkerCtor, ['event_streaming_worker.js']); + final Object worker = _callConstructor( + sharedWorkerCtor, + ['assets/packages/komodo_defi_framework/event_streaming_worker.js'], + ); final Object? portMaybe = _getProperty(worker, 'port'); if (portMaybe == null) return () {}; final Object port = portMaybe; diff --git a/packages/komodo_defi_framework/pubspec.yaml b/packages/komodo_defi_framework/pubspec.yaml index d847d2e1..da15921f 100644 --- a/packages/komodo_defi_framework/pubspec.yaml +++ b/packages/komodo_defi_framework/pubspec.yaml @@ -66,6 +66,7 @@ flutter: - assets/config/ - assets/coin_icons/png/ - app_build/build_config.json + - assets/web/ - path: assets/transformer_invoker.txt transformers: From 256b51f801b559cffd82307dcb72a45c86e5d468 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Mon, 27 Oct 2025 17:29:47 +0100 Subject: [PATCH 11/35] fix(web): correct SharedWorker path to package asset under assets/web/ --- .../lib/src/streaming/event_streaming_platform_web.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart index 498c0d8f..c1c41576 100644 --- a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart @@ -24,7 +24,7 @@ SharedWorkerUnsubscribe connectSharedWorker( final Object sharedWorkerCtor = _getGlobalProperty('SharedWorker'); final Object worker = _callConstructor( sharedWorkerCtor, - ['assets/packages/komodo_defi_framework/event_streaming_worker.js'], + ['assets/packages/komodo_defi_framework/assets/web/event_streaming_worker.js'], ); final Object? portMaybe = _getProperty(worker, 'port'); if (portMaybe == null) return () {}; From 8688ae266c9beb9381c9b4125cd398a2ae994695 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Mon, 27 Oct 2025 18:59:20 +0100 Subject: [PATCH 12/35] refactor(streaming): improve type safety with sealed event classes - Replace string-based event types with sealed class hierarchy - Create typed event classes for all stream types (Balance, Orderbook, Network, Heartbeat, SwapStatus, OrderStatus, TxHistory, ShutdownSignal) - Use private enum for internal string mapping while exposing typed API - Make StreamEnableResponse generic to link responses to event types - Update event_streaming_service with type-safe filtering methods - Organize events into separate files using part directives - Enable exhaustive pattern matching with switch expressions Benefits: - Compile-time type safety eliminates string-based checks - Better IDE support with autocomplete and type hints - Reduced runtime errors from type mismatches - Clearer public API with explicit event types --- .../src/binance/data/binance_repository.dart | 5 ++ .../coingecko/data/coingecko_repository.dart | 5 ++ .../repository_priority_manager_test.dart | 5 ++ .../repository_selection_strategy_test.dart | 20 ++++++ .../lib/komodo_defi_framework.dart | 45 ++++++------ .../src/streaming/event_streaming_models.dart | 6 -- .../streaming/event_streaming_service.dart | 56 ++++++++++++--- .../src/streaming/events/balance_event.dart | 29 ++++++++ .../src/streaming/events/heartbeat_event.dart | 22 ++++++ .../lib/src/streaming/events/kdf_event.dart | 68 +++++++++++++++++++ .../src/streaming/events/network_event.dart | 29 ++++++++ .../streaming/events/order_status_event.dart | 29 ++++++++ .../src/streaming/events/orderbook_event.dart | 59 ++++++++++++++++ .../events/shutdown_signal_event.dart | 26 +++++++ .../streaming/events/swap_status_event.dart | 29 ++++++++ .../streaming/events/tx_history_event.dart | 33 +++++++++ .../lib/src/rpc_methods/rpc_methods.dart | 9 +-- .../streaming/streaming_balance_enable.dart | 8 ++- .../streaming/streaming_common.dart | 11 ++- .../streaming/streaming_heartbeat_enable.dart | 8 ++- .../streaming/streaming_network_enable.dart | 8 ++- .../streaming_order_status_enable.dart | 8 ++- .../streaming/streaming_orderbook_enable.dart | 8 ++- .../streaming/streaming_rpc_namespace.dart | 22 +++++- .../streaming_shutdown_signal_enable.dart | 32 +++++++++ .../streaming_swap_status_enable.dart | 8 ++- .../streaming_tx_history_enable.dart | 12 ++-- 27 files changed, 531 insertions(+), 69 deletions(-) delete mode 100644 packages/komodo_defi_framework/lib/src/streaming/event_streaming_models.dart create mode 100644 packages/komodo_defi_framework/lib/src/streaming/events/balance_event.dart create mode 100644 packages/komodo_defi_framework/lib/src/streaming/events/heartbeat_event.dart create mode 100644 packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart create mode 100644 packages/komodo_defi_framework/lib/src/streaming/events/network_event.dart create mode 100644 packages/komodo_defi_framework/lib/src/streaming/events/order_status_event.dart create mode 100644 packages/komodo_defi_framework/lib/src/streaming/events/orderbook_event.dart create mode 100644 packages/komodo_defi_framework/lib/src/streaming/events/shutdown_signal_event.dart create mode 100644 packages/komodo_defi_framework/lib/src/streaming/events/swap_status_event.dart create mode 100644 packages/komodo_defi_framework/lib/src/streaming/events/tx_history_event.dart create mode 100644 packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_shutdown_signal_enable.dart diff --git a/packages/komodo_cex_market_data/lib/src/binance/data/binance_repository.dart b/packages/komodo_cex_market_data/lib/src/binance/data/binance_repository.dart index 9dec1869..84e95c66 100644 --- a/packages/komodo_cex_market_data/lib/src/binance/data/binance_repository.dart +++ b/packages/komodo_cex_market_data/lib/src/binance/data/binance_repository.dart @@ -443,4 +443,9 @@ class BinanceRepository implements CexRepository { return false; } } + + @override + void dispose() { + // No resources to dispose in this implementation + } } diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_repository.dart b/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_repository.dart index 5c2b5639..8694f8ce 100644 --- a/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_repository.dart +++ b/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_repository.dart @@ -325,4 +325,9 @@ class CoinGeckoRepository implements CexRepository { // For paid plans with cutoff from 2013/2018, return a reasonable batch size return daysSinceCutoff > 365 ? 365 : daysSinceCutoff; } + + @override + void dispose() { + // No resources to dispose in this implementation + } } diff --git a/packages/komodo_cex_market_data/test/repository_priority_manager_test.dart b/packages/komodo_cex_market_data/test/repository_priority_manager_test.dart index 6152d8b2..f0647b0f 100644 --- a/packages/komodo_cex_market_data/test/repository_priority_manager_test.dart +++ b/packages/komodo_cex_market_data/test/repository_priority_manager_test.dart @@ -150,6 +150,11 @@ class TestUnknownRepository implements CexRepository { ) async { return false; } + + @override + void dispose() { + // No resources to dispose in mock + } } void main() { diff --git a/packages/komodo_cex_market_data/test/repository_selection_strategy_test.dart b/packages/komodo_cex_market_data/test/repository_selection_strategy_test.dart index b027ad78..0135406e 100644 --- a/packages/komodo_cex_market_data/test/repository_selection_strategy_test.dart +++ b/packages/komodo_cex_market_data/test/repository_selection_strategy_test.dart @@ -115,6 +115,11 @@ class MockSupportingRepository implements CexRepository { @override bool canHandleAsset(AssetId assetId) => true; + @override + void dispose() { + // No resources to dispose in mock + } + @override String toString() => 'MockRepository($name)'; } @@ -170,6 +175,11 @@ class MockFailingRepository implements CexRepository { @override bool canHandleAsset(AssetId assetId) => true; + @override + void dispose() { + // No resources to dispose in mock + } + @override String toString() => 'MockFailingRepository'; } @@ -420,6 +430,11 @@ class MockGeckoStyleRepository implements CexRepository { @override bool canHandleAsset(AssetId assetId) => true; + @override + void dispose() { + // No resources to dispose in mock + } + @override String toString() => 'MockGeckoStyleRepository'; } @@ -488,6 +503,11 @@ class MockPaprikaStyleRepository implements CexRepository { @override bool canHandleAsset(AssetId assetId) => true; + @override + void dispose() { + // No resources to dispose in mock + } + @override String toString() => 'MockPaprikaStyleRepository'; } diff --git a/packages/komodo_defi_framework/lib/komodo_defi_framework.dart b/packages/komodo_defi_framework/lib/komodo_defi_framework.dart index c983d068..0b1a3118 100644 --- a/packages/komodo_defi_framework/lib/komodo_defi_framework.dart +++ b/packages/komodo_defi_framework/lib/komodo_defi_framework.dart @@ -6,42 +6,21 @@ import 'package:komodo_defi_framework/src/config/kdf_logging_config.dart'; import 'package:komodo_defi_framework/src/config/kdf_startup_config.dart'; import 'package:komodo_defi_framework/src/operations/kdf_operations_factory.dart'; import 'package:komodo_defi_framework/src/operations/kdf_operations_interface.dart'; +import 'package:komodo_defi_framework/src/streaming/event_streaming_service.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:komodo_defi_framework/src/streaming/event_streaming_service.dart'; export 'package:komodo_defi_framework/src/client/kdf_api_client.dart'; export 'package:komodo_defi_framework/src/config/kdf_config.dart'; export 'package:komodo_defi_framework/src/config/kdf_startup_config.dart'; export 'package:komodo_defi_framework/src/services/seed_node_service.dart'; export 'package:komodo_defi_framework/src/streaming/event_streaming_service.dart'; -export 'package:komodo_defi_framework/src/streaming/event_streaming_models.dart'; +export 'package:komodo_defi_framework/src/streaming/events/kdf_event.dart'; export 'src/operations/kdf_operations_interface.dart'; class KomodoDefiFramework implements ApiClient { - KomodoDefiFramework._({ - required IKdfHostConfig hostConfig, - void Function(String)? externalLogger, - // required KdfApiClient? client, - }) : _hostConfig = hostConfig { - _kdfOperations = createKdfOperations( - hostConfig: hostConfig, - logCallback: _log, - ); - - if (externalLogger != null) { - _initLogStream(externalLogger); - } - } - - /// Enable debug logging for RPC calls (method names, durations, success/failure) - /// This can be controlled via app configuration - static bool enableDebugLogging = true; - - final Logger _logger = Logger('KomodoDefiFramework'); - factory KomodoDefiFramework.create({ required IKdfHostConfig hostConfig, void Function(String)? externalLogger, @@ -65,6 +44,26 @@ class KomodoDefiFramework implements ApiClient { // client: KdfApiClient(this, rpcPassword: hostConfig.rpcPassword), ).._kdfOperations = kdfOperations; } + KomodoDefiFramework._({ + required IKdfHostConfig hostConfig, + void Function(String)? externalLogger, + // required KdfApiClient? client, + }) : _hostConfig = hostConfig { + _kdfOperations = createKdfOperations( + hostConfig: hostConfig, + logCallback: _log, + ); + + if (externalLogger != null) { + _initLogStream(externalLogger); + } + } + + /// Enable debug logging for RPC calls (method names, durations, success/failure) + /// This can be controlled via app configuration + static bool enableDebugLogging = true; + + final Logger _logger = Logger('KomodoDefiFramework'); // late final ApiClient client; final IKdfHostConfig _hostConfig; diff --git a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_models.dart b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_models.dart deleted file mode 100644 index 42d5860e..00000000 --- a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_models.dart +++ /dev/null @@ -1,6 +0,0 @@ -class KdfEvent { - KdfEvent({required this.type, required this.message}); - - final String type; - final Map message; -} diff --git a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart index 19d3b70a..96411032 100644 --- a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart @@ -4,7 +4,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:komodo_defi_framework/src/streaming/event_streaming_models.dart'; +import 'package:komodo_defi_framework/src/streaming/events/kdf_event.dart'; import 'package:komodo_defi_framework/src/streaming/event_streaming_platform_stub.dart' if (dart.library.html) 'package:komodo_defi_framework/src/streaming/event_streaming_platform_web.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; @@ -25,18 +25,56 @@ class KdfEventStreamingService { _unsubscribe ??= connectSharedWorker((data) { try { final map = JsonMap.from(data! as Map); - final type = map.value('_type'); - final message = map.value('message'); - _events.add(KdfEvent(type: type, message: message)); - } catch (_) { - // ignore + // Parse to typed event using the sealed class hierarchy + final event = KdfEvent.fromJson(map); + _events.add(event); + } catch (e) { + // Log parsing errors for debugging (silently ignore for now) + if (kDebugMode) { + print('Failed to parse stream event: $e'); + } } }); } - /// Convenience filter function to get a stream of a specific event type - Stream whereType(String type) => - events.where((e) => e.type == type); + /// Generic filter for a specific event type with proper type casting + Stream whereEventType() => + events.where((e) => e is T).cast(); + + /// Get a stream of balance update events + Stream get balanceEvents => whereEventType(); + + /// Get a stream of orderbook update events + Stream get orderbookEvents => + whereEventType(); + + /// Get a stream of network connectivity events + Stream get networkEvents => whereEventType(); + + /// Get a stream of heartbeat events + Stream get heartbeatEvents => + whereEventType(); + + /// Get a stream of swap status update events + Stream get swapStatusEvents => + whereEventType(); + + /// Get a stream of order status update events + Stream get orderStatusEvents => + whereEventType(); + + /// Get a stream of transaction history events + Stream get txHistoryEvents => + whereEventType(); + + /// Get a stream of shutdown signal events. + /// + /// This stream emits events when OS signals (like SIGINT, SIGTERM) are + /// received by KDF before graceful shutdown. + /// + /// Note: This feature is not supported on Windows and doesn't run on Web. + Stream get shutdownSignals => + whereEventType(); /// Cleanup Future dispose() async { diff --git a/packages/komodo_defi_framework/lib/src/streaming/events/balance_event.dart b/packages/komodo_defi_framework/lib/src/streaming/events/balance_event.dart new file mode 100644 index 00000000..434642e1 --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/events/balance_event.dart @@ -0,0 +1,29 @@ +part of 'kdf_event.dart'; + +/// Balance update event from stream::balance::enable +class BalanceEvent extends KdfEvent { + BalanceEvent({ + required this.coin, + required this.balance, + }); + + @override + EventTypeString get typeEnum => EventTypeString.balance; + + factory BalanceEvent.fromJson(JsonMap json) { + return BalanceEvent( + coin: json.value('coin'), + balance: BalanceInfo.fromJson(json.value('balance')), + ); + } + + /// The coin ticker this balance update is for + final String coin; + + /// The updated balance information + final BalanceInfo balance; + + @override + String toString() => 'BalanceEvent(coin: $coin, balance: $balance)'; +} + diff --git a/packages/komodo_defi_framework/lib/src/streaming/events/heartbeat_event.dart b/packages/komodo_defi_framework/lib/src/streaming/events/heartbeat_event.dart new file mode 100644 index 00000000..339b235d --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/events/heartbeat_event.dart @@ -0,0 +1,22 @@ +part of 'kdf_event.dart'; + +/// Heartbeat event from stream::heartbeat::enable +class HeartbeatEvent extends KdfEvent { + HeartbeatEvent({required this.timestamp}); + + @override + EventTypeString get typeEnum => EventTypeString.heartbeat; + + factory HeartbeatEvent.fromJson(JsonMap json) { + return HeartbeatEvent( + timestamp: json.value('timestamp'), + ); + } + + /// Unix timestamp of the heartbeat + final int timestamp; + + @override + String toString() => 'HeartbeatEvent(timestamp: $timestamp)'; +} + diff --git a/packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart b/packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart new file mode 100644 index 00000000..4bb7efaa --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart @@ -0,0 +1,68 @@ +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; + +part 'balance_event.dart'; +part 'heartbeat_event.dart'; +part 'network_event.dart'; +part 'order_status_event.dart'; +part 'orderbook_event.dart'; +part 'shutdown_signal_event.dart'; +part 'swap_status_event.dart'; +part 'tx_history_event.dart'; + +/// Private enum for internal event type string mapping +enum EventTypeString { + balance('BALANCE'), + orderbook('ORDERBOOK'), + network('NETWORK'), + heartbeat('HEARTBEAT'), + swapStatus('SWAP_STATUS'), + orderStatus('ORDER_STATUS'), + txHistory('TX_HISTORY'), + shutdownSignal('SHUTDOWN_SIGNAL'); + + const EventTypeString(this.value); + final String value; +} + +/// Base class for all KDF stream events. +/// +/// This is a sealed class, which means you can exhaustively pattern match +/// on all possible event types using switch expressions. +/// +/// Example: +/// ```dart +/// final event = KdfEvent.fromJson(json); +/// switch (event) { +/// case BalanceEvent(:final coin, :final balance): +/// print('Balance for $coin: $balance'); +/// case OrderbookEvent(:final base, :final rel): +/// print('Orderbook update for $base/$rel'); +/// // ... handle other event types +/// } +/// ``` +sealed class KdfEvent { + const KdfEvent(); + + /// Parse a KdfEvent from raw JSON data + static KdfEvent fromJson(JsonMap json) { + final typeString = json.value('_type'); + final message = json.value('message'); + + return switch (typeString) { + 'BALANCE' => BalanceEvent.fromJson(message), + 'ORDERBOOK' => OrderbookEvent.fromJson(message), + 'NETWORK' => NetworkEvent.fromJson(message), + 'HEARTBEAT' => HeartbeatEvent.fromJson(message), + 'SWAP_STATUS' => SwapStatusEvent.fromJson(message), + 'ORDER_STATUS' => OrderStatusEvent.fromJson(message), + 'TX_HISTORY' => TxHistoryEvent.fromJson(message), + 'SHUTDOWN_SIGNAL' => ShutdownSignalEvent.fromJson(message), + _ => throw ArgumentError('Unknown event type: $typeString'), + }; + } + + /// Internal method to get the event type enum for linking with RPC responses + EventTypeString get typeEnum; +} + diff --git a/packages/komodo_defi_framework/lib/src/streaming/events/network_event.dart b/packages/komodo_defi_framework/lib/src/streaming/events/network_event.dart new file mode 100644 index 00000000..d7f0378c --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/events/network_event.dart @@ -0,0 +1,29 @@ +part of 'kdf_event.dart'; + +/// Network connectivity event from stream::network::enable +class NetworkEvent extends KdfEvent { + NetworkEvent({ + required this.netid, + required this.peers, + }); + + @override + EventTypeString get typeEnum => EventTypeString.network; + + factory NetworkEvent.fromJson(JsonMap json) { + return NetworkEvent( + netid: json.value('netid'), + peers: json.value('peers'), + ); + } + + /// Network ID + final int netid; + + /// Number of connected peers + final int peers; + + @override + String toString() => 'NetworkEvent(netid: $netid, peers: $peers)'; +} + diff --git a/packages/komodo_defi_framework/lib/src/streaming/events/order_status_event.dart b/packages/komodo_defi_framework/lib/src/streaming/events/order_status_event.dart new file mode 100644 index 00000000..a62a63ce --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/events/order_status_event.dart @@ -0,0 +1,29 @@ +part of 'kdf_event.dart'; + +/// Order status update event from stream::order_status::enable +class OrderStatusEvent extends KdfEvent { + OrderStatusEvent({ + required this.uuid, + required this.orderInfo, + }); + + @override + EventTypeString get typeEnum => EventTypeString.orderStatus; + + factory OrderStatusEvent.fromJson(JsonMap json) { + return OrderStatusEvent( + uuid: json.value('uuid'), + orderInfo: MyOrderInfo.fromJson(json.value('order')), + ); + } + + /// The UUID of the order + final String uuid; + + /// Detailed order information + final MyOrderInfo orderInfo; + + @override + String toString() => 'OrderStatusEvent(uuid: $uuid)'; +} + diff --git a/packages/komodo_defi_framework/lib/src/streaming/events/orderbook_event.dart b/packages/komodo_defi_framework/lib/src/streaming/events/orderbook_event.dart new file mode 100644 index 00000000..1ca5beee --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/events/orderbook_event.dart @@ -0,0 +1,59 @@ +part of 'kdf_event.dart'; + +/// Orderbook update event from stream::orderbook::enable +class OrderbookEvent extends KdfEvent { + OrderbookEvent({ + required this.base, + required this.rel, + required this.asks, + required this.bids, + }); + + @override + EventTypeString get typeEnum => EventTypeString.orderbook; + + factory OrderbookEvent.fromJson(JsonMap json) { + final asks = (json.value>('asks')) + .map((e) => _parseOrderbookEntry(e as JsonMap)) + .toList(); + final bids = (json.value>('bids')) + .map((e) => _parseOrderbookEntry(e as JsonMap)) + .toList(); + + return OrderbookEvent( + base: json.value('base'), + rel: json.value('rel'), + asks: asks, + bids: bids, + ); + } + + static Map _parseOrderbookEntry(JsonMap json) { + return { + 'price': json.value('price'), + 'max_volume': json.value('max_volume'), + if (json.containsKey('min_volume')) + 'min_volume': json.value('min_volume'), + if (json.containsKey('uuid')) 'uuid': json.value('uuid'), + if (json.containsKey('pubkey')) 'pubkey': json.value('pubkey'), + if (json.containsKey('age')) 'age': json.value('age'), + }; + } + + /// Base coin ticker + final String base; + + /// Rel/quote coin ticker + final String rel; + + /// List of ask (sell) orders + final List> asks; + + /// List of bid (buy) orders + final List> bids; + + @override + String toString() => + 'OrderbookEvent(base: $base, rel: $rel, asks: ${asks.length}, bids: ${bids.length})'; +} + diff --git a/packages/komodo_defi_framework/lib/src/streaming/events/shutdown_signal_event.dart b/packages/komodo_defi_framework/lib/src/streaming/events/shutdown_signal_event.dart new file mode 100644 index 00000000..3263cb10 --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/events/shutdown_signal_event.dart @@ -0,0 +1,26 @@ +part of 'kdf_event.dart'; + +/// Shutdown signal event broadcasted when OS signals (like SIGINT, SIGTERM) +/// are received by KDF before graceful shutdown. +/// +/// Note: This feature is not supported on Windows and doesn't run on Web. +class ShutdownSignalEvent extends KdfEvent { + ShutdownSignalEvent({required this.signalName}); + + @override + EventTypeString get typeEnum => EventTypeString.shutdownSignal; + + factory ShutdownSignalEvent.fromJson(JsonMap json) { + return ShutdownSignalEvent( + signalName: json.value('message'), + ); + } + + /// The name of the OS signal received (e.g., "SIGINT", "SIGTERM") + /// or "UNKNOWN($id)" for signals that cannot be gracefully handled. + final String signalName; + + @override + String toString() => 'ShutdownSignalEvent(signalName: $signalName)'; +} + diff --git a/packages/komodo_defi_framework/lib/src/streaming/events/swap_status_event.dart b/packages/komodo_defi_framework/lib/src/streaming/events/swap_status_event.dart new file mode 100644 index 00000000..03c51af5 --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/events/swap_status_event.dart @@ -0,0 +1,29 @@ +part of 'kdf_event.dart'; + +/// Swap status update event from stream::swap_status::enable +class SwapStatusEvent extends KdfEvent { + SwapStatusEvent({ + required this.uuid, + required this.swapInfo, + }); + + @override + EventTypeString get typeEnum => EventTypeString.swapStatus; + + factory SwapStatusEvent.fromJson(JsonMap json) { + return SwapStatusEvent( + uuid: json.value('uuid'), + swapInfo: SwapInfo.fromJson(json.value('data')), + ); + } + + /// The UUID of the swap + final String uuid; + + /// Detailed swap information + final SwapInfo swapInfo; + + @override + String toString() => 'SwapStatusEvent(uuid: $uuid)'; +} + diff --git a/packages/komodo_defi_framework/lib/src/streaming/events/tx_history_event.dart b/packages/komodo_defi_framework/lib/src/streaming/events/tx_history_event.dart new file mode 100644 index 00000000..826bd0c1 --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/events/tx_history_event.dart @@ -0,0 +1,33 @@ +part of 'kdf_event.dart'; + +/// Transaction history event from stream::tx_history::enable +class TxHistoryEvent extends KdfEvent { + TxHistoryEvent({ + required this.coin, + required this.transactions, + }); + + @override + EventTypeString get typeEnum => EventTypeString.txHistory; + + factory TxHistoryEvent.fromJson(JsonMap json) { + final txList = json.value>('transactions'); + return TxHistoryEvent( + coin: json.value('coin'), + transactions: txList + .map((tx) => TransactionInfo.fromJson(tx as JsonMap)) + .toList(), + ); + } + + /// The coin ticker this transaction history is for + final String coin; + + /// List of transaction information + final List transactions; + + @override + String toString() => + 'TxHistoryEvent(coin: $coin, transactions: ${transactions.length})'; +} + diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/rpc_methods.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/rpc_methods.dart index 824f740e..a207e444 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/rpc_methods.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/rpc_methods.dart @@ -56,16 +56,17 @@ export 'orderbook/orderbook_rpc_namespace.dart'; export 'orderbook/set_order.dart'; export 'qtum/enable_qtum.dart'; export 'qtum/qtum_rpc_namespace.dart'; +export 'streaming/streaming_balance_enable.dart'; export 'streaming/streaming_common.dart'; +export 'streaming/streaming_disable.dart'; export 'streaming/streaming_heartbeat_enable.dart'; export 'streaming/streaming_network_enable.dart'; -export 'streaming/streaming_balance_enable.dart'; -export 'streaming/streaming_orderbook_enable.dart'; export 'streaming/streaming_order_status_enable.dart'; +export 'streaming/streaming_orderbook_enable.dart'; +export 'streaming/streaming_rpc_namespace.dart'; +export 'streaming/streaming_shutdown_signal_enable.dart'; export 'streaming/streaming_swap_status_enable.dart'; export 'streaming/streaming_tx_history_enable.dart'; -export 'streaming/streaming_disable.dart'; -export 'streaming/streaming_rpc_namespace.dart'; export 'tendermint/enable_tendermint_token.dart'; export 'tendermint/enable_tendermint_with_assets.dart'; export 'tendermint/task_enable_tendermint_init.dart'; diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_balance_enable.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_balance_enable.dart index 3cbf4e0f..61816549 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_balance_enable.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_balance_enable.dart @@ -1,11 +1,12 @@ +import 'package:komodo_defi_framework/komodo_defi_framework.dart'; import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'streaming_common.dart'; /// stream::balance::enable -class StreamBalanceEnableRequest - extends BaseRequest { +class StreamBalanceEnableRequest extends BaseRequest< + StreamEnableResponse, GeneralErrorResponse> { StreamBalanceEnableRequest({ required String rpcPass, required this.coin, @@ -31,7 +32,8 @@ class StreamBalanceEnableRequest }); @override - StreamEnableResponse parse(JsonMap json) => StreamEnableResponse.parse(json); + StreamEnableResponse parse(JsonMap json) => + StreamEnableResponse.parse(json); } diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_common.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_common.dart index 10d62a8b..4c6c7072 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_common.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_common.dart @@ -1,18 +1,23 @@ +import 'package:komodo_defi_framework/komodo_defi_framework.dart'; import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; /// Generic response for stream enable methods returning a streamer identifier -class StreamEnableResponse extends BaseResponse { - StreamEnableResponse({required super.mmrpc, required this.streamerId}); +class StreamEnableResponse extends BaseResponse { + StreamEnableResponse({ + required super.mmrpc, + required this.streamerId, + }); factory StreamEnableResponse.parse(JsonMap json) { final result = json.value('result'); - return StreamEnableResponse( + return StreamEnableResponse( mmrpc: json.value('mmrpc'), streamerId: result.value('streamer_id'), ); } + /// The unique identifier for this stream final String streamerId; @override diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_heartbeat_enable.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_heartbeat_enable.dart index c871f4b5..c5f63247 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_heartbeat_enable.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_heartbeat_enable.dart @@ -1,11 +1,12 @@ +import 'package:komodo_defi_framework/komodo_defi_framework.dart'; import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'streaming_common.dart'; /// stream::heartbeat::enable -class StreamHeartbeatEnableRequest - extends BaseRequest { +class StreamHeartbeatEnableRequest extends BaseRequest< + StreamEnableResponse, GeneralErrorResponse> { StreamHeartbeatEnableRequest({ required String rpcPass, this.clientId, @@ -31,7 +32,8 @@ class StreamHeartbeatEnableRequest }); @override - StreamEnableResponse parse(JsonMap json) => StreamEnableResponse.parse(json); + StreamEnableResponse parse(JsonMap json) => + StreamEnableResponse.parse(json); } diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_network_enable.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_network_enable.dart index d70df277..442f1d37 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_network_enable.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_network_enable.dart @@ -1,11 +1,12 @@ +import 'package:komodo_defi_framework/komodo_defi_framework.dart'; import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'streaming_common.dart'; /// stream::network::enable -class StreamNetworkEnableRequest - extends BaseRequest { +class StreamNetworkEnableRequest extends BaseRequest< + StreamEnableResponse, GeneralErrorResponse> { StreamNetworkEnableRequest({ required String rpcPass, this.clientId, @@ -31,7 +32,8 @@ class StreamNetworkEnableRequest }); @override - StreamEnableResponse parse(JsonMap json) => StreamEnableResponse.parse(json); + StreamEnableResponse parse(JsonMap json) => + StreamEnableResponse.parse(json); } diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_order_status_enable.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_order_status_enable.dart index da1dbacc..7add780e 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_order_status_enable.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_order_status_enable.dart @@ -1,11 +1,12 @@ +import 'package:komodo_defi_framework/komodo_defi_framework.dart'; import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'streaming_common.dart'; /// stream::order_status::enable -class StreamOrderStatusEnableRequest - extends BaseRequest { +class StreamOrderStatusEnableRequest extends BaseRequest< + StreamEnableResponse, GeneralErrorResponse> { StreamOrderStatusEnableRequest({required String rpcPass, this.clientId}) : super( method: 'stream::order_status::enable', @@ -21,7 +22,8 @@ class StreamOrderStatusEnableRequest }); @override - StreamEnableResponse parse(JsonMap json) => StreamEnableResponse.parse(json); + StreamEnableResponse parse(JsonMap json) => + StreamEnableResponse.parse(json); } diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_orderbook_enable.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_orderbook_enable.dart index 9f260f3c..159a0f1e 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_orderbook_enable.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_orderbook_enable.dart @@ -1,11 +1,12 @@ +import 'package:komodo_defi_framework/komodo_defi_framework.dart'; import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'streaming_common.dart'; /// stream::orderbook::enable -class StreamOrderbookEnableRequest - extends BaseRequest { +class StreamOrderbookEnableRequest extends BaseRequest< + StreamEnableResponse, GeneralErrorResponse> { StreamOrderbookEnableRequest({ required String rpcPass, required this.base, @@ -31,7 +32,8 @@ class StreamOrderbookEnableRequest }); @override - StreamEnableResponse parse(JsonMap json) => StreamEnableResponse.parse(json); + StreamEnableResponse parse(JsonMap json) => + StreamEnableResponse.parse(json); } diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_rpc_namespace.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_rpc_namespace.dart index ac6f6576..725459a2 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_rpc_namespace.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_rpc_namespace.dart @@ -3,8 +3,8 @@ import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; /// RPC namespace for streaming methods. /// /// Provides enable/disable methods for different streaming topics such as -/// heartbeat, network, balances, orderbook, order status, swap status, and -/// transaction history. +/// heartbeat, network, balances, orderbook, order status, swap status, +/// transaction history, and shutdown signals. class StreamingMethodsNamespace extends BaseRpcMethodNamespace { StreamingMethodsNamespace(super.client); @@ -117,6 +117,24 @@ class StreamingMethodsNamespace extends BaseRpcMethodNamespace { ); } + /// Enable shutdown signal stream + /// + /// Enables a stream that broadcasts OS shutdown signals + /// (like SIGINT, SIGTERM) before the KDF gracefully shuts down. + /// + /// Note: This feature is not supported on Windows and doesn't run on Web. + Future enableShutdownSignal({ + int? clientId, + String? rpcPass, + }) { + return execute( + StreamShutdownSignalEnableRequest( + rpcPass: rpcPass ?? this.rpcPass ?? '', + clientId: clientId, + ), + ); + } + /// Disable a previously enabled stream Future disable({ required int clientId, diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_shutdown_signal_enable.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_shutdown_signal_enable.dart new file mode 100644 index 00000000..ed631fc8 --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_shutdown_signal_enable.dart @@ -0,0 +1,32 @@ +import 'package:komodo_defi_framework/komodo_defi_framework.dart'; +import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; + +import 'streaming_common.dart'; + +/// stream::shutdown_signal::enable +/// +/// Enables a stream that broadcasts OS shutdown signals (like SIGINT, SIGTERM) +/// before the KDF gracefully shuts down. +/// +/// Note: This feature is not supported on Windows and doesn't run on Web. +class StreamShutdownSignalEnableRequest extends BaseRequest< + StreamEnableResponse, GeneralErrorResponse> { + StreamShutdownSignalEnableRequest({required String rpcPass, this.clientId}) + : super( + method: 'stream::shutdown_signal::enable', + rpcPass: rpcPass, + mmrpc: RpcVersion.v2_0, + ); + + final int? clientId; + + @override + JsonMap toJson() => super.toJson().deepMerge({ + 'params': {if (clientId != null) 'client_id': clientId}, + }); + + @override + StreamEnableResponse parse(JsonMap json) => + StreamEnableResponse.parse(json); +} diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_swap_status_enable.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_swap_status_enable.dart index 84612157..7353840f 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_swap_status_enable.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_swap_status_enable.dart @@ -1,11 +1,12 @@ +import 'package:komodo_defi_framework/komodo_defi_framework.dart'; import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'streaming_common.dart'; /// stream::swap_status::enable -class StreamSwapStatusEnableRequest - extends BaseRequest { +class StreamSwapStatusEnableRequest extends BaseRequest< + StreamEnableResponse, GeneralErrorResponse> { StreamSwapStatusEnableRequest({required String rpcPass, this.clientId}) : super( method: 'stream::swap_status::enable', @@ -21,7 +22,8 @@ class StreamSwapStatusEnableRequest }); @override - StreamEnableResponse parse(JsonMap json) => StreamEnableResponse.parse(json); + StreamEnableResponse parse(JsonMap json) => + StreamEnableResponse.parse(json); } diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_tx_history_enable.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_tx_history_enable.dart index 2fbef8bc..47e1801f 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_tx_history_enable.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_tx_history_enable.dart @@ -1,3 +1,4 @@ +import 'package:komodo_defi_framework/komodo_defi_framework.dart'; import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; @@ -5,7 +6,11 @@ import 'streaming_common.dart'; /// stream::tx_history::enable class StreamTxHistoryEnableRequest - extends BaseRequest { + extends + BaseRequest< + StreamEnableResponse, + GeneralErrorResponse + > { StreamTxHistoryEnableRequest({ required String rpcPass, required this.coin, @@ -25,7 +30,6 @@ class StreamTxHistoryEnableRequest }); @override - StreamEnableResponse parse(JsonMap json) => StreamEnableResponse.parse(json); + StreamEnableResponse parse(JsonMap json) => + StreamEnableResponse.parse(json); } - - From 0ef0f44c94ef1df4cfe16781fccf2c4079e42774 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Mon, 27 Oct 2025 19:19:21 +0100 Subject: [PATCH 13/35] feat(sdk): add internal event streaming manager with lifecycle management - Create EventStreamingManager in komodo_defi_sdk package - Implement automatic stream lifecycle handling (enable/disable) - Add reference counting for shared stream subscriptions - Support all event types: balance, orderbook, tx history, swap status, order status, network, heartbeat, and shutdown signals - Reduce boilerplate with generic _subscribeToStream method using template method pattern - Register manager in DI container for internal use by domain managers - Manager is not publicly exposed, intended for use by domain-specific managers to provide real-time updates --- .../komodo_defi_sdk/lib/src/bootstrap.dart | 10 + .../lib/src/komodo_defi_sdk.dart | 5 +- .../streaming/event_streaming_manager.dart | 343 ++++++++++++++++++ 3 files changed, 356 insertions(+), 2 deletions(-) create mode 100644 packages/komodo_defi_sdk/lib/src/streaming/event_streaming_manager.dart diff --git a/packages/komodo_defi_sdk/lib/src/bootstrap.dart b/packages/komodo_defi_sdk/lib/src/bootstrap.dart index fff16e45..794656c2 100644 --- a/packages/komodo_defi_sdk/lib/src/bootstrap.dart +++ b/packages/komodo_defi_sdk/lib/src/bootstrap.dart @@ -17,6 +17,7 @@ import 'package:komodo_defi_sdk/src/market_data/market_data_manager.dart' import 'package:komodo_defi_sdk/src/message_signing/message_signing_manager.dart'; import 'package:komodo_defi_sdk/src/pubkeys/pubkey_manager.dart'; import 'package:komodo_defi_sdk/src/storage/secure_rpc_password_mixin.dart'; +import 'package:komodo_defi_sdk/src/streaming/event_streaming_manager.dart'; import 'package:komodo_defi_sdk/src/withdrawals/legacy_withdrawal_manager.dart'; import 'package:komodo_defi_sdk/src/withdrawals/withdrawal_manager.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; @@ -62,6 +63,15 @@ Future bootstrap({ return framework.client; }, dependsOn: [KomodoDefiFramework]); + // Event streaming manager (internal use by managers for real-time updates) + container.registerSingletonAsync(() async { + final framework = await container.getAsync(); + return EventStreamingManager( + client: framework.client, + eventService: framework.streaming, + ); + }, dependsOn: [KomodoDefiFramework]); + // Auth and storage dependencies container.registerSingletonAsync(() async { final framework = await container.getAsync(); diff --git a/packages/komodo_defi_sdk/lib/src/komodo_defi_sdk.dart b/packages/komodo_defi_sdk/lib/src/komodo_defi_sdk.dart index da18ce4c..2b4fd8a2 100644 --- a/packages/komodo_defi_sdk/lib/src/komodo_defi_sdk.dart +++ b/packages/komodo_defi_sdk/lib/src/komodo_defi_sdk.dart @@ -1,5 +1,5 @@ -import 'dart:developer'; import 'dart:async'; +import 'dart:developer'; import 'package:get_it/get_it.dart'; import 'package:komodo_defi_framework/komodo_defi_framework.dart'; @@ -12,10 +12,10 @@ import 'package:komodo_defi_sdk/src/market_data/market_data_manager.dart'; import 'package:komodo_defi_sdk/src/message_signing/message_signing_manager.dart'; import 'package:komodo_defi_sdk/src/pubkeys/pubkey_manager.dart'; import 'package:komodo_defi_sdk/src/storage/secure_rpc_password_mixin.dart'; +import 'package:komodo_defi_sdk/src/streaming/event_streaming_manager.dart'; import 'package:komodo_defi_sdk/src/withdrawals/withdrawal_manager.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; -import 'package:komodo_defi_sdk/src/activation_config/activation_config_service.dart'; /// A high-level SDK that provides a simple way to build cross-platform applications /// using the Komodo DeFi Framework, with a primary focus on wallet functionality. @@ -492,6 +492,7 @@ class KomodoDefiSdk with SecureRpcPasswordMixin { _initializationFuture = null; await Future.wait([ + _disposeIfRegistered((m) => m.dispose()), _disposeIfRegistered((m) => m.dispose()), _disposeIfRegistered((m) => m.dispose()), _disposeIfRegistered((m) => m.dispose()), diff --git a/packages/komodo_defi_sdk/lib/src/streaming/event_streaming_manager.dart b/packages/komodo_defi_sdk/lib/src/streaming/event_streaming_manager.dart new file mode 100644 index 00000000..3da926a1 --- /dev/null +++ b/packages/komodo_defi_sdk/lib/src/streaming/event_streaming_manager.dart @@ -0,0 +1,343 @@ +import 'dart:async'; + +import 'package:flutter/foundation.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_types/komodo_defi_types.dart'; + +/// Internal manager for handling event stream lifecycle. +/// +/// This class abstracts away the complexity of managing event streams, +/// including: +/// - Enabling and disabling streams +/// - Tracking active subscriptions +/// - Managing streamer IDs and client IDs +/// - Automatic cleanup +/// - Reference counting for shared streams +/// +/// This class is not publicly exposed by the SDK. +class EventStreamingManager { + /// Creates a new event streaming manager. + /// + /// Requires an [ApiClient] for making RPC calls and a [KdfEventStreamingService] + /// for receiving events. + EventStreamingManager({ + required ApiClient client, + required KdfEventStreamingService eventService, + }) : _rpcMethods = KomodoDefiRpcMethods(client), + _eventService = eventService; + + final KomodoDefiRpcMethods _rpcMethods; + final KdfEventStreamingService _eventService; + + // Client ID used for all streaming operations + // In a production app, this could be configurable or derived from app state + static const int _defaultClientId = 1; + + // Active stream subscriptions keyed by a unique identifier + final Map _activeStreams = {}; + + // Reference counters for shared streams (e.g., heartbeat, network) + final Map _streamRefCounts = {}; + + /// Generic method to handle stream subscription with automatic lifecycle + /// management. This reduces boilerplate by extracting common subscription logic. + Future> _subscribeToStream({ + required String key, + required Future Function() enableStream, + required Stream eventStream, + }) async { + // Check if stream is already active + final existing = _activeStreams[key]; + if (existing != null && !existing.isCancelled) { + _incrementRefCount(key); + return _createTypedSubscription(key, eventStream); + } + + // Enable new stream + final response = await enableStream(); + + final streamerId = response.streamerId; + _activeStreams[key] = _StreamSubscription( + streamerId: streamerId, + clientId: _defaultClientId, + ); + _incrementRefCount(key); + + return _createTypedSubscription(key, eventStream); + } + + /// Enable balance stream for a specific coin. + /// + /// Returns a [StreamSubscription] that can be used to listen to balance + /// events and cancel the subscription. + Future> subscribeToBalance({ + required String coin, + StreamConfig? config, + }) => _subscribeToStream( + key: 'balance:$coin', + enableStream: () => _rpcMethods.streaming.enableBalance( + coin: coin, + clientId: _defaultClientId, + config: config, + ), + eventStream: _eventService.balanceEvents.where((e) => e.coin == coin), + ); + + /// Enable orderbook stream for a trading pair. + /// + /// Returns a [StreamSubscription] that can be used to listen to orderbook + /// events and cancel the subscription. + Future> subscribeToOrderbook({ + required String base, + required String rel, + }) => _subscribeToStream( + key: 'orderbook:$base:$rel', + enableStream: () => _rpcMethods.streaming.enableOrderbook( + base: base, + rel: rel, + clientId: _defaultClientId, + ), + eventStream: _eventService.orderbookEvents.where( + (e) => e.base == base && e.rel == rel, + ), + ); + + /// Enable transaction history stream for a specific coin. + /// + /// Returns a [StreamSubscription] that can be used to listen to transaction + /// history events and cancel the subscription. + Future> subscribeToTxHistory({ + required String coin, + }) => _subscribeToStream( + key: 'tx_history:$coin', + enableStream: () => _rpcMethods.streaming.enableTxHistory( + coin: coin, + clientId: _defaultClientId, + ), + eventStream: _eventService.txHistoryEvents.where((e) => e.coin == coin), + ); + + /// Enable swap status stream. + /// + /// Returns a [StreamSubscription] that can be used to listen to swap status + /// events and cancel the subscription. + Future> subscribeToSwapStatus() => + _subscribeToStream( + key: 'swap_status', + enableStream: () => + _rpcMethods.streaming.enableSwapStatus(clientId: _defaultClientId), + eventStream: _eventService.swapStatusEvents, + ); + + /// Enable order status stream. + /// + /// Returns a [StreamSubscription] that can be used to listen to order status + /// events and cancel the subscription. + Future> subscribeToOrderStatus() => + _subscribeToStream( + key: 'order_status', + enableStream: () => + _rpcMethods.streaming.enableOrderStatus(clientId: _defaultClientId), + eventStream: _eventService.orderStatusEvents, + ); + + /// Enable network status stream. + /// + /// Returns a [StreamSubscription] that can be used to listen to network + /// events and cancel the subscription. + Future> subscribeToNetwork({ + StreamConfig? config, + bool? alwaysSend, + }) => _subscribeToStream( + key: 'network', + enableStream: () => _rpcMethods.streaming.enableNetwork( + clientId: _defaultClientId, + config: config, + alwaysSend: alwaysSend, + ), + eventStream: _eventService.networkEvents, + ); + + /// Enable heartbeat stream. + /// + /// Returns a [StreamSubscription] that can be used to listen to heartbeat + /// events and cancel the subscription. + Future> subscribeToHeartbeat({ + StreamConfig? config, + bool? alwaysSend, + }) => _subscribeToStream( + key: 'heartbeat', + enableStream: () => _rpcMethods.streaming.enableHeartbeat( + clientId: _defaultClientId, + config: config, + alwaysSend: alwaysSend, + ), + eventStream: _eventService.heartbeatEvents, + ); + + /// Enable shutdown signal stream. + /// + /// Note: This feature is not supported on Windows and doesn't run on Web. + /// + /// Returns a [StreamSubscription] that can be used to listen to shutdown + /// signal events and cancel the subscription. + Future> + subscribeToShutdownSignals() => _subscribeToStream( + key: 'shutdown_signal', + enableStream: () => + _rpcMethods.streaming.enableShutdownSignal(clientId: _defaultClientId), + eventStream: _eventService.shutdownSignals, + ); + + /// Create a typed subscription that handles reference counting and cleanup. + StreamSubscription _createTypedSubscription( + String key, + Stream stream, + ) { + // Create a broadcast stream controller to wrap the original stream + // This allows us to properly handle cleanup + final controller = StreamController.broadcast(); + + final innerSubscription = stream.listen( + controller.add, + onError: controller.addError, + onDone: controller.close, + ); + + // Wrap the subscription to handle cleanup on cancel + return _ManagedStreamSubscription( + controller.stream.listen(null), + onCancel: () async { + await innerSubscription.cancel(); + await controller.close(); + await _handleStreamCancelled(key); + }, + ); + } + + /// Increment reference count for a stream. + void _incrementRefCount(String key) { + _streamRefCounts[key] = (_streamRefCounts[key] ?? 0) + 1; + } + + /// Handle stream cancellation with reference counting. + Future _handleStreamCancelled(String key) async { + final refCount = (_streamRefCounts[key] ?? 1) - 1; + _streamRefCounts[key] = refCount; + + // Only disable the stream if no more references exist + if (refCount <= 0) { + _streamRefCounts.remove(key); + await _disableStream(key); + } + } + + /// Disable a stream by key. + Future _disableStream(String key) async { + final subscription = _activeStreams[key]; + if (subscription == null || subscription.isCancelled) { + return; + } + + try { + await _rpcMethods.streaming.disable( + clientId: subscription.clientId, + streamerId: subscription.streamerId, + ); + + subscription.markCancelled(); + _activeStreams.remove(key); + } on Exception catch (e) { + if (kDebugMode) { + print('Failed to disable stream $key: $e'); + } + // Still mark as cancelled and remove from active streams + subscription.markCancelled(); + _activeStreams.remove(key); + } + } + + /// Get a list of all active stream keys. + List get activeStreamKeys => _activeStreams.keys.toList(); + + /// Check if a specific stream is active. + bool isStreamActive(String key) { + final subscription = _activeStreams[key]; + return subscription != null && !subscription.isCancelled; + } + + /// Disable all active streams and clean up resources. + Future dispose() async { + final keys = _activeStreams.keys.toList(); + + // Disable all streams in parallel + await Future.wait( + keys.map(_disableStream), + // Continue even if some fail + ); + + _activeStreams.clear(); + _streamRefCounts.clear(); + } +} + +/// Internal subscription metadata. +class _StreamSubscription { + _StreamSubscription({required this.streamerId, required this.clientId}); + + final String streamerId; + final int clientId; + bool isCancelled = false; + + void markCancelled() { + isCancelled = true; + } +} + +/// Wrapper around StreamSubscription that handles cleanup. +class _ManagedStreamSubscription implements StreamSubscription { + _ManagedStreamSubscription(this._inner, {required this.onCancel}); + + final StreamSubscription _inner; + final Future Function() onCancel; + + @override + Future cancel() async { + await _inner.cancel(); + await onCancel(); + } + + @override + void onData(void Function(T data)? handleData) { + _inner.onData(handleData); + } + + @override + void onError(Function? handleError) { + _inner.onError(handleError); + } + + @override + void onDone(void Function()? handleDone) { + _inner.onDone(handleDone); + } + + @override + Future asFuture([E? futureValue]) { + return _inner.asFuture(futureValue); + } + + @override + bool get isPaused => _inner.isPaused; + + @override + void pause([Future? resumeSignal]) { + _inner.pause(resumeSignal); + } + + @override + void resume() { + _inner.resume(); + } +} From dccd8139b99668bdf14049f581a82add7706fb3f Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Mon, 27 Oct 2025 20:02:28 +0100 Subject: [PATCH 14/35] perf: eliminate RPC polling by using event streaming Replace periodic polling with real-time event streaming in BalanceManager and TransactionHistoryManager to reduce RPC spam and improve efficiency. Changes: - BalanceManager: Replace 1-minute polling interval with balance event streaming - TransactionHistoryManager: Replace balance-driven polling with TX history event streaming - Bootstrap: Inject EventStreamingManager into both managers - Remove polling configuration (intervals, retry counters, debug flags) - Fix shouldEnableTransactionHistory to always return true for streaming support Benefits: - Eliminates periodic RPC calls every 60 seconds - Real-time updates instead of up to 1-minute delays - Better resource utilization (updates only when data changes) - Automatic reconnection and error handling via EventStreamingManager Refs: #3238 --- .../lib/src/balances/balance_manager.dart | 121 +++++------- .../komodo_defi_sdk/lib/src/bootstrap.dart | 12 +- ...therscan_transaction_history_strategy.dart | 13 +- .../transaction_history_manager.dart | 177 ++++++++---------- 4 files changed, 138 insertions(+), 185 deletions(-) diff --git a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart index cd971a3b..96bb232e 100644 --- a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart @@ -5,6 +5,7 @@ import 'package:komodo_defi_sdk/src/activation/activation_manager.dart'; import 'package:komodo_defi_sdk/src/activation/shared_activation_coordinator.dart'; import 'package:komodo_defi_sdk/src/assets/asset_lookup.dart'; import 'package:komodo_defi_sdk/src/pubkeys/pubkey_manager.dart'; +import 'package:komodo_defi_sdk/src/streaming/event_streaming_manager.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:logging/logging.dart'; @@ -60,10 +61,12 @@ class BalanceManager implements IBalanceManager { required KomodoDefiLocalAuth auth, required PubkeyManager? pubkeyManager, required SharedActivationCoordinator? activationCoordinator, + required EventStreamingManager eventStreamingManager, }) : _activationCoordinator = activationCoordinator, _pubkeyManager = pubkeyManager, _assetLookup = assetLookup, - _auth = auth { + _auth = auth, + _eventStreamingManager = eventStreamingManager { // Listen for auth state changes _authSubscription = _auth.authStateChanges.listen(_handleAuthStateChanged); _logger.fine('Initialized'); @@ -74,11 +77,8 @@ class BalanceManager implements IBalanceManager { PubkeyManager? _pubkeyManager; final IAssetLookup _assetLookup; final KomodoDefiLocalAuth _auth; + final EventStreamingManager _eventStreamingManager; StreamSubscription? _authSubscription; - final Duration _defaultPollingInterval = const Duration(minutes: 1); // consider DI/config override - - /// Enable debug logging for balance polling - static bool enableDebugLogging = false; /// Cache of the latest known balances for each asset final Map _balanceCache = {}; @@ -336,78 +336,45 @@ class BalanceManager implements IBalanceManager { if (!controller.isClosed) controller.add(balance); } - // Set up periodic polling for balance updates - final periodicStream = Stream.periodic(_defaultPollingInterval); - _activeWatchers[assetId] = periodicStream - .asyncMap((void _) async { - if (_isDisposed) return null; - - // Check if dependencies are still initialized - if (_activationCoordinator == null || _pubkeyManager == null) { - return null; - } - - // Check if user is still authenticated - final currentUser = await _auth.currentUser; - if (currentUser == null || - currentUser.walletId != _currentWalletId) { - return null; // Don't fetch balance if user changed or logged out - } - - if (enableDebugLogging) { - _logger.info( - '[POLLING] Fetching balance for ${assetId.name} (every ${_defaultPollingInterval.inSeconds}s)', - ); - } - - try { - // Ensure asset is activated if needed - final isActive = await _ensureAssetActivated( - asset, - activateIfNeeded, - ); - - // Only fetch balance if asset is active - if (isActive) { - final balance = await getBalance(assetId); - if (enableDebugLogging) { - _logger.info( - '[POLLING] Balance fetched for ${assetId.name}: ${balance.total}', - ); - } - return balance; - } - } catch (e, s) { - // Just log the error and continue with the last known balance - // This prevents the stream from terminating on transient errors - if (enableDebugLogging) { - _logger.warning( - '[POLLING] Balance fetch failed for ${assetId.name}: $e', - e, - s, - ); - } - } - - // Return the last known balance if we can't fetch a new one - return lastKnown(assetId); - }) - .listen( - (BalanceInfo? balance) { - if (balance != null && !controller.isClosed) { - controller.add(balance); - } - }, - onError: (Object error) { - if (!controller.isClosed) controller.addError(error); - }, - onDone: () { - _stopWatchingBalance(assetId); - _logger.fine('Stopped watching ${assetId.name}'); - }, - cancelOnError: false, - ); - } catch (e) { + // Subscribe to balance event stream for real-time updates + _logger.fine('Subscribing to balance stream for ${assetId.name}'); + final balanceStreamSubscription = await _eventStreamingManager + .subscribeToBalance(coin: assetId.name); + + _activeWatchers[assetId] = balanceStreamSubscription + ..onData((balanceEvent) { + if (_isDisposed) return; + + // Verify the event is for the correct coin + if (balanceEvent.coin != assetId.name) return; + + // Update cache with the new balance + _balanceCache[assetId] = balanceEvent.balance; + + // Emit the balance update to listeners + if (!controller.isClosed) { + controller.add(balanceEvent.balance); + _logger.fine( + 'Balance update received for ${assetId.name}: ${balanceEvent.balance.total}', + ); + } + }) + ..onError((Object error) { + if (!controller.isClosed) { + controller.addError(error); + } + _logger.warning('Balance stream error for ${assetId.name}', error); + }) + ..onDone(() { + _stopWatchingBalance(assetId); + _logger.fine('Balance stream closed for ${assetId.name}'); + }); + } catch (e, s) { + _logger.warning( + 'Failed to start balance watcher for ${assetId.name}', + e, + s, + ); if (!controller.isClosed) controller.addError(e); } } diff --git a/packages/komodo_defi_sdk/lib/src/bootstrap.dart b/packages/komodo_defi_sdk/lib/src/bootstrap.dart index 794656c2..502e2dd1 100644 --- a/packages/komodo_defi_sdk/lib/src/bootstrap.dart +++ b/packages/komodo_defi_sdk/lib/src/bootstrap.dart @@ -133,6 +133,8 @@ Future bootstrap({ container.registerSingletonAsync(() async { final assets = await container.getAsync(); final auth = await container.getAsync(); + final eventStreamingManager = await container + .getAsync(); // Create BalanceManager without its dependencies on SharedActivationCoordinator and PubkeyManager initially return BalanceManager( @@ -141,8 +143,9 @@ Future bootstrap({ assetLookup: assets, pubkeyManager: null, // Will be set after PubkeyManager is created auth: auth, + eventStreamingManager: eventStreamingManager, ); - }, dependsOn: [AssetManager, KomodoDefiLocalAuth]); + }, dependsOn: [AssetManager, KomodoDefiLocalAuth, EventStreamingManager]); // Register activation manager with asset manager dependency container.registerSingletonAsync( @@ -273,16 +276,17 @@ Future bootstrap({ final auth = await container.getAsync(); final assetProvider = await container.getAsync(); final pubkeys = await container.getAsync(); - final balances = await container.getAsync(); final activationCoordinator = await container .getAsync(); + final eventStreamingManager = await container + .getAsync(); return TransactionHistoryManager( client, auth, assetProvider, activationCoordinator, pubkeyManager: pubkeys, - balanceManager: balances, + eventStreamingManager: eventStreamingManager, ); }, dependsOn: [ @@ -290,8 +294,8 @@ Future bootstrap({ KomodoDefiLocalAuth, AssetManager, PubkeyManager, - BalanceManager, SharedActivationCoordinator, + EventStreamingManager, ], ); diff --git a/packages/komodo_defi_sdk/lib/src/transaction_history/strategies/etherscan_transaction_history_strategy.dart b/packages/komodo_defi_sdk/lib/src/transaction_history/strategies/etherscan_transaction_history_strategy.dart index 80e6421b..1a9d5b10 100644 --- a/packages/komodo_defi_sdk/lib/src/transaction_history/strategies/etherscan_transaction_history_strategy.dart +++ b/packages/komodo_defi_sdk/lib/src/transaction_history/strategies/etherscan_transaction_history_strategy.dart @@ -221,11 +221,16 @@ class EtherscanProtocolHelper { return asset.protocol is Erc20Protocol && getApiUrlForAsset(asset) != null; } - /// Whether transaction history should also be fetched via mm2. + /// Whether transaction history should be enabled in KDF during activation. /// - /// When Etherscan does not support the provided [asset], transaction history - /// must fall back to mm2 RPC calls. - bool shouldEnableTransactionHistory(Asset asset) => !supportsProtocol(asset); + /// This must always return `true` because the SDK now uses event streaming + /// for real-time transaction updates. Even for assets supported by Etherscan, + /// KDF's transaction history must be enabled to allow the streaming system + /// to emit transaction events. + /// + /// Note: The Etherscan strategy is still used for fetching historical + /// transactions (pagination), while streaming provides real-time updates. + bool shouldEnableTransactionHistory(Asset asset) => true; /// Constructs the appropriate API URL for a given asset Uri? getApiUrlForAsset(Asset asset) { diff --git a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart index b2b8412a..437cb61c 100644 --- a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart @@ -1,10 +1,9 @@ import 'dart:async'; -import 'dart:math' as math; import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; -import 'package:komodo_defi_sdk/src/balances/balance_manager.dart'; import 'package:komodo_defi_sdk/src/_internal_exports.dart'; import 'package:komodo_defi_sdk/src/pubkeys/pubkey_manager.dart'; +import 'package:komodo_defi_sdk/src/streaming/event_streaming_manager.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; /// Core interface for transaction history manager @@ -37,18 +36,18 @@ class TransactionHistoryManager implements _TransactionHistoryManager { this._assetProvider, this._activationCoordinator, { required PubkeyManager pubkeyManager, - required BalanceManager balanceManager, + required EventStreamingManager eventStreamingManager, TransactionStorage? storage, }) : _storage = storage ?? TransactionStorage.defaultForPlatform(), _strategyFactory = TransactionHistoryStrategyFactory( pubkeyManager, _auth, ), - _balanceManager = balanceManager { + _eventStreamingManager = eventStreamingManager { // Subscribe to auth changes directly in constructor _authSubscription = _auth.authStateChanges.listen((user) { if (user == null) { - _stopAllPolling(); + _stopAllStreaming(); } }); } @@ -58,16 +57,13 @@ class TransactionHistoryManager implements _TransactionHistoryManager { final IAssetProvider _assetProvider; final SharedActivationCoordinator _activationCoordinator; final TransactionStorage _storage; - final BalanceManager _balanceManager; + final EventStreamingManager _eventStreamingManager; final _streamControllers = >{}; - final _balanceSubscriptions = >{}; - final _lastObservedBalance = {}; + final _txHistorySubscriptions = >{}; final _syncInProgress = {}; final _rateLimiter = _RateLimiter(const Duration(milliseconds: 500)); - // Maximum consecutive retry attempts when polling fails transiently - static const _maxPollingRetries = 3; static const _maxBatchSize = 50; bool _isDisposed = false; @@ -75,15 +71,14 @@ class TransactionHistoryManager implements _TransactionHistoryManager { final TransactionHistoryStrategyFactory _strategyFactory; - void _stopAllPolling() { + void _stopAllStreaming() { if (_isDisposed) return; - // Cancel all balance subscriptions - for (final sub in _balanceSubscriptions.values) { + // Cancel all transaction history subscriptions + for (final sub in _txHistorySubscriptions.values) { sub.cancel(); } - _balanceSubscriptions.clear(); - _lastObservedBalance.clear(); + _txHistorySubscriptions.clear(); // Close controllers in a separate iteration to avoid modification during iteration final controllers = _streamControllers.values.toList(); @@ -254,14 +249,14 @@ class TransactionHistoryManager implements _TransactionHistoryManager { asset.id, () => StreamController.broadcast( onListen: () { - // Start balance-driven polling only once per asset - if (!_balanceSubscriptions.containsKey(asset.id)) { - _startPolling(asset); + // Start transaction history streaming only once per asset + if (!_txHistorySubscriptions.containsKey(asset.id)) { + _startStreaming(asset); } }, onCancel: () async { if (!_streamControllers[asset.id]!.hasListener) { - _stopPolling(asset.id); + _stopStreaming(asset.id); await _streamControllers[asset.id]?.close(); _streamControllers.remove(asset.id); } @@ -328,62 +323,11 @@ class TransactionHistoryManager implements _TransactionHistoryManager { if (_isDisposed) return; await _storage.clearTransactions(asset.id, await _getCurrentWalletId()); - _stopPolling(asset.id); + _stopStreaming(asset.id); await _streamControllers[asset.id]?.close(); _streamControllers.remove(asset.id); } - Future _pollNewTransactions(Asset asset, [int retryCount = 0]) async { - if (_isDisposed || _syncInProgress.contains(asset.id)) return; - - try { - final strategy = _strategyFactory.forAsset(asset); - final lastTx = await _storage.getLatestTransactionId( - asset.id, - await _getCurrentWalletId(), - ); - - await _rateLimiter.throttle(); - - final response = await strategy.fetchTransactionHistory( - _client, - asset, - lastTx != null - ? TransactionBasedPagination( - fromId: lastTx, - itemCount: _maxBatchSize, - ) - : const PagePagination(pageNumber: 1, itemsPerPage: _maxBatchSize), - ); - - // If asset is no longer being watched, stop - if (!_streamControllers.containsKey(asset.id)) return; - - if (response.transactions.isNotEmpty) { - final newTransactions = response.transactions - .map((tx) => tx.asTransaction(asset.id)) - .toList(); - - await _batchStoreTransactions(newTransactions); - - final controller = _streamControllers[asset.id]; - if (controller != null && !controller.isClosed) { - for (final tx in newTransactions) { - controller.add(tx); - } - } - } - } catch (e) { - if (retryCount < _maxPollingRetries) { - final delay = Duration(seconds: math.pow(2, retryCount).toInt()); - await Future.delayed( - delay, - () => _pollNewTransactions(asset, retryCount + 1), - ); - } - } - } - Future _ensureAssetActivated(Asset asset) async { final activationResult = await _activationCoordinator.activateAsset(asset); if (activationResult.isFailure) { @@ -414,39 +358,72 @@ class TransactionHistoryManager implements _TransactionHistoryManager { } } - void _startPolling(Asset asset) { + Future _startStreaming(Asset asset) async { // Ensure we don't duplicate subscriptions - _stopPolling(asset.id); - - // Initial sync: poll once on activation - _pollNewTransactions(asset); - - // Subscribe to balance changes and trigger history fetch only when balance changes - _balanceSubscriptions[asset.id] = _balanceManager - .watchBalance(asset.id) - .listen( - (BalanceInfo balance) { - final last = _lastObservedBalance[asset.id]; - final changed = - last == null || - balance.total != last.total || - balance.spendable != last.spendable; - _lastObservedBalance[asset.id] = balance; - if (changed) { - _pollNewTransactions(asset); + _stopStreaming(asset.id); + + // Ensure asset is activated before subscribing + try { + await _ensureAssetActivated(asset); + } catch (e) { + final controller = _streamControllers[asset.id]; + if (controller != null && !controller.isClosed) { + controller.addError(e); + } + return; + } + + // Subscribe to transaction history event stream for real-time updates + try { + final txHistoryStreamSubscription = await _eventStreamingManager + .subscribeToTxHistory(coin: asset.id.name); + + _txHistorySubscriptions[asset.id] = txHistoryStreamSubscription + ..onData((txHistoryEvent) async { + if (_isDisposed) return; + + // Verify the event is for the correct coin + if (txHistoryEvent.coin != asset.id.name) return; + + // Process new transactions + final transactions = txHistoryEvent.transactions + .map((tx) => tx.asTransaction(asset.id)) + .toList(); + + if (transactions.isEmpty) return; + + // Store transactions in local storage + await _batchStoreTransactions(transactions); + + // Emit each transaction to listeners + final controller = _streamControllers[asset.id]; + if (controller != null && !controller.isClosed) { + for (final tx in transactions) { + controller.add(tx); } - }, - onError: (Object error) { - // Keep subscription alive; BalanceManager should recover - }, - ); + } + }) + ..onError((Object error) { + final controller = _streamControllers[asset.id]; + if (controller != null && !controller.isClosed) { + controller.addError(error); + } + }) + ..onDone(() { + _stopStreaming(asset.id); + }); + } catch (e) { + final controller = _streamControllers[asset.id]; + if (controller != null && !controller.isClosed) { + controller.addError(e); + } + } } - void _stopPolling(AssetId assetId) { - // Cancel balance subscription - _balanceSubscriptions[assetId]?.cancel(); - _balanceSubscriptions.remove(assetId); - _lastObservedBalance.remove(assetId); + void _stopStreaming(AssetId assetId) { + // Cancel transaction history subscription + _txHistorySubscriptions[assetId]?.cancel(); + _txHistorySubscriptions.remove(assetId); } Future dispose() async { From 5e4814397c28242975040e7260f0b65194950117 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Mon, 27 Oct 2025 20:35:08 +0100 Subject: [PATCH 15/35] fix(cache): address PR review issues - error handling and race conditions - Health check: log transient RPC failures instead of triggering false sign-outs - ActivatedAssetsCache: fix race condition using generation counter and Completer pattern - NFT activation: aggregate and report all errors instead of only the last one - Auth service: document 5-minute user cache staleness trade-off Refs: #262 --- .../lib/src/auth/auth_service.dart | 9 ++++ .../auth_service_operations_extension.dart | 16 +++++-- .../activation/nft_activation_service.dart | 19 +++++--- .../src/assets/activated_assets_cache.dart | 47 ++++++++++++++----- 4 files changed, 67 insertions(+), 24 deletions(-) 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 00983db5..ae5e99ed 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,6 +5,7 @@ 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'; part 'auth_service_auth_extension.dart'; @@ -38,6 +39,13 @@ abstract interface class IAuthService { /// Returns the [KdfUser] associated with the active wallet if KDF is running, /// otherwise null. + /// + /// **Performance Note**: This method returns the last user emitted by health + /// checks (updated every 5 minutes) to reduce RPC load. This means the + /// returned value could be up to 5 minutes stale if the active wallet is + /// changed externally. For most use cases, this trade-off is acceptable and + /// significantly reduces RPC spam. + /// /// NOTE: this function does not start/stop KDF or modify the active user, /// so atomic read/write protection is not used within and not required when /// calling this function. @@ -109,6 +117,7 @@ class KdfAuthService implements IAuthService { StreamController.broadcast(); final SecureLocalStorage _secureStorage = SecureLocalStorage(); final ReadWriteMutex _authMutex = ReadWriteMutex(); + final Logger _logger = Logger('KdfAuthService'); KdfUser? _lastEmittedUser; Timer? _healthCheckTimer; diff --git a/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart b/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart index e33fa0c9..1c181835 100644 --- a/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart +++ b/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart @@ -34,11 +34,17 @@ extension KdfAuthServiceOperationsExtension on KdfAuthService { // User state changed _emitAuthStateChange(currentUser); } - } catch (e) { - // If we can't check status, assume KDF is not running properly - if (_lastEmittedUser != null) { - _emitAuthStateChange(null); - } + } catch (e, s) { + // Log the error but don't immediately sign out on transient RPC failures. + // The next health check (in 5 minutes) will verify if this is persistent. + // This prevents false sign-outs during temporary network issues. + _logger.warning( + 'Health check failed, will retry on next interval', + e, + s, + ); + // Note: We intentionally do NOT emit null here to avoid false sign-outs + // from transient errors. KDF may still be running and user authenticated. } } } diff --git a/packages/komodo_defi_sdk/lib/src/activation/nft_activation_service.dart b/packages/komodo_defi_sdk/lib/src/activation/nft_activation_service.dart index c347a7eb..752a3925 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/nft_activation_service.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/nft_activation_service.dart @@ -66,8 +66,8 @@ class NftActivationService { _activatedAssetsCache.invalidate(); } - /// Ensures all [nftTickers] are activated. Any failures are logged and the - /// last encountered exception is rethrown after attempting all activations. + /// Ensures all [nftTickers] are activated. Failures are collected and an + /// aggregate exception is thrown if any activations fail. Future enableNftChains( Iterable nftTickers, { NftActivationParams? activationParams, @@ -83,18 +83,25 @@ class NftActivationService { return; } - Exception? lastError; + final errors = {}; for (final asset in assetsById.values) { try { await enableNft(asset, activationParams: activationParams); } on Object catch (e, s) { _logger.severe('Failed to enable NFT asset ${asset.id.id}', e, s); - lastError = e is Exception ? e : Exception(e.toString()); + errors[asset.id] = e; } } - if (lastError != null) { - throw lastError; + if (errors.isNotEmpty) { + final failedAssets = errors.keys.map((id) => id.id).join(', '); + final errorSummary = errors.entries + .map((e) => '${e.key.id}: ${e.value}') + .join('; '); + throw Exception( + 'Failed to activate ${errors.length} of ${assetsById.length} NFT assets. ' + 'Failed: [$failedAssets]. Errors: $errorSummary', + ); } } } diff --git a/packages/komodo_defi_sdk/lib/src/assets/activated_assets_cache.dart b/packages/komodo_defi_sdk/lib/src/assets/activated_assets_cache.dart index 1ca48da7..1da96c4a 100644 --- a/packages/komodo_defi_sdk/lib/src/assets/activated_assets_cache.dart +++ b/packages/komodo_defi_sdk/lib/src/assets/activated_assets_cache.dart @@ -33,10 +33,13 @@ class ActivatedAssetsCache { List? _cache; DateTime? _lastFetchAt; - Future>? _pendingFetch; + Completer>? _pendingCompleter; StreamSubscription? _authSubscription; bool _isDisposed = false; + // Generation counter to invalidate in-flight fetches + int _generation = 0; + /// Returns the cached activated assets, refreshing when the TTL has expired /// or when [forceRefresh] is true. Future> getActivatedAssets({bool forceRefresh = false}) async { @@ -50,22 +53,32 @@ class ActivatedAssetsCache { return _cache!; } - final inflight = _pendingFetch; - if (inflight != null) { - return inflight; + // If a fetch is already in progress, return its future + if (_pendingCompleter != null) { + return _pendingCompleter!.future; } - final future = _fetchActivatedAssets(); - _pendingFetch = future; + // Capture the current generation to detect if we're invalidated + final generation = _generation; + final completer = Completer>(); + _pendingCompleter = completer; + try { - final assets = await future; - _cache = assets; - _lastFetchAt = _clock(); + final assets = await _fetchActivatedAssets(); + + // Only update cache if we haven't been invalidated while fetching + if (_generation == generation) { + _cache = assets; + _lastFetchAt = _clock(); + } + + completer.complete(assets); return assets; + } catch (e) { + completer.completeError(e); + rethrow; } finally { - if (identical(_pendingFetch, future)) { - _pendingFetch = null; - } + _pendingCompleter = null; } } @@ -76,10 +89,18 @@ class ActivatedAssetsCache { } /// Clears the current cache forcing the next lookup to hit the network. + /// + /// If a fetch is currently in progress, it will be allowed to complete for + /// callers who are awaiting it, but its result will not update the cache. + /// This is achieved using a generation counter that is incremented on each + /// invalidation, preventing stale in-flight fetches from populating the cache. void invalidate() { _cache = null; _lastFetchAt = null; - _pendingFetch = null; + _pendingCompleter = null; + + // Increment generation to mark any in-flight fetches as stale + _generation++; } /// Disposes the cache, cancelling auth subscriptions and clearing state. From 62ac254e01be1b21497077078ec8f74cea1e259b Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Mon, 27 Oct 2025 20:37:58 +0100 Subject: [PATCH 16/35] chore: add event streaming logging --- .../streaming/event_streaming_service.dart | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart index 96411032..17eddfef 100644 --- a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart @@ -27,6 +27,13 @@ class KdfEventStreamingService { final map = JsonMap.from(data! as Map); // Parse to typed event using the sealed class hierarchy final event = KdfEvent.fromJson(map); + + // Log received events in debug mode + if (kDebugMode) { + final summary = _summarizeEvent(event); + print('[EventStream] Received ${event.typeEnum.value}: $summary'); + } + _events.add(event); } catch (e) { // Log parsing errors for debugging (silently ignore for now) @@ -82,5 +89,22 @@ class KdfEventStreamingService { await _events.close(); } + /// Provides a concise summary of an event for debug logging + String _summarizeEvent(KdfEvent event) { + return switch (event) { + BalanceEvent(:final coin, :final balance) => + 'coin=$coin, spendable=${balance.spendable}, ' + 'unspendable=${balance.unspendable}', + OrderbookEvent(:final base, :final rel) => 'pair=$base/$rel', + NetworkEvent(:final netid, :final peers) => 'netid=$netid, peers=$peers', + HeartbeatEvent(:final timestamp) => 'timestamp=$timestamp', + SwapStatusEvent(:final uuid) => 'uuid=$uuid', + OrderStatusEvent(:final uuid) => 'uuid=$uuid', + TxHistoryEvent(:final coin, :final transactions) => + 'coin=$coin, txCount=${transactions.length}', + ShutdownSignalEvent(:final signalName) => 'signal=$signalName', + }; + } + SharedWorkerUnsubscribe? _unsubscribe; } From 7f7d5e70e725b78651fc38cc56a8d2ab44ed6da3 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Mon, 27 Oct 2025 20:47:49 +0100 Subject: [PATCH 17/35] fix(rpc): address critical review feedback on caching implementation - Fix incorrect unawaited() usage in pubkey_manager by properly extracting Future - Add eagerError: false to event_streaming_manager dispose for robust cleanup - Replace unsafe String cast with whereType() in pubkeys_storage - Add race condition check in transaction_history_manager _startStreaming - Capture timestamp at fetch start in activated_assets_cache for accurate TTL - Add error handling to sparkline_repository dispose to ensure all cleanup --- .../lib/src/sparkline_repository.dart | 12 ++++++++-- .../src/assets/activated_assets_cache.dart | 3 ++- .../lib/src/pubkeys/pubkey_manager.dart | 24 ++++++++++--------- .../lib/src/pubkeys/pubkeys_storage.dart | 3 +-- .../streaming/event_streaming_manager.dart | 2 +- .../transaction_history_manager.dart | 6 +++++ 6 files changed, 33 insertions(+), 17 deletions(-) diff --git a/packages/komodo_cex_market_data/lib/src/sparkline_repository.dart b/packages/komodo_cex_market_data/lib/src/sparkline_repository.dart index 9dd11a43..0579b7af 100644 --- a/packages/komodo_cex_market_data/lib/src/sparkline_repository.dart +++ b/packages/komodo_cex_market_data/lib/src/sparkline_repository.dart @@ -180,12 +180,20 @@ class SparklineRepository with RepositoryFallbackMixin { /// Releases held resources such as HTTP clients and Hive boxes. Future dispose() async { for (final repository in _repositories) { - repository.dispose(); + try { + repository.dispose(); + } catch (e, st) { + _logger.severe('Error disposing repository: $repository', e, st); + } } final box = _box; if (box != null && box.isOpen) { - await box.close(); + try { + await box.close(); + } catch (e, st) { + _logger.severe('Error closing Hive box', e, st); + } } _box = null; isInitialized = false; diff --git a/packages/komodo_defi_sdk/lib/src/assets/activated_assets_cache.dart b/packages/komodo_defi_sdk/lib/src/assets/activated_assets_cache.dart index 1da96c4a..e2e65b20 100644 --- a/packages/komodo_defi_sdk/lib/src/assets/activated_assets_cache.dart +++ b/packages/komodo_defi_sdk/lib/src/assets/activated_assets_cache.dart @@ -60,6 +60,7 @@ class ActivatedAssetsCache { // Capture the current generation to detect if we're invalidated final generation = _generation; + final fetchStart = _clock(); // Capture timestamp at fetch start final completer = Completer>(); _pendingCompleter = completer; @@ -69,7 +70,7 @@ class ActivatedAssetsCache { // Only update cache if we haven't been invalidated while fetching if (_generation == generation) { _cache = assets; - _lastFetchAt = _clock(); + _lastFetchAt = fetchStart; // Use start time for accurate TTL } completer.complete(assets); diff --git a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart index 57d8cc38..be6d1b30 100644 --- a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart @@ -99,17 +99,19 @@ class PubkeyManager implements IPubkeyManager { if (hydrated != null) { _pubkeysCache[asset.id] = hydrated; // Fire-and-forget fresh refresh; deduped if one is already running - unawaited(() async { - try { - final fresh = await _fetchFreshPubkeys(asset, walletId); - final controller = _pubkeysControllers[asset.id]; - if (controller != null && !controller.isClosed && fresh != hydrated) { - controller.add(fresh); - } - } catch (_) { - // best-effort background refresh - } - }()); + final refreshFuture = _fetchFreshPubkeys(asset, walletId) + .then((fresh) { + final controller = _pubkeysControllers[asset.id]; + if (controller != null && + !controller.isClosed && + fresh != hydrated) { + controller.add(fresh); + } + }) + .catchError((_) { + // best-effort background refresh + }); + unawaited(refreshFuture); return hydrated; } diff --git a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkeys_storage.dart b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkeys_storage.dart index e511f8e5..e8dae420 100644 --- a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkeys_storage.dart +++ b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkeys_storage.dart @@ -45,8 +45,7 @@ class HivePubkeysStorage implements PubkeysStorage { final box = await _openBox(); final prefix = '${walletId.compoundId}|'; final result = >{}; - for (final dynamicKey in box.keys) { - final key = dynamicKey as String; + for (final key in box.keys.whereType()) { if (!key.startsWith(prefix)) continue; final record = box.get(key); if (record == null) continue; diff --git a/packages/komodo_defi_sdk/lib/src/streaming/event_streaming_manager.dart b/packages/komodo_defi_sdk/lib/src/streaming/event_streaming_manager.dart index 3da926a1..80683347 100644 --- a/packages/komodo_defi_sdk/lib/src/streaming/event_streaming_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/streaming/event_streaming_manager.dart @@ -274,7 +274,7 @@ class EventStreamingManager { // Disable all streams in parallel await Future.wait( keys.map(_disableStream), - // Continue even if some fail + eagerError: false, // Continue even if some fail ); _activeStreams.clear(); diff --git a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart index 437cb61c..538bcf70 100644 --- a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart @@ -378,6 +378,12 @@ class TransactionHistoryManager implements _TransactionHistoryManager { final txHistoryStreamSubscription = await _eventStreamingManager .subscribeToTxHistory(coin: asset.id.name); + // Check again to avoid race condition: only store if not already present + if (_txHistorySubscriptions.containsKey(asset.id)) { + await txHistoryStreamSubscription.cancel(); + return; + } + _txHistorySubscriptions[asset.id] = txHistoryStreamSubscription ..onData((txHistoryEvent) async { if (_isDisposed) return; From ac0c2f0e652e6075a473990d708b42dca844f129 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Mon, 27 Oct 2025 20:52:14 +0100 Subject: [PATCH 18/35] perf(auth): use shutdown event streaming to minimize RPC polling Subscribe to KDF shutdown signals to immediately detect when KDF shuts down, eliminating the need for frequent polling. This provides near-instant shutdown detection (< 1 second) compared to periodic health checks. - Add shutdown signal subscription in KdfAuthService - Subscribe to shutdown events and immediately update auth state - Enable shutdown signal stream via RPC on initialization - Clean up subscription on dispose - Health checks now serve as backup for edge cases Benefits: - Reduces getWalletNames RPC calls significantly - Provides instant user sign-out on KDF shutdown - Maintains graceful degradation if streaming unavailable --- .../lib/src/auth/auth_service.dart | 4 ++ .../auth_service_operations_extension.dart | 72 +++++++++++++++++-- 2 files changed, 69 insertions(+), 7 deletions(-) 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 ae5e99ed..2ceee396 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 @@ -109,6 +109,7 @@ abstract interface class IAuthService { class KdfAuthService implements IAuthService { KdfAuthService(this._kdfFramework, this._hostConfig) { _startHealthCheck(); + _subscribeToShutdownSignals(); } final KomodoDefiFramework _kdfFramework; @@ -121,6 +122,7 @@ class KdfAuthService implements IAuthService { KdfUser? _lastEmittedUser; Timer? _healthCheckTimer; + StreamSubscription? _shutdownSubscription; // Cache for wallet users list to avoid spamming get_wallet_names List? _usersCache; @@ -437,6 +439,8 @@ class KdfAuthService implements IAuthService { // only be acquired once the active read/write operations complete. await _lockWriteOperation(() async { _healthCheckTimer?.cancel(); + await _shutdownSubscription?.cancel(); + _shutdownSubscription = null; await _stopKdf(); _authStateController.close(); _lastEmittedUser = null; diff --git a/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart b/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart index 1c181835..4a52c787 100644 --- a/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart +++ b/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart @@ -11,14 +11,76 @@ extension KdfAuthServiceOperationsExtension on KdfAuthService { void _startHealthCheck() { _healthCheckTimer?.cancel(); - // Reduce frequency to prevent excessive wallet name checks. - // Health checks do not need sub-second responsiveness; 5 minutes is ample. + // With shutdown signal streaming in place, health checks serve primarily + // as a backup for edge cases where the event stream might miss a shutdown. + // Reduced from 5 minutes to 30 minutes to minimize RPC spam while + // maintaining a safety net for detecting stale KDF instances. _healthCheckTimer = Timer.periodic( const Duration(minutes: 5), (_) => _checkKdfHealth(), ); } + /// Subscribes to shutdown signal events from KDF to immediately detect + /// when KDF is shutting down, eliminating the need for frequent polling. + /// + /// This provides near-instant detection of KDF shutdown (< 1 second) compared + /// to the periodic health check (up to 30 minutes delay). + void _subscribeToShutdownSignals() { + _shutdownSubscription?.cancel(); + + // Enable shutdown signal streaming via RPC and subscribe to events + _shutdownSubscription = _kdfFramework.streaming.shutdownSignals.listen( + _handleShutdownSignal, + onError: (Object error, StackTrace stackTrace) { + _logger.warning( + 'Error in shutdown signal stream, ' + 'will rely on periodic health checks', + error, + stackTrace, + ); + }, + cancelOnError: false, + ); + + // Enable the shutdown signal stream on KDF + // Note: This is fire-and-forget; if it fails, we'll rely on health checks + _enableShutdownStream().catchError((Object error) { + _logger.warning( + 'Failed to enable shutdown signal stream, ' + 'will rely on periodic health checks: $error', + ); + }); + } + + /// Enables the shutdown signal stream on KDF. + Future _enableShutdownStream() async { + try { + if (!await _kdfFramework.isRunning()) { + return; + } + + await _client.rpc.streaming.enableShutdownSignal(clientId: 1); + _logger.info('Shutdown signal stream enabled successfully'); + } catch (e) { + // Log but don't throw - streaming is a nice-to-have optimization + _logger.warning('Could not enable shutdown signal stream: $e'); + } + } + + /// Handles shutdown signal events by immediately updating auth state. + void _handleShutdownSignal(ShutdownSignalEvent event) { + _logger.info( + 'Received shutdown signal (${event.signalName}), ' + 'signing out user immediately', + ); + + // Immediately emit signed out state without waiting for health check + if (_lastEmittedUser != null) { + _emitAuthStateChange(null); + } + } + Future _checkKdfHealth() async { try { final isRunning = await _kdfFramework.isRunning(); @@ -38,11 +100,7 @@ extension KdfAuthServiceOperationsExtension on KdfAuthService { // Log the error but don't immediately sign out on transient RPC failures. // The next health check (in 5 minutes) will verify if this is persistent. // This prevents false sign-outs during temporary network issues. - _logger.warning( - 'Health check failed, will retry on next interval', - e, - s, - ); + _logger.warning('Health check failed, will retry on next interval', e, s); // Note: We intentionally do NOT emit null here to avoid false sign-outs // from transient errors. KDF may still be running and user authenticated. } From ba9ccd66d1bba6c21780ea92a191eddc8b5da4c3 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Mon, 27 Oct 2025 21:04:40 +0100 Subject: [PATCH 19/35] feat(rpc): optimize initial balance/history for newly created wallets - Assume zero balance for first-time asset enablement in new wallets - Assume empty transaction history for first-time asset enablement in new wallets - Detect new wallets by absence of any asset activation history - Avoids unnecessary RPC spam when activating first assets in new wallets - Does NOT apply to imported wallets (they have activation history) - Uses AssetHistoryStorage to track which assets have been enabled per wallet - Wire up shared AssetHistoryStorage instance in SDK bootstrap --- .../lib/src/balances/balance_manager.dart | 41 +++++++++++++++++-- .../komodo_defi_sdk/lib/src/bootstrap.dart | 2 + .../transaction_history_manager.dart | 35 +++++++++++++++- 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart index 96bb232e..ddb9da73 100644 --- a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart @@ -1,8 +1,10 @@ import 'dart:async'; +import 'package:decimal/decimal.dart'; import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; import 'package:komodo_defi_sdk/src/activation/activation_manager.dart'; import 'package:komodo_defi_sdk/src/activation/shared_activation_coordinator.dart'; +import 'package:komodo_defi_sdk/src/assets/asset_history_storage.dart'; import 'package:komodo_defi_sdk/src/assets/asset_lookup.dart'; import 'package:komodo_defi_sdk/src/pubkeys/pubkey_manager.dart'; import 'package:komodo_defi_sdk/src/streaming/event_streaming_manager.dart'; @@ -62,11 +64,13 @@ class BalanceManager implements IBalanceManager { required PubkeyManager? pubkeyManager, required SharedActivationCoordinator? activationCoordinator, required EventStreamingManager eventStreamingManager, + AssetHistoryStorage? assetHistoryStorage, }) : _activationCoordinator = activationCoordinator, _pubkeyManager = pubkeyManager, _assetLookup = assetLookup, _auth = auth, - _eventStreamingManager = eventStreamingManager { + _eventStreamingManager = eventStreamingManager, + _assetHistoryStorage = assetHistoryStorage ?? AssetHistoryStorage() { // Listen for auth state changes _authSubscription = _auth.authStateChanges.listen(_handleAuthStateChanged); _logger.fine('Initialized'); @@ -78,6 +82,7 @@ class BalanceManager implements IBalanceManager { final IAssetLookup _assetLookup; final KomodoDefiLocalAuth _auth; final EventStreamingManager _eventStreamingManager; + final AssetHistoryStorage _assetHistoryStorage; StreamSubscription? _authSubscription; /// Cache of the latest known balances for each asset @@ -319,19 +324,49 @@ class BalanceManager implements IBalanceManager { _currentWalletId = user.walletId; _logger.fine('Starting balance watcher for ${assetId.name}'); + // Optimization: Check if this is a newly created wallet (no asset history) + final previouslyEnabledAssets = await _assetHistoryStorage.getWalletAssets( + user.walletId, + ); + final isFirstTimeEnabling = !previouslyEnabledAssets.contains(assetId.id); + + // If wallet has NO asset activation history at all, it's new (not imported) + // This is simpler and more robust than time-based checks + final isNewWallet = previouslyEnabledAssets.isEmpty; + // Emit the last known balance immediately if available final maybeKnownBalance = lastKnown(assetId); if (maybeKnownBalance != null) { controller.add(maybeKnownBalance); _logger.fine('Emitted initial balance for ${assetId.name}'); + } else if (isFirstTimeEnabling && isNewWallet) { + // For newly created wallets (not imported) on first-time asset enablement, + // assume zero balance to reduce RPC spam + final zeroBalance = BalanceInfo( + total: Decimal.zero, + spendable: Decimal.zero, + unspendable: Decimal.zero, + ); + _balanceCache[assetId] = zeroBalance; + controller.add(zeroBalance); + _logger.fine( + 'Emitted zero balance for first-time asset ${assetId.name} in new wallet', + ); } try { // Ensure asset is activated if needed final isActive = await _ensureAssetActivated(asset, activateIfNeeded); - // If active, get the first balance - if (isActive) { + // Mark asset as seen after successful activation + if (isActive && isFirstTimeEnabling) { + await _assetHistoryStorage.addAssetToWallet(user.walletId, assetId.id); + + // Fetch real balance (will update from zero for new wallets) + final balance = await getBalance(assetId); + if (!controller.isClosed) controller.add(balance); + } else if (isActive) { + // If active but not first time, still get balance final balance = await getBalance(assetId); if (!controller.isClosed) controller.add(balance); } diff --git a/packages/komodo_defi_sdk/lib/src/bootstrap.dart b/packages/komodo_defi_sdk/lib/src/bootstrap.dart index 502e2dd1..90a5d673 100644 --- a/packages/komodo_defi_sdk/lib/src/bootstrap.dart +++ b/packages/komodo_defi_sdk/lib/src/bootstrap.dart @@ -144,6 +144,7 @@ Future bootstrap({ pubkeyManager: null, // Will be set after PubkeyManager is created auth: auth, eventStreamingManager: eventStreamingManager, + assetHistoryStorage: container(), ); }, dependsOn: [AssetManager, KomodoDefiLocalAuth, EventStreamingManager]); @@ -287,6 +288,7 @@ Future bootstrap({ activationCoordinator, pubkeyManager: pubkeys, eventStreamingManager: eventStreamingManager, + assetHistoryStorage: container(), ); }, dependsOn: [ diff --git a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart index 538bcf70..2e854181 100644 --- a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; import 'package:komodo_defi_sdk/src/_internal_exports.dart'; +import 'package:komodo_defi_sdk/src/assets/asset_history_storage.dart'; import 'package:komodo_defi_sdk/src/pubkeys/pubkey_manager.dart'; import 'package:komodo_defi_sdk/src/streaming/event_streaming_manager.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; @@ -38,12 +39,14 @@ class TransactionHistoryManager implements _TransactionHistoryManager { required PubkeyManager pubkeyManager, required EventStreamingManager eventStreamingManager, TransactionStorage? storage, + AssetHistoryStorage? assetHistoryStorage, }) : _storage = storage ?? TransactionStorage.defaultForPlatform(), _strategyFactory = TransactionHistoryStrategyFactory( pubkeyManager, _auth, ), - _eventStreamingManager = eventStreamingManager { + _eventStreamingManager = eventStreamingManager, + _assetHistoryStorage = assetHistoryStorage ?? AssetHistoryStorage() { // Subscribe to auth changes directly in constructor _authSubscription = _auth.authStateChanges.listen((user) { if (user == null) { @@ -58,6 +61,7 @@ class TransactionHistoryManager implements _TransactionHistoryManager { final SharedActivationCoordinator _activationCoordinator; final TransactionStorage _storage; final EventStreamingManager _eventStreamingManager; + final AssetHistoryStorage _assetHistoryStorage; final _streamControllers = >{}; final _txHistorySubscriptions = >{}; @@ -104,6 +108,35 @@ class TransactionHistoryManager implements _TransactionHistoryManager { itemsPerPage: _maxBatchSize, ); + // Optimization: Check if this is a newly created wallet (no asset history) + final user = await _auth.currentUser; + if (user != null && pagination is PagePagination && pagination.pageNumber == 1) { + final previouslyEnabledAssets = await _assetHistoryStorage.getWalletAssets( + user.walletId, + ); + final isFirstTimeEnabling = !previouslyEnabledAssets.contains(asset.id.id); + + // If wallet has NO asset activation history at all, it's new (not imported) + final isNewWallet = previouslyEnabledAssets.isEmpty; + + // For newly created wallets (not imported) on first-time asset enablement, + // assume empty transaction history to reduce RPC spam + if (isFirstTimeEnabling && isNewWallet) { + // Still need to activate the asset + await _ensureAssetActivated(asset); + + // Mark asset as seen after activation + await _assetHistoryStorage.addAssetToWallet(user.walletId, asset.id.id); + + return TransactionPage( + transactions: const [], + total: 0, + currentPage: 1, + totalPages: 1, + ); + } + } + // First try to get from local storage final localPage = await _storage.getTransactions( asset.id, From 522731649c533b1ffc647496f5d469d03812dc79 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Mon, 27 Oct 2025 21:07:22 +0100 Subject: [PATCH 20/35] fix(auth): track imported vs created wallets to prevent incorrect optimizations - Add 'isImported' metadata to KdfUser during registration - Pass mnemonic presence to _registerNewUser to determine if imported - Update balance/history managers to check isImported flag - Prevents incorrectly assuming zero balance for imported wallets - Optimization now only applies to genuinely new wallets (not imported) BREAKING: Imported wallets will now correctly fetch real balances/history on first use instead of incorrectly showing zero --- .../lib/src/auth/auth_service.dart | 3 +- .../src/auth/auth_service_auth_extension.dart | 7 ++++- .../lib/src/balances/balance_manager.dart | 11 +++---- .../transaction_history_manager.dart | 30 ++++++++++++------- 4 files changed, 33 insertions(+), 18 deletions(-) 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 2ceee396..4836bf16 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 @@ -222,7 +222,8 @@ class KdfAuthService implements IAuthService { ); return _lockWriteOperation(() async { - final currentUser = await _registerNewUser(config, options); + final isImported = mnemonic != null; + final currentUser = await _registerNewUser(config, options, isImported); _emitAuthStateChange(currentUser); _invalidateUsersCache(); return currentUser; 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 4a3572ca..91dbd055 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 @@ -44,6 +44,7 @@ extension KdfAuthServiceAuthExtension on KdfAuthService { Future _registerNewUser( KdfStartupConfig config, AuthOptions authOptions, + bool isImported, ) async { await _restartKdf(config); final status = await _kdfFramework.kdfMainStatus(); @@ -56,7 +57,11 @@ extension KdfAuthServiceAuthExtension on KdfAuthService { final walletId = WalletId.fromName(config.walletName!, authOptions); final isBip39Seed = await _isSeedBip39Compatible(config); - final currentUser = KdfUser(walletId: walletId, isBip39Seed: isBip39Seed); + final currentUser = KdfUser( + walletId: walletId, + isBip39Seed: isBip39Seed, + metadata: {'isImported': isImported}, + ); await _secureStorage.saveUser(currentUser); // Do not allow authentication to proceed for HD wallets if the seed is not diff --git a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart index ddb9da73..967b3fc9 100644 --- a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart @@ -324,15 +324,16 @@ class BalanceManager implements IBalanceManager { _currentWalletId = user.walletId; _logger.fine('Starting balance watcher for ${assetId.name}'); - // Optimization: Check if this is a newly created wallet (no asset history) + // Optimization: Check if this is a newly created wallet (not imported) final previouslyEnabledAssets = await _assetHistoryStorage.getWalletAssets( user.walletId, ); final isFirstTimeEnabling = !previouslyEnabledAssets.contains(assetId.id); - // If wallet has NO asset activation history at all, it's new (not imported) - // This is simpler and more robust than time-based checks - final isNewWallet = previouslyEnabledAssets.isEmpty; + // Check metadata to determine if this was an imported wallet + // Only optimize for genuinely new wallets, not imported ones + final isImported = user.metadata['isImported'] == true; + final isNewWallet = previouslyEnabledAssets.isEmpty && !isImported; // Emit the last known balance immediately if available final maybeKnownBalance = lastKnown(assetId); @@ -361,7 +362,7 @@ class BalanceManager implements IBalanceManager { // Mark asset as seen after successful activation if (isActive && isFirstTimeEnabling) { await _assetHistoryStorage.addAssetToWallet(user.walletId, assetId.id); - + // Fetch real balance (will update from zero for new wallets) final balance = await getBalance(assetId); if (!controller.isClosed) controller.add(balance); diff --git a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart index 2e854181..b9c5061a 100644 --- a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart @@ -108,26 +108,34 @@ class TransactionHistoryManager implements _TransactionHistoryManager { itemsPerPage: _maxBatchSize, ); - // Optimization: Check if this is a newly created wallet (no asset history) + // Optimization: Check if this is a newly created wallet (not imported) final user = await _auth.currentUser; - if (user != null && pagination is PagePagination && pagination.pageNumber == 1) { - final previouslyEnabledAssets = await _assetHistoryStorage.getWalletAssets( - user.walletId, + if (user != null && + pagination is PagePagination && + pagination.pageNumber == 1) { + final previouslyEnabledAssets = await _assetHistoryStorage + .getWalletAssets(user.walletId); + final isFirstTimeEnabling = !previouslyEnabledAssets.contains( + asset.id.id, ); - final isFirstTimeEnabling = !previouslyEnabledAssets.contains(asset.id.id); - - // If wallet has NO asset activation history at all, it's new (not imported) - final isNewWallet = previouslyEnabledAssets.isEmpty; + + // Check metadata to determine if this was an imported wallet + // Only optimize for genuinely new wallets, not imported ones + final isImported = user.metadata['isImported'] == true; + final isNewWallet = previouslyEnabledAssets.isEmpty && !isImported; // For newly created wallets (not imported) on first-time asset enablement, // assume empty transaction history to reduce RPC spam if (isFirstTimeEnabling && isNewWallet) { // Still need to activate the asset await _ensureAssetActivated(asset); - + // Mark asset as seen after activation - await _assetHistoryStorage.addAssetToWallet(user.walletId, asset.id.id); - + await _assetHistoryStorage.addAssetToWallet( + user.walletId, + asset.id.id, + ); + return TransactionPage( transactions: const [], total: 0, From 7665a30ba91c2f2d54d58cb67f1830d93ae0d5e4 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Mon, 27 Oct 2025 22:37:12 +0100 Subject: [PATCH 21/35] fix: remove errors from KDF merge --- .../test/src/trezor/trezor_auth_service_test.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/komodo_defi_local_auth/test/src/trezor/trezor_auth_service_test.dart b/packages/komodo_defi_local_auth/test/src/trezor/trezor_auth_service_test.dart index 69a368e7..4cd461aa 100644 --- a/packages/komodo_defi_local_auth/test/src/trezor/trezor_auth_service_test.dart +++ b/packages/komodo_defi_local_auth/test/src/trezor/trezor_auth_service_test.dart @@ -137,9 +137,6 @@ class _FakeAuthService implements IAuthService { @override Future isSignedIn() async => activeUser != null; - @override - Future ensureKdfHealthy() async => true; - @override Future restoreSession(KdfUser user) async { activeUser = user; From 9c7fe205341f44edbbf1bd83dc0460d09cddce44 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Mon, 27 Oct 2025 22:37:27 +0100 Subject: [PATCH 22/35] chore: roll `coins` --- packages/komodo_defi_framework/app_build/build_config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/komodo_defi_framework/app_build/build_config.json b/packages/komodo_defi_framework/app_build/build_config.json index f26a8393..f8ccbaa8 100644 --- a/packages/komodo_defi_framework/app_build/build_config.json +++ b/packages/komodo_defi_framework/app_build/build_config.json @@ -68,7 +68,7 @@ "coins": { "fetch_at_build_enabled": true, "update_commit_on_build": true, - "bundled_coins_repo_commit": "3d23cb5dcc82d4bb8c88f8ebf67ad3fb51ed3b6b", + "bundled_coins_repo_commit": "0dbf93eb28b1394367e2fa912340799349bade84", "coins_repo_api_url": "https://api.github.com/repos/KomodoPlatform/coins", "coins_repo_content_url": "https://raw.githubusercontent.com/KomodoPlatform/coins", "coins_repo_branch": "master", From 211f513d54151463fcb191f49c5ef1baf99551ee Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Mon, 27 Oct 2025 23:48:19 +0100 Subject: [PATCH 23/35] fix: misc streaming fixes --- .../event_streaming_platform_web.dart | 31 ++++++++++++------- .../lib/src/auth/auth_service.dart | 2 ++ .../auth_service_operations_extension.dart | 12 +++++-- .../streaming/event_streaming_manager.dart | 2 +- 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart index c1c41576..8d6a7477 100644 --- a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart @@ -3,29 +3,34 @@ // ignore: avoid_web_libraries_in_flutter import 'dart:html' as html show Event; +import 'package:flutter/foundation.dart'; import 'package:js/js_util.dart' as jsu; typedef SharedWorkerUnsubscribe = void Function(); -Object _getGlobalProperty(String name) => jsu.getProperty(jsu.globalThis, name); +Object _getGlobalProperty(String name) => + jsu.getProperty(jsu.globalThis, name); -Object? _getProperty(Object o, String name) => jsu.getProperty(o, name); +Object? _getProperty(Object o, String name) => + jsu.getProperty(o, name); -void _setProperty(Object o, String name, Object? value) => jsu.setProperty(o, name, value); +void _setProperty(Object o, String name, Object? value) => + jsu.setProperty(o, name, value); -T _callConstructor(Object ctor, List args) => jsu.callConstructor(ctor, args) as T; +T _callConstructor(Object ctor, List args) => + jsu.callConstructor(ctor, args) as T; -T _callMethod(Object o, String name, List args) => jsu.callMethod(o, name, args) as T; +T _callMethod(Object o, String name, List args) => + jsu.callMethod(o, name, args) as T; SharedWorkerUnsubscribe connectSharedWorker( void Function(Object? data) onMessage, ) { try { final Object sharedWorkerCtor = _getGlobalProperty('SharedWorker'); - final Object worker = _callConstructor( - sharedWorkerCtor, - ['assets/packages/komodo_defi_framework/assets/web/event_streaming_worker.js'], - ); + final Object worker = _callConstructor(sharedWorkerCtor, [ + 'assets/packages/komodo_defi_framework/assets/web/event_streaming_worker.js', + ]); final Object? portMaybe = _getProperty(worker, 'port'); if (portMaybe == null) return () {}; final Object port = portMaybe; @@ -33,10 +38,14 @@ SharedWorkerUnsubscribe connectSharedWorker( void handler(html.Event e) { final Object? data = _getProperty(e, 'data'); + + if (kDebugMode) { + print('EventStream: Received message: $data'); + } onMessage(data); } - _setProperty(port, 'onmessage', handler); + _setProperty(port, 'onmessage', jsu.allowInterop(handler)); return () { try { @@ -48,5 +57,3 @@ SharedWorkerUnsubscribe connectSharedWorker( return () {}; } } - - 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 4836bf16..2cee880e 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 @@ -1,5 +1,7 @@ import 'dart:async'; +import 'dart:io'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:komodo_defi_framework/komodo_defi_framework.dart'; import 'package:komodo_defi_local_auth/src/auth/storage/secure_storage.dart'; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; diff --git a/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart b/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart index 4a52c787..36dcbbff 100644 --- a/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart +++ b/packages/komodo_defi_local_auth/lib/src/auth/auth_service_operations_extension.dart @@ -55,13 +55,21 @@ extension KdfAuthServiceOperationsExtension on KdfAuthService { /// Enables the shutdown signal stream on KDF. Future _enableShutdownStream() async { + // TODO: Remove if/when shutdown signal stream is supported on Web + // and Windows + if (kIsWeb || Platform.isWindows) { + _logger.info('Shutdown signal stream not supported on Web'); + return; + } try { if (!await _kdfFramework.isRunning()) { return; } - await _client.rpc.streaming.enableShutdownSignal(clientId: 1); - _logger.info('Shutdown signal stream enabled successfully'); + await _client.rpc.streaming.enableShutdownSignal(); + _logger.info( + '[EVENT STREAM] Shutdown signal stream enabled successfully', + ); } catch (e) { // Log but don't throw - streaming is a nice-to-have optimization _logger.warning('Could not enable shutdown signal stream: $e'); diff --git a/packages/komodo_defi_sdk/lib/src/streaming/event_streaming_manager.dart b/packages/komodo_defi_sdk/lib/src/streaming/event_streaming_manager.dart index 80683347..f7b36940 100644 --- a/packages/komodo_defi_sdk/lib/src/streaming/event_streaming_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/streaming/event_streaming_manager.dart @@ -32,7 +32,7 @@ class EventStreamingManager { // Client ID used for all streaming operations // In a production app, this could be configurable or derived from app state - static const int _defaultClientId = 1; + static const int _defaultClientId = 0; // Active stream subscriptions keyed by a unique identifier final Map _activeStreams = {}; From 5d31da6fc6da8a68648b7abcf293edd198200b97 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Tue, 28 Oct 2025 02:31:59 +0100 Subject: [PATCH 24/35] feat(sdk): add event streaming support for task status updates - Add event streaming service and configuration - Implement task event handling and unknown event fallback - Add RPC task shepherd method for task status monitoring - Update balance manager to support event-driven updates - Add platform-specific event streaming implementations - Enhance sync status with event streaming capabilities This reduces RPC polling by leveraging KDF event streaming for task status updates. --- .../app_build/build_config.json | 2 +- .../lib/komodo_defi_framework.dart | 42 ++++---- .../src/config/event_streaming_config.dart | 39 ++++++++ .../lib/src/config/kdf_startup_config.dart | 26 +++-- .../event_streaming_platform_io.dart | 96 +++++++++++++++++++ .../event_streaming_platform_stub.dart | 15 +-- .../event_streaming_platform_web.dart | 11 ++- .../streaming/event_streaming_service.dart | 64 ++++++++----- .../lib/src/streaming/events/kdf_event.dart | 26 ++++- .../lib/src/streaming/events/task_event.dart | 23 +++++ .../src/streaming/events/unknown_event.dart | 20 ++++ packages/komodo_defi_framework/pubspec.yaml | 1 + .../utility/rpc_task_shepherd.dart | 6 +- .../lib/src/balances/balance_manager.dart | 4 +- .../lib/src/generic/sync_status.dart | 21 ++-- 15 files changed, 316 insertions(+), 80 deletions(-) create mode 100644 packages/komodo_defi_framework/lib/src/config/event_streaming_config.dart create mode 100644 packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_io.dart create mode 100644 packages/komodo_defi_framework/lib/src/streaming/events/task_event.dart create mode 100644 packages/komodo_defi_framework/lib/src/streaming/events/unknown_event.dart diff --git a/packages/komodo_defi_framework/app_build/build_config.json b/packages/komodo_defi_framework/app_build/build_config.json index f8ccbaa8..ccf8197e 100644 --- a/packages/komodo_defi_framework/app_build/build_config.json +++ b/packages/komodo_defi_framework/app_build/build_config.json @@ -68,7 +68,7 @@ "coins": { "fetch_at_build_enabled": true, "update_commit_on_build": true, - "bundled_coins_repo_commit": "0dbf93eb28b1394367e2fa912340799349bade84", + "bundled_coins_repo_commit": "7830529ac85d56fe394844798bc20656c9092b41", "coins_repo_api_url": "https://api.github.com/repos/KomodoPlatform/coins", "coins_repo_content_url": "https://raw.githubusercontent.com/KomodoPlatform/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 0cfbba1c..5788f22e 100644 --- a/packages/komodo_defi_framework/lib/komodo_defi_framework.dart +++ b/packages/komodo_defi_framework/lib/komodo_defi_framework.dart @@ -12,6 +12,7 @@ import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:logging/logging.dart'; export 'package:komodo_defi_framework/src/client/kdf_api_client.dart'; +export 'package:komodo_defi_framework/src/config/event_streaming_config.dart'; export 'package:komodo_defi_framework/src/config/kdf_config.dart'; export 'package:komodo_defi_framework/src/config/kdf_startup_config.dart'; export 'package:komodo_defi_framework/src/services/seed_node_service.dart'; @@ -106,10 +107,12 @@ class KomodoDefiFramework implements ApiClient { } } - // Streaming service (web: SharedWorker integration) + // Streaming service (web: SharedWorker; native: SSE) KdfEventStreamingService? _streamingService; KdfEventStreamingService get streaming { - return _streamingService ??= KdfEventStreamingService()..initialize(); + return _streamingService ??= KdfEventStreamingService( + hostConfig: _hostConfig, + )..initialize(); } //TODO! Figure out best way to handle overlap between startup and host @@ -228,12 +231,12 @@ class KomodoDefiFramework implements ApiClient { // Extract method name for logging final method = request['method'] as String?; final stopwatch = Stopwatch()..start(); - + // Log activation parameters before the call if (method != null && _isActivationMethod(method)) { _logActivationParameters(method, request); } - + try { final response = (await _kdfOperations.mm2Rpc( request..setIfAbsentOrEmpty('userpass', _hostConfig.rpcPassword), @@ -272,25 +275,26 @@ class KomodoDefiFramework implements ApiClient { method == 'get_enabled_coins' || method == 'my_balance'; } - + bool _isActivationMethod(String method) { return method.contains('enable') || method.contains('task::enable') || method.contains('task_enable'); } - + void _logActivationParameters(String method, JsonMap request) { try { final params = request['params'] as Map?; if (params == null) return; - + final ticker = params['ticker'] as String?; - final activationParams = params['activation_params'] as Map?; - + final activationParams = + params['activation_params'] as Map?; + if (ticker != null) { _logger.info('[ACTIVATION] Enabling coin: $ticker'); } - + if (activationParams != null) { // Log key activation parameters final mode = activationParams['mode']; @@ -299,9 +303,9 @@ class KomodoDefiFramework implements ApiClient { final rpcUrls = activationParams['rpc_urls']; final tokensRequests = activationParams['erc20_tokens_requests']; final bchUrls = activationParams['bchd_urls']; - + final paramsSummary = {}; - + if (mode != null) paramsSummary['mode'] = mode; if (nodes != null) { paramsSummary['nodes_count'] = (nodes as List).length; @@ -318,20 +322,22 @@ class KomodoDefiFramework implements ApiClient { if (bchUrls != null) { paramsSummary['bchd_urls_count'] = (bchUrls as List).length; } - + // Add other relevant fields if (activationParams['swap_contract_address'] != null) { - paramsSummary['swap_contract'] = activationParams['swap_contract_address']; + paramsSummary['swap_contract'] = + activationParams['swap_contract_address']; } if (activationParams['platform'] != null) { paramsSummary['platform'] = activationParams['platform']; } if (activationParams['contract_address'] != null) { - paramsSummary['contract_address'] = activationParams['contract_address']; + paramsSummary['contract_address'] = + activationParams['contract_address']; } - + _logger.info('[ACTIVATION] Parameters: $paramsSummary'); - + // Log full activation params for detailed debugging _logger.fine('[ACTIVATION] Full params: $activationParams'); } @@ -340,7 +346,7 @@ class KomodoDefiFramework implements ApiClient { _logger.info('[ACTIVATION] Error logging parameters: $e'); } } - + void _logElectrumConnectionInfo(String method, JsonMap response) { try { // Log connection information from enable responses diff --git a/packages/komodo_defi_framework/lib/src/config/event_streaming_config.dart b/packages/komodo_defi_framework/lib/src/config/event_streaming_config.dart new file mode 100644 index 00000000..645070c1 --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/config/event_streaming_config.dart @@ -0,0 +1,39 @@ +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; + +/// Configuration for KDF event streaming +/// +/// This configuration enables Server-Sent Events (SSE) streaming from KDF. +/// See: https://komodoplatform.com/en/docs/komodo-defi-framework/setup/configure-mm2-json/ +class EventStreamingConfiguration { + const EventStreamingConfiguration({ + this.accessControlAllowOrigin = '*', + this.workerPath, + }); + + /// Create from JSON + factory EventStreamingConfiguration.fromJson(JsonMap json) { + return EventStreamingConfiguration( + accessControlAllowOrigin: + json['access_control_allow_origin'] as String? ?? '*', + workerPath: json['worker_path'] as String?, + ); + } + + /// CORS access control header value + /// Defaults to '*' to allow all origins + final String accessControlAllowOrigin; + + /// Path to the worker script (primarily for web platforms) + /// Optional, defaults to null + final String? workerPath; + + /// Default configuration with permissive CORS + static const EventStreamingConfiguration defaultConfig = + EventStreamingConfiguration(); + + /// Convert to JSON format for KDF startup configuration + JsonMap toJson() => { + 'access_control_allow_origin': accessControlAllowOrigin, + if (workerPath != null) 'worker_path': workerPath, + }; +} diff --git a/packages/komodo_defi_framework/lib/src/config/kdf_startup_config.dart b/packages/komodo_defi_framework/lib/src/config/kdf_startup_config.dart index 11faef81..60e99a54 100644 --- a/packages/komodo_defi_framework/lib/src/config/kdf_startup_config.dart +++ b/packages/komodo_defi_framework/lib/src/config/kdf_startup_config.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:komodo_coins/komodo_coins.dart'; +import 'package:komodo_defi_framework/src/config/event_streaming_config.dart'; import 'package:komodo_defi_framework/src/config/seed_node_validator.dart'; import 'package:komodo_defi_framework/src/services/seed_node_service.dart' show SeedNodeService; @@ -36,6 +37,7 @@ class KdfStartupConfig { required this.disableP2p, required this.iAmSeed, required this.isBootstrapNode, + required this.eventStreamingConfiguration, }) { SeedNodeValidator.validate( seedNodes: seedNodes, @@ -65,6 +67,7 @@ class KdfStartupConfig { final bool? disableP2p; final bool? iAmSeed; final bool? isBootstrapNode; + final EventStreamingConfiguration? eventStreamingConfiguration; // Either a list of coin JSON objects or a string of the path to a file // containing a list of coin JSON objects. @@ -92,6 +95,7 @@ class KdfStartupConfig { bool? disableP2p, bool? iAmSeed, bool? isBootstrapNode, + EventStreamingConfiguration? eventStreamingConfiguration, }) async { assert( !kIsWeb || userHome == null && dbDir == null, @@ -108,9 +112,10 @@ class KdfStartupConfig { ); assert( - hdAccountId == null, - 'HD Account ID is not supported yet in the SDK. ' - 'Use at your own risk.'); + hdAccountId == null, + 'HD Account ID is not supported yet in the SDK. ' + 'Use at your own risk.', + ); // Validate seed node configuration before creating the object SeedNodeValidator.validate( @@ -142,6 +147,9 @@ class KdfStartupConfig { hdAccountId: hdAccountId, allowRegistrations: allowRegistrations, enableHd: enableHd, + eventStreamingConfiguration: + eventStreamingConfiguration ?? + EventStreamingConfiguration.defaultConfig, ); } @@ -166,13 +174,12 @@ class KdfStartupConfig { String? rpcPassword, String? rpcIp, int rpcPort = 7783, + EventStreamingConfiguration? eventStreamingConfiguration, }) async { final (String? home, String? dbDir) = await _getAndSetupUserHome(); - final ( - seedNodes: seeds, - netId: netId, - ) = await SeedNodeService.fetchSeedNodes(); + final (seedNodes: seeds, netId: netId) = + await SeedNodeService.fetchSeedNodes(); return KdfStartupConfig._( walletName: null, @@ -196,6 +203,9 @@ class KdfStartupConfig { seedNodes: seeds, iAmSeed: false, isBootstrapNode: false, + eventStreamingConfiguration: + eventStreamingConfiguration ?? + EventStreamingConfiguration.defaultConfig, ); } @@ -225,6 +235,8 @@ class KdfStartupConfig { if (disableP2p != null) 'disable_p2p': disableP2p, if (iAmSeed != null) 'i_am_seed': iAmSeed, if (isBootstrapNode != null) 'is_bootstrap_node': isBootstrapNode, + if (eventStreamingConfiguration != null) + 'event_streaming_configuration': eventStreamingConfiguration!.toJson(), }; } diff --git a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_io.dart b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_io.dart new file mode 100644 index 00000000..b7b664a4 --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_io.dart @@ -0,0 +1,96 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_client_sse/flutter_client_sse.dart' as sse; +import 'package:flutter_client_sse/constants/sse_request_type_enum.dart' + as sset; +import 'package:komodo_defi_framework/src/config/kdf_config.dart'; + +typedef EventStreamUnsubscribe = void Function(); + +// Centralized constants to avoid repeated literals +const String _kEventStreamPath = '/event-stream'; +const String _kLocalRpcBaseUrl = 'http://127.0.0.1:7783'; +const Map _defaultSseHeaders = {}; + +String _composeEventsPath(String basePath) { + if (basePath.isEmpty || basePath == '/') return _kEventStreamPath; + return basePath.endsWith('/') + ? '${basePath.substring(0, basePath.length)}event-stream' + : '$basePath$_kEventStreamPath'; +} + +bool _isLikelyJson(String data) => data.startsWith('{') || data.startsWith('['); + +Uri _buildEventsUrl(IKdfHostConfig hostConfig) { + if (hostConfig is RemoteConfig) { + final uri = hostConfig.rpcUrl; + final eventsPath = _composeEventsPath(uri.path); + return uri.replace(path: eventsPath); + } + + final uri = Uri.parse(_kLocalRpcBaseUrl); + return uri.replace(path: _kEventStreamPath); +} + +EventStreamUnsubscribe connectEventStream({ + IKdfHostConfig? hostConfig, + required void Function(Object? data) onMessage, +}) { + assert(hostConfig != null, 'hostConfig is required'); + final IKdfHostConfig cfg = hostConfig!; + final Uri url = _buildEventsUrl(cfg); + final String urlString = url.toString(); + bool isClosed = false; + StreamSubscription? sub; + + void log(String msg) { + if (kDebugMode) { + print('[EventStream][IO] $msg'); + } + } + + Future start() async { + try { + sub = + sse.SSEClient.subscribeToSSE( + url: urlString, + method: sset.SSERequestType.GET, + header: _defaultSseHeaders, + ).listen( + (event) { + final String? raw = event.data; + if (raw == null) return; + final String data = raw.trim(); + if (data.isEmpty) return; + final bool looksJson = _isLikelyJson(data); + if (!looksJson) return; + try { + final decoded = json.decode(data); + onMessage(decoded); + } catch (e) { + log('Failed to decode event data: $e'); + } + }, + onError: (Object error, StackTrace stack) { + log('SSE error: $error'); + }, + ); + log('Connected to $urlString'); + } catch (e) { + log('Failed to start SSE: $e'); + } + } + + // Fire and forget + unawaited(start()); + + return () async { + if (isClosed) return; + isClosed = true; + try { + await sub?.cancel(); + } catch (_) {} + }; +} diff --git a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_stub.dart b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_stub.dart index fce8b8b8..93cfbf7e 100644 --- a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_stub.dart +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_stub.dart @@ -1,10 +1,11 @@ -typedef SharedWorkerUnsubscribe = void Function(); +import 'package:komodo_defi_framework/src/config/kdf_config.dart'; -SharedWorkerUnsubscribe connectSharedWorker( - void Function(Object? data) onMessage, -) { - // No-op on non-web platforms +typedef EventStreamUnsubscribe = void Function(); + +EventStreamUnsubscribe connectEventStream({ + IKdfHostConfig? hostConfig, + required void Function(Object? data) onMessage, +}) { + // No-op default implementation; actual logic provided by IO/Web variants return () {}; } - - diff --git a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart index 8d6a7477..1fe5d09f 100644 --- a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart @@ -6,7 +6,9 @@ import 'dart:html' as html show Event; import 'package:flutter/foundation.dart'; import 'package:js/js_util.dart' as jsu; -typedef SharedWorkerUnsubscribe = void Function(); +import 'package:komodo_defi_framework/src/config/kdf_config.dart'; + +typedef EventStreamUnsubscribe = void Function(); Object _getGlobalProperty(String name) => jsu.getProperty(jsu.globalThis, name); @@ -23,9 +25,10 @@ T _callConstructor(Object ctor, List args) => T _callMethod(Object o, String name, List args) => jsu.callMethod(o, name, args) as T; -SharedWorkerUnsubscribe connectSharedWorker( - void Function(Object? data) onMessage, -) { +EventStreamUnsubscribe connectEventStream({ + IKdfHostConfig? hostConfig, + required void Function(Object? data) onMessage, +}) { try { final Object sharedWorkerCtor = _getGlobalProperty('SharedWorker'); final Object worker = _callConstructor(sharedWorkerCtor, [ diff --git a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart index 17eddfef..01f766b5 100644 --- a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart @@ -4,44 +4,49 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:komodo_defi_framework/src/streaming/events/kdf_event.dart'; +import 'package:komodo_defi_framework/src/config/kdf_config.dart'; import 'package:komodo_defi_framework/src/streaming/event_streaming_platform_stub.dart' + if (dart.library.io) 'package:komodo_defi_framework/src/streaming/event_streaming_platform_io.dart' if (dart.library.html) 'package:komodo_defi_framework/src/streaming/event_streaming_platform_web.dart'; +import 'package:komodo_defi_framework/src/streaming/events/kdf_event.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; typedef EventPredicate = bool Function(KdfEvent event); class KdfEventStreamingService { - KdfEventStreamingService(); + KdfEventStreamingService({IKdfHostConfig? hostConfig}) + : _hostConfig = hostConfig; + + final IKdfHostConfig? _hostConfig; final StreamController _events = StreamController.broadcast(); Stream get events => _events.stream; - /// Start listening to WASM SharedWorker forwarded messages (web only). - /// No-op on non-web platforms. + /// Start listening to stream events. + /// - Web: Connects to SharedWorker forwarded messages. + /// - Native (IO): Connects to SSE endpoint exposed by KDF RPC server. void initialize() { - if (!kIsWeb) return; - _unsubscribe ??= connectSharedWorker((data) { - try { - final map = JsonMap.from(data! as Map); - // Parse to typed event using the sealed class hierarchy - final event = KdfEvent.fromJson(map); - - // Log received events in debug mode - if (kDebugMode) { - final summary = _summarizeEvent(event); - print('[EventStream] Received ${event.typeEnum.value}: $summary'); - } - - _events.add(event); - } catch (e) { - // Log parsing errors for debugging (silently ignore for now) - if (kDebugMode) { - print('Failed to parse stream event: $e'); - } + _unsubscribe ??= connectEventStream( + hostConfig: _hostConfig, + onMessage: _onIncomingData, + ); + } + + void _onIncomingData(Object? data) { + try { + final map = JsonMap.from(data! as Map); + final event = KdfEvent.fromJson(map); + if (kDebugMode) { + final summary = _summarizeEvent(event); + print('[EventStream] Received ${event.typeEnum.value}: $summary'); + } + _events.add(event); + } catch (e) { + if (kDebugMode) { + print('Failed to parse stream event: $e'); } - }); + } } /// Generic filter for a specific event type with proper type casting @@ -74,6 +79,13 @@ class KdfEventStreamingService { Stream get txHistoryEvents => whereEventType(); + /// Get a stream of task update events + Stream get taskEvents => whereEventType(); + + /// Get a stream of task update events for a specific task ID + Stream taskEventsForId(int taskId) => + taskEvents.where((event) => event.taskId == taskId); + /// Get a stream of shutdown signal events. /// /// This stream emits events when OS signals (like SIGINT, SIGTERM) are @@ -100,11 +112,13 @@ class KdfEventStreamingService { HeartbeatEvent(:final timestamp) => 'timestamp=$timestamp', SwapStatusEvent(:final uuid) => 'uuid=$uuid', OrderStatusEvent(:final uuid) => 'uuid=$uuid', + TaskEvent(:final taskId) => 'taskId=$taskId', TxHistoryEvent(:final coin, :final transactions) => 'coin=$coin, txCount=${transactions.length}', ShutdownSignalEvent(:final signalName) => 'signal=$signalName', + UnknownEvent(:final typeString) => 'unknown type=$typeString', }; } - SharedWorkerUnsubscribe? _unsubscribe; + EventStreamUnsubscribe? _unsubscribe; } diff --git a/packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart b/packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart index 4bb7efaa..11602dce 100644 --- a/packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart +++ b/packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; @@ -8,7 +9,9 @@ part 'order_status_event.dart'; part 'orderbook_event.dart'; part 'shutdown_signal_event.dart'; part 'swap_status_event.dart'; +part 'task_event.dart'; part 'tx_history_event.dart'; +part 'unknown_event.dart'; /// Private enum for internal event type string mapping enum EventTypeString { @@ -18,6 +21,7 @@ enum EventTypeString { heartbeat('HEARTBEAT'), swapStatus('SWAP_STATUS'), orderStatus('ORDER_STATUS'), + task('TASK'), txHistory('TX_HISTORY'), shutdownSignal('SHUTDOWN_SIGNAL'); @@ -38,6 +42,8 @@ enum EventTypeString { /// print('Balance for $coin: $balance'); /// case OrderbookEvent(:final base, :final rel): /// print('Orderbook update for $base/$rel'); +/// case TaskEvent(:final taskId, :final taskData): +/// print('Task $taskId update: $taskData'); /// // ... handle other event types /// } /// ``` @@ -49,6 +55,15 @@ sealed class KdfEvent { final typeString = json.value('_type'); final message = json.value('message'); + // Handle TASK:{taskId} pattern + if (typeString.startsWith('TASK:')) { + final taskIdStr = typeString.substring(5); // Remove "TASK:" prefix + final taskId = int.tryParse(taskIdStr); + if (taskId != null) { + return TaskEvent.fromJson(message, taskId); + } + } + return switch (typeString) { 'BALANCE' => BalanceEvent.fromJson(message), 'ORDERBOOK' => OrderbookEvent.fromJson(message), @@ -58,11 +73,18 @@ sealed class KdfEvent { 'ORDER_STATUS' => OrderStatusEvent.fromJson(message), 'TX_HISTORY' => TxHistoryEvent.fromJson(message), 'SHUTDOWN_SIGNAL' => ShutdownSignalEvent.fromJson(message), - _ => throw ArgumentError('Unknown event type: $typeString'), + _ => _handleUnknownEvent(typeString, message), }; } + /// Handles unknown event types by logging and returning an UnknownEvent + static UnknownEvent _handleUnknownEvent(String typeString, JsonMap message) { + if (kDebugMode) { + print('[EventStream] Unknown event type: $typeString'); + } + return UnknownEvent(typeString: typeString, rawData: message); + } + /// Internal method to get the event type enum for linking with RPC responses EventTypeString get typeEnum; } - diff --git a/packages/komodo_defi_framework/lib/src/streaming/events/task_event.dart b/packages/komodo_defi_framework/lib/src/streaming/events/task_event.dart new file mode 100644 index 00000000..760298ec --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/events/task_event.dart @@ -0,0 +1,23 @@ +part of 'kdf_event.dart'; + +/// Task update event for RPC task status changes +/// Event type format: TASK:{taskId} +class TaskEvent extends KdfEvent { + TaskEvent({required this.taskId, required this.taskData}); + + factory TaskEvent.fromJson(JsonMap json, int taskId) { + return TaskEvent(taskId: taskId, taskData: json); + } + + @override + EventTypeString get typeEnum => EventTypeString.task; + + /// The task ID this update is for + final int taskId; + + /// The task update data + final JsonMap taskData; + + @override + String toString() => 'TaskEvent(taskId: $taskId)'; +} diff --git a/packages/komodo_defi_framework/lib/src/streaming/events/unknown_event.dart b/packages/komodo_defi_framework/lib/src/streaming/events/unknown_event.dart new file mode 100644 index 00000000..1f96d2bf --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/events/unknown_event.dart @@ -0,0 +1,20 @@ +part of 'kdf_event.dart'; + +/// Represents an unknown or unsupported event type received from the stream. +/// These events are logged but don't cause the stream to fail. +class UnknownEvent extends KdfEvent { + UnknownEvent({required this.typeString, required this.rawData}); + + /// The raw event type string that was not recognized + final String typeString; + + /// The raw event data + final JsonMap rawData; + + @override + EventTypeString get typeEnum => + throw UnsupportedError('UnknownEvent does not have a type enum mapping'); + + @override + String toString() => 'UnknownEvent(type: $typeString)'; +} diff --git a/packages/komodo_defi_framework/pubspec.yaml b/packages/komodo_defi_framework/pubspec.yaml index da15921f..3ead6045 100644 --- a/packages/komodo_defi_framework/pubspec.yaml +++ b/packages/komodo_defi_framework/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: flutter_web_plugins: sdk: flutter http: ^1.4.0 + flutter_client_sse: ^2.0.3 komodo_coin_updates: ^1.1.1 komodo_coins: ^0.3.1+2 komodo_defi_types: ^0.3.2+1 diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/utility/rpc_task_shepherd.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/utility/rpc_task_shepherd.dart index 9a6560ab..35dca19b 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/utility/rpc_task_shepherd.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/utility/rpc_task_shepherd.dart @@ -22,12 +22,16 @@ class TaskShepherd { /// It will NOT be called when the task completes naturally. /// If not provided, the task cannot be canceled and cancelling the stream /// will not cancel the task. + /// + /// Note: For event-based task watching, use the `KdfEventStreamingService` + /// with `taskEventsForId()` method to listen for task updates instead of + /// polling. This provides real-time updates with lower latency and reduced + /// RPC calls. static Stream executeTask({ required Future Function() initTask, required Future Function(int taskId) getTaskStatus, required bool Function(T) checkTaskStatus, Future Function(int taskId)? cancelTask, - // TODO: Implement mechanism for event-interface watching. Duration pollingInterval = const Duration(seconds: 1), }) { final controller = StreamController(); diff --git a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart index 967b3fc9..9291bd4f 100644 --- a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart @@ -15,7 +15,7 @@ import 'package:logging/logging.dart'; abstract class IBalanceManager { /// Gets the current balance for an asset. /// Will ensure the asset is activated before querying. - /// + /// /// Note: If the asset was recently activated through [ActivationManager], /// the balance will typically be pre-cached and return immediately. However, /// this should not be relied upon as a way to check activation status. @@ -329,7 +329,7 @@ class BalanceManager implements IBalanceManager { user.walletId, ); final isFirstTimeEnabling = !previouslyEnabledAssets.contains(assetId.id); - + // Check metadata to determine if this was an imported wallet // Only optimize for genuinely new wallets, not imported ones final isImported = user.metadata['isImported'] == true; diff --git a/packages/komodo_defi_types/lib/src/generic/sync_status.dart b/packages/komodo_defi_types/lib/src/generic/sync_status.dart index 494b472a..494e35a2 100644 --- a/packages/komodo_defi_types/lib/src/generic/sync_status.dart +++ b/packages/komodo_defi_types/lib/src/generic/sync_status.dart @@ -1,3 +1,5 @@ +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; + enum SyncStatusEnum { notStarted, inProgress, @@ -10,20 +12,13 @@ enum SyncStatusEnum { if (value == null) { return null; } + final sanitizedValue = value + .replaceAll('SyncStatusEnum.', '') + .toLowerCase(); - switch (value) { - case 'NotStarted': - return SyncStatusEnum.notStarted; - case 'InProgress': - return SyncStatusEnum.inProgress; - case 'Success': - case 'Ok': - return SyncStatusEnum.success; - case 'Error': - return SyncStatusEnum.error; - default: - throw ArgumentError.value(value, 'value', 'Invalid sync status'); - } + return SyncStatusEnum.values.firstWhereOrNull( + (e) => e.name.toLowerCase() == sanitizedValue, + ); } } From be78b9ea89ff0e5470c256572d3089235df8ac1d Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Tue, 28 Oct 2025 02:44:39 +0100 Subject: [PATCH 25/35] fix(activation): force cache refresh when verifying asset availability The _waitForCoinAvailability method was failing to verify asset activation because isAssetActive() was using cached data instead of fetching fresh data from the backend. This caused transaction history to fail with a connection error even though assets were successfully activated. Changes: - Add forceRefresh parameter to ActivationManager.isAssetActive() - Update SharedActivationCoordinator._waitForCoinAvailability() to force refresh on each availability check - This ensures we bypass the 2-second cache TTL and get real-time data Fixes issue where transaction history shows 'Connection to Komodo servers are failing' error after asset activation completes successfully. --- .../lib/src/activation/activation_manager.dart | 9 +++++++-- .../src/activation/shared_activation_coordinator.dart | 6 +++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart b/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart index 68c26702..45060eff 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart @@ -253,13 +253,18 @@ class ActivationManager { } /// Check if specific asset is active - Future isAssetActive(AssetId assetId) async { + Future isAssetActive( + AssetId assetId, { + bool forceRefresh = false, + }) async { if (_isDisposed) { throw StateError('ActivationManager has been disposed'); } try { - final activeAssets = await getActiveAssets(); + final activeAssets = forceRefresh + ? await _activatedAssetsCache.getActivatedAssetIds(forceRefresh: true) + : await getActiveAssets(); return activeAssets.contains(assetId); } catch (e) { debugPrint('Failed to check if asset is active: $e'); diff --git a/packages/komodo_defi_sdk/lib/src/activation/shared_activation_coordinator.dart b/packages/komodo_defi_sdk/lib/src/activation/shared_activation_coordinator.dart index f5be4699..4c977b10 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/shared_activation_coordinator.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/shared_activation_coordinator.dart @@ -320,7 +320,11 @@ class SharedActivationCoordinator { for (int attempt = 0; attempt < maxRetries; attempt++) { try { - final isAvailable = await _activationManager.isAssetActive(assetId); + // Force refresh to bypass cache and get fresh data from backend + final isAvailable = await _activationManager.isAssetActive( + assetId, + forceRefresh: true, + ); if (isAvailable) { log( 'Coin ${assetId.id} became available after ${attempt + 1} attempts', From bb50720a1ffc49d64571633caa20b8ae867398d0 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Tue, 28 Oct 2025 02:49:33 +0100 Subject: [PATCH 26/35] fix(streaming): use asset config ID instead of display name for event subscriptions The balance and transaction history event subscriptions were using asset.id.name (the human-friendly display name like 'Bitcoin') instead of asset.id.id (the config ID like 'BTC-segwit'). This caused the RPC enable streaming calls to fail because the backend expects the config ID. Changes: - BalanceManager: Use assetId.id instead of assetId.name for subscribeToBalance - TransactionHistoryManager: Use asset.id.id instead of asset.id.name for subscribeToTxHistory - Update event filtering to match using config ID as well This fixes the 'Failed to start balance watcher' errors and resolves the transaction history connection error. --- .../lib/src/balances/balance_manager.dart | 8 ++++---- .../transaction_history_manager.dart | 13 ++++++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart index 9291bd4f..4a4e8e67 100644 --- a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart @@ -15,7 +15,7 @@ import 'package:logging/logging.dart'; abstract class IBalanceManager { /// Gets the current balance for an asset. /// Will ensure the asset is activated before querying. - /// + /// /// Note: If the asset was recently activated through [ActivationManager], /// the balance will typically be pre-cached and return immediately. However, /// this should not be relied upon as a way to check activation status. @@ -373,16 +373,16 @@ class BalanceManager implements IBalanceManager { } // Subscribe to balance event stream for real-time updates - _logger.fine('Subscribing to balance stream for ${assetId.name}'); + _logger.fine('Subscribing to balance stream for ${assetId.id}'); final balanceStreamSubscription = await _eventStreamingManager - .subscribeToBalance(coin: assetId.name); + .subscribeToBalance(coin: assetId.id); _activeWatchers[assetId] = balanceStreamSubscription ..onData((balanceEvent) { if (_isDisposed) return; // Verify the event is for the correct coin - if (balanceEvent.coin != assetId.name) return; + if (balanceEvent.coin != assetId.id) return; // Update cache with the new balance _balanceCache[assetId] = balanceEvent.balance; diff --git a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart index b9c5061a..a2d685cb 100644 --- a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart @@ -163,7 +163,14 @@ class TransactionHistoryManager implements _TransactionHistoryManager { return localPage; } - await _ensureAssetActivated(asset); + // Skip activation check if we have local transaction history, as this + // implies the asset was previously activated. This reduces RPC spam when + // opening the coin details page repeatedly for already-activated assets. + final hasLocalHistory = localPage.transactions.isNotEmpty; + + if (!hasLocalHistory) { + await _ensureAssetActivated(asset); + } // Get appropriate strategy for the asset final strategy = _strategyFactory.forAsset(asset); @@ -417,7 +424,7 @@ class TransactionHistoryManager implements _TransactionHistoryManager { // Subscribe to transaction history event stream for real-time updates try { final txHistoryStreamSubscription = await _eventStreamingManager - .subscribeToTxHistory(coin: asset.id.name); + .subscribeToTxHistory(coin: asset.id.id); // Check again to avoid race condition: only store if not already present if (_txHistorySubscriptions.containsKey(asset.id)) { @@ -430,7 +437,7 @@ class TransactionHistoryManager implements _TransactionHistoryManager { if (_isDisposed) return; // Verify the event is for the correct coin - if (txHistoryEvent.coin != asset.id.name) return; + if (txHistoryEvent.coin != asset.id.id) return; // Process new transactions final transactions = txHistoryEvent.transactions From 6df831553c07270a098bc5874742de12980176e1 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Tue, 28 Oct 2025 03:51:24 +0100 Subject: [PATCH 27/35] perf: reduce RPC spam in activation strategies and managers --- .../eth_task_activation_strategy.dart | 23 +-- .../utxo_activation_strategy.dart | 39 ++-- .../zhtlc_activation_strategy.dart | 25 +-- .../lib/src/balances/balance_manager.dart | 153 +++++++++++++- .../transaction_history_manager.dart | 193 ++++++++++++++++-- 5 files changed, 358 insertions(+), 75 deletions(-) diff --git a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_task_activation_strategy.dart b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_task_activation_strategy.dart index 6586d379..7c755963 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_task_activation_strategy.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_task_activation_strategy.dart @@ -74,32 +74,25 @@ class EthTaskActivationStrategy extends ProtocolActivationStrategy { ), ); - final activationParams = EthWithTokensActivationParams.fromJson(asset.protocol.config) - .copyWith( + final activationParams = + EthWithTokensActivationParams.fromJson( + asset.protocol.config, + ).copyWith( erc20Tokens: - children - ?.map((e) => TokensRequest(ticker: e.id.id)) - .toList() ?? + children?.map((e) => TokensRequest(ticker: e.id.id)).toList() ?? [], txHistory: const EtherscanProtocolHelper() .shouldEnableTransactionHistory(asset), privKeyPolicy: privKeyPolicy, ); - + // Debug logging for ETH task-based activation log( '[RPC] Activating ETH platform (task-based): ${asset.id.id}', name: 'EthTaskActivationStrategy', ); log( - '[RPC] Activation parameters: ${jsonEncode({ - 'ticker': asset.id.id, - 'protocol': asset.protocol.subClass.formatted, - 'token_count': children?.length ?? 0, - 'tokens': children?.map((e) => e.id.id).toList() ?? [], - 'activation_params': activationParams.toRpcParams(), - 'priv_key_policy': privKeyPolicy.toJson(), - })}', + '[RPC] Activation parameters: ${jsonEncode({'ticker': asset.id.id, 'protocol': asset.protocol.subClass.formatted, 'token_count': children?.length ?? 0, 'tokens': children?.map((e) => e.id.id).toList() ?? [], 'activation_params': activationParams.toRpcParams(), 'priv_key_policy': privKeyPolicy.toJson()})}', name: 'EthTaskActivationStrategy', ); @@ -107,7 +100,7 @@ class EthTaskActivationStrategy extends ProtocolActivationStrategy { ticker: asset.id.id, params: activationParams, ); - + log( '[RPC] Task initiated for ${asset.id.id}, task_id: ${taskResponse.taskId}', name: 'EthTaskActivationStrategy', diff --git a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/utxo_activation_strategy.dart b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/utxo_activation_strategy.dart index c1ba95ef..39635777 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/utxo_activation_strategy.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/utxo_activation_strategy.dart @@ -40,11 +40,10 @@ class UtxoActivationStrategy extends ProtocolActivationStrategy { stepCount: 5, additionalInfo: { 'chainType': protocol.subClass.formatted, - 'mode': - protocol - .defaultActivationParams(privKeyPolicy: privKeyPolicy) - .mode - ?.rpc, + 'mode': protocol + .defaultActivationParams(privKeyPolicy: privKeyPolicy) + .mode + ?.rpc, 'txVersion': protocol.txVersion, 'pubtype': protocol.pubtype, }, @@ -61,26 +60,17 @@ class UtxoActivationStrategy extends ProtocolActivationStrategy { ), ); - final activationParams = protocol.defaultActivationParams(privKeyPolicy: privKeyPolicy); - + final activationParams = protocol.defaultActivationParams( + privKeyPolicy: privKeyPolicy, + ); + // Debug logging for UTXO/Electrum activation log( '[ELECTRUM] Activating UTXO coin: ${asset.id.id}', name: 'UtxoActivationStrategy', ); log( - '[ELECTRUM] Activation parameters: ${jsonEncode({ - 'ticker': asset.id.id, - 'mode': activationParams.mode?.rpc, - 'utxo_params': activationParams.toRpcParams(), - 'protocol_type': protocol.subClass.formatted, - 'tx_version': protocol.txVersion, - 'pubtype': protocol.pubtype, - 'p2shtype': protocol.p2shtype, - 'wiftype': protocol.wiftype, - 'electrum_servers': protocol.requiredServers.toJsonRequest(), - 'priv_key_policy': privKeyPolicy.toJson(), - })}', + '[ELECTRUM] Activation parameters: ${jsonEncode({'ticker': asset.id.id, 'mode': activationParams.mode?.rpc, 'utxo_params': activationParams.toRpcParams(), 'protocol_type': protocol.subClass.formatted, 'tx_version': protocol.txVersion, 'pubtype': protocol.pubtype, 'p2shtype': protocol.p2shtype, 'wiftype': protocol.wiftype, 'electrum_servers': protocol.requiredServers.toJsonRequest(), 'priv_key_policy': privKeyPolicy.toJson()})}', name: 'UtxoActivationStrategy', ); @@ -88,7 +78,7 @@ class UtxoActivationStrategy extends ProtocolActivationStrategy { ticker: asset.id.id, params: activationParams, ); - + log( '[ELECTRUM] Task initiated for ${asset.id.id}, task_id: ${taskResponse.taskId}', name: 'UtxoActivationStrategy', @@ -171,8 +161,13 @@ class UtxoActivationStrategy extends ProtocolActivationStrategy { } } - ({String status, double percentage, ActivationStep step, Map info}) - _parseUtxoStatus(String status) { + ({ + String status, + double percentage, + ActivationStep step, + Map info, + }) + _parseUtxoStatus(String status) { switch (status) { case 'ConnectingElectrum': return ( 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 123e01ba..f20a3ef5 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 @@ -68,11 +68,9 @@ class ZhtlcActivationStrategy extends ProtocolActivationStrategy { final effectivePollingInterval = userConfig.taskStatusPollingIntervalMs != null && - userConfig.taskStatusPollingIntervalMs! > 0 - ? Duration( - milliseconds: userConfig.taskStatusPollingIntervalMs!, - ) - : pollingInterval; + userConfig.taskStatusPollingIntervalMs! > 0 + ? Duration(milliseconds: userConfig.taskStatusPollingIntervalMs!) + : pollingInterval; var params = ZhtlcActivationParams.fromConfigJson(protocol.config) .copyWith( @@ -94,8 +92,10 @@ class ZhtlcActivationStrategy extends ProtocolActivationStrategy { syncParams: oneShotSync, ); params = params.copyWith( - mode: - ActivationMode(rpc: params.mode!.rpc, rpcData: updatedRpcData), + mode: ActivationMode( + rpc: params.mode!.rpc, + rpcData: updatedRpcData, + ), ); } } @@ -108,16 +108,7 @@ class ZhtlcActivationStrategy extends ProtocolActivationStrategy { name: 'ZhtlcActivationStrategy', ); 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(), - })}', + '[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', ); diff --git a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart index 4a4e8e67..a19ef80b 100644 --- a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart @@ -84,6 +84,10 @@ class BalanceManager implements IBalanceManager { final EventStreamingManager _eventStreamingManager; final AssetHistoryStorage _assetHistoryStorage; StreamSubscription? _authSubscription; + final Duration _defaultPollingInterval = const Duration(seconds: 30); + + /// Enable debug logging for balance polling fallback + static bool enableDebugLogging = true; /// Cache of the latest known balances for each asset final Map _balanceCache = {}; @@ -377,6 +381,47 @@ class BalanceManager implements IBalanceManager { final balanceStreamSubscription = await _eventStreamingManager .subscribeToBalance(coin: assetId.id); + var hasFallenBack = false; + Future fallbackToPolling({ + String reason = 'stream stopped', + Object? error, + StackTrace? stackTrace, + }) async { + if (hasFallenBack || _isDisposed) return; + hasFallenBack = true; + + _logger.info( + 'Falling back to balance polling for ${assetId.name}: $reason', + ); + + try { + await balanceStreamSubscription.cancel(); + } catch (cancelError, cancelStack) { + _logger.fine( + 'Error cancelling balance stream for ${assetId.name}', + cancelError, + cancelStack, + ); + } + + if (_activeWatchers[assetId] == balanceStreamSubscription) { + await _startBalancePolling( + asset: asset, + assetId: assetId, + controller: controller, + activateIfNeeded: activateIfNeeded, + ); + } + + if (error != null) { + _logger.warning( + 'Balance stream fallback reason for ${assetId.name}: $error', + error, + stackTrace, + ); + } + } + _activeWatchers[assetId] = balanceStreamSubscription ..onData((balanceEvent) { if (_isDisposed) return; @@ -395,15 +440,17 @@ class BalanceManager implements IBalanceManager { ); } }) - ..onError((Object error) { - if (!controller.isClosed) { - controller.addError(error); - } - _logger.warning('Balance stream error for ${assetId.name}', error); + ..onError((Object error, StackTrace stackTrace) { + unawaited( + fallbackToPolling( + reason: 'stream error', + error: error, + stackTrace: stackTrace, + ), + ); }) ..onDone(() { - _stopWatchingBalance(assetId); - _logger.fine('Balance stream closed for ${assetId.name}'); + unawaited(fallbackToPolling(reason: 'stream closed')); }); } catch (e, s) { _logger.warning( @@ -411,10 +458,100 @@ class BalanceManager implements IBalanceManager { e, s, ); - if (!controller.isClosed) controller.addError(e); + await _startBalancePolling( + asset: asset, + assetId: assetId, + controller: controller, + activateIfNeeded: activateIfNeeded, + ); } } + Future _startBalancePolling({ + required Asset asset, + required AssetId assetId, + required StreamController controller, + required bool activateIfNeeded, + }) async { + if (_isDisposed || controller.isClosed) return; + + _logger.fine('Starting balance polling fallback for ${assetId.name}'); + + final periodicStream = Stream.periodic(_defaultPollingInterval); + final subscription = periodicStream + .asyncMap((_) async { + if (_isDisposed) return null; + + if (_activationCoordinator == null || _pubkeyManager == null) { + return null; + } + + final currentUser = await _auth.currentUser; + if (currentUser == null || currentUser.walletId != _currentWalletId) { + return null; + } + + if (enableDebugLogging) { + _logger.info( + '[POLLING] Fetching balance for ${assetId.name} ' + '(every ${_defaultPollingInterval.inSeconds}s)', + ); + } + + try { + final isActive = await _ensureAssetActivated( + asset, + activateIfNeeded, + ); + + if (isActive) { + final balance = await getBalance(assetId); + if (enableDebugLogging) { + _logger.info( + '[POLLING] Balance fetched for ${assetId.name}: ' + '${balance.total}', + ); + } + return balance; + } + } catch (error, stackTrace) { + if (enableDebugLogging) { + _logger.warning( + '[POLLING] Balance fetch failed for ${assetId.name}', + error, + stackTrace, + ); + } + } + + return lastKnown(assetId); + }) + .listen( + (balance) { + if (balance != null && !controller.isClosed) { + controller.add(balance); + } + }, + onError: (Object error, StackTrace stackTrace) { + if (!controller.isClosed) { + controller.addError(error); + } + _logger.warning( + 'Balance polling error for ${assetId.name}', + error, + stackTrace, + ); + }, + onDone: () { + _stopWatchingBalance(assetId); + _logger.fine('Balance polling closed for ${assetId.name}'); + }, + cancelOnError: false, + ); + + _activeWatchers[assetId] = subscription; + } + /// Stop watching the balance for a specific asset void _stopWatchingBalance(AssetId assetId) { final watcher = _activeWatchers[assetId]; diff --git a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart index a2d685cb..6c02c4e9 100644 --- a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart @@ -1,5 +1,8 @@ import 'dart:async'; +import 'dart:math' as math; +import 'package:komodo_defi_framework/komodo_defi_framework.dart' + show BalanceEvent; import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; import 'package:komodo_defi_sdk/src/_internal_exports.dart'; import 'package:komodo_defi_sdk/src/assets/asset_history_storage.dart'; @@ -65,9 +68,15 @@ class TransactionHistoryManager implements _TransactionHistoryManager { final _streamControllers = >{}; final _txHistorySubscriptions = >{}; + final _pollingTimers = {}; + final _balanceFallbackSubscriptions = + >{}; + final _lastBalanceForPolling = {}; final _syncInProgress = {}; final _rateLimiter = _RateLimiter(const Duration(milliseconds: 500)); + static const _defaultPollingInterval = Duration(seconds: 30); + static const _maxPollingRetries = 3; static const _maxBatchSize = 50; bool _isDisposed = false; @@ -80,10 +89,21 @@ class TransactionHistoryManager implements _TransactionHistoryManager { // Cancel all transaction history subscriptions for (final sub in _txHistorySubscriptions.values) { - sub.cancel(); + unawaited(sub.cancel()); } _txHistorySubscriptions.clear(); + // Cancel polling timers + for (final timer in _pollingTimers.values) { + timer.cancel(); + } + _pollingTimers.clear(); + + for (final sub in _balanceFallbackSubscriptions.values) { + unawaited(sub.cancel()); + } + _balanceFallbackSubscriptions.clear(); + // Close controllers in a separate iteration to avoid modification during iteration final controllers = _streamControllers.values.toList(); _streamControllers.clear(); @@ -432,6 +452,26 @@ class TransactionHistoryManager implements _TransactionHistoryManager { return; } + var hasFallenBack = false; + Future fallbackToPolling({ + String reason = 'stream stopped', + Object? error, + StackTrace? stackTrace, + }) async { + if (hasFallenBack || _isDisposed) return; + hasFallenBack = true; + + if (_txHistorySubscriptions[asset.id] == txHistoryStreamSubscription) { + _txHistorySubscriptions.remove(asset.id); + } + + try { + await txHistoryStreamSubscription.cancel(); + } catch (_) {} + + await _startPolling(asset); + } + _txHistorySubscriptions[asset.id] = txHistoryStreamSubscription ..onData((txHistoryEvent) async { if (_isDisposed) return; @@ -457,27 +497,143 @@ class TransactionHistoryManager implements _TransactionHistoryManager { } } }) - ..onError((Object error) { - final controller = _streamControllers[asset.id]; - if (controller != null && !controller.isClosed) { - controller.addError(error); - } + ..onError((Object error, StackTrace stackTrace) { + unawaited( + fallbackToPolling( + reason: 'stream error', + error: error, + stackTrace: stackTrace, + ), + ); }) ..onDone(() { - _stopStreaming(asset.id); + unawaited(fallbackToPolling(reason: 'stream closed')); }); - } catch (e) { - final controller = _streamControllers[asset.id]; - if (controller != null && !controller.isClosed) { - controller.addError(e); - } + } catch (_) { + await _startPolling(asset); } } void _stopStreaming(AssetId assetId) { - // Cancel transaction history subscription _txHistorySubscriptions[assetId]?.cancel(); _txHistorySubscriptions.remove(assetId); + _stopPolling(assetId); + } + + Future _pollNewTransactions(Asset asset, [int retryCount = 0]) async { + if (_isDisposed) return; + + try { + await _ensureAssetActivated(asset); + final strategy = _strategyFactory.forAsset(asset); + final latestId = await _storage.getLatestTransactionId( + asset.id, + await _getCurrentWalletId(), + ); + + final response = await strategy.fetchTransactionHistory( + _client, + asset, + latestId != null + ? TransactionBasedPagination( + fromId: latestId, + itemCount: _maxBatchSize, + ) + : const PagePagination(pageNumber: 1, itemsPerPage: _maxBatchSize), + ); + + if (!_pollingTimers.containsKey(asset.id)) return; + + if (response.transactions.isNotEmpty) { + final newTransactions = response.transactions + .map((tx) => tx.asTransaction(asset.id)) + .toList(); + + await _batchStoreTransactions(newTransactions); + + final controller = _streamControllers[asset.id]; + if (controller != null && !controller.isClosed) { + for (final tx in newTransactions) { + controller.add(tx); + } + } + } + } catch (_) { + if (!_pollingTimers.containsKey(asset.id)) return; + + if (retryCount < _maxPollingRetries) { + final delaySeconds = math.pow(2, retryCount).toInt(); + await Future.delayed( + Duration(seconds: delaySeconds), + () => _pollNewTransactions(asset, retryCount + 1), + ); + } + } + } + + Future _startPolling(Asset asset) async { + _stopPolling(asset.id); + + try { + final balanceSubscription = await _eventStreamingManager + .subscribeToBalance(coin: asset.id.id); + + _balanceFallbackSubscriptions[asset.id] = balanceSubscription + ..onData((balanceEvent) { + if (_isDisposed) return; + if (balanceEvent.coin != asset.id.id) return; + + final previous = _lastBalanceForPolling[asset.id]; + final current = balanceEvent.balance; + + final hasChanged = + previous == null || + previous.total != current.total || + previous.spendable != current.spendable || + previous.unspendable != current.unspendable; + + if (hasChanged) { + _lastBalanceForPolling[asset.id] = current; + unawaited(_pollNewTransactions(asset)); + } + }) + ..onError((Object error, StackTrace stackTrace) { + _startTimerPolling(asset); + }) + ..onDone(() { + _startTimerPolling(asset); + }); + + // Initial sync to ensure we have the latest data + unawaited(_pollNewTransactions(asset)); + } catch (_) { + _startTimerPolling(asset); + } + } + + void _startTimerPolling(Asset asset) { + final balanceSub = _balanceFallbackSubscriptions.remove(asset.id); + if (balanceSub != null) { + unawaited(balanceSub.cancel()); + } + _pollingTimers[asset.id]?.cancel(); + _pollingTimers[asset.id] = Timer.periodic( + _defaultPollingInterval, + (_) => _pollNewTransactions(asset), + ); + unawaited(_pollNewTransactions(asset)); + } + + void _stopPolling(AssetId assetId) { + _pollingTimers[assetId]?.cancel(); + _pollingTimers.remove(assetId); + + final balanceSub = _balanceFallbackSubscriptions.remove(assetId); + if (balanceSub != null) { + unawaited(balanceSub.cancel()); + } + + _lastBalanceForPolling.remove(assetId); } Future dispose() async { @@ -486,6 +642,17 @@ class TransactionHistoryManager implements _TransactionHistoryManager { await _authSubscription?.cancel(); + for (final sub in _txHistorySubscriptions.values) { + await sub.cancel(); + } + _txHistorySubscriptions.clear(); + + final timers = _pollingTimers.values.toList(); + _pollingTimers.clear(); + for (final timer in timers) { + timer.cancel(); + } + final controllers = _streamControllers.values.toList(); _streamControllers.clear(); for (final controller in controllers) { From 6dd9b328539e8176b0c7da3605ac51e1ca639411 Mon Sep 17 00:00:00 2001 From: Francois Date: Tue, 28 Oct 2025 07:37:22 +0200 Subject: [PATCH 28/35] fix(auth-service): update cache alongside storage for metadata updates --- .../komodo_defi_local_auth/lib/src/auth/auth_service.dart | 7 +++++++ 1 file changed, 7 insertions(+) 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 2cee880e..d60cc602 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 @@ -497,6 +497,13 @@ class KdfAuthService implements IAuthService { final updatedUser = user.copyWith(metadata: metadata); await _secureStorage.saveUser(updatedUser); + + // Update cache silently without triggering auth state change. Updating the + // storage and cache at the same time emulates the same behaviour as before. + // Update user metadata for any subsequent access without emitting auth + // state changes, as the metadata field is currently used for events like + // coin activation, wallet type (derivation), and seed backup status + _lastEmittedUser = updatedUser; } @override From cd9640b501d1d82e043759abbedd86c0060858f4 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Tue, 28 Oct 2025 13:30:38 +0100 Subject: [PATCH 29/35] chore: roll KDF to release preview Roll KDF to the `dev` branch version which will be used for KW release. --- .../app_build/build_config.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/komodo_defi_framework/app_build/build_config.json b/packages/komodo_defi_framework/app_build/build_config.json index ccf8197e..944e27c3 100644 --- a/packages/komodo_defi_framework/app_build/build_config.json +++ b/packages/komodo_defi_framework/app_build/build_config.json @@ -1,7 +1,7 @@ { "api": { - "api_commit_hash": "96023711777feda55990a7510c352485d8a5c7a5", - "branch": "staging", + "api_commit_hash": "9aa41b4c741907d59e4887db08cf84fb78e967e0", + "branch": "dev", "fetch_at_build_enabled": true, "concurrent_downloads_enabled": true, "source_urls": [ @@ -13,14 +13,14 @@ "web": { "matching_pattern": "^(?:kdf_[a-f0-9]{7,40}-wasm|mm2_[a-f0-9]{7,40}-wasm|mm2-[a-f0-9]{7,40}-wasm)\\.zip$", "valid_zip_sha256_checksums": [ - "37738eb7d487aefa125ffed8e2de0be0d4279752234cfb90c94542d6a054d6f3" + "f92d61595317c16b8f8294c038c7c9215aca94187e22aedc7e0adeba93c94d82" ], "path": "web/kdf/bin" }, "ios": { "matching_pattern": "^(?:kdf_[a-f0-9]{7,40}-ios-aarch64|mm2_[a-f0-9]{7,40}-ios-aarch64|mm2-[a-f0-9]{7,40}-ios-aarch64-CI)\\.zip$", "valid_zip_sha256_checksums": [ - "eef4d2f5ddd000d9c6be7b9b1afcd6e1265096ca4d31664b2481ea89493d1a72" + "4f18e9e82ca16e7b1133cabd898d7503f925be1b7d06693f687c34866617da2e" ], "path": "ios" }, @@ -31,35 +31,35 @@ "mac-arm64" ], "valid_zip_sha256_checksums": [ - "3943c7ad8cab1e7263eb9693936909df87ae60816f09d16435d7122411895624" + "169db9b1410888d7da9f6a0ead2e1a05834528982cebbb70a2e2e3d01e220b69" ], "path": "macos/bin" }, "windows": { "matching_pattern": "^(?:kdf_[a-f0-9]{7,40}-win-x86-64|mm2_[a-f0-9]{7,40}-win-x86-64|mm2-[a-f0-9]{7,40}-Win64)\\.zip$", "valid_zip_sha256_checksums": [ - "c875dac3a4e850dffd68a16036350acfbdde21285f35f0889e4b8abd7c75b67f" + "d4f5954071df5c2c23016a69074bf19d3daeba10ab413fda2fce8205ee32184c" ], "path": "windows/bin" }, "android-armv7": { "matching_pattern": "^(?:kdf_[a-f0-9]{7,40}-android-armv7|mm2_[a-f0-9]{7,40}-android-armv7|mm2-[a-f0-9]{7,40}-android-armv7-CI)\\.zip$", "valid_zip_sha256_checksums": [ - "4125917ceacfbc9da6856bf84281e48768e8a6d7f537019575c751ec1cab0164" + "d964f608787683de67ddde7d827dee88185509cc5ffeb6e85d5f7e2aeba84f08" ], "path": "android/app/src/main/cpp/libs/armeabi-v7a" }, "android-aarch64": { "matching_pattern": "^(?:kdf_[a-f0-9]{7,40}-android-aarch64|mm2_[a-f0-9]{7,40}-android-aarch64|mm2-[a-f0-9]{7,40}-android-aarch64-CI)\\.zip$", "valid_zip_sha256_checksums": [ - "c99c96c08c02b9d0ebe91c5dbad57aeda3d0b39d0b960343f8831fd70e0af032" + "32927dfa36a283d344c34cd1514ca8ea3233f20752ec15b7929309cf4df969c2" ], "path": "android/app/src/main/cpp/libs/arm64-v8a" }, "linux": { "matching_pattern": "^(?:kdf_[a-f0-9]{7,40}-linux-x86-64|mm2_[a-f0-9]{7,40}-linux-x86-64|mm2-[a-f0-9]{7,40}-Linux-Release)\\.zip$", "valid_zip_sha256_checksums": [ - "04f57689eba9c7d9a901bae3da7c954f2cfa248140a0112df1ab5920871d2926" + "e3315a46cb9e1957206b36036954bce51d61d8c4784ed878cc93360b9d01c1cb" ], "path": "linux/bin" } From cd542280c5b7a750fde269cab1f684276d2e8609 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Tue, 28 Oct 2025 14:16:35 +0100 Subject: [PATCH 30/35] fix(stream): normalize KDF stream _type parsing for suffixed IDs; format modified files --- .../app_build/build_config.json | 2 +- .../lib/src/streaming/events/kdf_event.dart | 10 +- .../src/activation/activation_exceptions.dart | 72 ++++++ .../eth_task_activation_strategy.dart | 10 +- .../eth_with_tokens_activation_strategy.dart | 35 +-- .../lib/src/balances/balance_manager.dart | 127 ++++++++++ .../lib/src/pubkeys/pubkey_manager.dart | 21 +- ...therscan_transaction_history_strategy.dart | 22 +- .../zhtlc_transaction_strategy.dart | 3 + .../transaction_history_manager.dart | 222 ++++++++++++++++-- .../transaction_history_strategies.dart | 6 + .../lib/src/assets/asset.dart | 8 + .../lib/src/coin_classes/protocol_class.dart | 32 +++ .../transaction_history_strategy.dart | 8 + .../bin/update_api_config.dart | 33 ++- 15 files changed, 542 insertions(+), 69 deletions(-) create mode 100644 packages/komodo_defi_sdk/lib/src/activation/activation_exceptions.dart diff --git a/packages/komodo_defi_framework/app_build/build_config.json b/packages/komodo_defi_framework/app_build/build_config.json index 944e27c3..e0b1483f 100644 --- a/packages/komodo_defi_framework/app_build/build_config.json +++ b/packages/komodo_defi_framework/app_build/build_config.json @@ -68,7 +68,7 @@ "coins": { "fetch_at_build_enabled": true, "update_commit_on_build": true, - "bundled_coins_repo_commit": "7830529ac85d56fe394844798bc20656c9092b41", + "bundled_coins_repo_commit": "561576a817f0fb40663d0ce04a04755b7cf8a8a2", "coins_repo_api_url": "https://api.github.com/repos/KomodoPlatform/coins", "coins_repo_content_url": "https://raw.githubusercontent.com/KomodoPlatform/coins", "coins_repo_branch": "master", diff --git a/packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart b/packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart index 11602dce..ea8da001 100644 --- a/packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart +++ b/packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart @@ -64,7 +64,15 @@ sealed class KdfEvent { } } - return switch (typeString) { + // Some event types include contextual suffixes (e.g. "TX_HISTORY:COIN", + // "ORDERBOOK:BASE:REL"). Normalize by stripping everything after the first + // ':' so the base type can be matched, while keeping message payload for + // concrete details (coin, pair, uuid, etc.). + final normalizedType = typeString.contains(':') + ? typeString.substring(0, typeString.indexOf(':')) + : typeString; + + return switch (normalizedType) { 'BALANCE' => BalanceEvent.fromJson(message), 'ORDERBOOK' => OrderbookEvent.fromJson(message), 'NETWORK' => NetworkEvent.fromJson(message), diff --git a/packages/komodo_defi_sdk/lib/src/activation/activation_exceptions.dart b/packages/komodo_defi_sdk/lib/src/activation/activation_exceptions.dart new file mode 100644 index 00000000..fb742011 --- /dev/null +++ b/packages/komodo_defi_sdk/lib/src/activation/activation_exceptions.dart @@ -0,0 +1,72 @@ +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +/// Base exception for activation-related errors +class ActivationFailedException implements Exception { + const ActivationFailedException({ + required this.assetId, + required this.message, + this.errorCode, + this.originalError, + }); + + final AssetId assetId; + final String message; + final String? errorCode; + final Object? originalError; + + @override + String toString() { + final buffer = StringBuffer('ActivationFailedException: '); + buffer.write('Asset ${assetId.name} activation failed'); + if (errorCode != null) { + buffer.write(' (code: $errorCode)'); + } + buffer.write(': $message'); + return buffer.toString(); + } +} + +/// Exception thrown when asset activation times out +class ActivationTimeoutException extends ActivationFailedException { + const ActivationTimeoutException({ + required super.assetId, + required super.message, + super.errorCode, + super.originalError, + }); + + @override + String toString() { + return 'ActivationTimeoutException: Asset ${assetId.name} activation timed out: $message'; + } +} + +/// Exception thrown when asset activation is not supported +class ActivationNotSupportedException extends ActivationFailedException { + const ActivationNotSupportedException({ + required super.assetId, + required super.message, + super.errorCode, + super.originalError, + }); + + @override + String toString() { + return 'ActivationNotSupportedException: Asset ${assetId.name} activation not supported: $message'; + } +} + +/// Exception thrown when asset activation fails due to network issues +class ActivationNetworkException extends ActivationFailedException { + const ActivationNetworkException({ + required super.assetId, + required super.message, + super.errorCode, + super.originalError, + }); + + @override + String toString() { + return 'ActivationNetworkException: Asset ${assetId.name} activation failed due to network issues: $message'; + } +} diff --git a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_task_activation_strategy.dart b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_task_activation_strategy.dart index 7c755963..e4e62a92 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_task_activation_strategy.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_task_activation_strategy.dart @@ -74,6 +74,13 @@ class EthTaskActivationStrategy extends ProtocolActivationStrategy { ), ); + // Compute tx_history flag similar to non-task strategy + final txHistoryFlag = asset.supportsTxHistoryStreaming + ? true + : const EtherscanProtocolHelper().shouldEnableTransactionHistory( + asset, + ); + final activationParams = EthWithTokensActivationParams.fromJson( asset.protocol.config, @@ -81,8 +88,7 @@ class EthTaskActivationStrategy extends ProtocolActivationStrategy { erc20Tokens: children?.map((e) => TokensRequest(ticker: e.id.id)).toList() ?? [], - txHistory: const EtherscanProtocolHelper() - .shouldEnableTransactionHistory(asset), + txHistory: txHistoryFlag, privKeyPolicy: privKeyPolicy, ); diff --git a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_with_tokens_activation_strategy.dart b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_with_tokens_activation_strategy.dart index f72110ed..d8d3cf56 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_with_tokens_activation_strategy.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_with_tokens_activation_strategy.dart @@ -93,32 +93,33 @@ class EthWithTokensActivationStrategy extends ProtocolActivationStrategy { ), ); - final activationParams = EthWithTokensActivationParams.fromJson(asset.protocol.config) - .copyWith( + // Compute whether to enable tx_history at activation: + // - If tx history streaming is supported by KDF, always true. + // - Else, only true if the chosen history strategy requires KDF tx history. + final txHistoryFlag = asset.supportsTxHistoryStreaming + ? true + : const EtherscanProtocolHelper().shouldEnableTransactionHistory( + asset, + ); + + final activationParams = + EthWithTokensActivationParams.fromJson( + asset.protocol.config, + ).copyWith( erc20Tokens: - children - ?.map((e) => TokensRequest(ticker: e.id.id)) - .toList() ?? + children?.map((e) => TokensRequest(ticker: e.id.id)).toList() ?? [], - txHistory: const EtherscanProtocolHelper() - .shouldEnableTransactionHistory(asset), + txHistory: txHistoryFlag, privKeyPolicy: privKeyPolicy, ); - + // Debug logging for ETH platform activation log( '[RPC] Activating ETH platform: ${asset.id.id}', name: 'EthWithTokensActivationStrategy', ); log( - '[RPC] Activation parameters: ${jsonEncode({ - 'ticker': asset.id.id, - 'protocol': asset.protocol.subClass.formatted, - 'token_count': children?.length ?? 0, - 'tokens': children?.map((e) => e.id.id).toList() ?? [], - 'activation_params': activationParams.toRpcParams(), - 'priv_key_policy': privKeyPolicy.toJson(), - })}', + '[RPC] Activation parameters: ${jsonEncode({'ticker': asset.id.id, 'protocol': asset.protocol.subClass.formatted, 'token_count': children?.length ?? 0, 'tokens': children?.map((e) => e.id.id).toList() ?? [], 'activation_params': activationParams.toRpcParams(), 'priv_key_policy': privKeyPolicy.toJson()})}', name: 'EthWithTokensActivationStrategy', ); @@ -126,7 +127,7 @@ class EthWithTokensActivationStrategy extends ProtocolActivationStrategy { ticker: asset.id.id, params: activationParams, ); - + log( '[RPC] Successfully activated ETH platform: ${asset.id.id} with ${children?.length ?? 0} tokens', name: 'EthWithTokensActivationStrategy', diff --git a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart index a19ef80b..451e8101 100644 --- a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:decimal/decimal.dart'; import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; +import 'package:komodo_defi_sdk/src/activation/activation_exceptions.dart'; import 'package:komodo_defi_sdk/src/activation/activation_manager.dart'; import 'package:komodo_defi_sdk/src/activation/shared_activation_coordinator.dart'; import 'package:komodo_defi_sdk/src/assets/asset_history_storage.dart'; @@ -98,6 +99,9 @@ class BalanceManager implements IBalanceManager { /// Stream controllers for each asset being watched final Map> _balanceControllers = {}; + /// Stale-guard timers to periodically refresh balances even while streaming + final Map _staleBalanceTimers = {}; + /// Current wallet ID being tracked WalletId? _currentWalletId; @@ -121,6 +125,8 @@ class BalanceManager implements IBalanceManager { _pubkeyManager = manager; } + bool _supportsBalanceStreaming(Asset asset) => asset.supportsBalanceStreaming; + /// Handle authentication state changes Future _handleAuthStateChanged(KdfUser? user) async { if (_isDisposed) return; @@ -363,6 +369,20 @@ class BalanceManager implements IBalanceManager { // Ensure asset is activated if needed final isActive = await _ensureAssetActivated(asset, activateIfNeeded); + // If activation was requested but failed, emit error + if (activateIfNeeded && !isActive) { + if (!controller.isClosed) { + controller.addError( + ActivationFailedException( + assetId: assetId, + message: 'Asset activation failed', + errorCode: 'BALANCE_ACTIVATION_ERROR', + ), + ); + } + return; + } + // Mark asset as seen after successful activation if (isActive && isFirstTimeEnabling) { await _assetHistoryStorage.addAssetToWallet(user.walletId, assetId.id); @@ -377,6 +397,16 @@ class BalanceManager implements IBalanceManager { } // Subscribe to balance event stream for real-time updates + if (!_supportsBalanceStreaming(asset)) { + await _startBalancePolling( + asset: asset, + assetId: assetId, + controller: controller, + activateIfNeeded: activateIfNeeded, + ); + return; + } + _logger.fine('Subscribing to balance stream for ${assetId.id}'); final balanceStreamSubscription = await _eventStreamingManager .subscribeToBalance(coin: assetId.id); @@ -439,6 +469,28 @@ class BalanceManager implements IBalanceManager { 'Balance update received for ${assetId.name}: ${balanceEvent.balance.total}', ); } + + // Trigger background refresh to sync per-address balances + // This ensures address balances match the updated total + // and notifies any watchPubkeys stream listeners + if (_pubkeyManager != null) { + _pubkeyManager! + .precachePubkeys(asset) + .then((_) { + _logger.fine( + 'Pubkeys refreshed after balance update for ' + '${assetId.name}', + ); + }) + .catchError((Object e, StackTrace s) { + _logger.fine( + 'Failed to refresh pubkeys for ${assetId.name}', + e, + s, + ); + }) + .ignore(); + } }) ..onError((Object error, StackTrace stackTrace) { unawaited( @@ -452,6 +504,14 @@ class BalanceManager implements IBalanceManager { ..onDone(() { unawaited(fallbackToPolling(reason: 'stream closed')); }); + + // Start stale-guard to periodically confirm balance in case of missed events + _startStaleBalanceGuard( + asset: asset, + assetId: assetId, + controller: controller, + activateIfNeeded: activateIfNeeded, + ); } catch (e, s) { _logger.warning( 'Failed to start balance watcher for ${assetId.name}', @@ -560,10 +620,71 @@ class BalanceManager implements IBalanceManager { _activeWatchers.remove(assetId); _logger.fine('Stopped watcher for ${assetId.name}'); } + _stopStaleBalanceGuard(assetId); // Don't close the controller here, just remove the watcher // The controller will be closed when all listeners are gone } + void _startStaleBalanceGuard({ + required Asset asset, + required AssetId assetId, + required StreamController controller, + required bool activateIfNeeded, + }) { + // Cancel any existing timer first + _staleBalanceTimers[assetId]?.cancel(); + + _staleBalanceTimers[assetId] = Timer.periodic(_defaultPollingInterval, ( + _, + ) async { + if (_isDisposed || controller.isClosed) return; + try { + final isActive = await _ensureAssetActivated(asset, activateIfNeeded); + if (!isActive) return; + + final latest = await getBalance(assetId); + final previous = _balanceCache[assetId]; + final changed = + previous == null || + previous.total != latest.total || + previous.spendable != latest.spendable || + previous.unspendable != latest.unspendable; + if (changed) { + _balanceCache[assetId] = latest; + if (!controller.isClosed) { + controller.add(latest); + } + } + } catch (_) { + // best-effort; swallow transient errors + } + }); + + // Kick off an immediate one-shot refresh + unawaited(() async { + try { + final isActive = await _ensureAssetActivated(asset, activateIfNeeded); + if (!isActive) return; + final latest = await getBalance(assetId); + final previous = _balanceCache[assetId]; + final changed = + previous == null || + previous.total != latest.total || + previous.spendable != latest.spendable || + previous.unspendable != latest.unspendable; + if (changed && !controller.isClosed) { + _balanceCache[assetId] = latest; + controller.add(latest); + } + } catch (_) {} + }()); + } + + void _stopStaleBalanceGuard(AssetId assetId) { + _staleBalanceTimers[assetId]?.cancel(); + _staleBalanceTimers.remove(assetId); + } + @override BalanceInfo? lastKnown(AssetId assetId) { if (_isDisposed) { @@ -628,6 +749,12 @@ class BalanceManager implements IBalanceManager { _balanceCache.clear(); _currentWalletId = null; _logger.fine('Disposed'); + + // Cancel any remaining stale-guard timers + for (final timer in _staleBalanceTimers.values) { + timer.cancel(); + } + _staleBalanceTimers.clear(); } @override diff --git a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart index be6d1b30..c25b6cbf 100644 --- a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; import 'package:komodo_defi_sdk/src/_internal_exports.dart'; +import 'package:komodo_defi_sdk/src/activation/activation_exceptions.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'; @@ -111,7 +112,7 @@ class PubkeyManager implements IPubkeyManager { .catchError((_) { // best-effort background refresh }); - unawaited(refreshFuture); + refreshFuture.ignore(); return hydrated; } @@ -174,7 +175,7 @@ class PubkeyManager implements IPubkeyManager { final strategy = await _resolvePubkeyStrategy(asset); final pubkeys = await strategy.getPubkeys(asset.id, _client); _pubkeysCache[asset.id] = pubkeys; - unawaited(_persistPubkeysForWallet(walletId, asset, pubkeys)); + _persistPubkeysForWallet(walletId, asset, pubkeys).ignore(); return pubkeys; }(); @@ -387,7 +388,21 @@ class PubkeyManager implements IPubkeyManager { cancelOnError: false, ); } catch (e) { - if (!controller.isClosed) controller.addError(e); + if (!controller.isClosed) { + if (e is ActivationFailedException) { + controller.addError(e); + } else { + // Wrap other errors in ActivationFailedException for consistency + controller.addError( + ActivationFailedException( + assetId: asset.id, + message: e.toString(), + errorCode: 'PUBKEY_ACTIVATION_ERROR', + originalError: e, + ), + ); + } + } } } diff --git a/packages/komodo_defi_sdk/lib/src/transaction_history/strategies/etherscan_transaction_history_strategy.dart b/packages/komodo_defi_sdk/lib/src/transaction_history/strategies/etherscan_transaction_history_strategy.dart index 1a9d5b10..63c12c03 100644 --- a/packages/komodo_defi_sdk/lib/src/transaction_history/strategies/etherscan_transaction_history_strategy.dart +++ b/packages/komodo_defi_sdk/lib/src/transaction_history/strategies/etherscan_transaction_history_strategy.dart @@ -30,6 +30,13 @@ class EtherscanTransactionStrategy extends TransactionHistoryStrategy { @override bool supportsAsset(Asset asset) => _protocolHelper.supportsProtocol(asset); + @override + bool requiresKdfTransactionHistory(Asset asset) { + // Etherscan-backed history does not require KDF tx_history to be enabled + // for pagination; streaming remains disabled for EVM in KDF. + return false; + } + @override Future fetchTransactionHistory( ApiClient client, @@ -221,16 +228,13 @@ class EtherscanProtocolHelper { return asset.protocol is Erc20Protocol && getApiUrlForAsset(asset) != null; } - /// Whether transaction history should be enabled in KDF during activation. - /// - /// This must always return `true` because the SDK now uses event streaming - /// for real-time transaction updates. Even for assets supported by Etherscan, - /// KDF's transaction history must be enabled to allow the streaming system - /// to emit transaction events. + /// Whether KDF transaction history should be enabled during activation. /// - /// Note: The Etherscan strategy is still used for fetching historical - /// transactions (pagination), while streaming provides real-time updates. - bool shouldEnableTransactionHistory(Asset asset) => true; + /// For EVM-compatible assets handled by the Etherscan strategy, we do not + /// require KDF's tx_history to be enabled at activation because history is + /// sourced externally and KDF does not support tx history streaming for EVM. + /// This reduces unnecessary RPC work during activation. + bool shouldEnableTransactionHistory(Asset asset) => false; /// Constructs the appropriate API URL for a given asset Uri? getApiUrlForAsset(Asset asset) { diff --git a/packages/komodo_defi_sdk/lib/src/transaction_history/strategies/zhtlc_transaction_strategy.dart b/packages/komodo_defi_sdk/lib/src/transaction_history/strategies/zhtlc_transaction_strategy.dart index 6e3490ef..f0e99cdb 100644 --- a/packages/komodo_defi_sdk/lib/src/transaction_history/strategies/zhtlc_transaction_strategy.dart +++ b/packages/komodo_defi_sdk/lib/src/transaction_history/strategies/zhtlc_transaction_strategy.dart @@ -42,4 +42,7 @@ class ZhtlcTransactionStrategy extends TransactionHistoryStrategy { @override bool supportsAsset(Asset asset) => asset.protocol is ZhtlcProtocol; + + @override + bool requiresKdfTransactionHistory(Asset asset) => true; } diff --git a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart index 6c02c4e9..8383ce63 100644 --- a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_manager.dart @@ -5,6 +5,7 @@ import 'package:komodo_defi_framework/komodo_defi_framework.dart' show BalanceEvent; import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; import 'package:komodo_defi_sdk/src/_internal_exports.dart'; +import 'package:komodo_defi_sdk/src/activation/activation_exceptions.dart'; import 'package:komodo_defi_sdk/src/assets/asset_history_storage.dart'; import 'package:komodo_defi_sdk/src/pubkeys/pubkey_manager.dart'; import 'package:komodo_defi_sdk/src/streaming/event_streaming_manager.dart'; @@ -69,6 +70,8 @@ class TransactionHistoryManager implements _TransactionHistoryManager { final _streamControllers = >{}; final _txHistorySubscriptions = >{}; final _pollingTimers = {}; + // Periodic confirmations refresh timers while streaming is healthy + final _confirmationsTimers = {}; final _balanceFallbackSubscriptions = >{}; final _lastBalanceForPolling = {}; @@ -84,12 +87,18 @@ class TransactionHistoryManager implements _TransactionHistoryManager { final TransactionHistoryStrategyFactory _strategyFactory; + // Streaming capability helpers based on asset properties + bool _supportsBalanceStreaming(Asset asset) => asset.supportsBalanceStreaming; + + bool _supportsTxHistoryStreaming(Asset asset) => + asset.supportsTxHistoryStreaming; + void _stopAllStreaming() { if (_isDisposed) return; // Cancel all transaction history subscriptions for (final sub in _txHistorySubscriptions.values) { - unawaited(sub.cancel()); + sub.cancel().ignore(); } _txHistorySubscriptions.clear(); @@ -99,8 +108,14 @@ class TransactionHistoryManager implements _TransactionHistoryManager { } _pollingTimers.clear(); + // Cancel confirmations refresh timers + for (final timer in _confirmationsTimers.values) { + timer.cancel(); + } + _confirmationsTimers.clear(); + for (final sub in _balanceFallbackSubscriptions.values) { - unawaited(sub.cancel()); + sub.cancel().ignore(); } _balanceFallbackSubscriptions.clear(); @@ -240,7 +255,21 @@ class TransactionHistoryManager implements _TransactionHistoryManager { throw ArgumentError('Asset ${asset.id.name} not found'); } - await _ensureAssetActivated(asset); + try { + await _ensureAssetActivated(asset); + } catch (e) { + if (e is ActivationFailedException) { + rethrow; + } else { + // Wrap other errors in ActivationFailedException for consistency + throw ActivationFailedException( + assetId: asset.id, + message: e.toString(), + errorCode: 'TX_HISTORY_ACTIVATION_ERROR', + originalError: e, + ); + } + } final strategy = _strategyFactory.forAsset(asset); // First try to get any cached transactions @@ -399,8 +428,11 @@ class TransactionHistoryManager implements _TransactionHistoryManager { Future _ensureAssetActivated(Asset asset) async { final activationResult = await _activationCoordinator.activateAsset(asset); if (activationResult.isFailure) { - throw StateError( - 'Failed to activate asset ${asset.id.name}. ${activationResult.errorMessage}', + throw ActivationFailedException( + assetId: asset.id, + message: activationResult.errorMessage ?? 'Unknown activation error', + errorCode: 'ACTIVATION_FAILED', + originalError: activationResult.errorMessage, ); } } @@ -436,13 +468,30 @@ class TransactionHistoryManager implements _TransactionHistoryManager { } catch (e) { final controller = _streamControllers[asset.id]; if (controller != null && !controller.isClosed) { - controller.addError(e); + if (e is ActivationFailedException) { + controller.addError(e); + } else { + // Wrap other errors in ActivationFailedException for consistency + controller.addError( + ActivationFailedException( + assetId: asset.id, + message: e.toString(), + errorCode: 'TX_WATCH_ACTIVATION_ERROR', + originalError: e, + ), + ); + } } return; } // Subscribe to transaction history event stream for real-time updates try { + // Gate by KDF capability to avoid unsupported streaming RPCs + if (!_supportsTxHistoryStreaming(asset)) { + await _startPolling(asset); + return; + } final txHistoryStreamSubscription = await _eventStreamingManager .subscribeToTxHistory(coin: asset.id.id); @@ -509,6 +558,9 @@ class TransactionHistoryManager implements _TransactionHistoryManager { ..onDone(() { unawaited(fallbackToPolling(reason: 'stream closed')); }); + + // Keep confirmations fresh even while the stream is healthy + _startConfirmationsRefresh(asset); } catch (_) { await _startPolling(asset); } @@ -518,6 +570,62 @@ class TransactionHistoryManager implements _TransactionHistoryManager { _txHistorySubscriptions[assetId]?.cancel(); _txHistorySubscriptions.remove(assetId); _stopPolling(assetId); + _stopConfirmationsRefresh(assetId); + } + + bool _isPollingActive(AssetId assetId) => + _pollingTimers.containsKey(assetId) || + _balanceFallbackSubscriptions.containsKey(assetId); + + bool _updateLastKnownBalance(AssetId assetId, BalanceInfo balance) { + final previous = _lastBalanceForPolling[assetId]; + _lastBalanceForPolling[assetId] = balance; + + return previous == null || + previous.total != balance.total || + previous.spendable != balance.spendable || + previous.unspendable != balance.unspendable; + } + + Future _syncHistoryIfBalanceChanged( + Asset asset, { + BalanceInfo? balance, + bool force = false, + }) async { + if (_isDisposed) return; + if (!_isPollingActive(asset.id)) return; + + var shouldSync = force; + + if (balance != null) { + final hasChanged = _updateLastKnownBalance(asset.id, balance); + shouldSync = shouldSync || hasChanged; + } + + if (!shouldSync) return; + + await _pollNewTransactions(asset); + } + + Future _pollBalanceAndSyncHistory( + Asset asset, { + bool force = false, + }) async { + if (_isDisposed) return; + + try { + await _ensureAssetActivated(asset); + final response = await _client.rpc.wallet.myBalance(coin: asset.id.id); + await _syncHistoryIfBalanceChanged( + asset, + balance: response.balance, + force: force, + ); + } catch (_) { + if (force) { + await _pollNewTransactions(asset); + } + } } Future _pollNewTransactions(Asset asset, [int retryCount = 0]) async { @@ -542,7 +650,7 @@ class TransactionHistoryManager implements _TransactionHistoryManager { : const PagePagination(pageNumber: 1, itemsPerPage: _maxBatchSize), ); - if (!_pollingTimers.containsKey(asset.id)) return; + if (!_isPollingActive(asset.id)) return; if (response.transactions.isNotEmpty) { final newTransactions = response.transactions @@ -575,6 +683,12 @@ class TransactionHistoryManager implements _TransactionHistoryManager { _stopPolling(asset.id); try { + // Prefer balance event stream when supported; otherwise, use timer polling + if (!_supportsBalanceStreaming(asset)) { + _startTimerPolling(asset); + return; + } + final balanceSubscription = await _eventStreamingManager .subscribeToBalance(coin: asset.id.id); @@ -583,19 +697,10 @@ class TransactionHistoryManager implements _TransactionHistoryManager { if (_isDisposed) return; if (balanceEvent.coin != asset.id.id) return; - final previous = _lastBalanceForPolling[asset.id]; - final current = balanceEvent.balance; - - final hasChanged = - previous == null || - previous.total != current.total || - previous.spendable != current.spendable || - previous.unspendable != current.unspendable; - - if (hasChanged) { - _lastBalanceForPolling[asset.id] = current; - unawaited(_pollNewTransactions(asset)); - } + _syncHistoryIfBalanceChanged( + asset, + balance: balanceEvent.balance, + ).ignore(); }) ..onError((Object error, StackTrace stackTrace) { _startTimerPolling(asset); @@ -604,8 +709,9 @@ class TransactionHistoryManager implements _TransactionHistoryManager { _startTimerPolling(asset); }); - // Initial sync to ensure we have the latest data - unawaited(_pollNewTransactions(asset)); + // Initial sync to ensure we have the latest data without + // immediately resorting to history polling on every interval. + unawaited(_pollBalanceAndSyncHistory(asset, force: true)); } catch (_) { _startTimerPolling(asset); } @@ -614,14 +720,14 @@ class TransactionHistoryManager implements _TransactionHistoryManager { void _startTimerPolling(Asset asset) { final balanceSub = _balanceFallbackSubscriptions.remove(asset.id); if (balanceSub != null) { - unawaited(balanceSub.cancel()); + balanceSub.cancel().ignore(); } _pollingTimers[asset.id]?.cancel(); _pollingTimers[asset.id] = Timer.periodic( _defaultPollingInterval, - (_) => _pollNewTransactions(asset), + (_) => _pollBalanceAndSyncHistory(asset).ignore(), ); - unawaited(_pollNewTransactions(asset)); + _pollBalanceAndSyncHistory(asset, force: true).ignore(); } void _stopPolling(AssetId assetId) { @@ -630,12 +736,70 @@ class TransactionHistoryManager implements _TransactionHistoryManager { final balanceSub = _balanceFallbackSubscriptions.remove(assetId); if (balanceSub != null) { - unawaited(balanceSub.cancel()); + balanceSub.cancel().ignore(); } _lastBalanceForPolling.remove(assetId); } + // Periodically refresh the most recent transactions to update confirmations + void _startConfirmationsRefresh(Asset asset) { + // Cancel any existing timer first + _confirmationsTimers[asset.id]?.cancel(); + + _confirmationsTimers[asset.id] = Timer.periodic( + _defaultPollingInterval, + (_) => _refreshRecentConfirmations(asset), + ); + + // Kick off an immediate refresh + _refreshRecentConfirmations(asset).ignore(); + } + + void _stopConfirmationsRefresh(AssetId assetId) { + _confirmationsTimers[assetId]?.cancel(); + _confirmationsTimers.remove(assetId); + } + + Future _refreshRecentConfirmations(Asset asset) async { + if (_isDisposed) return; + + try { + // Avoid hammering the backend + await _rateLimiter.throttle(); + + // Ensure asset is active (no-op if already active) + await _ensureAssetActivated(asset); + + final strategy = _strategyFactory.forAsset(asset); + // Fetch the first page to update the most recent txs' confirmations + final response = await strategy.fetchTransactionHistory( + _client, + asset, + const PagePagination(pageNumber: 1, itemsPerPage: _maxBatchSize), + ); + + if (_isDisposed) return; + + if (response.transactions.isEmpty) return; + + final transactions = response.transactions + .map((tx) => tx.asTransaction(asset.id)) + .toList(); + + await _batchStoreTransactions(transactions); + + final controller = _streamControllers[asset.id]; + if (controller != null && !controller.isClosed) { + for (final tx in transactions) { + controller.add(tx); + } + } + } catch (_) { + // Best-effort refresh; swallow transient errors + } + } + Future dispose() async { if (_isDisposed) return; _isDisposed = true; @@ -660,6 +824,12 @@ class TransactionHistoryManager implements _TransactionHistoryManager { } _syncInProgress.clear(); + + // Cancel confirmations refresh timers + for (final timer in _confirmationsTimers.values) { + timer.cancel(); + } + _confirmationsTimers.clear(); } } diff --git a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_strategies.dart b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_strategies.dart index aefe6318..87620db9 100644 --- a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_strategies.dart +++ b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_history_strategies.dart @@ -91,6 +91,9 @@ class V2TransactionStrategy extends TransactionHistoryStrategy { @override bool supportsAsset(Asset asset) => _supportedProtocols.any((type) => asset.protocol.runtimeType == type); + + @override + bool requiresKdfTransactionHistory(Asset asset) => true; } /// Strategy for fetching transaction history using the legacy API @@ -131,6 +134,9 @@ class LegacyTransactionStrategy extends TransactionHistoryStrategy { @override bool supportsAsset(Asset asset) => asset.protocol is! ZhtlcProtocol; + + @override + bool requiresKdfTransactionHistory(Asset asset) => true; } /// Strategy for fetching ZHTLC transaction history diff --git a/packages/komodo_defi_types/lib/src/assets/asset.dart b/packages/komodo_defi_types/lib/src/assets/asset.dart index 6a702518..0c44026f 100644 --- a/packages/komodo_defi_types/lib/src/assets/asset.dart +++ b/packages/komodo_defi_types/lib/src/assets/asset.dart @@ -85,6 +85,14 @@ class Asset extends Equatable { ); } + /// Whether KDF supports balance streaming for this asset. + bool get supportsBalanceStreaming => + protocol.supportsBalanceStreaming(isChildAsset: id.parentId != null); + + /// Whether KDF supports transaction history streaming for this asset. + bool get supportsTxHistoryStreaming => + protocol.supportsTxHistoryStreaming(isChildAsset: id.parentId != null); + JsonMap toJson() => { 'protocol': protocol.toJson(), 'id': id.toJson(), diff --git a/packages/komodo_defi_types/lib/src/coin_classes/protocol_class.dart b/packages/komodo_defi_types/lib/src/coin_classes/protocol_class.dart index 557304ad..659f9e59 100644 --- a/packages/komodo_defi_types/lib/src/coin_classes/protocol_class.dart +++ b/packages/komodo_defi_types/lib/src/coin_classes/protocol_class.dart @@ -139,6 +139,38 @@ abstract class ProtocolClass with ExplorerUrlMixin implements Equatable { return ProtocolClass.fromJson(variantConfig); } + /// Declarative streaming capabilities based on protocol subclass and + /// whether the asset is a child token (e.g. QRC20 token). + bool supportsBalanceStreaming({required bool isChildAsset}) { + // Unsupported: SLP tokens, Tendermint tokens, Sia (feature-gated) + if (subClass == CoinSubClass.slp || + subClass == CoinSubClass.tendermintToken || + subClass == CoinSubClass.sia) { + return false; + } + // QRC20 tokens (child assets) do not support balance streaming + if (subClass == CoinSubClass.qrc20 && isChildAsset) { + return false; + } + return true; + } + + bool supportsTxHistoryStreaming({required bool isChildAsset}) { + // EVM does not support tx history streaming in KDF + if (evmCoinSubClasses.contains(subClass)) { + return false; + } + // Unsupported: QRC20 tokens, SLP tokens, Tendermint tokens + if (subClass == CoinSubClass.slp || + subClass == CoinSubClass.tendermintToken) { + return false; + } + if (subClass == CoinSubClass.qrc20 && isChildAsset) { + return false; + } + return true; + } + ActivationParams defaultActivationParams({ PrivateKeyPolicy privKeyPolicy = const PrivateKeyPolicy.contextPrivKey(), }) => ActivationParams.fromConfigJson( diff --git a/packages/komodo_defi_types/lib/src/transactions/transaction_history_strategy.dart b/packages/komodo_defi_types/lib/src/transactions/transaction_history_strategy.dart index 86744414..2b8e6a0f 100644 --- a/packages/komodo_defi_types/lib/src/transactions/transaction_history_strategy.dart +++ b/packages/komodo_defi_types/lib/src/transactions/transaction_history_strategy.dart @@ -19,6 +19,14 @@ abstract class TransactionHistoryStrategy { /// Whether this strategy supports the given asset bool supportsAsset(Asset asset); + /// Whether this strategy requires KDF transaction history to be enabled + /// during activation for real-time updates and pagination to work. + /// + /// Default is true; strategies that source history externally (e.g. Etherscan) + /// can override to false so activation can skip setting `tx_history` when + /// streaming is also unsupported for the asset. + bool requiresKdfTransactionHistory(Asset asset) => true; + /// Whether this strategy supports the given pagination mode bool supportsPaginationMode(Type paginationType) { return supportedPaginationModes.contains(paginationType); diff --git a/packages/komodo_wallet_cli/bin/update_api_config.dart b/packages/komodo_wallet_cli/bin/update_api_config.dart index 70bc7c69..a4711be3 100644 --- a/packages/komodo_wallet_cli/bin/update_api_config.dart +++ b/packages/komodo_wallet_cli/bin/update_api_config.dart @@ -426,17 +426,30 @@ class KdfFetcher { final checksum = await calculateChecksum(zipFilePath); log.info('Calculated checksum: $checksum'); - // Update platform config with new checksum (accumulate unique) - final checksums = - (platformConfig['valid_zip_sha256_checksums'] as List) - .map((e) => e.toString()) - .toSet(); - if (!checksums.contains(checksum)) { - checksums.add(checksum); - platformConfig['valid_zip_sha256_checksums'] = checksums.toList(); - log.info('Added new checksum to platform config: $checksum'); + // Replace existing checksums when the commit changes; otherwise, accumulate + final previousCommit = (apiConfig['api_commit_hash'] as String?); + final isCommitChanged = + previousCommit == null || previousCommit != commitHash; + + if (isCommitChanged) { + platformConfig['valid_zip_sha256_checksums'] = [checksum]; + log.info( + 'API commit changed from ${previousCommit ?? 'undefined'} to $commitHash; ' + 'replaced existing checksums for platform $platform', + ); } else { - log.info('Checksum already exists in platform config'); + // Update platform config with new checksum (accumulate unique) + final checksums = + (platformConfig['valid_zip_sha256_checksums'] as List) + .map((e) => e.toString()) + .toSet(); + if (!checksums.contains(checksum)) { + checksums.add(checksum); + platformConfig['valid_zip_sha256_checksums'] = checksums.toList(); + log.info('Added new checksum to platform config: $checksum'); + } else { + log.info('Checksum already exists in platform config'); + } } } catch (e) { log.severe('Error updating platform config for $platform: $e'); From 8b6ebedcefc46ab82b004468a4d661451dea7360 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Tue, 28 Oct 2025 15:52:08 +0100 Subject: [PATCH 31/35] fix: use correct streaming worker js path --- .../assets/web/event_streaming_worker.js | 6 +- .../src/config/event_streaming_config.dart | 17 ++++-- .../lib/src/config/kdf_startup_config.dart | 4 +- .../event_streaming_platform_io.dart | 58 +++++++++---------- 4 files changed, 43 insertions(+), 42 deletions(-) diff --git a/packages/komodo_defi_framework/assets/web/event_streaming_worker.js b/packages/komodo_defi_framework/assets/web/event_streaming_worker.js index 1897a52f..f80d791b 100644 --- a/packages/komodo_defi_framework/assets/web/event_streaming_worker.js +++ b/packages/komodo_defi_framework/assets/web/event_streaming_worker.js @@ -12,10 +12,8 @@ onconnect = function (e) { try { const data = msgEvent.data; for (const p of connections) { - try { p.postMessage(data); } catch (_) {} + try { p.postMessage(data); } catch (_) { } } - } catch (_) {} + } catch (_) { } }; }; - - diff --git a/packages/komodo_defi_framework/lib/src/config/event_streaming_config.dart b/packages/komodo_defi_framework/lib/src/config/event_streaming_config.dart index 645070c1..fcaebcd2 100644 --- a/packages/komodo_defi_framework/lib/src/config/event_streaming_config.dart +++ b/packages/komodo_defi_framework/lib/src/config/event_streaming_config.dart @@ -10,12 +10,18 @@ class EventStreamingConfiguration { this.workerPath, }); + /// Default configuration with permissive CORS + EventStreamingConfiguration.defaultConfig() + : workerPath = _kDefaultWorkerPath, + accessControlAllowOrigin = _kDefaultAccessControlAllowOrigin; + /// Create from JSON factory EventStreamingConfiguration.fromJson(JsonMap json) { return EventStreamingConfiguration( accessControlAllowOrigin: - json['access_control_allow_origin'] as String? ?? '*', - workerPath: json['worker_path'] as String?, + json['access_control_allow_origin'] as String? ?? + _kDefaultAccessControlAllowOrigin, + workerPath: json['worker_path'] as String? ?? _kDefaultWorkerPath, ); } @@ -27,9 +33,10 @@ class EventStreamingConfiguration { /// Optional, defaults to null final String? workerPath; - /// Default configuration with permissive CORS - static const EventStreamingConfiguration defaultConfig = - EventStreamingConfiguration(); + static const String _kDefaultWorkerPath = + 'assets/packages/komodo_defi_framework/assets/web/event_streaming_worker.js'; + + static const String _kDefaultAccessControlAllowOrigin = '*'; /// Convert to JSON format for KDF startup configuration JsonMap toJson() => { diff --git a/packages/komodo_defi_framework/lib/src/config/kdf_startup_config.dart b/packages/komodo_defi_framework/lib/src/config/kdf_startup_config.dart index 60e99a54..a3eb0b0f 100644 --- a/packages/komodo_defi_framework/lib/src/config/kdf_startup_config.dart +++ b/packages/komodo_defi_framework/lib/src/config/kdf_startup_config.dart @@ -149,7 +149,7 @@ class KdfStartupConfig { enableHd: enableHd, eventStreamingConfiguration: eventStreamingConfiguration ?? - EventStreamingConfiguration.defaultConfig, + EventStreamingConfiguration.defaultConfig(), ); } @@ -205,7 +205,7 @@ class KdfStartupConfig { isBootstrapNode: false, eventStreamingConfiguration: eventStreamingConfiguration ?? - EventStreamingConfiguration.defaultConfig, + EventStreamingConfiguration.defaultConfig(), ); } diff --git a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_io.dart b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_io.dart index b7b664a4..d488b5cd 100644 --- a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_io.dart +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_io.dart @@ -1,83 +1,79 @@ import 'dart:async'; -import 'dart:convert'; import 'package:flutter/foundation.dart'; -import 'package:flutter_client_sse/flutter_client_sse.dart' as sse; import 'package:flutter_client_sse/constants/sse_request_type_enum.dart' as sset; +import 'package:flutter_client_sse/flutter_client_sse.dart' as sse; import 'package:komodo_defi_framework/src/config/kdf_config.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; typedef EventStreamUnsubscribe = void Function(); -// Centralized constants to avoid repeated literals -const String _kEventStreamPath = '/event-stream'; -const String _kLocalRpcBaseUrl = 'http://127.0.0.1:7783'; -const Map _defaultSseHeaders = {}; - -String _composeEventsPath(String basePath) { - if (basePath.isEmpty || basePath == '/') return _kEventStreamPath; - return basePath.endsWith('/') - ? '${basePath.substring(0, basePath.length)}event-stream' - : '$basePath$_kEventStreamPath'; -} - -bool _isLikelyJson(String data) => data.startsWith('{') || data.startsWith('['); - Uri _buildEventsUrl(IKdfHostConfig hostConfig) { if (hostConfig is RemoteConfig) { - final uri = hostConfig.rpcUrl; - final eventsPath = _composeEventsPath(uri.path); - return uri.replace(path: eventsPath); + final Uri base = hostConfig.rpcUrl; + return base.replace( + pathSegments: [...base.pathSegments, 'event-stream'], + ); } - final uri = Uri.parse(_kLocalRpcBaseUrl); - return uri.replace(path: _kEventStreamPath); + return Uri( + scheme: 'http', + host: '127.0.0.1', + port: 7783, + pathSegments: const ['event-stream'], + ); } EventStreamUnsubscribe connectEventStream({ - IKdfHostConfig? hostConfig, required void Function(Object? data) onMessage, + IKdfHostConfig? hostConfig, }) { - assert(hostConfig != null, 'hostConfig is required'); final IKdfHostConfig cfg = hostConfig!; final Uri url = _buildEventsUrl(cfg); - final String urlString = url.toString(); bool isClosed = false; StreamSubscription? sub; void log(String msg) { if (kDebugMode) { + // TODO: Move to central logging system print('[EventStream][IO] $msg'); } } Future start() async { try { + // Some servers accept rpc_pass in headers, but KDF exposes `?userpass=` + // as query for SSE. We still set Accept header to ensure SSE content type. sub = sse.SSEClient.subscribeToSSE( - url: urlString, + url: url.toString(), method: sset.SSERequestType.GET, - header: _defaultSseHeaders, + header: { + 'userpass': cfg.rpcPassword, + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, ).listen( (event) { final String? raw = event.data; if (raw == null) return; final String data = raw.trim(); if (data.isEmpty) return; - final bool looksJson = _isLikelyJson(data); - if (!looksJson) return; try { - final decoded = json.decode(data); + final decoded = jsonFromString(data); onMessage(decoded); } catch (e) { log('Failed to decode event data: $e'); } }, - onError: (Object error, StackTrace stack) { + onError: (Object error) { log('SSE error: $error'); }, ); - log('Connected to $urlString'); + log('Connected to $url'); } catch (e) { log('Failed to start SSE: $e'); } From 47a4adddd007825d7e2ff54ed07cc4dd1c7fd75c Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Tue, 28 Oct 2025 16:18:59 +0100 Subject: [PATCH 32/35] fix: tx history streaming --- .../streaming/event_streaming_service.dart | 29 +++++++++++++++++- .../streaming/events/tx_history_event.dart | 30 +++++++++++-------- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart index 01f766b5..b5117939 100644 --- a/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart @@ -2,6 +2,7 @@ // messages from the WASM layer using `mm2_net::handle_worker_stream`. import 'dart:async'; +import 'dart:convert' as convert; import 'package:flutter/foundation.dart'; import 'package:komodo_defi_framework/src/config/kdf_config.dart'; @@ -35,7 +36,33 @@ class KdfEventStreamingService { void _onIncomingData(Object? data) { try { - final map = JsonMap.from(data! as Map); + if (data == null) return; + JsonMap? map; + + if (data is String) { + final String trimmed = data.trim(); + // First attempt: direct JSON object string + map = tryParseJson(trimmed); + if (map == null) { + // Second attempt: payload is a JSON string wrapped in quotes + try { + final dynamic once = convert.jsonDecode(trimmed); + if (once is String) { + map = tryParseJson(once); + } else if (once is Map) { + map = JsonMap.from(once); + } + } catch (_) {} + } + + if (map == null) { + throw ArgumentError('Unsupported event payload string'); + } + } else if (data is Map) { + map = JsonMap.from(data); + } else { + throw ArgumentError('Unsupported event data type: ${data.runtimeType}'); + } final event = KdfEvent.fromJson(map); if (kDebugMode) { final summary = _summarizeEvent(event); diff --git a/packages/komodo_defi_framework/lib/src/streaming/events/tx_history_event.dart b/packages/komodo_defi_framework/lib/src/streaming/events/tx_history_event.dart index 826bd0c1..1f31fb84 100644 --- a/packages/komodo_defi_framework/lib/src/streaming/events/tx_history_event.dart +++ b/packages/komodo_defi_framework/lib/src/streaming/events/tx_history_event.dart @@ -2,22 +2,29 @@ part of 'kdf_event.dart'; /// Transaction history event from stream::tx_history::enable class TxHistoryEvent extends KdfEvent { - TxHistoryEvent({ - required this.coin, - required this.transactions, - }); + TxHistoryEvent({required this.coin, required this.transactions}); @override EventTypeString get typeEnum => EventTypeString.txHistory; factory TxHistoryEvent.fromJson(JsonMap json) { - final txList = json.value>('transactions'); - return TxHistoryEvent( - coin: json.value('coin'), - transactions: txList - .map((tx) => TransactionInfo.fromJson(tx as JsonMap)) - .toList(), - ); + // Some backends emit a single transaction object as the message payload + // instead of wrapping it in a { transactions: [...] } structure. + // Support both shapes by normalizing to a list. + final String coin = json.value('coin'); + + final List txList = json.containsKey('transactions') + ? json.value>('transactions') + : [json]; + + final List parsed = txList.map((dynamic tx) { + final JsonMap map = tx is Map ? JsonMap.from(tx) : (tx as JsonMap); + // Ensure required fields exist with sensible defaults for streaming + map.putIfAbsent('confirmations', () => 0); + return TransactionInfo.fromJson(map); + }).toList(); + + return TxHistoryEvent(coin: coin, transactions: parsed); } /// The coin ticker this transaction history is for @@ -30,4 +37,3 @@ class TxHistoryEvent extends KdfEvent { String toString() => 'TxHistoryEvent(coin: $coin, transactions: ${transactions.length})'; } - From 2bd1c68a89c8fb28867244f378d97977b2f1af3b Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Tue, 28 Oct 2025 16:27:23 +0100 Subject: [PATCH 33/35] fix: improve robustness of event parsing --- .../lib/src/streaming/events/kdf_event.dart | 107 ++++++++++++++++-- 1 file changed, 96 insertions(+), 11 deletions(-) diff --git a/packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart b/packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart index ea8da001..6a1b4730 100644 --- a/packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart +++ b/packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:decimal/decimal.dart'; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; @@ -53,14 +54,14 @@ sealed class KdfEvent { /// Parse a KdfEvent from raw JSON data static KdfEvent fromJson(JsonMap json) { final typeString = json.value('_type'); - final message = json.value('message'); + final dynamic message = json.value('message'); // Handle TASK:{taskId} pattern if (typeString.startsWith('TASK:')) { final taskIdStr = typeString.substring(5); // Remove "TASK:" prefix final taskId = int.tryParse(taskIdStr); if (taskId != null) { - return TaskEvent.fromJson(message, taskId); + return TaskEvent.fromJson(_asJsonMap(message), taskId); } } @@ -73,18 +74,102 @@ sealed class KdfEvent { : typeString; return switch (normalizedType) { - 'BALANCE' => BalanceEvent.fromJson(message), - 'ORDERBOOK' => OrderbookEvent.fromJson(message), - 'NETWORK' => NetworkEvent.fromJson(message), - 'HEARTBEAT' => HeartbeatEvent.fromJson(message), - 'SWAP_STATUS' => SwapStatusEvent.fromJson(message), - 'ORDER_STATUS' => OrderStatusEvent.fromJson(message), - 'TX_HISTORY' => TxHistoryEvent.fromJson(message), - 'SHUTDOWN_SIGNAL' => ShutdownSignalEvent.fromJson(message), - _ => _handleUnknownEvent(typeString, message), + 'BALANCE' => _parseBalanceEvent(typeString, message), + 'ORDERBOOK' => OrderbookEvent.fromJson(_asJsonMap(message)), + 'NETWORK' => NetworkEvent.fromJson(_asJsonMap(message)), + 'HEARTBEAT' => HeartbeatEvent.fromJson(_asJsonMap(message)), + 'SWAP_STATUS' => SwapStatusEvent.fromJson(_asJsonMap(message)), + 'ORDER_STATUS' => OrderStatusEvent.fromJson(_asJsonMap(message)), + 'TX_HISTORY' => TxHistoryEvent.fromJson(_asJsonMap(message)), + 'SHUTDOWN_SIGNAL' => ShutdownSignalEvent.fromJson(_asJsonMap(message)), + _ => _handleUnknownEvent(typeString, _wrapUnknown(message)), }; } + static JsonMap _asJsonMap(dynamic value) { + if (value is Map) { + return JsonMap.from(value); + } + if (value is String) { + return JsonMapExtension.jsonFromString(value); + } + throw ArgumentError( + 'Expected type Map for message, but got ${value.runtimeType}', + ); + } + + static JsonMap _wrapUnknown(dynamic value) { + if (value is Map) return JsonMap.from(value); + return {'raw': value}; + } + + /// Normalize BALANCE messages which may come as either a Map or a List. + static BalanceEvent _parseBalanceEvent(String typeString, dynamic message) { + // If the message is already a map with expected shape, parse directly + if (message is Map) { + return BalanceEvent.fromJson(JsonMap.from(message)); + } + + // Otherwise, handle list payloads by aggregating balances for the coin + if (message is List) { + // Extract coin suffix from type, e.g. BALANCE:DOC -> DOC + String? coinFromType; + final int firstColon = typeString.indexOf(':'); + if (firstColon != -1 && firstColon + 1 < typeString.length) { + final int nextColon = typeString.indexOf(':', firstColon + 1); + coinFromType = nextColon == -1 + ? typeString.substring(firstColon + 1) + : typeString.substring(firstColon + 1, nextColon); + } + + final List entries = message + .whereType>() + .map((e) => JsonMap.from(e)) + .toList(); + + // Determine coin from type or first entry ticker + final String coin = + coinFromType ?? + (entries.isNotEmpty + ? (entries.first.valueOrNull('ticker') ?? 'UNKNOWN') + : 'UNKNOWN'); + + Decimal spendable = Decimal.zero; + Decimal unspendable = Decimal.zero; + + for (final JsonMap entry in entries) { + final String? ticker = entry.valueOrNull('ticker'); + if (coinFromType != null && ticker != coinFromType) { + continue; + } + final JsonMap bal = entry.value('balance'); + final Decimal s = + bal.valueOrNull('spendable')?.toDecimalOrNull ?? + Decimal.zero; + final Decimal u = + bal.valueOrNull('unspendable')?.toDecimalOrNull ?? + Decimal.zero; + spendable += s; + unspendable += u; + } + + final JsonMap normalized = { + 'coin': coin, + 'balance': { + 'spendable': spendable.toString(), + 'unspendable': unspendable.toString(), + }, + }; + + return BalanceEvent.fromJson(normalized); + } + + // Fallback: unknown shape + throw ArgumentError( + 'Expected BALANCE message to be Map or List, got ${message.runtimeType}', + ); + } + /// Handles unknown event types by logging and returning an UnknownEvent static UnknownEvent _handleUnknownEvent(String typeString, JsonMap message) { if (kDebugMode) { From 33067ed7092d8baec8489b98ec7e44ad8064bc1b Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Wed, 29 Oct 2025 00:56:30 +0100 Subject: [PATCH 34/35] fix: backwards compatibility for KDF API status --- packages/komodo_defi_types/lib/src/generic/sync_status.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/komodo_defi_types/lib/src/generic/sync_status.dart b/packages/komodo_defi_types/lib/src/generic/sync_status.dart index 494e35a2..2791cdd5 100644 --- a/packages/komodo_defi_types/lib/src/generic/sync_status.dart +++ b/packages/komodo_defi_types/lib/src/generic/sync_status.dart @@ -16,6 +16,11 @@ enum SyncStatusEnum { .replaceAll('SyncStatusEnum.', '') .toLowerCase(); + // Map 'ok' to 'success' for backward compatibility with KDF API + if (sanitizedValue == 'ok') { + return SyncStatusEnum.success; + } + return SyncStatusEnum.values.firstWhereOrNull( (e) => e.name.toLowerCase() == sanitizedValue, ); From 74d4999cdbcf06beb44eb04d069c507a349862af Mon Sep 17 00:00:00 2001 From: Nitride <77973576+CharlVS@users.noreply.github.com> Date: Wed, 29 Oct 2025 03:42:25 +0100 Subject: [PATCH 35/35] Refactor: Improve transaction comparison logic (#268) This change refactors the transaction comparison logic in `InMemoryTransactionStorage` to ensure that all transactions are available during comparison. This prevents potential exceptions and improves the stability of the transaction history storage. Co-authored-by: Cursor Agent --- .../transaction_storage.dart | 88 ++++++++++++------- 1 file changed, 54 insertions(+), 34 deletions(-) diff --git a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_storage.dart b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_storage.dart index 40a3f6ad..a8a35358 100644 --- a/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_storage.dart +++ b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_storage.dart @@ -55,20 +55,25 @@ class InMemoryTransactionStorage implements TransactionStorage { static const int? _maxTransactionsPerAsset = null; /// Compare transactions for ordering within the SplayTreeMap + /// + /// Orders transactions by timestamp (newest first), with internalId as a + /// tiebreaker for stable ordering. All transactions being compared must + /// exist in the provided [transactions] map. int _compareTransactions( String a, String b, AssetTransactionHistoryId assetTxHistoryId, Map transactions, ) { - final assetTxHistory = _storage[assetTxHistoryId]; - - // the transactions - final txA = transactions[a] ?? assetTxHistory?[a]; - final txB = transactions[b] ?? assetTxHistory?[b]; + // Look up transactions only from the provided map + final txA = transactions[a]; + final txB = transactions[b]; if (txA == null || txB == null) { - throw TransactionStorageException('Transaction not found in comparison'); + throw TransactionStorageException( + 'Transaction not found in comparison: ' + '${txA == null ? a : b} missing from transactions map', + ); } // Order by timestamp descending, then by internalId for stable ordering @@ -82,10 +87,12 @@ class InMemoryTransactionStorage implements TransactionStorage { for (final assetTxHistoryId in _storage.keys) { final assetTransactions = _storage[assetTxHistoryId] ?? {}; + // Convert existing map entries to a regular map for comparison function + final assetTxMap = {...assetTransactions}; _storage[assetTxHistoryId] = SplayTreeMap( (a, b) => - _compareTransactions(a, b, assetTxHistoryId, assetTransactions), - ); + _compareTransactions(a, b, assetTxHistoryId, assetTxMap), + )..addAll(assetTxMap); } }); } @@ -102,21 +109,29 @@ class InMemoryTransactionStorage implements TransactionStorage { await _mutex.protect(() async { final assetHistoryId = AssetTransactionHistoryId(walletId, transaction.assetId); - final txMap = {transaction.internalId: transaction}; - // recreate the entire splaytreemap here, since the txMap passed to - // _compareTransactions is not updated once the entry already exists, - // resulting in comparison exceptions due to missing transactions. - // This is a workaround for the issue, and should be revisited. - // TODO: consider using a standard map, and sorting the transactions at - // retreival instead of storage. + final newTxMap = {transaction.internalId: transaction}; + + // Merge existing and new transactions into a single map BEFORE + // creating the SplayTreeMap. This prevents stack overflow by ensuring + // the comparison function has direct access to all transactions + // without needing to recursively look them up from storage. _storage.update( assetHistoryId, - (existingMap) => SplayTreeMap( - (a, b) => _compareTransactions(a, b, assetHistoryId, txMap), - )..[transaction.internalId] = transaction, + (existingMap) { + // Create merged map with all transactions + final allTxMap = { + ...existingMap, + ...newTxMap, + }; + + // Create SplayTreeMap with merged map for comparisons + return SplayTreeMap( + (a, b) => _compareTransactions(a, b, assetHistoryId, allTxMap), + )..addAll(allTxMap); + }, ifAbsent: () => SplayTreeMap( - (a, b) => _compareTransactions(a, b, assetHistoryId, txMap), - )..[transaction.internalId] = transaction, + (a, b) => _compareTransactions(a, b, assetHistoryId, newTxMap), + )..addAll(newTxMap), ); }); @@ -138,27 +153,32 @@ class InMemoryTransactionStorage implements TransactionStorage { final grouped = groupBy(transactions, (tx) => tx.assetId); for (final entry in grouped.entries) { - final txMap = Map.fromEntries( + final newTxMap = Map.fromEntries( entry.value.map((tx) => MapEntry(tx.internalId, tx)), ); final assetHistoryId = AssetTransactionHistoryId(user, entry.key); - // recreate the entire splaytreemap here, since the txMap passed to - // _compareTransactions is not updated once the entry already exists, - // resulting in comparison exceptions due to missing transactions. - // This is a workaround for the issue, and should be revisited. - // TODO: consider using a standard map, and sorting the transactions - // at retreival instead of storage. + // Merge existing and new transactions into a single map BEFORE + // creating the SplayTreeMap. This prevents stack overflow by ensuring + // the comparison function has direct access to all transactions + // without needing to recursively look them up from storage. _storage.update( assetHistoryId, - (existingMap) => SplayTreeMap( - (a, b) => _compareTransactions(a, b, assetHistoryId, txMap), - ) - ..addEntries(existingMap.entries) - ..addEntries(txMap.entries), + (existingMap) { + // Create merged map with all transactions + final allTxMap = { + ...existingMap, + ...newTxMap, + }; + + // Create SplayTreeMap with merged map for comparisons + return SplayTreeMap( + (a, b) => _compareTransactions(a, b, assetHistoryId, allTxMap), + )..addAll(allTxMap); + }, ifAbsent: () => SplayTreeMap( - (a, b) => _compareTransactions(a, b, assetHistoryId, txMap), - )..addEntries(txMap.entries), + (a, b) => _compareTransactions(a, b, assetHistoryId, newTxMap), + )..addAll(newTxMap), ); }