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 8cf951f8d..30759807b 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 @@ -8,5 +8,8 @@ export 'coin_config_repository.dart'; export 'coin_config_repository_factory.dart'; export 'coin_config_storage.dart'; export 'config_transform.dart'; +export 'custom_token_storage.dart'; +export 'custom_token_store.dart'; +export 'no_op_custom_token_storage.dart'; export 'github_coin_config_provider.dart'; export 'local_asset_coin_config_provider.dart'; diff --git a/packages/komodo_coin_updates/lib/src/coins_config/asset_parser.dart b/packages/komodo_coin_updates/lib/src/coins_config/asset_parser.dart index 1375a1cd2..8a87dd0d1 100644 --- a/packages/komodo_coin_updates/lib/src/coins_config/asset_parser.dart +++ b/packages/komodo_coin_updates/lib/src/coins_config/asset_parser.dart @@ -28,11 +28,11 @@ class AssetParser { /// - [logContext]: Optional context string for logging (e.g., 'from asset bundle') /// /// Returns a list of successfully parsed assets. - Future> parseAssetsFromConfig( + List parseAssetsFromConfig( Map> transformedConfigs, { bool Function(Map)? shouldFilterCoin, String? logContext, - }) async { + }) { final context = logContext != null ? ' $logContext' : ''; _log.info( @@ -143,6 +143,94 @@ class AssetParser { return assets; } + /// Rebuilds parent-child relationships for a list of assets. + /// + /// This method implements a two-pass strategy similar to parseAssetsFromConfig: + /// 1. First pass: Identify platform assets (no parent) and collect their AssetIds + /// 2. Second pass: Reparse child assets using the known platform AssetIds + /// + /// This is useful when loading assets from storage where parent-child relationships + /// need to be reconstructed. + /// + /// Parameters: + /// - [assets]: List of assets to rebuild relationships for + /// - [logContext]: Optional context string for logging + /// + /// Returns a list of assets with properly rebuilt parent-child relationships. + List rebuildParentChildRelationships( + Iterable assets, { + String? logContext, + }) { + final context = logContext != null ? ' $logContext' : ''; + + _log.fine( + 'Rebuilding parent-child relationships for ${assets.length} assets$context', + ); + + // Convert assets back to config format for re-parsing + final assetConfigs = >{}; + for (final asset in assets) { + assetConfigs[asset.id.symbol.assetConfigId] = asset.protocol.config; + } + + return parseAssetsFromConfig( + assetConfigs, + logContext: 'while rebuilding relationships$context', + ); + } + + /// Rebuilds parent-child relationships for a list of assets using known parent IDs. + /// + /// This method is more efficient than the double-pass strategy when you already + /// know the parent AssetIds. It directly reconstructs child assets with proper + /// parent relationships without needing to identify platform assets first. + /// + /// Parameters: + /// - [assets]: List of assets to rebuild relationships for + /// - [knownParentIds]: Set of known parent AssetIds for resolving relationships + /// - [logContext]: Optional context string for logging + /// + /// Returns a list of assets with properly rebuilt parent-child relationships. + List rebuildParentChildRelationshipsWithKnownParents( + Iterable assets, + Set knownParentIds, { + String? logContext, + }) { + final context = logContext != null ? ' $logContext' : ''; + + _log.fine( + 'Rebuilding parent-child relationships for ${assets.length} assets ' + 'with ${knownParentIds.length} known parent IDs$context', + ); + + final rebuiltAssets = []; + + for (final asset in assets) { + try { + // Reconstruct the asset using the known parent IDs + final rebuiltAsset = Asset.fromJson( + asset.protocol.config, + knownIds: knownParentIds, + ); + rebuiltAssets.add(rebuiltAsset); + } catch (e, s) { + _log.warning( + 'Failed to rebuild asset ${asset.id.id} with known parents: $e', + e, + s, + ); + // Fall back to the original asset if reconstruction fails + rebuiltAssets.add(asset); + } + } + + _log.fine( + 'Successfully rebuilt ${rebuiltAssets.length} assets with known parents$context', + ); + + return rebuiltAssets; + } + /// Helper method to check if a coin configuration has no parent. bool _hasNoParent(Map coinData) => coinData['parent_coin'] == null; diff --git a/packages/komodo_coin_updates/lib/src/coins_config/coin_config_repository.dart b/packages/komodo_coin_updates/lib/src/coins_config/coin_config_repository.dart index 71d211214..c2a9cf9b0 100644 --- a/packages/komodo_coin_updates/lib/src/coins_config/coin_config_repository.dart +++ b/packages/komodo_coin_updates/lib/src/coins_config/coin_config_repository.dart @@ -104,10 +104,8 @@ class CoinConfigRepository implements CoinConfigStorage { /// Retrieves all assets from storage, excluding any whose symbol appears /// in [excludedAssets]. Returns an empty list if storage is empty. /// - /// This method implements a two-pass parsing strategy to rebuild parent-child - /// relationships between assets, similar to AssetParser: - /// 1. First pass: Parse platform assets (no parent) to get their AssetIds - /// 2. Second pass: Reparse child assets using known platform AssetIds + /// This method uses the AssetParser to rebuild parent-child relationships + /// between assets that were loaded from storage. Future> getAssets({ List excludedAssets = const [], }) async { @@ -119,18 +117,15 @@ class CoinConfigRepository implements CoinConfigStorage { final values = await Future.wait( keys.map((dynamic key) => box.get(key as String)), ); - final allAssetConfigs = values + final rawAssets = values .whereType() .where((a) => !excludedAssets.contains(a.id.id)) - .map( - (asset) => - MapEntry(asset.id.symbol.assetConfigId, asset.protocol.config), - ); + .toList(); - final transformedConfigs = Map>.fromEntries( - allAssetConfigs, + return _assetParser.rebuildParentChildRelationships( + rawAssets, + logContext: 'from storage', ); - return _assetParser.parseAssetsFromConfig(transformedConfigs); } @override diff --git a/packages/komodo_coin_updates/lib/src/coins_config/custom_token_storage.dart b/packages/komodo_coin_updates/lib/src/coins_config/custom_token_storage.dart new file mode 100644 index 000000000..ae5d4b87f --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/coins_config/custom_token_storage.dart @@ -0,0 +1,199 @@ +import 'package:hive_ce/hive.dart'; +import 'package:komodo_coin_updates/komodo_coin_updates.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:logging/logging.dart'; + +/// Storage for custom tokens that are not part of the official coin configuration. +/// These tokens are persisted independently from the main coin configuration +/// and are not affected by coin config updates. +class CustomTokenStorage implements CustomTokenStore { + /// Creates a custom token storage instance. + /// [customTokensBoxName] is the name of the Hive box for storing custom tokens. + /// [customTokensBox] is an optional pre-opened LazyBox for testing/mocking. + CustomTokenStorage({ + this.customTokensBoxName = 'custom_tokens', + LazyBox? customTokensBox, + AssetParser assetParser = const AssetParser(), + }) : _customTokensBox = customTokensBox, + _assetParser = assetParser; + + static final Logger _log = Logger('CustomTokenStorage'); + + /// The name of the Hive box for custom tokens. + final String customTokensBoxName; + + /// Not final to allow for reopening if closed or in a corrupted state. + LazyBox? _customTokensBox; + + /// The asset parser used to rebuild parent-child relationships. + final AssetParser _assetParser; + + @override + Future init() async { + // Initialize by opening the box - this ensures storage is ready + await _openCustomTokensBox(); + } + + @override + Future storeCustomToken(Asset asset) async { + _log.fine('Storing custom token ${asset.id.id}'); + final box = await _openCustomTokensBox(); + await box.put(asset.id.id, asset); + } + + @override + Future storeCustomTokens(List assets) async { + _log.fine('Storing ${assets.length} custom tokens'); + final box = await _openCustomTokensBox(); + final putMap = {for (final a in assets) a.id.id: a}; + await box.putAll(putMap); + } + + @override + Future> getAllCustomTokens(Set knownIds) async { + _log.fine('Retrieving all custom tokens'); + final box = await _openCustomTokensBox(); + final keys = box.keys.cast(); + final values = await Future.wait(keys.map(box.get)); + + return _assetParser + .rebuildParentChildRelationshipsWithKnownParents( + values.whereType(), + knownIds, + logContext: 'for custom tokens', + ) + .map( + (asset) => asset.copyWith( + // IMPORTANT: This cast to Erc20Protocol is by design for now, + // as custom tokens are currently only supported for ERC20. + // This may change in future versions to support other protocols. + protocol: (asset.protocol as Erc20Protocol).copyWith( + isCustomToken: true, + ), + ), + ) + .toList(); + } + + @override + Future getCustomToken(AssetId assetId) async { + _log.fine('Retrieving custom token ${assetId.id}'); + final box = await _openCustomTokensBox(); + final asset = await box.get(assetId.id); + return asset?.copyWith( + // IMPORTANT: This cast to Erc20Protocol is by design for now, + // as custom tokens are currently only supported for ERC20. + // This may change in future versions to support other protocols. + protocol: (asset.protocol as Erc20Protocol).copyWith(isCustomToken: true), + ); + } + + @override + Future hasCustomToken(AssetId assetId) async { + final box = await _openCustomTokensBox(); + return box.containsKey(assetId.id); + } + + @override + Future deleteCustomToken(AssetId assetId) async { + _log.fine('Deleting custom token ${assetId.id}'); + final box = await _openCustomTokensBox(); + final existed = box.containsKey(assetId.id); + await box.delete(assetId.id); + return existed; + } + + @override + Future deleteCustomTokens(List assetIds) async { + _log.fine('Deleting ${assetIds.length} custom tokens'); + final box = await _openCustomTokensBox(); + final keys = assetIds.map((id) => id.id).toList(); + + // Count how many actually exist before deletion + var deletedCount = 0; + for (final key in keys) { + if (box.containsKey(key)) { + deletedCount++; + } + } + + await box.deleteAll(keys); + return deletedCount; + } + + @override + Future deleteAllCustomTokens() async { + _log.fine('Deleting all custom tokens'); + final box = await _openCustomTokensBox(); + await box.clear(); + } + + @override + Future hasCustomTokens() async { + final exists = await Hive.boxExists(customTokensBoxName); + if (!exists) return false; + final box = await _openCustomTokensBox(); + return box.isNotEmpty; + } + + @override + Future upsertCustomToken(Asset asset) async { + final box = await _openCustomTokensBox(); + final existed = box.containsKey(asset.id.id); + await box.put(asset.id.id, asset); + + if (existed) { + _log.fine('Updated existing custom token ${asset.id.id}'); + } else { + _log.fine('Stored new custom token ${asset.id.id}'); + } + + return existed; + } + + @override + Future addCustomTokenIfNotExists(Asset asset) async { + final box = await _openCustomTokensBox(); + if (box.containsKey(asset.id.id)) { + _log.fine('Custom token ${asset.id.id} already exists, skipping'); + return false; + } + + await box.put(asset.id.id, asset); + _log.fine('Added new custom token ${asset.id.id}'); + return true; + } + + @override + Future getCustomTokenCount() async { + final box = await _openCustomTokensBox(); + return box.length; + } + + @override + Future dispose() async { + if (_customTokensBox != null) { + _log.fine('Closing custom tokens box'); + await _customTokensBox!.close(); + _customTokensBox = null; + } + } + + Future> _openCustomTokensBox() async { + if (_customTokensBox == null || !_customTokensBox!.isOpen) { + _log.fine('Opening custom tokens box "$customTokensBoxName"'); + try { + _customTokensBox = await Hive.openLazyBox(customTokensBoxName); + } catch (e) { + _log.warning('Failed to open custom tokens box, retrying: $e'); + // If the box is in a corrupted state, try to delete and recreate + if (await Hive.boxExists(customTokensBoxName)) { + await _customTokensBox?.close(); + } + _customTokensBox = await Hive.openLazyBox(customTokensBoxName); + } + } + + return _customTokensBox!; + } +} diff --git a/packages/komodo_coin_updates/lib/src/coins_config/custom_token_storage_interface.dart b/packages/komodo_coin_updates/lib/src/coins_config/custom_token_storage_interface.dart new file mode 100644 index 000000000..4bbe7e898 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/coins_config/custom_token_storage_interface.dart @@ -0,0 +1,56 @@ +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +/// Interface for custom token storage operations +abstract class CustomTokenStore { + /// Initializes/opens the underlying storage if required. + Future init(); + + /// Stores a single custom token. + /// If a token with the same AssetId already exists, it will be overwritten. + Future storeCustomToken(Asset asset); + + /// Stores multiple custom tokens atomically (all-or-nothing). + /// Existing tokens with the same AssetIds will be overwritten. + /// Implementations should throw on partial failure. + Future storeCustomTokens(List assets); + + /// Retrieves all custom tokens from storage. + /// Returns an empty list if none. + /// Implementations should return a deterministic order (e.g., sorted by AssetId). + Future> getAllCustomTokens(); + + /// Retrieves a single custom token by its AssetId. + /// Returns null if the token is not found. + Future getCustomToken(AssetId assetId); + + /// Checks if a custom token exists in storage. + Future hasCustomToken(AssetId assetId); + + /// Deletes a single custom token by its AssetId. Returns true if a token was deleted. + Future deleteCustomToken(AssetId assetId); + + /// Deletes multiple custom tokens by their AssetIds. Returns number of tokens deleted. + Future deleteCustomTokens(List assetIds); + + /// Deletes all custom tokens from storage. + Future deleteAllCustomTokens(); + + /// Returns true if any custom tokens are stored. + Future hasCustomTokens(); + + /// Upserts a custom token: updates if it exists, inserts otherwise. + /// Returns true if updated, false if inserted. + Future upsertCustomToken(Asset asset); + + /// Adds a custom token to storage if it doesn't already exist. + /// Returns true if the token was added, false if it already existed. + Future addCustomTokenIfNotExists(Asset asset); + + /// Returns the number of custom tokens in storage. + Future getCustomTokenCount(); + + /// Closes the storage and releases resources. + /// Must be idempotent and safe to call multiple times. + /// Should complete after in-flight operations finish or are safely cancelled. + Future dispose(); +} diff --git a/packages/komodo_coin_updates/lib/src/coins_config/custom_token_store.dart b/packages/komodo_coin_updates/lib/src/coins_config/custom_token_store.dart new file mode 100644 index 000000000..2fc2dbb91 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/coins_config/custom_token_store.dart @@ -0,0 +1,56 @@ +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +/// Interface for custom token storage operations +abstract class CustomTokenStore { + /// Initializes/opens the underlying storage if required. + Future init(); + + /// Stores a single custom token. + /// If a token with the same AssetId already exists, it will be overwritten. + Future storeCustomToken(Asset asset); + + /// Stores multiple custom tokens atomically (all-or-nothing). + /// Existing tokens with the same AssetIds will be overwritten. + /// Implementations should throw on partial failure. + Future storeCustomTokens(List assets); + + /// Retrieves all custom tokens from storage. + /// Returns an empty list if none. + /// Implementations should return a deterministic order (e.g., sorted by AssetId). + Future> getAllCustomTokens(Set knownIds); + + /// Retrieves a single custom token by its AssetId. + /// Returns null if the token is not found. + Future getCustomToken(AssetId assetId); + + /// Checks if a custom token exists in storage. + Future hasCustomToken(AssetId assetId); + + /// Deletes a single custom token by its AssetId. Returns true if a token was deleted. + Future deleteCustomToken(AssetId assetId); + + /// Deletes multiple custom tokens by their AssetIds. Returns number of tokens deleted. + Future deleteCustomTokens(List assetIds); + + /// Deletes all custom tokens from storage. + Future deleteAllCustomTokens(); + + /// Returns true if any custom tokens are stored. + Future hasCustomTokens(); + + /// Upserts a custom token: updates if it exists, inserts otherwise. + /// Returns true if updated, false if inserted. + Future upsertCustomToken(Asset asset); + + /// Adds a custom token to storage if it doesn't already exist. + /// Returns true if the token was added, false if it already existed. + Future addCustomTokenIfNotExists(Asset asset); + + /// Returns the number of custom tokens in storage. + Future getCustomTokenCount(); + + /// Closes the storage and releases resources. + /// Must be idempotent and safe to call multiple times. + /// Should complete after in-flight operations finish or are safely cancelled. + Future dispose(); +} diff --git a/packages/komodo_coin_updates/lib/src/coins_config/no_op_custom_token_storage.dart b/packages/komodo_coin_updates/lib/src/coins_config/no_op_custom_token_storage.dart new file mode 100644 index 000000000..ba8e4a17c --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/coins_config/no_op_custom_token_storage.dart @@ -0,0 +1,89 @@ +import 'package:komodo_coin_updates/src/coins_config/custom_token_store.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +/// A no-op implementation of [CustomTokenStore] that always returns empty results. +/// This is useful in scenarios where custom tokens should not be included, +/// such as in startup coin providers that need to provide a minimal coin list +/// to initialize the system without user-specific customizations. +class NoOpCustomTokenStorage implements CustomTokenStore { + const NoOpCustomTokenStorage(); + + @override + Future init() async { + // No-op: nothing to initialize + } + + @override + Future storeCustomToken(Asset asset) async { + // No-op: doesn't store anything + } + + @override + Future storeCustomTokens(List assets) async { + // No-op: doesn't store anything + } + + @override + Future> getAllCustomTokens(Set knownIds) async { + // Always returns empty list + return []; + } + + @override + Future getCustomToken(AssetId assetId) async { + // Always returns null (no custom tokens) + return null; + } + + @override + Future hasCustomToken(AssetId assetId) async { + // Never has any custom tokens + return false; + } + + @override + Future deleteCustomToken(AssetId assetId) async { + // No-op: nothing to delete, returns false (nothing was deleted) + return false; + } + + @override + Future deleteCustomTokens(List assetIds) async { + // No-op: nothing to delete, returns 0 (no tokens deleted) + return 0; + } + + @override + Future deleteAllCustomTokens() async { + // No-op: nothing to delete + } + + @override + Future hasCustomTokens() async { + // Never has any custom tokens + return false; + } + + @override + Future upsertCustomToken(Asset asset) async { + // No-op: doesn't upsert anything, returns false (not updated) + return false; + } + + @override + Future addCustomTokenIfNotExists(Asset asset) async { + // No-op: doesn't add anything, returns false (not added) + return false; + } + + @override + Future getCustomTokenCount() async { + // Always has zero custom tokens + return 0; + } + + @override + Future dispose() async { + // No-op: nothing to dispose + } +} diff --git a/packages/komodo_coins/lib/komodo_coins.dart b/packages/komodo_coins/lib/komodo_coins.dart index e6e754d73..cc6cdbc68 100644 --- a/packages/komodo_coins/lib/komodo_coins.dart +++ b/packages/komodo_coins/lib/komodo_coins.dart @@ -5,6 +5,8 @@ library komodo_coins; export 'src/asset_filter.dart'; +export 'src/asset_management/coin_config_manager.dart' + show CoinConfigManager, StrategicCoinConfigManager; export 'src/komodo_asset_update_manager.dart' show AssetsUpdateManager, KomodoAssetsUpdateManager; export 'src/startup/startup_coins_provider.dart' show StartupCoinsProvider; diff --git a/packages/komodo_coins/lib/src/asset_filter.dart b/packages/komodo_coins/lib/src/asset_filter.dart index 17bb7639a..31fe41080 100644 --- a/packages/komodo_coins/lib/src/asset_filter.dart +++ b/packages/komodo_coins/lib/src/asset_filter.dart @@ -12,6 +12,24 @@ abstract class AssetFilterStrategy extends Equatable { /// Returns `true` if the asset should be included. bool shouldInclude(Asset asset, JsonMap coinConfig); + /// Factory method to create a strategy instance from a strategy ID. + /// Used for reconstructing strategies from cached strategy IDs. + static AssetFilterStrategy? fromStrategyId(String strategyId) { + switch (strategyId) { + case 'none': + return const NoAssetFilterStrategy(); + case 'trezor': + // Using default hiddenAssets - in practice, this should work for most cases + return const TrezorAssetFilterStrategy(); + case 'utxo': + return const UtxoAssetFilterStrategy(); + case 'evm': + return const EvmAssetFilterStrategy(); + default: + return null; + } + } + @override List get props => [strategyId]; } @@ -32,7 +50,7 @@ class NoAssetFilterStrategy extends AssetFilterStrategy { /// at this time, so they are also excluded. class TrezorAssetFilterStrategy extends AssetFilterStrategy { const TrezorAssetFilterStrategy({this.hiddenAssets = const {}}) - : super('trezor'); + : super('trezor'); final Set hiddenAssets; @@ -42,11 +60,13 @@ class TrezorAssetFilterStrategy extends AssetFilterStrategy { // AVAX, BNB, ETH, FTM, etc. currently fail to activate on Trezor, // so we exclude them from the Trezor asset list. - final isProtocolSupported = subClass == CoinSubClass.utxo || + final isProtocolSupported = + subClass == CoinSubClass.utxo || subClass == CoinSubClass.smartChain || subClass == CoinSubClass.qrc20; - final hasTrezorCoinField = coinConfig['trezor_coin'] is String && + final hasTrezorCoinField = + coinConfig['trezor_coin'] is String && (coinConfig['trezor_coin'] as String).isNotEmpty; final isExcludedAsset = hiddenAssets.contains(asset.id.id); diff --git a/packages/komodo_coins/lib/src/asset_management/coin_config_manager.dart b/packages/komodo_coins/lib/src/asset_management/coin_config_manager.dart index d163b52eb..b8f47e2c6 100644 --- a/packages/komodo_coins/lib/src/asset_management/coin_config_manager.dart +++ b/packages/komodo_coins/lib/src/asset_management/coin_config_manager.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:collection'; +import 'package:komodo_coin_updates/komodo_coin_updates.dart'; import 'package:komodo_coins/src/asset_filter.dart'; import 'package:komodo_coins/src/asset_management/coin_config_fallback_mixin.dart'; import 'package:komodo_coins/src/asset_management/loading_strategy.dart'; @@ -53,6 +54,12 @@ abstract class CoinConfigManager { /// Disposes of all resources Future dispose(); + + /// Stores a custom token + Future storeCustomToken(Asset asset); + + /// Deletes a custom token + Future deleteCustomToken(AssetId assetId); } /// Implementation of [CoinConfigManager] that uses strategy pattern for loading @@ -63,11 +70,13 @@ class StrategicCoinConfigManager required List configSources, LoadingStrategy? loadingStrategy, Set defaultPriorityTickers = const {}, + CustomTokenStore? customTokenStorage, }) { return StrategicCoinConfigManager._internal( configSources: configSources, loadingStrategy: loadingStrategy ?? StorageFirstLoadingStrategy(), defaultPriorityTickers: defaultPriorityTickers, + customTokenStorage: customTokenStorage ?? CustomTokenStorage(), ); } @@ -75,15 +84,18 @@ class StrategicCoinConfigManager required List configSources, required LoadingStrategy loadingStrategy, required Set defaultPriorityTickers, + required CustomTokenStore customTokenStorage, }) : _configSources = configSources, _loadingStrategy = loadingStrategy, - _defaultPriorityTickers = Set.unmodifiable(defaultPriorityTickers); + _defaultPriorityTickers = Set.unmodifiable(defaultPriorityTickers), + _customTokenStorage = customTokenStorage; static final _logger = Logger('StrategicCoinConfigManager'); final List _configSources; final LoadingStrategy _loadingStrategy; final Set _defaultPriorityTickers; + final CustomTokenStore _customTokenStorage; // Required by CoinConfigFallbackMixin @override @@ -185,6 +197,7 @@ class StrategicCoinConfigManager ); _assets = _mapAssets(assets); + await _loadAndMergeCustomTokens(); _logger.info('Loaded ${assets.length} assets'); } @@ -234,6 +247,7 @@ class StrategicCoinConfigManager ); _assets = _mapAssets(assets); + await _loadAndMergeCustomTokens(); _filterCache.clear(); // Clear cache after refresh // Refresh commit hash cache when assets are refreshed @@ -325,6 +339,71 @@ class StrategicCoinConfigManager .toSet(); } + /// Loads custom tokens and merges them directly into _assets + Future _loadAndMergeCustomTokens() async { + try { + final knownIds = _assets!.keys.toSet(); + final customTokens = await _customTokenStorage.getAllCustomTokens( + knownIds, + ); + if (customTokens.isEmpty) { + return; + } + + // Add custom tokens to _assets, handling conflicts by creating duplicate entries + for (final customToken in customTokens) { + _assets![customToken.id] = customToken; + } + + _logger.fine('Merged ${customTokens.length} custom tokens into assets'); + } catch (e, s) { + _logger.warning('Failed to load custom tokens', e, s); + } + } + + /// Updates filter caches when an asset is added + void _updateFilterCachesForAddedAsset(Asset asset) { + for (final entry in _filterCache.entries) { + final strategyId = entry.key; + final cachedAssets = entry.value; + + // Create a strategy instance using the factory method + final strategy = AssetFilterStrategy.fromStrategyId(strategyId); + if (strategy != null) { + final config = asset.protocol.config; + if (strategy.shouldInclude(asset, config)) { + cachedAssets[asset.id] = asset; + } + } + } + } + + /// Updates filter caches when an asset is removed + void _updateFilterCachesForRemovedAsset(AssetId assetId) { + for (final cachedAssets in _filterCache.values) { + cachedAssets.remove(assetId); + } + } + + @override + Future storeCustomToken(Asset asset) async { + _checkNotDisposed(); + _assertInitialized(); + + await _customTokenStorage.storeCustomToken(asset); + _assets![asset.id] = asset; + _updateFilterCachesForAddedAsset(asset); + } + + @override + Future deleteCustomToken(AssetId assetId) async { + _checkNotDisposed(); + _assertInitialized(); + await _customTokenStorage.deleteCustomToken(assetId); + _assets!.remove(assetId); + _updateFilterCachesForRemovedAsset(assetId); + } + @override Future dispose() async { if (_isDisposed) { @@ -336,6 +415,7 @@ class StrategicCoinConfigManager _assets = null; _filterCache.clear(); _cachedCommitHash = null; // Clear commit hash cache + await _customTokenStorage.dispose(); // Dispose custom token storage clearSourceHealthData(); // Clear mixin data _logger.fine('Disposed StrategicCoinConfigManager'); } diff --git a/packages/komodo_coins/lib/src/komodo_asset_update_manager.dart b/packages/komodo_coins/lib/src/komodo_asset_update_manager.dart index b2aaae68a..3aadee05b 100644 --- a/packages/komodo_coins/lib/src/komodo_asset_update_manager.dart +++ b/packages/komodo_coins/lib/src/komodo_asset_update_manager.dart @@ -68,12 +68,14 @@ class KomodoAssetsUpdateManager implements AssetsUpdateManager { this.enableAutoUpdate = true, this.appStoragePath, this.appName, + CustomTokenStore? customTokenStorage, }) : _configRepository = configRepository ?? AssetRuntimeUpdateConfigRepository(), _transformer = transformer ?? const CoinConfigTransformer(), _dataFactory = dataFactory ?? const DefaultCoinConfigDataFactory(), _loadingStrategy = loadingStrategy ?? StorageFirstLoadingStrategy(), - _updateStrategy = updateStrategy ?? const BackgroundUpdateStrategy(); + _updateStrategy = updateStrategy ?? const BackgroundUpdateStrategy(), + _customTokenStorage = customTokenStorage; static final Logger _log = Logger('KomodoAssetsUpdateManager'); @@ -92,6 +94,7 @@ class KomodoAssetsUpdateManager implements AssetsUpdateManager { final CoinConfigDataFactory _dataFactory; final LoadingStrategy _loadingStrategy; final UpdateStrategy _updateStrategy; + final CustomTokenStore? _customTokenStorage; // Internal managers using strategy pattern CoinConfigManager? _assetsManager; @@ -140,6 +143,7 @@ class KomodoAssetsUpdateManager implements AssetsUpdateManager { configSources: configProviders, loadingStrategy: _loadingStrategy, defaultPriorityTickers: defaultPriorityTickers, + customTokenStorage: _customTokenStorage ?? CustomTokenStorage(), ); // Initialize update manager diff --git a/packages/komodo_coins/lib/src/startup/startup_coins_provider.dart b/packages/komodo_coins/lib/src/startup/startup_coins_provider.dart index b8b4c3666..8915557c8 100644 --- a/packages/komodo_coins/lib/src/startup/startup_coins_provider.dart +++ b/packages/komodo_coins/lib/src/startup/startup_coins_provider.dart @@ -30,6 +30,7 @@ class StartupCoinsProvider { LoadingStrategy? loadingStrategy, String? appStoragePath, String? appName, + CustomTokenStore? customTokenStorage, }) async { final resolvedAppName = appName ?? 'komodo_coins'; @@ -70,6 +71,8 @@ class StartupCoinsProvider { manager = StrategicCoinConfigManager( configSources: sources, loadingStrategy: loadingStrategy ?? StorageFirstLoadingStrategy(), + customTokenStorage: + customTokenStorage ?? const NoOpCustomTokenStorage(), ); await manager.init(); diff --git a/packages/komodo_coins/test/komodo_coins_cache_behavior_test.dart b/packages/komodo_coins/test/komodo_coins_cache_behavior_test.dart index de1d26c1a..a597acf75 100644 --- a/packages/komodo_coins/test/komodo_coins_cache_behavior_test.dart +++ b/packages/komodo_coins/test/komodo_coins_cache_behavior_test.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; import 'package:komodo_coin_updates/komodo_coin_updates.dart'; import 'package:komodo_coins/komodo_coins.dart' show KomodoAssetsUpdateManager, StartupCoinsProvider; @@ -38,13 +40,34 @@ class FakeRuntimeUpdateConfig extends Fake class FakeCoinConfigTransformer extends Fake implements CoinConfigTransformer {} +/// Helper function to get a temporary directory for Hive tests +Future getTempDir() async { + final tempDir = Directory.systemTemp.createTempSync('hive_test_'); + return tempDir; +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); - setUpAll(() { + + late Directory tempDir; + + setUpAll(() async { + // Initialize Hive for testing + tempDir = await getTempDir(); + Hive.init(tempDir.path); + registerFallbackValue(FakeRuntimeUpdateConfig()); registerFallbackValue(FakeCoinConfigTransformer()); }); + tearDownAll(() async { + await Hive.close(); + // Clean up temporary directory + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + group('KomodoCoins cache behavior', () { late MockRuntimeUpdateConfigRepository mockConfigRepository; late MockCoinConfigTransformer mockTransformer; diff --git a/packages/komodo_coins/test/komodo_coins_fallback_test.dart b/packages/komodo_coins/test/komodo_coins_fallback_test.dart index a827da20e..f0109ed94 100644 --- a/packages/komodo_coins/test/komodo_coins_fallback_test.dart +++ b/packages/komodo_coins/test/komodo_coins_fallback_test.dart @@ -1,4 +1,7 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; import 'package:komodo_coin_updates/komodo_coin_updates.dart'; import 'package:komodo_coins/komodo_coins.dart' show KomodoAssetsUpdateManager; import 'package:komodo_coins/src/update_management/update_strategy.dart'; @@ -36,8 +39,20 @@ class FakeCoinConfigTransformer extends Fake implements CoinConfigTransformer {} class FakeAssetId extends Fake implements AssetId {} +/// Helper function to get a temporary directory for Hive tests +Future getTempDir() async { + final tempDir = Directory.systemTemp.createTempSync('hive_test_'); + return tempDir; +} + void main() { - setUpAll(() { + late Directory tempDir; + + setUpAll(() async { + // Initialize Hive for testing + tempDir = await getTempDir(); + Hive.init(tempDir.path); + registerFallbackValue(FakeRuntimeUpdateConfig()); registerFallbackValue(FakeCoinConfigTransformer()); registerFallbackValue(UpdateRequestType.backgroundUpdate); @@ -45,6 +60,14 @@ void main() { registerFakeAssetTypes(); }); + tearDownAll(() async { + await Hive.close(); + // Clean up temporary directory + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + group('KomodoCoins Fallback to Local Assets', () { late MockRuntimeUpdateConfigRepository mockConfigRepository; late MockCoinConfigTransformer mockTransformer; diff --git a/packages/komodo_coins/test/strategic_coin_config_manager_test.dart b/packages/komodo_coins/test/strategic_coin_config_manager_test.dart index 89c625c47..a76722e85 100644 --- a/packages/komodo_coins/test/strategic_coin_config_manager_test.dart +++ b/packages/komodo_coins/test/strategic_coin_config_manager_test.dart @@ -1,4 +1,7 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; import 'package:komodo_coin_updates/komodo_coin_updates.dart'; import 'package:komodo_coins/src/asset_filter.dart'; import 'package:komodo_coins/src/asset_management/coin_config_manager.dart'; @@ -18,6 +21,8 @@ class MockCoinConfigRepository extends Mock implements CoinConfigRepository {} class MockLocalAssetCoinConfigProvider extends Mock implements LocalAssetCoinConfigProvider {} +class MockCustomTokenStorage extends Mock implements CustomTokenStore {} + // Fake classes for mocktail fallback values class FakeRuntimeUpdateConfig extends Fake implements AssetRuntimeUpdateConfig {} @@ -26,14 +31,34 @@ class FakeCoinConfigTransformer extends Fake implements CoinConfigTransformer {} class FakeAssetId extends Fake implements AssetId {} +/// Helper function to get a temporary directory for Hive tests +Future getTempDir() async { + final tempDir = Directory.systemTemp.createTempSync('hive_test_'); + return tempDir; +} + void main() { - setUpAll(() { + late Directory tempDir; + + setUpAll(() async { + // Create a temporary directory for Hive + tempDir = await getTempDir(); + Hive.init(tempDir.path); + registerFallbackValue(FakeRuntimeUpdateConfig()); registerFallbackValue(FakeCoinConfigTransformer()); registerFallbackValue(LoadingRequestType.initialLoad); registerFakeAssetTypes(); }); + tearDownAll(() async { + await Hive.close(); + // Clean up temporary directory + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + group('StrategicCoinConfigManager', () { late MockCoinConfigSource mockStorageSource; late MockCoinConfigSource mockLocalSource; @@ -238,7 +263,7 @@ void main() { final filtered = manager.filteredAssets(filter); expect(filtered, isNotEmpty); - // All test assets should be UTXO type + // All test assets should be smart chain type (based on actual parsing behavior) expect( filtered.values.every( (asset) => @@ -393,7 +418,7 @@ void main() { throwsStateError, ); expect( - () => manager.findByTicker('KMD', CoinSubClass.utxo), + () => manager.findByTicker('KMD', CoinSubClass.smartChain), throwsStateError, ); expect(() => manager.findVariantsOfCoin('KMD'), throwsStateError); @@ -496,6 +521,406 @@ void main() { expect(manager.isInitialized, isTrue); }); }); + + group('Custom Token Management', () { + late MockCustomTokenStorage mockCustomTokenStorage; + late StrategicCoinConfigManager manager; + + // Create test custom tokens using UTXO type for simplicity + final customTokenConfig1 = { + 'coin': 'CUSTOM1', + 'fname': 'Custom Token 1', + 'chain_id': 0, + 'type': 'UTXO', + 'protocol': {'type': 'UTXO'}, + 'is_testnet': false, + }; + final customToken1 = Asset.fromJson(customTokenConfig1); + + final customTokenConfig2 = { + 'coin': 'CUSTOM2', + 'fname': 'Custom Token 2', + 'chain_id': 1, + 'type': 'UTXO', + 'protocol': {'type': 'UTXO'}, + 'is_testnet': false, + }; + final customToken2 = Asset.fromJson(customTokenConfig2); + + // Create a custom token that conflicts with existing asset (KMD) + final conflictingTokenConfig = { + 'coin': 'KMD', + 'fname': 'Custom KMD Token', + 'chain_id': 1, + 'type': 'UTXO', + 'protocol': {'type': 'UTXO'}, + 'is_testnet': false, + }; + final conflictingToken = Asset.fromJson(conflictingTokenConfig); + + setUp(() async { + mockCustomTokenStorage = MockCustomTokenStorage(); + + // Set up mock custom token storage behavior + when( + () => mockCustomTokenStorage.getAllCustomTokens(any()), + ).thenAnswer((_) async => []); + when( + () => mockCustomTokenStorage.storeCustomToken(any()), + ).thenAnswer((_) async {}); + when(() => mockCustomTokenStorage.deleteCustomToken(any())).thenAnswer(( + _, + ) async { + return true; + }); + when(() => mockCustomTokenStorage.dispose()).thenAnswer((_) async {}); + + manager = StrategicCoinConfigManager( + configSources: [mockStorageSource, mockLocalSource], + loadingStrategy: mockLoadingStrategy, + customTokenStorage: mockCustomTokenStorage, + ); + await manager.init(); + }); + + tearDown(() async { + await manager.dispose(); + }); + + group('Initialization with custom tokens', () { + test('loads custom tokens during initialization', () async { + // Set up custom tokens to be returned during init + when( + () => mockCustomTokenStorage.getAllCustomTokens(any()), + ).thenAnswer((_) async => [customToken1, customToken2]); + + final managerWithTokens = StrategicCoinConfigManager( + configSources: [mockStorageSource, mockLocalSource], + loadingStrategy: mockLoadingStrategy, + customTokenStorage: mockCustomTokenStorage, + ); + await managerWithTokens.init(); + + // Verify custom tokens are included in all assets + final allAssets = managerWithTokens.all; + expect(allAssets.containsKey(customToken1.id), isTrue); + expect(allAssets.containsKey(customToken2.id), isTrue); + expect(allAssets[customToken1.id], equals(customToken1)); + expect(allAssets[customToken2.id], equals(customToken2)); + + await managerWithTokens.dispose(); + }); + + test('handles custom token loading failure gracefully', () async { + when( + () => mockCustomTokenStorage.getAllCustomTokens(any()), + ).thenThrow(Exception('Storage error')); + + final managerWithError = StrategicCoinConfigManager( + configSources: [mockStorageSource, mockLocalSource], + loadingStrategy: mockLoadingStrategy, + customTokenStorage: mockCustomTokenStorage, + ); + + // Should not throw during initialization + await expectLater(managerWithError.init(), completes); + expect(managerWithError.isInitialized, isTrue); + + await managerWithError.dispose(); + }); + + test('handles conflict resolution with existing assets', () async { + // Set up conflicting custom token + when( + () => mockCustomTokenStorage.getAllCustomTokens(any()), + ).thenAnswer((_) async => [conflictingToken]); + + final managerWithConflict = StrategicCoinConfigManager( + configSources: [mockStorageSource, mockLocalSource], + loadingStrategy: mockLoadingStrategy, + customTokenStorage: mockCustomTokenStorage, + ); + await managerWithConflict.init(); + + final allAssets = managerWithConflict.all; + + // Original KMD should still exist + expect(allAssets.containsKey(komodoAsset.id), isTrue); + + // Duplicate custom KMD should exist with modified id + final duplicateKeys = allAssets.keys.where( + (id) => + id.id.startsWith('KMD_custom') && + id.name.startsWith('Custom KMD Token_custom'), + ); + expect(duplicateKeys, hasLength(1)); + + final duplicateAsset = allAssets[duplicateKeys.first]!; + expect(duplicateAsset.protocol, equals(conflictingToken.protocol)); + expect( + duplicateAsset.isWalletOnly, + equals(conflictingToken.isWalletOnly), + ); + + await managerWithConflict.dispose(); + }); + }); + + group('Store custom token', () { + test('stores custom token and adds to memory', () async { + await manager.storeCustomToken(customToken1); + + // Verify storage method was called + verify( + () => mockCustomTokenStorage.storeCustomToken(customToken1), + ).called(1); + + // Verify token is added to in-memory assets + expect(manager.all.containsKey(customToken1.id), isTrue); + expect(manager.all[customToken1.id], equals(customToken1)); + }); + + test('handles storage failure gracefully', () async { + when( + () => mockCustomTokenStorage.storeCustomToken(any()), + ).thenThrow(Exception('Storage failed')); + + await expectLater( + manager.storeCustomToken(customToken1), + throwsException, + ); + + // Token should not be in memory if storage failed + expect(manager.all.containsKey(customToken1.id), isFalse); + }); + + test('handles conflict with existing asset during store', () async { + await manager.storeCustomToken(conflictingToken); + + // Verify storage method was called with original token + verify( + () => mockCustomTokenStorage.storeCustomToken(conflictingToken), + ).called(1); + + // Original KMD should still exist + expect(manager.all.containsKey(komodoAsset.id), isTrue); + + // Duplicate custom KMD should exist with modified id + final duplicateKeys = manager.all.keys.where( + (id) => + id.id.startsWith('KMD_custom') && + id.name.startsWith('Custom KMD Token_custom'), + ); + expect(duplicateKeys, hasLength(1)); + + final duplicateAsset = manager.all[duplicateKeys.first]!; + expect(duplicateAsset.protocol, equals(conflictingToken.protocol)); + }); + + test('throws StateError when not initialized', () async { + final uninitializedManager = StrategicCoinConfigManager( + configSources: [mockStorageSource, mockLocalSource], + customTokenStorage: mockCustomTokenStorage, + ); + + await expectLater( + uninitializedManager.storeCustomToken(customToken1), + throwsStateError, + ); + }); + + test('throws StateError when disposed', () async { + await manager.dispose(); + + await expectLater( + manager.storeCustomToken(customToken1), + throwsStateError, + ); + }); + }); + + group('Delete custom token', () { + test('deletes custom token from storage and memory', () async { + // First store a token + await manager.storeCustomToken(customToken1); + expect(manager.all.containsKey(customToken1.id), isTrue); + + // Then delete it + await manager.deleteCustomToken(customToken1.id); + + // Verify storage method was called + verify( + () => mockCustomTokenStorage.deleteCustomToken(customToken1.id), + ).called(1); + + // Verify token is removed from in-memory assets + expect(manager.all.containsKey(customToken1.id), isFalse); + }); + + test('handles storage failure gracefully', () async { + // First store a token + await manager.storeCustomToken(customToken1); + expect(manager.all.containsKey(customToken1.id), isTrue); + + when( + () => mockCustomTokenStorage.deleteCustomToken(any()), + ).thenThrow(Exception('Delete failed')); + + await expectLater( + manager.deleteCustomToken(customToken1.id), + throwsException, + ); + + // Token should still be in memory if storage delete failed + expect(manager.all.containsKey(customToken1.id), isTrue); + }); + + test('handles deletion of non-existent token', () async { + // Try to delete a token that doesn't exist + await manager.deleteCustomToken(customToken1.id); + + // Should not throw, storage method should still be called + verify( + () => mockCustomTokenStorage.deleteCustomToken(customToken1.id), + ).called(1); + }); + + test('throws StateError when not initialized', () async { + final uninitializedManager = StrategicCoinConfigManager( + configSources: [mockStorageSource, mockLocalSource], + customTokenStorage: mockCustomTokenStorage, + ); + + await expectLater( + uninitializedManager.deleteCustomToken(customToken1.id), + throwsStateError, + ); + }); + + test('throws StateError when disposed', () async { + await manager.dispose(); + + await expectLater( + manager.deleteCustomToken(customToken1.id), + throwsStateError, + ); + }); + }); + + group('Custom token integration with existing functionality', () { + test('custom tokens are included in filtered assets', () async { + // Store custom tokens of different types + final utxoCustomToken = Asset.fromJson({ + 'coin': 'CUSTOMUTXO', + 'fname': 'Custom UTXO Token', + 'chain_id': 1, + 'type': 'UTXO', + 'protocol': {'type': 'UTXO'}, + 'is_testnet': false, + }); + + await manager.storeCustomToken(utxoCustomToken); + + // Filter for UTXO assets + const filter = UtxoAssetFilterStrategy(); + final filtered = manager.filteredAssets(filter); + + // Custom UTXO token should be included + expect(filtered.containsKey(utxoCustomToken.id), isTrue); + }); + + test('custom tokens are found by ticker search', () async { + await manager.storeCustomToken(customToken1); + + final found = manager.findByTicker( + 'CUSTOM1', + CoinSubClass.smartChain, + ); + expect(found, isNotNull); + expect(found, equals(customToken1)); + }); + + test('custom tokens are included in variant search', () async { + await manager.storeCustomToken(customToken1); + + final variants = manager.findVariantsOfCoin('CUSTOM1'); + expect(variants, contains(customToken1)); + }); + + test('filter cache is cleared after custom token operations', () async { + // Get filtered results to populate cache + const filter = UtxoAssetFilterStrategy(); + final initialFiltered = manager.filteredAssets(filter); + + // Store a new UTXO custom token + final utxoCustomToken = Asset.fromJson({ + 'coin': 'CUSTOMUTXO', + 'fname': 'Custom UTXO Token', + 'chain_id': 1, + 'type': 'UTXO', + 'protocol': {'type': 'UTXO'}, + 'is_testnet': false, + }); + + await manager.storeCustomToken(utxoCustomToken); + + // Get filtered results again + final newFiltered = manager.filteredAssets(filter); + + // Results should be different (cache was cleared) + expect(newFiltered.length, equals(initialFiltered.length + 1)); + expect(newFiltered.containsKey(utxoCustomToken.id), isTrue); + }); + }); + + group('Refresh assets with custom tokens', () { + test('preserves custom tokens after refresh', () async { + // Store custom tokens + await manager.storeCustomToken(customToken1); + await manager.storeCustomToken(customToken2); + + expect(manager.all.containsKey(customToken1.id), isTrue); + expect(manager.all.containsKey(customToken2.id), isTrue); + + // Set up mock to return custom tokens during refresh + when( + () => mockCustomTokenStorage.getAllCustomTokens(any()), + ).thenAnswer((_) async => [customToken1, customToken2]); + + // Refresh assets + await manager.refreshAssets(); + + // Custom tokens should still be present + expect(manager.all.containsKey(customToken1.id), isTrue); + expect(manager.all.containsKey(customToken2.id), isTrue); + }); + + test('handles custom token loading failure during refresh', () async { + // Store custom tokens initially + await manager.storeCustomToken(customToken1); + expect(manager.all.containsKey(customToken1.id), isTrue); + + // Make custom token loading fail during refresh + when( + () => mockCustomTokenStorage.getAllCustomTokens(any()), + ).thenThrow(Exception('Storage error during refresh')); + + // Refresh should complete without throwing + await expectLater(manager.refreshAssets(), completes); + + // Manager should still be functional + expect(manager.isInitialized, isTrue); + }); + }); + + group('Dispose with custom token storage', () { + test('disposes custom token storage on manager disposal', () async { + await manager.dispose(); + + verify(() => mockCustomTokenStorage.dispose()).called(1); + }); + }); + }); }); } 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 91b4b850d..ef1ef7e33 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import 'package:komodo_coins/komodo_coins.dart'; import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; import 'package:komodo_defi_sdk/src/_internal_exports.dart'; @@ -16,19 +17,17 @@ class ActivationManager { this._client, this._auth, this._assetHistory, - this._customTokenHistory, this._assetLookup, - this._balanceManager, { - required IAssetRefreshNotifier assetRefreshNotifier, - }) : _assetRefreshNotifier = assetRefreshNotifier; + this._balanceManager, + this._assetsUpdateManager, + ); final ApiClient _client; final KomodoDefiLocalAuth _auth; final AssetHistoryStorage _assetHistory; - final CustomAssetHistoryStorage _customTokenHistory; final IAssetLookup _assetLookup; - final IAssetRefreshNotifier _assetRefreshNotifier; final IBalanceManager _balanceManager; + final KomodoAssetsUpdateManager _assetsUpdateManager; final _activationMutex = Mutex(); static const _operationTimeout = Duration(seconds: 30); @@ -192,14 +191,9 @@ class ActivationManager { if (progress.isSuccess) { final user = await _auth.currentUser; if (user != null) { - // TODO: consider abstracting this and other custom token operations out - // of the activation manager + // Store custom tokens using CoinConfigManager if (group.primary.protocol.isCustomToken) { - await _customTokenHistory.addAssetToWallet( - user.walletId, - group.primary, - _assetLookup.available.keys.toSet(), - ); + await _assetsUpdateManager.assets.storeCustomToken(group.primary); } else { await _assetHistory.addAssetToWallet( user.walletId, @@ -209,21 +203,9 @@ class ActivationManager { final allAssets = [group.primary, ...(group.children?.toList() ?? [])]; - // Wait for asset refresh to complete before precaching balances to ensure - // custom token is available for balance precaching. This prevents race - // conditions where balance precaching fails because the custom token - // isn't yet available in the asset lookup. - if (allAssets.any((asset) => asset.protocol.isCustomToken)) { - await _assetRefreshNotifier.notifyAndWaitForCustomTokensRefresh(); - } - for (final asset in allAssets) { if (asset.protocol.isCustomToken) { - await _customTokenHistory.addAssetToWallet( - user.walletId, - asset, - _assetLookup.available.keys.toSet(), - ); + await _assetsUpdateManager.assets.storeCustomToken(asset); } // Pre-cache balance for the activated asset 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 7260994b0..c2d9f81bc 100644 --- a/packages/komodo_defi_sdk/lib/src/assets/_assets_index.dart +++ b/packages/komodo_defi_sdk/lib/src/assets/_assets_index.dart @@ -8,5 +8,4 @@ export 'asset_history_storage.dart'; export 'asset_lookup.dart'; export 'asset_manager.dart'; export 'asset_pubkey_extensions.dart'; -export 'custom_asset_history_storage.dart'; export 'legacy_asset_extensions.dart'; diff --git a/packages/komodo_defi_sdk/lib/src/assets/asset_lookup.dart b/packages/komodo_defi_sdk/lib/src/assets/asset_lookup.dart index 312ec0727..11583d21d 100644 --- a/packages/komodo_defi_sdk/lib/src/assets/asset_lookup.dart +++ b/packages/komodo_defi_sdk/lib/src/assets/asset_lookup.dart @@ -25,12 +25,3 @@ abstract class IAssetProvider extends IAssetLookup { /// Get list of enabled coin tickers Future> getEnabledCoins(); } - -/// Interface for notifying about asset changes that require UI refresh -abstract interface class IAssetRefreshNotifier { - /// Notifies that custom tokens have changed and should be refreshed - void notifyCustomTokensChanged(); - - /// Notifies that custom tokens have changed and waits for refresh to complete - Future notifyAndWaitForCustomTokensRefresh(); -} 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 9b0ee64c7..642bd9f3e 100644 --- a/packages/komodo_defi_sdk/lib/src/assets/asset_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/assets/asset_manager.dart @@ -1,10 +1,6 @@ -// TODO: refactor to rely on komodo_coins cache instead of duplicating the -// splaytreemap cache here. This turns it into a thinner wrapper than it -// already is. -import 'dart:async' show StreamSubscription, unawaited; -import 'dart:collection'; +import 'dart:async' show StreamSubscription; -import 'package:flutter/foundation.dart' show ValueGetter, debugPrint; +import 'package:flutter/foundation.dart' show ValueGetter; import 'package:komodo_coins/komodo_coins.dart'; import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; @@ -12,8 +8,6 @@ import 'package:komodo_defi_sdk/src/_internal_exports.dart'; import 'package:komodo_defi_sdk/src/sdk/komodo_defi_sdk_config.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; -typedef AssetIdMap = SplayTreeMap; - /// Manages the lifecycle and state of crypto assets in the Komodo DeFi Framework. /// /// The AssetManager is responsible for: @@ -43,7 +37,7 @@ typedef AssetIdMap = SplayTreeMap; /// /// The manager listens to authentication changes to keep the available asset /// list in sync with the active wallet's capabilities. -class AssetManager implements IAssetProvider, IAssetRefreshNotifier { +class AssetManager implements IAssetProvider { /// Creates a new instance of AssetManager. /// /// This is typically created by the SDK and shouldn't need to be instantiated @@ -52,7 +46,6 @@ class AssetManager implements IAssetProvider, IAssetRefreshNotifier { this._client, this._auth, this._config, - this._customAssetHistory, this._activationManager, this._coins, ) { @@ -62,12 +55,10 @@ class AssetManager implements IAssetProvider, IAssetRefreshNotifier { final ApiClient _client; final KomodoDefiLocalAuth _auth; final KomodoDefiSdkConfig _config; - final CustomAssetHistoryStorage _customAssetHistory; final AssetsUpdateManager _coins; - late final AssetIdMap _orderedCoins; StreamSubscription? _authSubscription; bool _isDisposed = false; - AssetFilterStrategy? _currentFilterStrategy; + AssetFilterStrategy _currentFilterStrategy = const NoAssetFilterStrategy(); /// NB: This cannot be used during initialization. This is a workaround /// to publicly expose the activation manager's activation methods. @@ -80,21 +71,8 @@ class AssetManager implements IAssetProvider, IAssetRefreshNotifier { /// manually. Future init() async { await _coins.init(defaultPriorityTickers: _config.defaultAssets); - - _orderedCoins = AssetIdMap((keyA, keyB) { - final isDefaultA = _config.defaultAssets.contains(keyA.id); - final isDefaultB = _config.defaultAssets.contains(keyB.id); - - if (isDefaultA != isDefaultB) { - return isDefaultA ? -1 : 1; - } - - return keyA.toString().compareTo(keyB.toString()); - }); - - _refreshCoins(const NoAssetFilterStrategy()); - - await _refreshCustomTokens(); + // call get filtered assets to update the cache + _coins.filteredAssets(_currentFilterStrategy); } /// Exposes the currently active commit hash for coins config. @@ -103,53 +81,18 @@ class AssetManager implements IAssetProvider, IAssetRefreshNotifier { /// Exposes the latest available commit hash for coins config. Future get latestCoinsCommit async => _coins.getLatestCommitHash(); - void _refreshCoins(AssetFilterStrategy strategy) { - _orderedCoins - ..clear() - ..addAll(_coins.filteredAssets(strategy)); - } - /// Applies a new [strategy] for filtering available assets. /// /// This is called whenever the authentication state changes so the /// visible asset list always matches the capabilities of the active wallet. void setFilterStrategy(AssetFilterStrategy strategy) { - if (_currentFilterStrategy?.strategyId == strategy.strategyId) return; - - _currentFilterStrategy = strategy; - if (_coins.isInitialized) { - _refreshCoins(strategy); - // Also refresh custom tokens to apply the new filter strategy - unawaited(_refreshCustomTokens()); - } - } - - Future _refreshCustomTokens() async { - final user = await _auth.currentUser; - if (user == null) { - debugPrint('No user signed in, skipping custom token refresh'); + if (_currentFilterStrategy.strategyId == strategy.strategyId) { return; } - // Drop previously injected custom tokens to avoid stale entries - final toRemove = []; - _orderedCoins.forEach((id, asset) { - if (asset.protocol.isCustomToken) toRemove.add(id); - }); - for (final id in toRemove) { - _orderedCoins.remove(id); - } - - final customTokens = await _customAssetHistory.getWalletAssets( - user.walletId, - _orderedCoins.keys.toSet(), - ); - - final filteredCustomTokens = _filterCustomTokens(customTokens); - - for (final customToken in filteredCustomTokens) { - _orderedCoins[customToken.id] = customToken; - } + _currentFilterStrategy = strategy; + // call get filtered assets to update the cache + _coins.filteredAssets(_currentFilterStrategy); } /// Reacts to authentication changes by updating the active asset filter. @@ -188,9 +131,10 @@ class AssetManager implements IAssetProvider, IAssetRefreshNotifier { /// /// Default assets (configured in [KomodoDefiSdkConfig]) appear first, /// followed by other assets in alphabetical order. + /// The filtering and ordering is handled by the underlying coin_config_manager. @override - Map get available => Map.unmodifiable(_orderedCoins); - Map get availableOrdered => available; + Map get available => + Map.unmodifiable(_coins.filteredAssets(_currentFilterStrategy)); /// Returns currently activated assets for the signed-in user. /// @@ -223,9 +167,7 @@ class AssetManager implements IAssetProvider, IAssetRefreshNotifier { /// ``` @override Set findAssetsByConfigId(String ticker) { - // Create a defensive copy to prevent concurrent modification during iteration - final assetsCopy = List.of(_orderedCoins.values); - return assetsCopy.where((asset) => asset.id.id == ticker).toSet(); + return available.values.where((asset) => asset.id.id == ticker).toSet(); } /// Returns child assets for the given parent asset ID. @@ -239,9 +181,7 @@ class AssetManager implements IAssetProvider, IAssetRefreshNotifier { /// ``` @override Set childAssetsOf(AssetId parentId) { - // Create a defensive copy to prevent concurrent modification during iteration - final assetsCopy = List.of(_orderedCoins.values); - return assetsCopy + return available.values .where( (asset) => asset.id.isChildAsset && asset.id.parentId == parentId, ) @@ -274,40 +214,6 @@ class AssetManager implements IAssetProvider, IAssetRefreshNotifier { Stream activateAssets(List assets) => _activationManager().activateAssets(assets); - @override - void notifyCustomTokensChanged() { - // Refresh custom tokens when notified by the activation manager - unawaited( - _refreshCustomTokens().catchError((Object e, StackTrace s) { - debugPrint('Custom token refresh failed: $e'); - }), - ); - } - - @override - Future notifyAndWaitForCustomTokensRefresh() async { - try { - await _refreshCustomTokens(); - } catch (e) { - debugPrint('Custom token refresh failed: $e'); - rethrow; - } - } - - /// Filters custom tokens based on the current asset filtering strategy. - /// - /// Custom tokens don't have traditional coin configs, so we create a minimal - /// config structure to support filtering decisions. This ensures custom tokens - /// are properly filtered alongside regular assets. - Set _filterCustomTokens(Set customTokens) { - final strategy = _currentFilterStrategy; - if (strategy == null) return customTokens; - - return customTokens.where((Asset token) { - return strategy.shouldInclude(token, token.protocol.config); - }).toSet(); - } - /// Disposes of the asset manager, cleaning up resources. /// /// This is called automatically by the SDK when disposing. diff --git a/packages/komodo_defi_sdk/lib/src/assets/custom_asset_history_storage.dart b/packages/komodo_defi_sdk/lib/src/assets/custom_asset_history_storage.dart deleted file mode 100644 index dceb3c81f..000000000 --- a/packages/komodo_defi_sdk/lib/src/assets/custom_asset_history_storage.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; -import 'package:komodo_defi_types/komodo_defi_types.dart'; - -/// Custom token asset history storage for tokens not present in the live coins -/// configuration. -class CustomAssetHistoryStorage { - static const _storagePrefix = 'wallet_custom_assets_'; - final _storage = const FlutterSecureStorage(); - - /// Store custom tokens used by a wallet - Future storeWalletAssets(WalletId walletId, Set assets) async { - final key = _getStorageKey(walletId); - // Use the protocol config instead of asset toJson due to missing fields - // from the incomplete Asset.toJson implementation. Similar to the - // komodo_coin_updates/hive/hive_adapters.dart issue. - final assetsJsonArray = assets - .map((asset) => asset.protocol.config) - .toList(); - await _storage.write(key: key, value: assetsJsonArray.toJsonString()); - } - - /// Add a single asset to wallet's history - /// - /// [walletId] is the wallet to add the asset to. - /// [asset] is the asset to add to the wallet. - /// [knownAssets] is used to find the parent asset for child assets. - Future addAssetToWallet( - WalletId walletId, - Asset asset, - Set knownAssets, - ) async { - final assets = await getWalletAssets(walletId, knownAssets); - if (assets.any((historicalAsset) => historicalAsset.id.id == asset.id.id)) { - return; - } - assets.add(asset); - await storeWalletAssets(walletId, assets); - } - - /// Get all assets previously used by a wallet - /// - /// [walletId] is the wallet to get the assets from. - /// [knownAssets] is used to find the parent asset for child assets. - Future> getWalletAssets( - WalletId walletId, - Set knownAssets, - ) async { - final key = _getStorageKey(walletId); - final value = await _storage.read(key: key); - if (value == null || value.isEmpty) return {}; - final assetsJsonArray = jsonListFromString(value); - return assetsJsonArray - .map((json) => Asset.fromJson(json, knownIds: knownAssets)) - .toSet(); - } - - /// Clear wallet's custom token history - /// - /// [walletId] is the wallet to clear the assets from. - Future clearWalletAssets(WalletId walletId) async { - final key = _getStorageKey(walletId); - await _storage.delete(key: key); - } - - String _getStorageKey(WalletId walletId) => - '$_storagePrefix${walletId.pubkeyHash ?? walletId.name}'; -} diff --git a/packages/komodo_defi_sdk/lib/src/assets/legacy_asset_extensions.dart b/packages/komodo_defi_sdk/lib/src/assets/legacy_asset_extensions.dart index ca1dfdcbc..f1b32fd65 100644 --- a/packages/komodo_defi_sdk/lib/src/assets/legacy_asset_extensions.dart +++ b/packages/komodo_defi_sdk/lib/src/assets/legacy_asset_extensions.dart @@ -52,7 +52,7 @@ extension AssetTickerIndexExtension on AssetManager { if (_isInitialized) return; _tickerIndex ..clear() - ..addAll(_buildTickerIndex(availableOrdered.values)); + ..addAll(_buildTickerIndex(available.values)); _isInitialized = true; }); } diff --git a/packages/komodo_defi_sdk/lib/src/bootstrap.dart b/packages/komodo_defi_sdk/lib/src/bootstrap.dart index 1f65d9e35..7cd06c709 100644 --- a/packages/komodo_defi_sdk/lib/src/bootstrap.dart +++ b/packages/komodo_defi_sdk/lib/src/bootstrap.dart @@ -59,8 +59,9 @@ Future bootstrap({ // Asset history storage singletons container.registerLazySingleton(AssetHistoryStorage.new); - container.registerLazySingleton(CustomAssetHistoryStorage.new); - container.registerLazySingleton(KomodoAssetsUpdateManager.new); + container.registerSingletonAsync( + () async => KomodoAssetsUpdateManager(), + ); // Register asset manager first since it's a core dependency container.registerSingletonAsync(() async { @@ -70,7 +71,6 @@ Future bootstrap({ client, auth, config, - container(), () => container(), container(), ); @@ -96,25 +96,34 @@ Future bootstrap({ }, dependsOn: [AssetManager, KomodoDefiLocalAuth]); // Register activation manager with asset manager dependency - container.registerSingletonAsync(() async { - final client = await container.getAsync(); - final auth = await container.getAsync(); - final assetManager = await container.getAsync(); - final balanceManager = await container.getAsync(); + container.registerSingletonAsync( + () async { + final client = await container.getAsync(); + final auth = await container.getAsync(); + final assetManager = await container.getAsync(); + final balanceManager = await container.getAsync(); - final activationManager = ActivationManager( - client, - auth, - container(), - container(), - assetManager, - balanceManager, - // Separate interface used to avoid muddying the IAssetProvider interface - assetRefreshNotifier: assetManager, - ); + final activationManager = ActivationManager( + client, + auth, + container(), + assetManager, + balanceManager, + // Needed here to add custom tokens to the same instance + // as the asset manager + container(), + ); - return activationManager; - }, dependsOn: [ApiClient, KomodoDefiLocalAuth, AssetManager, BalanceManager]); + return activationManager; + }, + dependsOn: [ + ApiClient, + KomodoDefiLocalAuth, + AssetManager, + BalanceManager, + KomodoAssetsUpdateManager, + ], + ); // Register shared activation coordinator container.registerSingletonAsync(() async { diff --git a/packages/komodo_defi_types/lib/src/assets/asset.dart b/packages/komodo_defi_types/lib/src/assets/asset.dart index 00bc65d84..6a7025182 100644 --- a/packages/komodo_defi_types/lib/src/assets/asset.dart +++ b/packages/komodo_defi_types/lib/src/assets/asset.dart @@ -70,6 +70,21 @@ class Asset extends Equatable { /// coin config. bool get supportsMessageSigning => signMessagePrefix != null; + /// Creates a copy of this Asset with optionally modified fields. + Asset copyWith({ + AssetId? id, + ProtocolClass? protocol, + bool? isWalletOnly, + String? signMessagePrefix, + }) { + return Asset( + id: id ?? this.id, + protocol: protocol ?? this.protocol, + isWalletOnly: isWalletOnly ?? this.isWalletOnly, + signMessagePrefix: signMessagePrefix ?? this.signMessagePrefix, + ); + } + JsonMap toJson() => { 'protocol': protocol.toJson(), 'id': id.toJson(),