From 42ec148558a2463fe804aca5b5ab3d29ec8e6099 Mon Sep 17 00:00:00 2001 From: Kadan Stadelmann Date: Thu, 30 Oct 2025 22:24:00 +0100 Subject: [PATCH] =?UTF-8?q?fix(rpc):=20minimise=20RPC=20usage=20with=20com?= =?UTF-8?q?prehensive=20caching=20and=20streaming=E2=80=A6=20(#269)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(rpc): minimise RPC usage with comprehensive caching and streaming support (#262) * 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 * chore(format): run dart format on pubkey persistence and balance manager files * perf(sdk): dedupe pubkeys/address fetch, cache-first hydrate; throttle health checks; cache wallet names (#3238) * test(local-auth): add ensureKdfHealthy to FakeAuthService for Trezor tests * 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 * Refactor: Improve pubkey and balance fetching logic Co-authored-by: charl * fix: market data resource improvements * 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 * feat(streaming): add typed stream RPCs and web SharedWorker integration; expose streaming API in framework and rpc methods * feat(web): package event_streaming_worker.js in framework assets and load via package path * fix(web): correct SharedWorker path to package asset under assets/web/ * 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 * 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 * 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 * 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 * chore: add event streaming logging * 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 * 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 * 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 * 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 * fix: remove errors from KDF merge * chore: roll `coins` * fix: misc streaming fixes * 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. * 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. * 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. * perf: reduce RPC spam in activation strategies and managers * fix(auth-service): update cache alongside storage for metadata updates * chore: roll KDF to release preview Roll KDF to the `dev` branch version which will be used for KW release. * fix(stream): normalize KDF stream _type parsing for suffixed IDs; format modified files * fix: use correct streaming worker js path * fix: tx history streaming * fix: improve robustness of event parsing * fix: backwards compatibility for KDF API status * 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 --------- Co-authored-by: Cursor Agent Co-authored-by: Francois * fix config * fix timers leak * fix sse --------- Co-authored-by: Nitride <77973576+CharlVS@users.noreply.github.com> Co-authored-by: Cursor Agent Co-authored-by: Francois --- .../src/binance/data/binance_repository.dart | 5 + .../src/bootstrap/market_data_bootstrap.dart | 2 + .../lib/src/cex_repository.dart | 6 + .../coingecko/data/coingecko_repository.dart | 5 + .../data/coinpaprika_cex_provider.dart | 14 +- .../data/coinpaprika_repository.dart | 12 +- .../lib/src/sparkline_repository.dart | 27 +- .../repository_priority_manager_test.dart | 5 + .../repository_selection_strategy_test.dart | 20 + .../src/coins_config/_coins_config_index.dart | 2 +- .../app_build/build_config.json | 22 +- .../assets/web/event_streaming_worker.js | 19 + .../lib/komodo_defi_framework.dart | 108 ++-- .../src/config/event_streaming_config.dart | 46 ++ .../lib/src/config/kdf_startup_config.dart | 26 +- .../src/operations/kdf_operations_native.dart | 1 + .../event_streaming_platform_io.dart | 256 ++++++++ .../event_streaming_platform_stub.dart | 12 + .../event_streaming_platform_web.dart | 71 +++ .../streaming/event_streaming_service.dart | 225 +++++++ .../src/streaming/events/balance_event.dart | 29 + .../src/streaming/events/heartbeat_event.dart | 22 + .../lib/src/streaming/events/kdf_event.dart | 183 ++++++ .../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 + .../lib/src/streaming/events/task_event.dart | 23 + .../streaming/events/tx_history_event.dart | 39 ++ .../src/streaming/events/unknown_event.dart | 20 + packages/komodo_defi_framework/pubspec.yaml | 1 + .../lib/src/auth/auth_service.dart | 56 +- .../src/auth/auth_service_auth_extension.dart | 7 +- .../auth_service_operations_extension.dart | 89 ++- .../src/trezor/trezor_auth_service_test.dart | 38 +- .../lib/src/rpc_methods/rpc_methods.dart | 11 + .../streaming/streaming_balance_enable.dart | 39 ++ .../streaming/streaming_common.dart | 64 ++ .../streaming/streaming_disable.dart | 32 + .../streaming/streaming_heartbeat_enable.dart | 39 ++ .../streaming/streaming_network_enable.dart | 39 ++ .../streaming_order_status_enable.dart | 29 + .../streaming/streaming_orderbook_enable.dart | 39 ++ .../streaming/streaming_rpc_namespace.dart | 152 +++++ .../streaming_shutdown_signal_enable.dart | 32 + .../streaming_swap_status_enable.dart | 29 + .../streaming_tx_history_enable.dart | 35 ++ .../utility/rpc_task_shepherd.dart | 6 +- .../lib/src/rpc_methods_library.dart | 5 + .../komodo_defi_sdk/lib/komodo_defi_sdk.dart | 4 +- .../lib/src/activation/_activation_index.dart | 1 + .../src/activation/activation_exceptions.dart | 72 +++ .../src/activation/activation_manager.dart | 21 +- .../activation/nft_activation_service.dart | 107 ++++ .../eth_task_activation_strategy.dart | 33 +- .../eth_with_tokens_activation_strategy.dart | 35 +- .../utxo_activation_strategy.dart | 39 +- .../zhtlc_activation_strategy.dart | 25 +- .../shared_activation_coordinator.dart | 6 +- .../lib/src/assets/_assets_index.dart | 1 + .../src/assets/activated_assets_cache.dart | 144 +++++ .../lib/src/assets/asset_manager.dart | 29 +- .../lib/src/balances/balance_manager.dart | 407 ++++++++++--- .../komodo_defi_sdk/lib/src/bootstrap.dart | 50 +- .../lib/src/komodo_defi_sdk.dart | 106 +++- .../src/pubkeys/hive_pubkeys_adapters.dart | 143 +++++ .../lib/src/pubkeys/pubkey_manager.dart | 183 +++++- .../lib/src/pubkeys/pubkeys_storage.dart | 74 +++ .../lib/src/sdk/komodo_defi_sdk_config.dart | 8 + .../streaming/event_streaming_manager.dart | 507 ++++++++++++++++ ...therscan_transaction_history_strategy.dart | 17 +- .../zhtlc_transaction_strategy.dart | 3 + .../transaction_history_manager.dart | 550 +++++++++++++++--- .../transaction_history_strategies.dart | 6 + .../transaction_storage.dart | 88 +-- .../lib/src/assets/asset.dart | 8 + .../lib/src/coin_classes/protocol_class.dart | 32 + .../lib/src/generic/sync_status.dart | 24 +- .../transaction_history_strategy.dart | 8 + .../bin/update_api_config.dart | 33 +- 81 files changed, 4455 insertions(+), 423 deletions(-) create mode 100644 packages/komodo_defi_framework/assets/web/event_streaming_worker.js 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/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_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/task_event.dart create mode 100644 packages/komodo_defi_framework/lib/src/streaming/events/tx_history_event.dart create mode 100644 packages/komodo_defi_framework/lib/src/streaming/events/unknown_event.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_shutdown_signal_enable.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 create mode 100644 packages/komodo_defi_sdk/lib/src/activation/activation_exceptions.dart 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 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 create mode 100644 packages/komodo_defi_sdk/lib/src/streaming/event_streaming_manager.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 9dec1869f..84e95c663 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/bootstrap/market_data_bootstrap.dart b/packages/komodo_cex_market_data/lib/src/bootstrap/market_data_bootstrap.dart index 4a52ad453..5cc92e3d9 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 2571295ec..82af337e7 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/coingecko/data/coingecko_repository.dart b/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_repository.dart index 5c2b56399..8694f8ce4 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/lib/src/coinpaprika/data/coinpaprika_cex_provider.dart b/packages/komodo_cex_market_data/lib/src/coinpaprika/data/coinpaprika_cex_provider.dart index b15f2b72e..427441799 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 8135d55ca..293c14a61 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 1d1b8c002..0579b7afc 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,28 @@ class SparklineRepository with RepositoryFallbackMixin { return future; } + /// Releases held resources such as HTTP clients and Hive boxes. + Future dispose() async { + for (final repository in _repositories) { + try { + repository.dispose(); + } catch (e, st) { + _logger.severe('Error disposing repository: $repository', e, st); + } + } + + final box = _box; + if (box != null && box.isOpen) { + try { + await box.close(); + } catch (e, st) { + _logger.severe('Error closing Hive box', e, st); + } + } + _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_cex_market_data/test/repository_priority_manager_test.dart b/packages/komodo_cex_market_data/test/repository_priority_manager_test.dart index 6152d8b2a..f0647b0fa 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 b027ad78a..0135406e9 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_coin_updates/lib/src/coins_config/_coins_config_index.dart b/packages/komodo_coin_updates/lib/src/coins_config/_coins_config_index.dart index c94289926..59a914b0f 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_framework/app_build/build_config.json b/packages/komodo_defi_framework/app_build/build_config.json index f26a8393d..1c19bff50 100644 --- a/packages/komodo_defi_framework/app_build/build_config.json +++ b/packages/komodo_defi_framework/app_build/build_config.json @@ -1,8 +1,8 @@ { "api": { - "api_commit_hash": "96023711777feda55990a7510c352485d8a5c7a5", - "branch": "staging", - "fetch_at_build_enabled": true, + "api_commit_hash": "9aa41b4c741907d59e4887db08cf84fb78e967e0", + "branch": "dev", + "fetch_at_build_enabled": false, "concurrent_downloads_enabled": true, "source_urls": [ "https://api.github.com/repos/KomodoPlatform/komodo-defi-framework", @@ -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" } @@ -68,7 +68,7 @@ "coins": { "fetch_at_build_enabled": true, "update_commit_on_build": true, - "bundled_coins_repo_commit": "3d23cb5dcc82d4bb8c88f8ebf67ad3fb51ed3b6b", + "bundled_coins_repo_commit": "5344c5ab44a153020ea2bb3893a752f19df927d0", "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/assets/web/event_streaming_worker.js b/packages/komodo_defi_framework/assets/web/event_streaming_worker.js new file mode 100644 index 000000000..f80d791b3 --- /dev/null +++ b/packages/komodo_defi_framework/assets/web/event_streaming_worker.js @@ -0,0 +1,19 @@ +// 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/komodo_defi_framework.dart b/packages/komodo_defi_framework/lib/komodo_defi_framework.dart index a5ad0e96d..2f57237e5 100644 --- a/packages/komodo_defi_framework/lib/komodo_defi_framework.dart +++ b/packages/komodo_defi_framework/lib/komodo_defi_framework.dart @@ -6,39 +6,22 @@ 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'; 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'; +export 'package:komodo_defi_framework/src/streaming/event_streaming_service.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, @@ -62,6 +45,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; @@ -104,6 +107,16 @@ class KomodoDefiFramework implements ApiClient { } } + // Streaming service (web: SharedWorker; native: SSE) + // NOTE: SSE connection should be tied to authentication state. + // Do NOT call initialize() automatically - let the application control when to connect. + KdfEventStreamingService? _streamingService; + KdfEventStreamingService get streaming { + return _streamingService ??= KdfEventStreamingService( + hostConfig: _hostConfig, + ); + } + //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 @@ -231,32 +244,34 @@ class KomodoDefiFramework implements ApiClient { } return response; } - + // 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), )).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()}'); } @@ -297,7 +312,7 @@ class KomodoDefiFramework implements ApiClient { rethrow; } } - + bool _isElectrumRelatedMethod(String method) { return method.contains('electrum') || method.contains('enable') || @@ -305,25 +320,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']; @@ -332,9 +348,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; @@ -351,20 +367,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'); } @@ -373,7 +391,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 @@ -385,7 +403,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']; @@ -393,7 +411,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/config/event_streaming_config.dart b/packages/komodo_defi_framework/lib/src/config/event_streaming_config.dart new file mode 100644 index 000000000..fcaebcd22 --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/config/event_streaming_config.dart @@ -0,0 +1,46 @@ +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, + }); + + /// 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? ?? + _kDefaultAccessControlAllowOrigin, + workerPath: json['worker_path'] as String? ?? _kDefaultWorkerPath, + ); + } + + /// 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; + + 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() => { + '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 11faef81d..a3eb0b0f3 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/operations/kdf_operations_native.dart b/packages/komodo_defi_framework/lib/src/operations/kdf_operations_native.dart index 3bc5ddf14..e5650cdfe 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 @@ -332,6 +332,7 @@ class KdfOperationsNativeLibrary implements IKdfOperations { } void dispose() { + _client.close(); _logCallback.close(); // Ensure the NativeCallable is properly closed } } 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 000000000..29bc40d03 --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_io.dart @@ -0,0 +1,256 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:komodo_defi_framework/src/config/kdf_config.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; + +typedef EventStreamUnsubscribe = void Function(); + +// Default client ID used for SSE connections +const int _kDefaultClientId = 0; + +Uri _buildEventsUrl(IKdfHostConfig hostConfig, {int clientId = _kDefaultClientId}) { + if (hostConfig is RemoteConfig) { + final Uri base = hostConfig.rpcUrl; + return base.replace( + pathSegments: [...base.pathSegments, 'event-stream'], + queryParameters: {'id': clientId.toString()}, + ); + } + + return Uri( + scheme: 'http', + host: '127.0.0.1', + port: 7783, + pathSegments: const ['event-stream'], + queryParameters: {'id': clientId.toString()}, + ); +} + +/// Production-visible logger that always logs (not behind kDebugMode) +void _log(String msg) { + // Production-visible logging - always print for critical SSE lifecycle events + print('[EventStream][IO] $msg'); +} + +/// Performs a preflight RPC check to ensure KDF is ready before SSE connection +Future _preflightCheck(IKdfHostConfig cfg) async { + try { + _log('Preflight: Checking KDF availability...'); + final client = HttpClient(); + try { + final uri = cfg is RemoteConfig + ? cfg.rpcUrl + : Uri(scheme: 'http', host: '127.0.0.1', port: 7783); + + final request = await client.postUrl(uri); + request.headers.set('Content-Type', 'application/json'); + + // Simple version check to verify KDF is responding + final payload = jsonEncode({ + 'userpass': cfg.rpcPassword, + 'method': 'version', + }); + request.write(payload); + + final response = await request.close().timeout( + const Duration(seconds: 5), + onTimeout: () { + _log('Preflight: Timeout waiting for KDF response'); + throw TimeoutException('KDF version check timeout'); + }, + ); + + if (response.statusCode == 200) { + _log('Preflight: KDF is ready (status ${response.statusCode})'); + await response.drain(); + return true; + } else { + _log('Preflight: KDF returned status ${response.statusCode}'); + await response.drain(); + return false; + } + } finally { + client.close(); + } + } catch (e) { + _log('Preflight: Failed - $e'); + return false; + } +} + +/// Verifies SSE handshake by checking HTTP status and content-type +Future _verifyHandshake(HttpClientResponse response) async { + if (response.statusCode != 200) { + _log('Handshake: Failed - HTTP ${response.statusCode}'); + return false; + } + + final contentType = response.headers.contentType?.toString() ?? ''; + if (!contentType.contains('text/event-stream')) { + _log('Handshake: Failed - Invalid content-type: $contentType'); + return false; + } + + _log('Handshake: Success - HTTP 200, content-type: $contentType'); + return true; +} + +EventStreamUnsubscribe connectEventStream({ + required void Function(Object? data) onMessage, + required void Function() onFirstByte, + IKdfHostConfig? hostConfig, + int clientId = _kDefaultClientId, +}) { + final IKdfHostConfig cfg = hostConfig!; + final Uri url = _buildEventsUrl(cfg, clientId: clientId); + bool isClosed = false; + bool firstByteReceived = false; + HttpClient? httpClient; + HttpClientRequest? request; + StreamSubscription? streamSubscription; + int retryCount = 0; + const int maxRetries = 3; + const Duration retryDelay = Duration(seconds: 2); + + _log('SSE Start: Initializing connection to $url (client_id=$clientId)'); + + Future start() async { + if (isClosed) return; + + try { + // Step 1: Preflight RPC check + final preflightOk = await _preflightCheck(cfg); + if (!preflightOk) { + _log('SSE Start: Preflight check failed, retrying in ${retryDelay.inSeconds}s...'); + if (retryCount < maxRetries && !isClosed) { + retryCount++; + await Future.delayed(retryDelay); + unawaited(start()); + } else { + _log('SSE Start: Max retries ($maxRetries) reached, giving up'); + } + return; + } + + // Step 2: Open SSE connection with proper handshake verification + httpClient = HttpClient(); + httpClient!.connectionTimeout = const Duration(seconds: 10); + + _log('SSE Start: Opening connection to $url...'); + request = await httpClient!.getUrl(url); + request!.headers.set('Accept', 'text/event-stream'); + request!.headers.set('Cache-Control', 'no-cache'); + request!.headers.set('Connection', 'keep-alive'); + + final response = await request!.close(); + + // Step 3: Verify handshake + final handshakeOk = await _verifyHandshake(response); + if (!handshakeOk) { + _log('SSE Start: Handshake verification failed, retrying...'); + await response.drain(); + if (retryCount < maxRetries && !isClosed) { + retryCount++; + await Future.delayed(retryDelay); + unawaited(start()); + } else { + _log('SSE Start: Max retries ($maxRetries) reached, giving up'); + } + return; + } + + // Step 4: Connection established, start listening to events + _log('SSE Connected: Successfully connected to $url (client_id=$clientId)'); + _log('SSE Connected: Waiting for first byte from stream...'); + + // Parse SSE stream + final StringBuffer buffer = StringBuffer(); + streamSubscription = response + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen( + (line) { + if (isClosed) return; + + // Signal first byte received (any line, including comments/keepalives) + if (!firstByteReceived) { + firstByteReceived = true; + _log('SSE First Byte: Received first line from stream - server is flowing'); + onFirstByte(); + } + + if (line.startsWith('data: ')) { + final data = line.substring(6).trim(); + if (data.isNotEmpty) { + try { + final decoded = jsonFromString(data); + onMessage(decoded); + } catch (e) { + _log('SSE Data: Failed to decode event - $e'); + } + } + } else if (line.isEmpty && buffer.isNotEmpty) { + // Empty line marks end of event + buffer.clear(); + } + }, + onError: (error) { + if (!isClosed) { + _log('SSE Error: $error'); + // Attempt reconnection on error + if (retryCount < maxRetries) { + retryCount++; + _log('SSE Error: Reconnecting (attempt $retryCount/$maxRetries)...'); + Future.delayed(retryDelay, start); + } + } + }, + onDone: () { + if (!isClosed) { + _log('SSE Done: Connection closed by server'); + // Attempt reconnection if not manually closed + if (retryCount < maxRetries) { + retryCount++; + _log('SSE Done: Reconnecting (attempt $retryCount/$maxRetries)...'); + Future.delayed(retryDelay, start); + } + } + }, + cancelOnError: false, + ); + + // Reset retry count on successful connection + retryCount = 0; + + } catch (e) { + if (!isClosed) { + _log('SSE Start: Exception - $e'); + if (retryCount < maxRetries) { + retryCount++; + _log('SSE Start: Retrying (attempt $retryCount/$maxRetries)...'); + await Future.delayed(retryDelay); + unawaited(start()); + } else { + _log('SSE Start: Max retries ($maxRetries) reached, giving up'); + } + } + } + } + + // Start connection asynchronously + unawaited(start()); + + return () async { + if (isClosed) return; + isClosed = true; + _log('SSE Disconnect: Closing connection (client_id=$clientId)'); + try { + await streamSubscription?.cancel(); + httpClient?.close(force: true); + } catch (e) { + _log('SSE Disconnect: Error during cleanup - $e'); + } + }; +} 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 000000000..b3b73ba16 --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_stub.dart @@ -0,0 +1,12 @@ +import 'package:komodo_defi_framework/src/config/kdf_config.dart'; + +typedef EventStreamUnsubscribe = void Function(); + +EventStreamUnsubscribe connectEventStream({ + IKdfHostConfig? hostConfig, + required void Function(Object? data) onMessage, + required void Function() onFirstByte, +}) { + // 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 new file mode 100644 index 000000000..700df7f3e --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_platform_web.dart @@ -0,0 +1,71 @@ +// 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:flutter/foundation.dart'; +import 'package:js/js_util.dart' as jsu; + +import 'package:komodo_defi_framework/src/config/kdf_config.dart'; + +typedef EventStreamUnsubscribe = 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; + +EventStreamUnsubscribe connectEventStream({ + IKdfHostConfig? hostConfig, + required void Function(Object? data) onMessage, + required void Function() onFirstByte, +}) { + try { + final Object sharedWorkerCtor = _getGlobalProperty('SharedWorker'); + 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; + _callMethod(port, 'start', const []); + + bool firstMessageReceived = false; + + void handler(html.Event e) { + final Object? data = _getProperty(e, 'data'); + + // Signal first byte received on first message + if (!firstMessageReceived) { + firstMessageReceived = true; + onFirstByte(); + } + + if (kDebugMode) { + print('EventStream: Received message: $data'); + } + onMessage(data); + } + + _setProperty(port, 'onmessage', jsu.allowInterop(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 000000000..e52e4075e --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/event_streaming_service.dart @@ -0,0 +1,225 @@ +// 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 'dart:convert' as convert; + +import 'package:flutter/foundation.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); + +enum SseConnectionState { + disconnected, + connecting, + connected, +} + +class KdfEventStreamingService { + KdfEventStreamingService({IKdfHostConfig? hostConfig}) + : _hostConfig = hostConfig; + + final IKdfHostConfig? _hostConfig; + + final StreamController _events = StreamController.broadcast(); + Completer _firstByteCompleter = Completer(); + SseConnectionState _connectionState = SseConnectionState.disconnected; + + Stream get events => _events.stream; + + /// Future that completes when the first byte is received from the SSE stream. + /// This indicates the server's event loop is fully flowing and the client is registered. + Future get firstByteReceived => _firstByteCompleter.future; + + /// Current connection state + SseConnectionState get connectionState => _connectionState; + + /// Whether the SSE connection is currently connected + bool get isConnected => _connectionState == SseConnectionState.connected; + + /// Start listening to stream events. + /// - Web: Connects to SharedWorker forwarded messages. + /// - Native (IO): Connects to SSE endpoint exposed by KDF RPC server. + /// + /// DEPRECATED: Use connectIfNeeded() instead. This method is kept for backward compatibility + /// but should not be called at app startup. SSE connection should be tied to authentication state. + @Deprecated('Use connectIfNeeded() instead') + void initialize() { + connectIfNeeded(); + } + + /// Ensures SSE connection is established if not already connected. + /// This method is idempotent and can be called multiple times safely. + /// + /// Should be called: + /// - After user authentication completes + /// - Before attempting enable_* RPC calls + /// - After detecting UnknownClient errors (to trigger reconnection) + void connectIfNeeded() { + if (_connectionState != SseConnectionState.disconnected) { + // Already connecting or connected + return; + } + + _connectionState = SseConnectionState.connecting; + _log('SSE Connect: Initiating connection...'); + + _unsubscribe ??= connectEventStream( + hostConfig: _hostConfig, + onMessage: _onIncomingData, + onFirstByte: _onFirstByte, + ); + } + + /// Disconnect the SSE connection. + /// Should be called when user signs out. + void disconnect() { + if (_connectionState == SseConnectionState.disconnected) { + return; + } + + _log('SSE Disconnect: Closing connection...'); + _unsubscribe?.call(); + _unsubscribe = null; + _connectionState = SseConnectionState.disconnected; + + // Reset first byte completer for next connection + if (_firstByteCompleter.isCompleted) { + _firstByteCompleter = Completer(); + } + } + + void _onFirstByte() { + if (!_firstByteCompleter.isCompleted) { + _firstByteCompleter.complete(); + _connectionState = SseConnectionState.connected; + _log('SSE Connect: First byte received, connection established'); + } + } + + void _log(String message) { + if (kDebugMode) { + print('[EventStreamingService] $message'); + } + } + + void _onIncomingData(Object? data) { + try { + 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); + 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 + 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 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 + /// 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 { + _unsubscribe?.call(); + 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', + 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', + }; + } + + EventStreamUnsubscribe? _unsubscribe; +} 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 000000000..434642e12 --- /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 000000000..339b235d8 --- /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 000000000..6a1b47303 --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/events/kdf_event.dart @@ -0,0 +1,183 @@ +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'; + +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 'task_event.dart'; +part 'tx_history_event.dart'; +part 'unknown_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'), + task('TASK'), + 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'); +/// case TaskEvent(:final taskId, :final taskData): +/// print('Task $taskId update: $taskData'); +/// // ... 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 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(_asJsonMap(message), taskId); + } + } + + // 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' => _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) { + 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/network_event.dart b/packages/komodo_defi_framework/lib/src/streaming/events/network_event.dart new file mode 100644 index 000000000..d7f0378c8 --- /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 000000000..a62a63ce6 --- /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 000000000..1ca5beee6 --- /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 000000000..3263cb10c --- /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 000000000..03c51af52 --- /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/task_event.dart b/packages/komodo_defi_framework/lib/src/streaming/events/task_event.dart new file mode 100644 index 000000000..760298ecc --- /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/tx_history_event.dart b/packages/komodo_defi_framework/lib/src/streaming/events/tx_history_event.dart new file mode 100644 index 000000000..1f31fb84f --- /dev/null +++ b/packages/komodo_defi_framework/lib/src/streaming/events/tx_history_event.dart @@ -0,0 +1,39 @@ +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) { + // 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 + 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_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 000000000..1f96d2bf2 --- /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 d847d2e1a..da15921f5 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: 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 0bd811629..8a745108c 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'; @@ -40,6 +42,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. @@ -104,6 +113,7 @@ class KdfAuthService implements IAuthService { KdfAuthService(this._kdfFramework, this._hostConfig) : _sessionId = const Uuid().v4() { _logger.info('[$_sessionId] KdfAuthService initialized'); _startHealthCheck(); + _subscribeToShutdownSignals(); } final KomodoDefiFramework _kdfFramework; @@ -122,6 +132,12 @@ class KdfAuthService implements IAuthService { Future? _ongoingHealthCheck; DateTime? _lastHealthCheckAttempt; DateTime? _lastHealthCheckCompleted; + StreamSubscription? _shutdownSubscription; + + // 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); @@ -247,8 +263,10 @@ 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; }); } @@ -258,9 +276,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; @@ -274,6 +299,10 @@ class KdfAuthService implements IAuthService { return newUser; }), ); + + _usersCache = users; + _usersCacheTimestamp = DateTime.now(); + return users; }); } @@ -308,7 +337,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 => @@ -389,6 +424,7 @@ class KdfAuthService implements IAuthService { password: password, ); await _secureStorage.deleteUser(walletName); + _invalidateUsersCache(); } on DeleteWalletInvalidPasswordErrorResponse catch (e) { throw AuthException( e.error ?? 'Invalid password', @@ -431,6 +467,11 @@ class KdfAuthService implements IAuthService { }); } + void _invalidateUsersCache() { + _usersCache = null; + _usersCacheTimestamp = null; + } + @override Stream get authStateChanges => _authStateController.stream; @@ -440,6 +481,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; @@ -493,6 +536,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 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 4a3572ca8..91dbd0554 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_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 4c7c29765..36dcbbff6 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,16 +11,89 @@ extension KdfAuthServiceOperationsExtension on KdfAuthService { void _startHealthCheck() { _healthCheckTimer?.cancel(); + // 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(seconds: 5), + 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 { + // 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(); + _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'); + } + } + + /// 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(); - 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 @@ -31,11 +104,13 @@ 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_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 5ffa0ed77..4cd461aa5 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 @@ -267,10 +267,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(); @@ -450,21 +449,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(); @@ -524,8 +522,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, 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 9319dd79a..a207e4447 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,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_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 '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 000000000..618165490 --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_balance_enable.dart @@ -0,0 +1,39 @@ +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< + StreamEnableResponse, GeneralErrorResponse> { + 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 000000000..4c6c7072c --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_common.dart @@ -0,0 +1,64 @@ +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, + }); + + factory StreamEnableResponse.parse(JsonMap json) { + final result = json.value('result'); + return StreamEnableResponse( + mmrpc: json.value('mmrpc'), + streamerId: result.value('streamer_id'), + ); + } + + /// The unique identifier for this stream + 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 000000000..76febf9b2 --- /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 000000000..c5f632470 --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_heartbeat_enable.dart @@ -0,0 +1,39 @@ +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< + StreamEnableResponse, GeneralErrorResponse> { + 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 000000000..442f1d37c --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_network_enable.dart @@ -0,0 +1,39 @@ +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< + StreamEnableResponse, GeneralErrorResponse> { + 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 000000000..7add780e2 --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_order_status_enable.dart @@ -0,0 +1,29 @@ +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< + StreamEnableResponse, GeneralErrorResponse> { + 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 000000000..159a0f1ea --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_orderbook_enable.dart @@ -0,0 +1,39 @@ +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< + StreamEnableResponse, GeneralErrorResponse> { + 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 000000000..725459a21 --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_rpc_namespace.dart @@ -0,0 +1,152 @@ +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, +/// transaction history, and shutdown signals. +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, + ), + ); + } + + /// 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, + 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_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 000000000..ed631fc80 --- /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 new file mode 100644 index 000000000..7353840fa --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_swap_status_enable.dart @@ -0,0 +1,29 @@ +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< + StreamEnableResponse, GeneralErrorResponse> { + 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 000000000..47e1801f3 --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/streaming/streaming_tx_history_enable.dart @@ -0,0 +1,35 @@ +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::tx_history::enable +class StreamTxHistoryEnableRequest + extends + BaseRequest< + StreamEnableResponse, + GeneralErrorResponse + > { + 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/utility/rpc_task_shepherd.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/utility/rpc_task_shepherd.dart index 9a6560abf..35dca19b0 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_rpc_methods/lib/src/rpc_methods_library.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods_library.dart index c21e3437d..118df22f6 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); diff --git a/packages/komodo_defi_sdk/lib/komodo_defi_sdk.dart b/packages/komodo_defi_sdk/lib/komodo_defi_sdk.dart index cfa7cc810..054afc075 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 6ab3e5687..76d945de2 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_exceptions.dart b/packages/komodo_defi_sdk/lib/src/activation/activation_exceptions.dart new file mode 100644 index 000000000..fb7420111 --- /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/activation_manager.dart b/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart index 625adda70..45060eff0 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 {}; @@ -255,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/nft_activation_service.dart b/packages/komodo_defi_sdk/lib/src/activation/nft_activation_service.dart new file mode 100644 index 000000000..752a3925a --- /dev/null +++ b/packages/komodo_defi_sdk/lib/src/activation/nft_activation_service.dart @@ -0,0 +1,107 @@ +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. Failures are collected and an + /// aggregate exception is thrown if any activations fail. + 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; + } + + 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); + errors[asset.id] = e; + } + } + + 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/activation/protocol_strategies/eth_task_activation_strategy.dart b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_task_activation_strategy.dart index 6586d3792..e4e62a92f 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,31 @@ class EthTaskActivationStrategy extends ProtocolActivationStrategy { ), ); - final activationParams = EthWithTokensActivationParams.fromJson(asset.protocol.config) - .copyWith( + // 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, + ).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 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 +106,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/eth_with_tokens_activation_strategy.dart b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/eth_with_tokens_activation_strategy.dart index f72110ed3..d8d3cf56a 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/activation/protocol_strategies/utxo_activation_strategy.dart b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/utxo_activation_strategy.dart index c1ba95ef2..39635777e 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 123e01ba9..f20a3ef59 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/activation/shared_activation_coordinator.dart b/packages/komodo_defi_sdk/lib/src/activation/shared_activation_coordinator.dart index f5be46997..4c977b101 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', 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 c2d9f81bc..ec8c9d3e8 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 000000000..e2e65b204 --- /dev/null +++ b/packages/komodo_defi_sdk/lib/src/assets/activated_assets_cache.dart @@ -0,0 +1,144 @@ +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; + 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 { + _assertNotDisposed(); + + if (forceRefresh) { + invalidate(); + } + + if (_hasValidCache) { + return _cache!; + } + + // If a fetch is already in progress, return its future + if (_pendingCompleter != null) { + return _pendingCompleter!.future; + } + + // 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; + + try { + final assets = await _fetchActivatedAssets(); + + // Only update cache if we haven't been invalidated while fetching + if (_generation == generation) { + _cache = assets; + _lastFetchAt = fetchStart; // Use start time for accurate TTL + } + + completer.complete(assets); + return assets; + } catch (e) { + completer.completeError(e); + rethrow; + } finally { + _pendingCompleter = 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. + /// + /// 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; + _pendingCompleter = null; + + // Increment generation to mark any in-flight fetches as stale + _generation++; + } + + /// 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 642bd9f3e..ea19febfc 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/balances/balance_manager.dart b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart index 599c41808..84cbd4795 100644 --- a/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/balances/balance_manager.dart @@ -1,10 +1,14 @@ 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'; 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 +64,14 @@ class BalanceManager implements IBalanceManager { required KomodoDefiLocalAuth auth, required PubkeyManager? pubkeyManager, required SharedActivationCoordinator? activationCoordinator, + required EventStreamingManager eventStreamingManager, + AssetHistoryStorage? assetHistoryStorage, }) : _activationCoordinator = activationCoordinator, _pubkeyManager = pubkeyManager, _assetLookup = assetLookup, - _auth = auth { + _auth = auth, + _eventStreamingManager = eventStreamingManager, + _assetHistoryStorage = assetHistoryStorage ?? AssetHistoryStorage() { // Listen for auth state changes _authSubscription = _auth.authStateChanges.listen(_handleAuthStateChanged); _logger.fine('Initialized'); @@ -74,10 +82,12 @@ class BalanceManager implements IBalanceManager { PubkeyManager? _pubkeyManager; final IAssetLookup _assetLookup; final KomodoDefiLocalAuth _auth; + final EventStreamingManager _eventStreamingManager; + final AssetHistoryStorage _assetHistoryStorage; StreamSubscription? _authSubscription; final Duration _defaultPollingInterval = const Duration(seconds: 30); - - /// Enable debug logging for balance polling + + /// Enable debug logging for balance polling fallback static bool enableDebugLogging = true; /// Cache of the latest known balances for each asset @@ -89,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; @@ -112,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; @@ -144,6 +159,12 @@ class BalanceManager implements IBalanceManager { ); } + // Cancel all stale balance timers to prevent timer leak on wallet change + for (final timer in _staleBalanceTimers.values) { + timer.cancel(); + } + _staleBalanceTimers.clear(); + final List> controllers = _balanceControllers .values .toList(); @@ -319,93 +340,282 @@ class BalanceManager implements IBalanceManager { _currentWalletId = user.walletId; _logger.fine('Starting balance watcher for ${assetId.name}'); + // Optimization: Check if this is a newly created wallet (not imported) + final previouslyEnabledAssets = await _assetHistoryStorage.getWalletAssets( + 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; + final isNewWallet = previouslyEnabledAssets.isEmpty && !isImported; + // 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) { + // 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); + + // 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); } - // Set up periodic polling for balance updates - final periodicStream = Stream.periodic(_defaultPollingInterval); - _activeWatchers[assetId] = periodicStream - .asyncMap((void _) async { - if (_isDisposed) return null; + // Subscribe to balance event stream for real-time updates + if (!_supportsBalanceStreaming(asset)) { + await _startBalancePolling( + asset: asset, + assetId: assetId, + controller: controller, + activateIfNeeded: activateIfNeeded, + ); + return; + } - // Check if dependencies are still initialized - if (_activationCoordinator == null || _pubkeyManager == null) { - return null; - } + _logger.fine('Subscribing to balance stream for ${assetId.id}'); + 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', + ); - // 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 - } + try { + await balanceStreamSubscription.cancel(); + } catch (cancelError, cancelStack) { + _logger.fine( + 'Error cancelling balance stream for ${assetId.name}', + cancelError, + cancelStack, + ); + } - if (enableDebugLogging) { - _logger.info( - '[POLLING] Fetching balance for ${assetId.name} (every ${_defaultPollingInterval.inSeconds}s)', - ); - } + if (_activeWatchers[assetId] == balanceStreamSubscription) { + await _startBalancePolling( + asset: asset, + assetId: assetId, + controller: controller, + activateIfNeeded: activateIfNeeded, + ); + } - try { - // Ensure asset is activated if needed - final isActive = await _ensureAssetActivated( - asset, - activateIfNeeded, - ); + if (error != null) { + _logger.warning( + 'Balance stream fallback reason for ${assetId.name}: $error', + error, + stackTrace, + ); + } + } - // 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}', + _activeWatchers[assetId] = balanceStreamSubscription + ..onData((balanceEvent) { + if (_isDisposed) return; + + // Verify the event is for the correct coin + if (balanceEvent.coin != assetId.id) 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}', + ); + } + + // 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}', ); - } - return balance; - } - } catch (e) { - // Just log the error and continue with the last known balance - // This prevents the stream from terminating on transient errors + }) + .catchError((Object e, StackTrace s) { + _logger.fine( + 'Failed to refresh pubkeys for ${assetId.name}', + e, + s, + ); + }) + .ignore(); + } + }) + ..onError((Object error, StackTrace stackTrace) { + unawaited( + fallbackToPolling( + reason: 'stream error', + error: error, + stackTrace: stackTrace, + ), + ); + }) + ..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}', + e, + s, + ); + 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.warning('[POLLING] Balance fetch failed for ${assetId.name}: $e'); + _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, + ); - // 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) { - if (!controller.isClosed) controller.addError(e); - } + _activeWatchers[assetId] = subscription; } /// Stop watching the balance for a specific asset @@ -416,10 +626,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) { @@ -484,6 +755,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/bootstrap.dart b/packages/komodo_defi_sdk/lib/src/bootstrap.dart index 9a60ed5b2..3ea76836f 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(); @@ -94,25 +104,38 @@ 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 { 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( @@ -121,8 +144,10 @@ Future bootstrap({ assetLookup: assets, pubkeyManager: null, // Will be set after PubkeyManager is created auth: auth, + eventStreamingManager: eventStreamingManager, + assetHistoryStorage: container(), ); - }, dependsOn: [AssetManager, KomodoDefiLocalAuth]); + }, dependsOn: [AssetManager, KomodoDefiLocalAuth, EventStreamingManager]); // Register activation manager with asset manager dependency container.registerSingletonAsync( @@ -132,6 +157,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, @@ -143,6 +170,7 @@ Future bootstrap({ // Needed here to add custom tokens to the same instance // as the asset manager container(), + activatedAssetsCache, ); return activationManager; @@ -154,9 +182,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(); @@ -243,12 +280,16 @@ Future bootstrap({ final pubkeys = await container.getAsync(); final activationCoordinator = await container .getAsync(); + final eventStreamingManager = await container + .getAsync(); return TransactionHistoryManager( client, auth, assetProvider, activationCoordinator, pubkeyManager: pubkeys, + eventStreamingManager: eventStreamingManager, + assetHistoryStorage: container(), ); }, dependsOn: [ @@ -257,6 +298,7 @@ Future bootstrap({ AssetManager, PubkeyManager, SharedActivationCoordinator, + EventStreamingManager, ], ); 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 f93300051..e3273e232 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. @@ -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. @@ -283,6 +293,19 @@ class KomodoDefiSdk with SecureRpcPasswordMixin { BalanceManager get balances => _assertSdkInitialized(_container()); + /// The event streaming service instance. + /// + /// Provides access to SSE (Server-Sent Events) connection lifecycle management + /// for real-time balance and transaction history updates. + /// + /// Use [KdfEventStreamingService.connectIfNeeded] to establish SSE connection + /// after authentication, and [KdfEventStreamingService.disconnect] to clean up + /// on sign-out. + /// + /// Throws [StateError] if accessed before initialization. + KdfEventStreamingService get streaming => + _assertSdkInitialized(_container().streaming); + /// Public stream of framework logs. /// /// Subscribe to receive human-readable log messages from the underlying @@ -290,6 +313,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 @@ -405,8 +505,10 @@ 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()), _disposeIfRegistered((m) => m.dispose()), _disposeIfRegistered( (m) async => m.dispose(), 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 000000000..58ba825b5 --- /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 5892972ae..c25b6cbf6 100644 --- a/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/pubkeys/pubkey_manager.dart @@ -2,9 +2,11 @@ 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'; +import 'package:komodo_defi_sdk/src/pubkeys/pubkeys_storage.dart'; /// Interface defining the contract for pubkey management operations abstract class IPubkeyManager { @@ -40,7 +42,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 +56,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 = {}; @@ -57,6 +65,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; @@ -66,9 +76,48 @@ 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); - return strategy.getPubkeys(asset.id, _client); + // Serve from in-memory cache if available + final cached = _pubkeysCache[asset.id]; + if (cached != null) { + return cached; + } + + // If a network fetch for this asset is already in flight, await it + final existing = _inFlightPubkeyRequests[asset.id]; + if (existing != null) { + return existing; + } + + // 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 + 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 + }); + refreshFuture.ignore(); + return hydrated; + } + + // No hydration available, fetch fresh + return _fetchFreshPubkeys(asset, walletId); } /// Create a new pubkey for an asset if supported @@ -113,6 +162,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; + _persistPubkeysForWallet(walletId, asset, pubkeys).ignore(); + 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 @@ -160,6 +234,65 @@ class PubkeyManager implements IPubkeyManager { return _pubkeysCache[assetId]; } + // Removed unused non-wallet-stable helpers to avoid confusion + + // 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; @@ -180,12 +313,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); @@ -197,9 +324,19 @@ class PubkeyManager implements IPubkeyManager { } if (isActive) { - final first = await getPubkeys(asset); + // Try hydrate from persisted cache first for faster cold start + final walletId = _currentWalletId!; + final hydrated = await _hydrateFromStorageForWallet(walletId, asset); + if (hydrated != null) { + _pubkeysCache[asset.id] = hydrated; + if (!controller.isClosed) controller.add(hydrated); + } + + final first = await _fetchFreshPubkeys(asset, walletId); _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}'); } @@ -226,7 +363,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; } @@ -248,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, + ), + ); + } + } } } @@ -352,6 +506,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 new file mode 100644 index 000000000..e8dae420c --- /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 key in box.keys.whereType()) { + if (!key.startsWith(prefix)) continue; + final record = box.get(key); + if (record == null) continue; + // 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, + '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/sdk/komodo_defi_sdk_config.dart b/packages/komodo_defi_sdk/lib/src/sdk/komodo_defi_sdk_config.dart index 3944ee577..5792aeaa1 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, ); } 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 000000000..16349047e --- /dev/null +++ b/packages/komodo_defi_sdk/lib/src/streaming/event_streaming_manager.dart @@ -0,0 +1,507 @@ +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'; + +/// Production-visible logger for streaming operations +void _log(String msg) { + // Production-visible logging - always print for critical streaming events + print('[EventStreamingManager] $msg'); +} + +/// 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 { + _log('EventStreamingManager initialized (instance=${hashCode})'); + } + + 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 = 0; + + // Active stream subscriptions keyed by a unique identifier + final Map _activeStreams = {}; + + // Reference counters for shared streams (e.g., heartbeat, network) + final Map _streamRefCounts = {}; + + // Track if SSE connection is ready (first byte received + grace period elapsed) + bool _sseReadinessComplete = false; + Timer? _gracePeriodTimer; + + // Post-connect delay before first enable_* call (1500ms for robust readiness) + static const Duration _postConnectDelay = Duration(milliseconds: 1500); + + // Maximum time to wait for first byte before falling back to time-based delay + static const Duration _firstByteTimeout = Duration(seconds: 2); + + // Per-key in-flight guards to prevent duplicate enable_* calls + final Map>> _inFlightEnables = {}; + + /// Wait for SSE readiness: first byte received + grace period elapsed + Future _waitForSseReadiness() async { + if (_sseReadinessComplete) return; + + _log('Waiting for SSE readiness (first byte + ${_postConnectDelay.inMilliseconds}ms grace period)...'); + + // Ensure SSE connection is initiated + _eventService.connectIfNeeded(); + + // Wait for first byte with timeout + bool firstByteReceived = false; + try { + await _eventService.firstByteReceived.timeout( + _firstByteTimeout, + onTimeout: () { + _log('First byte timeout after ${_firstByteTimeout.inSeconds}s - SSE may not be connected'); + throw TimeoutException('First byte not received'); + }, + ); + firstByteReceived = true; + _log('First byte received from SSE stream'); + } on TimeoutException { + _log('WARNING: Proceeding without first byte confirmation - enable_* calls may fail'); + } catch (e) { + _log('Error waiting for first byte: $e'); + } + + // Additional grace period after first byte (or timeout) + if (firstByteReceived) { + _log('Waiting ${_postConnectDelay.inMilliseconds}ms grace period...'); + await Future.delayed(_postConnectDelay); + } else { + // If first byte wasn't received, wait longer to give SSE more time + final fallbackDelay = Duration(milliseconds: _postConnectDelay.inMilliseconds * 2); + _log('Waiting ${fallbackDelay.inMilliseconds}ms fallback delay (no first byte)...'); + await Future.delayed(fallbackDelay); + } + + _sseReadinessComplete = true; + _log('SSE readiness complete - ready for enable_* calls'); + } + + /// 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, + required String streamType, + String? coin, + }) async { + // Check if stream is already active + final existing = _activeStreams[key]; + if (existing != null && !existing.isCancelled) { + _log('Stream already active: $key (refCount=${_streamRefCounts[key]})'); + _incrementRefCount(key); + return _createTypedSubscription(key, eventStream); + } + + // Check if there's already an in-flight enable for this key + final inFlight = _inFlightEnables[key]; + if (inFlight != null) { + _log('Enable already in-flight for $key, awaiting completion...'); + final subscription = await inFlight; + _incrementRefCount(key); + return subscription as StreamSubscription; + } + + // Create the enable future and store it to prevent duplicates + final enableFuture = _performEnableStream( + key: key, + enableStream: enableStream, + eventStream: eventStream, + streamType: streamType, + coin: coin, + ); + _inFlightEnables[key] = enableFuture; + + try { + final subscription = await enableFuture; + return subscription; + } finally { + // Remove from in-flight map once complete + _inFlightEnables.remove(key); + } + } + + /// Performs the actual enable stream operation with readiness checks and retries + Future> _performEnableStream({ + required String key, + required Future Function() enableStream, + required Stream eventStream, + required String streamType, + String? coin, + }) async { + // Wait for SSE readiness (first byte + grace period) + await _waitForSseReadiness(); + + // Log enable_* attempt with details + final coinInfo = coin != null ? ', coin=$coin' : ''; + _log('Enable stream attempt: type=$streamType, key=$key, client_id=$_defaultClientId$coinInfo'); + + int attemptCount = 0; + const maxAttempts = 3; + const retryDelay = Duration(seconds: 1); + + while (attemptCount < maxAttempts) { + try { + attemptCount++; + + // Enable new stream + final response = await enableStream(); + + final streamerId = response.streamerId; + _activeStreams[key] = _StreamSubscription( + streamerId: streamerId, + clientId: _defaultClientId, + ); + _incrementRefCount(key); + + _log('Enable stream success: type=$streamType, key=$key, streamer_id=$streamerId'); + return _createTypedSubscription(key, eventStream); + + } catch (e) { + _log('Enable stream failed (attempt $attemptCount/$maxAttempts): type=$streamType, error=$e'); + + // Check if it's an UnknownClient error + final errorStr = e.toString(); + if (errorStr.contains('UnknownClient')) { + _log('UnknownClient error detected - server forgot client registration'); + + if (attemptCount < maxAttempts) { + // Force full SSE reconnect on UnknownClient regardless of connection state + // The client may think it's connected, but KDF has dropped the registration + _log('Forcing SSE reconnect due to UnknownClient error...'); + _eventService.disconnect(); + _sseReadinessComplete = false; // Reset readiness flag + _activeStreams.remove(key); // Clear stale stream entry + + // Reconnect SSE + _eventService.connectIfNeeded(); + + // Wait for full connection cycle (preflight + handshake + first byte + grace period) + await Future.delayed(const Duration(seconds: 3)); + + _log('Retrying enable_* after SSE reconnection...'); + await Future.delayed(retryDelay); + continue; + } + } + + // Rethrow if max attempts reached or non-UnknownClient error + rethrow; + } + } + + throw Exception('Failed to enable stream after $maxAttempts attempts: $key'); + } + + /// 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', + streamType: 'balance', + coin: 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', + streamType: 'orderbook', + coin: '$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', + streamType: 'tx_history', + coin: 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', + streamType: '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', + streamType: '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', + streamType: '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', + streamType: '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', + streamType: '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), + eagerError: false, // 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(); + } +} 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 80e6421b5..63c12c030 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,11 +228,13 @@ class EtherscanProtocolHelper { return asset.protocol is Erc20Protocol && getApiUrlForAsset(asset) != null; } - /// Whether transaction history should also be fetched via mm2. + /// Whether KDF transaction history should be enabled 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); + /// 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 6e3490ef6..f0e99cdb0 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 d80639a53..8383ce631 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,9 +1,14 @@ 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/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'; import 'package:komodo_defi_types/komodo_defi_types.dart'; /// Core interface for transaction history manager @@ -36,16 +41,20 @@ class TransactionHistoryManager implements _TransactionHistoryManager { this._assetProvider, this._activationCoordinator, { required PubkeyManager pubkeyManager, + required EventStreamingManager eventStreamingManager, TransactionStorage? storage, + AssetHistoryStorage? assetHistoryStorage, }) : _storage = storage ?? TransactionStorage.defaultForPlatform(), _strategyFactory = TransactionHistoryStrategyFactory( pubkeyManager, _auth, - ) { + ), + _eventStreamingManager = eventStreamingManager, + _assetHistoryStorage = assetHistoryStorage ?? AssetHistoryStorage() { // Subscribe to auth changes directly in constructor _authSubscription = _auth.authStateChanges.listen((user) { if (user == null) { - _stopAllPolling(); + _stopAllStreaming(); } }); } @@ -55,9 +64,17 @@ class TransactionHistoryManager implements _TransactionHistoryManager { final IAssetProvider _assetProvider; final SharedActivationCoordinator _activationCoordinator; final TransactionStorage _storage; + final EventStreamingManager _eventStreamingManager; + final AssetHistoryStorage _assetHistoryStorage; final _streamControllers = >{}; + final _txHistorySubscriptions = >{}; final _pollingTimers = {}; + // Periodic confirmations refresh timers while streaming is healthy + final _confirmationsTimers = {}; + final _balanceFallbackSubscriptions = + >{}; + final _lastBalanceForPolling = {}; final _syncInProgress = {}; final _rateLimiter = _RateLimiter(const Duration(milliseconds: 500)); @@ -70,15 +87,38 @@ class TransactionHistoryManager implements _TransactionHistoryManager { final TransactionHistoryStrategyFactory _strategyFactory; - void _stopAllPolling() { + // 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 polling timers + // Cancel all transaction history subscriptions + for (final sub in _txHistorySubscriptions.values) { + sub.cancel().ignore(); + } + _txHistorySubscriptions.clear(); + + // Cancel polling timers for (final timer in _pollingTimers.values) { timer.cancel(); } _pollingTimers.clear(); + // Cancel confirmations refresh timers + for (final timer in _confirmationsTimers.values) { + timer.cancel(); + } + _confirmationsTimers.clear(); + + for (final sub in _balanceFallbackSubscriptions.values) { + sub.cancel().ignore(); + } + _balanceFallbackSubscriptions.clear(); + // Close controllers in a separate iteration to avoid modification during iteration final controllers = _streamControllers.values.toList(); _streamControllers.clear(); @@ -103,12 +143,50 @@ class TransactionHistoryManager implements _TransactionHistoryManager { itemsPerPage: _maxBatchSize, ); + // 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); + final isFirstTimeEnabling = !previouslyEnabledAssets.contains( + asset.id.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; + 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, + ); + + return TransactionPage( + transactions: const [], + total: 0, + currentPage: 1, + totalPages: 1, + ); + } + } + // First try to get from local storage 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, ); @@ -120,7 +198,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); @@ -136,10 +221,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); @@ -171,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 @@ -197,13 +295,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 +309,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,13 +346,14 @@ class TransactionHistoryManager implements _TransactionHistoryManager { asset.id, () => StreamController.broadcast( onListen: () { - if (!_pollingTimers.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); } @@ -287,13 +385,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 +399,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; @@ -323,41 +420,242 @@ 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 _ensureAssetActivated(Asset asset) async { + final activationResult = await _activationCoordinator.activateAsset(asset); + if (activationResult.isFailure) { + throw ActivationFailedException( + assetId: asset.id, + message: activationResult.errorMessage ?? 'Unknown activation error', + errorCode: 'ACTIVATION_FAILED', + originalError: activationResult.errorMessage, + ); + } + } + + Future _getCurrentWalletId() async { + final currentUser = await _auth.currentUser; + if (currentUser == null) { + throw StateError('User is not logged in'); + } + return currentUser.walletId; + } + + Future _batchStoreTransactions(List transactions) async { + if (transactions.isEmpty) return; + + try { + await _storage.storeTransactions( + transactions, + await _getCurrentWalletId(), + ); + } catch (e) { + throw Exception('Failed to store transactions batch: $e'); + } + } + + Future _startStreaming(Asset asset) async { + // Ensure we don't duplicate subscriptions + _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) { + 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); + + // Check again to avoid race condition: only store if not already present + if (_txHistorySubscriptions.containsKey(asset.id)) { + await txHistoryStreamSubscription.cancel(); + 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; + + // Verify the event is for the correct coin + if (txHistoryEvent.coin != asset.id.id) 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, StackTrace stackTrace) { + unawaited( + fallbackToPolling( + reason: 'stream error', + error: error, + stackTrace: stackTrace, + ), + ); + }) + ..onDone(() { + unawaited(fallbackToPolling(reason: 'stream closed')); + }); + + // Keep confirmations fresh even while the stream is healthy + _startConfirmationsRefresh(asset); + } catch (_) { + await _startPolling(asset); + } + } + + void _stopStreaming(AssetId assetId) { + _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 { - if (_isDisposed || _syncInProgress.contains(asset.id)) return; + if (_isDisposed) return; try { + await _ensureAssetActivated(asset); final strategy = _strategyFactory.forAsset(asset); - final lastTx = await _storage.getLatestTransactionId( + final latestId = await _storage.getLatestTransactionId( asset.id, await _getCurrentWalletId(), ); - await _rateLimiter.throttle(); - final response = await strategy.fetchTransactionHistory( _client, asset, - lastTx != null + latestId != null ? TransactionBasedPagination( - fromId: lastTx, - itemCount: _maxBatchSize, - ) + fromId: latestId, + itemCount: _maxBatchSize, + ) : 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 - .map((tx) => tx.asTransaction(asset.id)) - .toList(); + final newTransactions = response.transactions + .map((tx) => tx.asTransaction(asset.id)) + .toList(); await _batchStoreTransactions(newTransactions); @@ -368,59 +666,138 @@ class TransactionHistoryManager implements _TransactionHistoryManager { } } } - } catch (e) { + } catch (_) { + if (!_pollingTimers.containsKey(asset.id)) return; + if (retryCount < _maxPollingRetries) { - final delay = Duration(seconds: math.pow(2, retryCount).toInt()); - await Future.delayed( - delay, + final delaySeconds = math.pow(2, retryCount).toInt(); + await Future.delayed( + Duration(seconds: delaySeconds), () => _pollNewTransactions(asset, retryCount + 1), ); } } } - 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}', - ); + Future _startPolling(Asset asset) async { + _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); + + _balanceFallbackSubscriptions[asset.id] = balanceSubscription + ..onData((balanceEvent) { + if (_isDisposed) return; + if (balanceEvent.coin != asset.id.id) return; + + _syncHistoryIfBalanceChanged( + asset, + balance: balanceEvent.balance, + ).ignore(); + }) + ..onError((Object error, StackTrace stackTrace) { + _startTimerPolling(asset); + }) + ..onDone(() { + _startTimerPolling(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); } } - Future _getCurrentWalletId() async { - final currentUser = await _auth.currentUser; - if (currentUser == null) { - throw StateError('User is not logged in'); + void _startTimerPolling(Asset asset) { + final balanceSub = _balanceFallbackSubscriptions.remove(asset.id); + if (balanceSub != null) { + balanceSub.cancel().ignore(); } - return currentUser.walletId; + _pollingTimers[asset.id]?.cancel(); + _pollingTimers[asset.id] = Timer.periodic( + _defaultPollingInterval, + (_) => _pollBalanceAndSyncHistory(asset).ignore(), + ); + _pollBalanceAndSyncHistory(asset, force: true).ignore(); } - Future _batchStoreTransactions(List transactions) async { - if (transactions.isEmpty) return; + void _stopPolling(AssetId assetId) { + _pollingTimers[assetId]?.cancel(); + _pollingTimers.remove(assetId); - try { - await _storage.storeTransactions( - transactions, - await _getCurrentWalletId(), - ); - } catch (e) { - throw Exception('Failed to store transactions batch: $e'); + final balanceSub = _balanceFallbackSubscriptions.remove(assetId); + if (balanceSub != null) { + balanceSub.cancel().ignore(); } + + _lastBalanceForPolling.remove(assetId); } - void _startPolling(Asset asset) { - _stopPolling(asset.id); - _pollingTimers[asset.id] = Timer.periodic( + // 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, - (_) => _pollNewTransactions(asset), + (_) => _refreshRecentConfirmations(asset), ); - _pollNewTransactions(asset); + + // Kick off an immediate refresh + _refreshRecentConfirmations(asset).ignore(); } - void _stopPolling(AssetId assetId) { - _pollingTimers[assetId]?.cancel(); - _pollingTimers.remove(assetId); + 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 { @@ -429,6 +806,11 @@ 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) { @@ -442,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 aefe6318c..87620db93 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_sdk/lib/src/transaction_history/transaction_storage.dart b/packages/komodo_defi_sdk/lib/src/transaction_history/transaction_storage.dart index 40a3f6ad1..a8a353585 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), ); } diff --git a/packages/komodo_defi_types/lib/src/assets/asset.dart b/packages/komodo_defi_types/lib/src/assets/asset.dart index 6a7025182..0c44026fb 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 557304ad6..659f9e592 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/generic/sync_status.dart b/packages/komodo_defi_types/lib/src/generic/sync_status.dart index 494b472af..2791cdd5f 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,18 @@ 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'); + // 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, + ); } } 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 86744414e..2b8e6a0f1 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 70bc7c698..a4711be35 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');