diff --git a/packages/komodo_coins/lib/komodo_coins.dart b/packages/komodo_coins/lib/komodo_coins.dart index cfbe2cb8..eb943c63 100644 --- a/packages/komodo_coins/lib/komodo_coins.dart +++ b/packages/komodo_coins/lib/komodo_coins.dart @@ -1,6 +1,7 @@ /// TODO! Library description library komodo_coins; +export 'src/asset_filter.dart'; export 'src/komodo_coins_base.dart'; /// A Calculator. diff --git a/packages/komodo_coins/lib/src/asset_filter.dart b/packages/komodo_coins/lib/src/asset_filter.dart new file mode 100644 index 00000000..0c6d0352 --- /dev/null +++ b/packages/komodo_coins/lib/src/asset_filter.dart @@ -0,0 +1,69 @@ +import 'package:equatable/equatable.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +/// Strategy interface for filtering assets based on coin configuration. +abstract class AssetFilterStrategy extends Equatable { + const AssetFilterStrategy(this.strategyId); + + /// A unique id for the strategy used for comparison and caching. + final String strategyId; + + /// Returns `true` if the asset should be included. + bool shouldInclude(Asset asset, JsonMap coinConfig); + + @override + List get props => [strategyId]; +} + +/// Default strategy that includes all assets. +class NoAssetFilterStrategy extends AssetFilterStrategy { + const NoAssetFilterStrategy() : super('none'); + + @override + bool shouldInclude(Asset asset, JsonMap coinConfig) => true; +} + +/// Filters assets that are not currently supported on Trezor. +/// This includes assets that are not UTXO-based or EVM-based tokens. +/// ETH, AVAX, BNB, FTM, etc. are excluded as they currently fail to +/// activate on Trezor. +/// ERC20, Arbitrum, and MATIC explicitly do not support Trezor via KDF +/// at this time, so they are also excluded. +class TrezorAssetFilterStrategy extends AssetFilterStrategy { + const TrezorAssetFilterStrategy() : super('trezor'); + + @override + bool shouldInclude(Asset asset, JsonMap coinConfig) { + final subClass = asset.protocol.subClass; + + // AVAX, BNB, ETH, FTM, etc. currently fail to activate on Trezor, + // so we exclude them from the Trezor asset list. + return subClass == CoinSubClass.utxo || + subClass == CoinSubClass.smartChain || + subClass == CoinSubClass.qrc20; + } +} + +/// Filters out assets that are not UTXO-based chains. +class UtxoAssetFilterStrategy extends AssetFilterStrategy { + const UtxoAssetFilterStrategy() : super('utxo'); + + @override + bool shouldInclude(Asset asset, JsonMap coinConfig) { + final subClass = asset.protocol.subClass; + return subClass == CoinSubClass.utxo || subClass == CoinSubClass.smartChain; + } +} + +/// Filters assets that are EVM-based tokens. +/// This includes various EVM-compatible chains like Ethereum, Binance, etc. +/// This strategy is necessary for external wallets like Metamask or +/// WalletConnect. +class EvmAssetFilterStrategy extends AssetFilterStrategy { + const EvmAssetFilterStrategy() : super('evm'); + + @override + bool shouldInclude(Asset asset, JsonMap coinConfig) => + evmCoinSubClasses.contains(asset.protocol.subClass); +} diff --git a/packages/komodo_coins/lib/src/komodo_coins_base.dart b/packages/komodo_coins/lib/src/komodo_coins_base.dart index 2acf5e5d..28385e01 100644 --- a/packages/komodo_coins/lib/src/komodo_coins_base.dart +++ b/packages/komodo_coins/lib/src/komodo_coins_base.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:komodo_coins/src/config_transform.dart'; +import 'package:komodo_coins/src/asset_filter.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; @@ -17,6 +18,7 @@ class KomodoCoins { } Map? _assets; + final Map> _filterCache = {}; @mustCallSuper Future init() async { @@ -76,8 +78,10 @@ class KomodoCoins { try { // Parse all possible AssetIds for this coin - final assetIds = - AssetId.parseAllTypes(coinData, knownIds: platformIds).map( + final assetIds = AssetId.parseAllTypes( + coinData, + knownIds: platformIds, + ).map( (id) => id.isChildAsset ? AssetId.parse(coinData, knownIds: platformIds) : id, @@ -111,6 +115,31 @@ class KomodoCoins { coinData.valueOrNull('parent_coin') == null; } + /// Returns the assets filtered using the provided [strategy]. + /// + /// This allows higher-level components, such as [AssetManager], to tailor + /// the visible asset list to the active authentication context. For example, + /// a hardware wallet may only support a subset of coins, which can be + /// enforced by supplying an appropriate [AssetFilterStrategy]. + Map filteredAssets(AssetFilterStrategy strategy) { + if (!isInitialized) { + throw StateError('Assets have not been initialized. Call init() first.'); + } + final cacheKey = strategy.strategyId; + final cached = _filterCache[cacheKey]; + if (cached != null) return cached; + + final result = {}; + for (final entry in _assets!.entries) { + final config = entry.value.protocol.config; + if (strategy.shouldInclude(entry.value, config)) { + result[entry.key] = entry.value; + } + } + _filterCache[cacheKey] = result; + return result; + } + // Helper methods Asset? findByTicker(String ticker, CoinSubClass subClass) { return all.entries diff --git a/packages/komodo_coins/test/asset_filter_test.dart b/packages/komodo_coins/test/asset_filter_test.dart new file mode 100644 index 00000000..19cb28d2 --- /dev/null +++ b/packages/komodo_coins/test/asset_filter_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_coins/src/asset_filter.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +void main() { + group('Asset filtering', () { + final btcConfig = { + 'coin': 'BTC', + 'fname': 'Bitcoin', + 'chain_id': 0, + 'type': 'UTXO', + 'protocol': {'type': 'UTXO'}, + 'is_testnet': false, + 'trezor_coin': 'Bitcoin', + }; + + final ethConfig = { + 'coin': 'ETH', + 'fname': 'Ethereum', + 'chain_id': 1, + 'type': 'ERC-20', + 'protocol': { + 'type': 'ETH', + 'protocol_data': {'chain_id': 1}, + }, + 'nodes': [ + {'url': 'https://rpc'}, + ], + 'swap_contract_address': '0xabc', + 'fallback_swap_contract': '0xdef', + }; + + final btc = Asset.fromJson(btcConfig); + final eth = Asset.fromJson(ethConfig); + + test('Trezor filter excludes assets missing trezor_coin', () { + const filter = TrezorAssetFilterStrategy(); + expect(filter.shouldInclude(btc, btc.protocol.config), isTrue); + expect(filter.shouldInclude(eth, eth.protocol.config), isFalse); + + final assets = {btc.id: btc, eth.id: eth}; + final filtered = {}; + for (final entry in assets.entries) { + if (filter.shouldInclude(entry.value, entry.value.protocol.config)) { + filtered[entry.key] = entry.value; + } + } + + expect(filtered.containsKey(btc.id), isTrue); + expect(filtered.containsKey(eth.id), isFalse); + }); + + test('Trezor filter ignores empty trezor_coin field', () { + final cfg = Map.from(btcConfig)..['trezor_coin'] = ''; + final asset = Asset.fromJson(cfg); + const filter = TrezorAssetFilterStrategy(); + expect(filter.shouldInclude(asset, asset.protocol.config), isFalse); + }); + + test('UTXO filter only includes utxo assets', () { + const filter = UtxoAssetFilterStrategy(); + expect(filter.shouldInclude(btc, btc.protocol.config), isTrue); + expect(filter.shouldInclude(eth, eth.protocol.config), isFalse); + }); + + test('UTXO filter accepts smartChain subclass', () { + final cfg = Map.from(btcConfig) + ..['type'] = 'SMART_CHAIN' + ..['protocol'] = {'type': 'UTXO'}; + final asset = Asset.fromJson(cfg); + const filter = UtxoAssetFilterStrategy(); + expect(asset.protocol.subClass, CoinSubClass.smartChain); + expect(filter.shouldInclude(asset, asset.protocol.config), isTrue); + }); + }); +} diff --git a/packages/komodo_defi_local_auth/lib/src/trezor/trezor_auth_service.dart b/packages/komodo_defi_local_auth/lib/src/trezor/trezor_auth_service.dart index c49e9a85..dc816e7a 100644 --- a/packages/komodo_defi_local_auth/lib/src/trezor/trezor_auth_service.dart +++ b/packages/komodo_defi_local_auth/lib/src/trezor/trezor_auth_service.dart @@ -208,7 +208,8 @@ class TrezorAuthService implements IAuthService { return users.firstWhereOrNull( (u) => u.walletId.name == trezorWalletName && - u.authOptions.privKeyPolicy == const PrivateKeyPolicy.trezor(), + u.walletId.authOptions.privKeyPolicy == + const PrivateKeyPolicy.trezor(), ); } diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/task_enable_eth_init.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/task_enable_eth_init.dart index 1dd4cd46..bc2fc030 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/task_enable_eth_init.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/task_enable_eth_init.dart @@ -29,4 +29,12 @@ class TaskEnableEthInit } return NewTaskResponse.parse(json); } + + @override + NewTaskResponse parse(Map json) { + if (GeneralErrorResponse.isErrorResponse(json)) { + throw GeneralErrorResponse.parse(json); + } + return NewTaskResponse.parse(json); + } } 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 40d90180..b450f539 100644 --- a/packages/komodo_defi_sdk/lib/src/assets/asset_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/assets/asset_manager.dart @@ -2,9 +2,11 @@ import 'dart:async'; import 'dart:collection'; + 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'; 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'; @@ -37,6 +39,9 @@ typedef AssetIdMap = SplayTreeMap; /// // Get all activated assets /// final activeAssets = await assetManager.getActivatedAssets(); /// ``` +/// +/// The manager listens to authentication changes to keep the available asset +/// list in sync with the active wallet's capabilities. class AssetManager implements IAssetProvider { /// Creates a new instance of AssetManager. /// @@ -48,7 +53,9 @@ class AssetManager implements IAssetProvider { this._config, this._customAssetHistory, this._activationManager, - ); + ) { + _authSubscription = _auth.authStateChanges.listen(_handleAuthStateChange); + } final ApiClient _client; final KomodoDefiLocalAuth _auth; @@ -56,6 +63,9 @@ class AssetManager implements IAssetProvider { final CustomAssetHistoryStorage _customAssetHistory; final KomodoCoins _coins = KomodoCoins(); late final AssetIdMap _orderedCoins; + StreamSubscription? _authSubscription; + bool _isDisposed = false; + AssetFilterStrategy? _currentFilterStrategy; /// NB: This cannot be used during initialization. This is a workaround /// to publicly expose the activation manager's activation methods. @@ -80,11 +90,29 @@ class AssetManager implements IAssetProvider { return keyA.toString().compareTo(keyB.toString()); }); - _orderedCoins.addAll(_coins.all); + _refreshCoins(const NoAssetFilterStrategy()); await _initializeCustomTokens(); } + void _refreshCoins(AssetFilterStrategy strategy) { + if (_currentFilterStrategy?.strategyId == strategy.strategyId) return; + _orderedCoins + ..clear() + ..addAll(_coins.filteredAssets(strategy)); + _currentFilterStrategy = 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 (_coins.isInitialized) { + _refreshCoins(strategy); + } + } + Future _initializeCustomTokens() async { final user = await _auth.currentUser; if (user != null) { @@ -97,6 +125,28 @@ class AssetManager implements IAssetProvider { } } + /// Reacts to authentication changes by updating the active asset filter. + /// + /// When a hardware wallet such as Trezor is connected we limit the list of + /// available assets to only those explicitly supported by that wallet. + void _handleAuthStateChange(KdfUser? user) { + if (_isDisposed) return; + + final isTrezor = + user?.walletId.authOptions.privKeyPolicy == + const PrivateKeyPolicy.trezor(); + + // Trezor does not support all assets yet, so we apply a filter here + // to only show assets that are compatible with Trezor. + // WalletConnect and Metamask will require similar handling in the future. + final strategy = + isTrezor + ? const TrezorAssetFilterStrategy() + : const NoAssetFilterStrategy(); + + setFilterStrategy(strategy); + } + /// Returns an asset by its [AssetId], if available. /// /// Returns null if no matching asset is found. @@ -199,6 +249,7 @@ class AssetManager implements IAssetProvider { /// /// This is called automatically by the SDK when disposing. Future dispose() async { - // No cleanup needed for now + _isDisposed = true; + await _authSubscription?.cancel(); } } diff --git a/packages/komodo_defi_types/lib/src/coin_classes/coin_subclasses.dart b/packages/komodo_defi_types/lib/src/coin_classes/coin_subclasses.dart index a9e8177f..fb0abf14 100644 --- a/packages/komodo_defi_types/lib/src/coin_classes/coin_subclasses.dart +++ b/packages/komodo_defi_types/lib/src/coin_classes/coin_subclasses.dart @@ -275,3 +275,21 @@ enum CoinSubClass { } } } + +const Set evmCoinSubClasses = { + CoinSubClass.avx20, + CoinSubClass.bep20, + CoinSubClass.ftm20, + CoinSubClass.matic, + CoinSubClass.hrc20, + CoinSubClass.arbitrum, + CoinSubClass.moonriver, + CoinSubClass.moonbeam, + CoinSubClass.ethereumClassic, + CoinSubClass.ubiq, + CoinSubClass.krc20, + CoinSubClass.ewt, + CoinSubClass.hecoChain, + CoinSubClass.rskSmartBitcoin, + CoinSubClass.erc20, +};