diff --git a/packages/komodo_defi_rpc_methods/lib/src/common_structures/general/address_format.dart b/packages/komodo_defi_rpc_methods/lib/src/common_structures/general/address_format.dart index 74cb1d8d..258f71cf 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/common_structures/general/address_format.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/common_structures/general/address_format.dart @@ -1,14 +1,106 @@ +import 'package:komodo_defi_types/komodo_defi_types.dart'; + class AddressFormat { const AddressFormat({ required this.format, required this.network, }); + factory AddressFormat.fromCoinSubClass( + CoinSubClass subClass, { + bool isBchNetwork = false, + }) { + switch (subClass) { + case CoinSubClass.erc20: + case CoinSubClass.ethereumClassic: + return AddressFormat( + format: AddressFormatType.mixedCase.toString(), + network: '', + ); + case CoinSubClass.qrc20: + return AddressFormat( + format: AddressFormatType.contract.toString(), + network: '', + ); + case CoinSubClass.utxo: + // The only explicitly defined coins are ETH, UTXO and QTUM, and the + // behaviour previously was to use cashaddress as the default + // unless the coin was ERC20. + // ignore: no_default_cases + default: + return AddressFormat( + format: AddressFormatType.cashAddress.toString(), + // Only set network for BCH coins + network: + isBchNetwork ? AddressFormatNetwork.bitcoinCash.toString() : '', + ); + } + } + final String format; final String network; Map toJson() => { 'format': format, - 'network': network, + if (network.isNotEmpty) 'network': network, }; } + +/// The address format to which the input address should be converted. +enum AddressFormatType { + /// Use for ETH, ERC20 coins + mixedCase, + + /// Use [cashAddress] OR [standard] for UTXO coins + cashAddress, + + /// Use [cashAddress] OR [standard] for UTXO coins + standard, + + /// Use [contract] or [wallet] for QTUM and QRC20 coins + contract, + + /// Use [contract] or [wallet] for QTUM and QRC20 coins + wallet; + + @override + String toString() { + switch (this) { + case AddressFormatType.mixedCase: + return 'mixedcase'; + case AddressFormatType.cashAddress: + return 'cashaddress'; + case AddressFormatType.standard: + return 'standard'; + case AddressFormatType.contract: + return 'contract'; + case AddressFormatType.wallet: + return 'wallet'; + } + } +} + +/// [AddressFormat] network prefix for [AddressFormatType.cashAddress] +/// format. Used only for UTXO coins, specifically BCH at the moment. +enum AddressFormatNetwork { + /// BCH main network (mainnet) + bitcoinCash, + + /// BCH test network (testnet) + bchTest, + + /// BCH regtest + bchReg; + + @override + String toString() { + switch (this) { + case AddressFormatNetwork.bitcoinCash: + return 'bitcoincash'; + case AddressFormatNetwork.bchTest: + return 'bchtest'; + case AddressFormatNetwork.bchReg: + return 'bchreg'; + } + } +} diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/address/convertaddress.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/address/convertaddress.dart index 25d4bc96..ec18a48d 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/address/convertaddress.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/address/convertaddress.dart @@ -9,10 +9,7 @@ class ConvertAddressRequest required this.coin, required this.fromAddress, required this.toAddressFormat, - }) : super( - method: 'convertaddress', - mmrpc: '2.0', - ); + }) : super(method: 'convertaddress', mmrpc: null); final String coin; final String fromAddress; @@ -21,11 +18,9 @@ class ConvertAddressRequest @override Map toJson() => { ...super.toJson(), - 'params': { - 'coin': coin, - 'from': fromAddress, - 'to_address_format': toAddressFormat.toJson(), - }, + 'coin': coin, + 'from': fromAddress, + 'to_address_format': toAddressFormat.toJson(), }; @override @@ -41,7 +36,7 @@ class ConvertAddressResponse extends BaseResponse { factory ConvertAddressResponse.parse(Map json) { return ConvertAddressResponse( - mmrpc: json.value('mmrpc'), + mmrpc: json.valueOrNull('mmrpc'), address: json.value('result', 'address'), ); } diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/enable_custom_erc20.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/enable_custom_erc20.dart new file mode 100644 index 00000000..c15d116e --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/enable_custom_erc20.dart @@ -0,0 +1,49 @@ +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; + +class EnableCustomErc20TokenRequest + extends BaseRequest + with RequestHandlingMixin { + EnableCustomErc20TokenRequest({ + required String rpcPass, + required this.ticker, + required this.activationParams, + required this.platform, + required this.contractAddress, + }) : super(method: 'enable_erc20', rpcPass: rpcPass, mmrpc: '2.0'); + + final String ticker; + final Erc20ActivationParams activationParams; + final String platform; + final String contractAddress; + + @override + Map toJson() { + assert( + platform.isNotEmpty, + 'Platform is required when activating a custom token.', + ); + assert( + contractAddress.isNotEmpty, + 'Contract address is required when activating a custom token.', + ); + + return super.toJson().deepMerge({ + 'params': { + 'ticker': ticker, + 'activation_params': activationParams.toRpcParams(), + 'protocol': { + 'type': 'ERC20', + 'protocol_data': { + 'platform': platform, + 'contract_address': contractAddress, + }, + }, + }, + }); + } + + @override + EnableErc20Response parse(Map json) => + EnableErc20Response.parse(json); +} diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/eth_rpc_extensions.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/eth_rpc_extensions.dart index ebbb2cf9..d93d1950 100644 --- a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/eth_rpc_extensions.dart +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/eth/eth_rpc_extensions.dart @@ -1,4 +1,5 @@ import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; +import 'package:komodo_defi_rpc_methods/src/rpc_methods/eth/enable_custom_erc20.dart'; /// Extensions for ETH-related RPC methods // lib/src/rpc_methods/eth/eth_rpc_extensions.dart @@ -32,4 +33,21 @@ class Erc20MethodsNamespace extends BaseRpcMethodNamespace { ), ); } + + Future enableCustomErc20Token({ + required String ticker, + required Erc20ActivationParams activationParams, + required String platform, + required String contractAddress, + }) { + return execute( + EnableCustomErc20TokenRequest( + rpcPass: rpcPass ?? '', + ticker: ticker, + activationParams: activationParams, + platform: platform, + contractAddress: contractAddress, + ), + ); + } } diff --git a/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/utility/get_token_info.dart b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/utility/get_token_info.dart new file mode 100644 index 00000000..e1c7fcdd --- /dev/null +++ b/packages/komodo_defi_rpc_methods/lib/src/rpc_methods/utility/get_token_info.dart @@ -0,0 +1,105 @@ +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; + +/// Request to get the ticker and decimals values required for custom token +/// activation, given a platform and contract as input +class GetTokenInfoRequest + extends BaseRequest + with RequestHandlingMixin { + GetTokenInfoRequest({ + required String rpcPass, + required this.protocolType, + required this.platform, + required this.contractAddress, + }) : super(method: 'get_token_info', rpcPass: rpcPass, mmrpc: '2.0'); + + /// Token type - e.g ERC20 for tokens on the Ethereum network + final String protocolType; + + /// The parent coin of the token's platform - e.g MATIC for PLG20 tokens + /// protocol_data.platform + final String platform; + + /// Must be mixed case The identifying hex string for the token's contract. + /// Can be found on sites like EthScan, BscScan & PolygonScan + /// platform_data.contract_address + final String contractAddress; + + @override + Map toJson() { + return super.toJson().deepMerge({ + 'params': { + 'protocol': { + 'type': protocolType, + 'protocol_data': { + 'platform': platform, + 'contract_address': contractAddress, + }, + }, + }, + }); + } + + @override + GetTokenInfoResponse parse(Map json) => + GetTokenInfoResponse.parse(json); +} + +class GetTokenInfoResponse extends BaseResponse { + GetTokenInfoResponse({ + required super.mmrpc, + required this.type, + required this.info, + }); + + factory GetTokenInfoResponse.parse(Map json) { + final result = json.value('result'); + return GetTokenInfoResponse( + mmrpc: json.valueOrNull('mmrpc'), + type: result.value('type'), + info: TokenInfo.fromJson(result.value('info')), + ); + } + + /// Token type - e.g PLG20 for tokens on the Polygon network + final String type; + final TokenInfo info; + + @override + Map toJson() { + return { + 'type': type, + 'info': info.toJson(), + }; + } +} + +class TokenInfo { + TokenInfo({ + required this.symbol, + required this.decimals, + }); + + factory TokenInfo.fromJson(Map json) { + return TokenInfo( + symbol: json.value('symbol'), + decimals: json.value('decimals'), + ); + } + + /// The ticker of the token linked to the contract address and + /// network requested + final String symbol; + + /// Defines the number of digits after the decimal point that should be + /// used to display the orderbook amounts, balance, and the value of inputs + /// to be used in the case of order creation or a withdraw transaction. + final int decimals; + + Map toJson() { + return { + 'symbol': symbol, + 'decimals': decimals, + }; + } +} 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 666f3dce..6d7d843c 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 @@ -4,6 +4,7 @@ // ignore_for_file: unused_field, unused_element import 'package:komodo_defi_rpc_methods/src/internal_exports.dart'; +import 'package:komodo_defi_rpc_methods/src/rpc_methods/utility/get_token_info.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; /// A class that provides a library of RPC methods used by the Komodo DeFi @@ -43,6 +44,7 @@ class KomodoDefiRpcMethods { // Add other namespaces here, e.g.: // TradeNamespace get trade => TradeNamespace(_client); // UtilityNamespace get utility => UtilityNamespace(_client); + UtilityMethods get utility => UtilityMethods(_client); } class TaskMethods extends BaseRpcMethodNamespace { @@ -73,6 +75,29 @@ class WalletMethods extends BaseRpcMethodNamespace { execute(GetPublicKeyHashRequest(rpcPass: rpcPass)); } +/// KDF v2 Utility Methods not specific to any larger feature +/// or namespace (e.g. current MTP, token info for custom token activation). +class UtilityMethods extends BaseRpcMethodNamespace { + UtilityMethods(super.client); + + /// Returns the ticker and decimals values required for activation of a custom + /// token, given a [platform], [protocolType], and [contractAddress]. + Future getTokenInfo({ + required String protocolType, + required String platform, + required String contractAddress, + String? rpcPass, + }) => + execute( + GetTokenInfoRequest( + protocolType: protocolType, + platform: platform, + contractAddress: contractAddress, + rpcPass: rpcPass ?? '', + ), + ); +} + class GeneralActivationMethods extends BaseRpcMethodNamespace { const GeneralActivationMethods(super.client); 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 34fa5aa8..a3b959f4 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/activation_manager.dart @@ -4,6 +4,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; import 'package:komodo_defi_sdk/src/_internal_exports.dart'; +import 'package:komodo_defi_sdk/src/assets/custom_asset_history_storage.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:mutex/mutex.dart'; @@ -13,24 +14,14 @@ class ActivationManager { this._client, this._auth, this._assetHistory, + this._customTokenHistory, this._assetLookup, - ) : _activator = SmartAssetActivator( - _client, - CompositeAssetActivator( - _client, - [ - UtxoActivationStrategy(_client), - Erc20ActivationStrategy(_client), - TendermintActivationStrategy(_client), - QtumActivationStrategy(_client), - ZhtlcActivationStrategy(_client), - ], - ), - ); + ) : _activator = ActivationStrategyFactory.createStrategy(_client); final ApiClient _client; final KomodoDefiLocalAuth _auth; final AssetHistoryStorage _assetHistory; + final CustomAssetHistoryStorage _customTokenHistory; final SmartAssetActivator _activator; final IAssetLookup _assetLookup; final _activationMutex = Mutex(); @@ -185,6 +176,16 @@ class ActivationManager { user.walletId, group.primary.id.id, ); + + final allAssets = [group.primary, ...(group.children?.toList() ?? [])]; + for (final asset in allAssets) { + if (asset.protocol.isCustomToken) { + await _customTokenHistory.addAssetToWallet( + user.walletId, + asset, + ); + } + } } if (!completer.isCompleted) { diff --git a/packages/komodo_defi_sdk/lib/src/activation/base_strategies/activation_strategy_base.dart b/packages/komodo_defi_sdk/lib/src/activation/base_strategies/activation_strategy_base.dart index ab602770..8271415f 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/base_strategies/activation_strategy_base.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/base_strategies/activation_strategy_base.dart @@ -14,6 +14,11 @@ abstract class BatchCapableActivator extends AssetActivator { const BatchCapableActivator(super.client); bool get supportsBatchActivation; + + /// Whether the activator supports activation of custom EVM-chain + /// tokens that are not part of the live coins configuration. + /// Defaults to false. + bool get supportsCustomTokenActivation => false; } /// Smart activator that chooses between batch/single methods @@ -129,6 +134,13 @@ abstract class ProtocolActivationStrategy extends BatchCapableActivator { @override bool canHandle(Asset asset) => + // | isCustomToken | supportsCustomTokenActivation | result | + // |---------------|------------------------------|--------| + // | true | true | true | + // | true | false | false | + // | false | true | true | + // | false | false | false | + (!asset.protocol.isCustomToken || supportsCustomTokenActivation) && supportedProtocols.contains(asset.protocol.subClass); Set get supportedProtocols; diff --git a/packages/komodo_defi_sdk/lib/src/activation/base_strategies/activation_strategy_factory.dart b/packages/komodo_defi_sdk/lib/src/activation/base_strategies/activation_strategy_factory.dart index ede1dbdc..2abf991f 100644 --- a/packages/komodo_defi_sdk/lib/src/activation/base_strategies/activation_strategy_factory.dart +++ b/packages/komodo_defi_sdk/lib/src/activation/base_strategies/activation_strategy_factory.dart @@ -1,4 +1,5 @@ import 'package:komodo_defi_sdk/src/activation/_activation.dart'; +import 'package:komodo_defi_sdk/src/activation/protocol_strategies/custom_erc20_activation_strategy.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; /// Factory for creating the complete activation strategy stack @@ -17,6 +18,7 @@ class ActivationStrategyFactory { TendermintActivationStrategy(client), QtumActivationStrategy(client), ZhtlcActivationStrategy(client), + CustomErc20ActivationStrategy(client), ], ), ); diff --git a/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/custom_erc20_activation_strategy.dart b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/custom_erc20_activation_strategy.dart new file mode 100644 index 00000000..d4c25364 --- /dev/null +++ b/packages/komodo_defi_sdk/lib/src/activation/protocol_strategies/custom_erc20_activation_strategy.dart @@ -0,0 +1,95 @@ +import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; +import 'package:komodo_defi_sdk/src/activation/_activation.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +/// Activation strategy for custom ERC20 tokens. This strategy is used to +/// activate tokens that are not part of the live coins configuration. +class CustomErc20ActivationStrategy extends ProtocolActivationStrategy { + const CustomErc20ActivationStrategy(super.client); + + @override + Set get supportedProtocols => { + CoinSubClass.erc20, + CoinSubClass.bep20, + CoinSubClass.ftm20, + CoinSubClass.matic, + CoinSubClass.avx20, + CoinSubClass.hrc20, + CoinSubClass.moonbeam, + CoinSubClass.moonriver, + CoinSubClass.ethereumClassic, + CoinSubClass.ubiq, + CoinSubClass.krc20, + CoinSubClass.ewt, + CoinSubClass.hecoChain, + CoinSubClass.rskSmartBitcoin, + CoinSubClass.arbitrum, + }; + + @override + bool get supportsCustomTokenActivation => true; + + @override + bool get supportsBatchActivation => true; + + @override + Stream activate( + Asset asset, [ + List? children, + ]) async* { + yield ActivationProgress( + status: 'Activating ${asset.id.name}...', + progressDetails: ActivationProgressDetails( + currentStep: 'initialization', + stepCount: 2, + additionalInfo: { + 'assetType': 'token', + 'protocol': asset.protocol.subClass.formatted, + }, + ), + ); + + try { + final protocolData = asset.protocol.config + .valueOrNull('protocol', 'protocol_data'); + if (protocolData == null) { + throw StateError('Protocol data is missing from custom token config'); + } + + await client.rpc.erc20.enableCustomErc20Token( + ticker: asset.id.id, + activationParams: Erc20ActivationParams.fromJsonConfig( + asset.protocol.config, + ), + platform: protocolData.value('platform'), + contractAddress: protocolData.value('contract_address'), + ); + + yield ActivationProgress.success( + details: ActivationProgressDetails( + currentStep: 'complete', + stepCount: 2, + additionalInfo: { + 'activatedChain': asset.id.name, + 'activationTime': DateTime.now().toIso8601String(), + 'childCount': children?.length ?? 0, + }, + ), + ); + } catch (e, stack) { + yield ActivationProgress( + status: 'Activation failed', + errorMessage: e.toString(), + isComplete: true, + progressDetails: ActivationProgressDetails( + currentStep: 'error', + stepCount: 2, + errorCode: 'ERC20_ACTIVATION_ERROR', + errorDetails: e.toString(), + stackTrace: stack.toString(), + ), + ); + } + } +} 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 94e8c31f..596770d9 100644 --- a/packages/komodo_defi_sdk/lib/src/assets/asset_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/assets/asset_manager.dart @@ -6,6 +6,7 @@ import 'dart:collection'; import 'package:komodo_coins/komodo_coins.dart'; import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; import 'package:komodo_defi_sdk/src/_internal_exports.dart'; +import 'package:komodo_defi_sdk/src/assets/custom_asset_history_storage.dart'; import 'package:komodo_defi_sdk/src/sdk/komodo_defi_sdk_config.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; @@ -42,12 +43,15 @@ class AssetManager implements IAssetProvider { this._client, this._auth, this._config, - ) : _assetHistory = AssetHistoryStorage(); + this._assetHistory, + this._customAssetHistory, + ); final ApiClient _client; final KomodoDefiLocalAuth _auth; final KomodoDefiSdkConfig _config; final AssetHistoryStorage _assetHistory; + final CustomAssetHistoryStorage _customAssetHistory; ActivationManager? _activationManager; @@ -97,6 +101,8 @@ class AssetManager implements IAssetProvider { _orderedCoins.addAll(_coins.all); + await initTickerIndex(); + final currentUser = await _auth.currentUser; await _onAuthStateChanged(currentUser); @@ -115,6 +121,12 @@ class AssetManager implements IAssetProvider { /// } /// ``` Stream activateAsset(Asset asset) { + // custom tokens are not in the coins list, so they have + // to be added to the indexes on login and activation + if (asset.protocol.isCustomToken) { + _orderedCoins[asset.id] = asset; + updateIndex(asset).ignore(); + } return _activation.activateAsset(asset); } @@ -162,6 +174,16 @@ class AssetManager implements IAssetProvider { Future _handlePreActivation(KdfUser user) async { final assetsToActivate = {}; + if (_config.preActivateCustomTokenAssets) { + final customTokens = + await _customAssetHistory.getWalletAssets(user.walletId); + assetsToActivate.addAll(customTokens); + for (final customToken in customTokens) { + _orderedCoins[customToken.id] = customToken; + await updateIndex(customToken); + } + } + if (_config.preActivateDefaultAssets) { for (final ticker in _config.defaultAssets) { final assets = findAssetsByTicker(ticker) @@ -263,13 +285,3 @@ class AssetManager implements IAssetProvider { await _authSubscription?.cancel(); } } - -class _AssetGroup { - _AssetGroup({ - required this.primary, - required this.children, - }); - - final Asset primary; - final List children; -} 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 new file mode 100644 index 00000000..cf89466b --- /dev/null +++ b/packages/komodo_defi_sdk/lib/src/assets/custom_asset_history_storage.dart @@ -0,0 +1,52 @@ +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); + final assetsJsonArray = assets.map((asset) => asset.toJson()).toList(); + await _storage.write( + key: key, + value: assetsJsonArray.toJsonString(), + ); + } + + /// Add a single asset to wallet's history + Future addAssetToWallet(WalletId walletId, Asset asset) async { + final assets = await getWalletAssets(walletId); + // Equatable operators not working as expected, so we need to check manually + 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 + Future> getWalletAssets(WalletId walletId) 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(Asset.fromJson).toSet(); + } + + /// Clear wallet's custom token history + 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 2b1bc5d4..c7cb1024 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 @@ -91,9 +91,9 @@ extension AssetTickerIndexExtension on AssetManager { _isInitialized = false; } - // Internal methods for maintaining the index + /// Internal methods for maintaining the index // ignore: unused_element - Future _updateIndex(Asset asset, {bool remove = false}) async { + Future updateIndex(Asset asset, {bool remove = false}) async { if (!_isInitialized) return; await _lock.protect(() async { _updateTickerIndex(asset, remove: remove); 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 7c371292..3422f513 100644 --- a/packages/komodo_defi_sdk/lib/src/komodo_defi_sdk.dart +++ b/packages/komodo_defi_sdk/lib/src/komodo_defi_sdk.dart @@ -3,6 +3,7 @@ import 'package:komodo_defi_framework/komodo_defi_framework.dart'; 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/addresses/address_operations.dart'; +import 'package:komodo_defi_sdk/src/assets/custom_asset_history_storage.dart'; import 'package:komodo_defi_sdk/src/pubkeys/pubkey_manager.dart'; import 'package:komodo_defi_sdk/src/sdk/komodo_defi_sdk_config.dart'; import 'package:komodo_defi_sdk/src/storage/secure_rpc_password_mixin.dart'; @@ -290,9 +291,16 @@ class KomodoDefiSdk with SecureRpcPasswordMixin { // Initialize asset history storage for sharing between managers final assetHistory = AssetHistoryStorage(); + final customAssetHistory = CustomAssetHistoryStorage(); // Initialize asset manager first as it implements IAssetLookup - _assets = AssetManager(_apiClient!, _auth!, _config); + _assets = AssetManager( + _apiClient!, + _auth!, + _config, + assetHistory, + customAssetHistory, + ); await _assets!.init(); // Initialize activation manager with asset lookup capabilities @@ -300,6 +308,7 @@ class KomodoDefiSdk with SecureRpcPasswordMixin { _apiClient!, _auth!, assetHistory, + customAssetHistory, _assets!, ); 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 a7bd4d27..19e0a349 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 @@ -4,6 +4,7 @@ class KomodoDefiSdkConfig { this.defaultAssets = const {'KMD', 'BTC', 'ETH', 'DOC', 'MARTY'}, this.preActivateDefaultAssets = true, this.preActivateHistoricalAssets = true, + this.preActivateCustomTokenAssets = true, this.maxPreActivationAttempts = 3, this.activationRetryDelay = const Duration(seconds: 2), }); @@ -17,6 +18,9 @@ class KomodoDefiSdkConfig { /// Whether to automatically activate previously used assets on login final bool preActivateHistoricalAssets; + /// Whether to automatically activate custom tokens on login + final bool preActivateCustomTokenAssets; + /// Maximum number of retry attempts for pre-activation final int maxPreActivationAttempts; @@ -27,6 +31,7 @@ class KomodoDefiSdkConfig { Set? defaultAssets, bool? preActivateDefaultAssets, bool? preActivateHistoricalAssets, + bool? preActivateCustomTokenAssets, int? maxPreActivationAttempts, Duration? activationRetryDelay, }) { @@ -36,6 +41,8 @@ class KomodoDefiSdkConfig { preActivateDefaultAssets ?? this.preActivateDefaultAssets, preActivateHistoricalAssets: preActivateHistoricalAssets ?? this.preActivateHistoricalAssets, + preActivateCustomTokenAssets: + preActivateCustomTokenAssets ?? this.preActivateCustomTokenAssets, maxPreActivationAttempts: maxPreActivationAttempts ?? this.maxPreActivationAttempts, activationRetryDelay: activationRetryDelay ?? this.activationRetryDelay, diff --git a/packages/komodo_defi_types/lib/src/assets/asset.dart b/packages/komodo_defi_types/lib/src/assets/asset.dart index 81f5c25a..57f0f205 100644 --- a/packages/komodo_defi_types/lib/src/assets/asset.dart +++ b/packages/komodo_defi_types/lib/src/assets/asset.dart @@ -18,6 +18,12 @@ class Asset extends Equatable { return Asset(id: assetId, protocol: protocol); } + factory Asset.fromJson(JsonMap json) { + final assetId = AssetId.parse(json, knownIds: const {}); + final protocol = ProtocolClass.fromJson(json); + return Asset(id: assetId, protocol: protocol); + } + /// Creates a variant of this asset with a different protocol type Asset? createVariant(CoinSubClass protocolType) { if (!protocol.supportsProtocolType(protocolType)) return null; @@ -39,13 +45,14 @@ class Asset extends Equatable { .toSet(); } - // /// Gets the appropriate activation strategy for this asset - // ActivationStrategy get activationStrategy => - // ActivationStrategyFactory.createForAsset(this); - final AssetId id; final ProtocolClass protocol; + JsonMap toJson() => { + ...protocol.toJson(), + ...id.toJson(), + }; + @override List get props => [id, protocol]; 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 e8ef1527..339ff619 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 @@ -1,14 +1,16 @@ +import 'package:equatable/equatable.dart'; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_defi_types/src/utils/json_type_utils.dart'; /// Base class for all protocol definitions -abstract class ProtocolClass with ExplorerUrlMixin { +abstract class ProtocolClass with ExplorerUrlMixin implements Equatable { const ProtocolClass({ required this.subClass, required this.config, this.supportedProtocols = const [], + this.isCustomToken = false, }); /// Creates the appropriate protocol class from JSON config @@ -89,6 +91,11 @@ abstract class ProtocolClass with ExplorerUrlMixin { final JsonMap config; final List supportedProtocols; + /// Whether this is a custom token activated by the user. + /// Only EVM tokens are supported (e.g. ETH), and they are activated + /// as wallet-only. + final bool isCustomToken; + /// Whether this protocol supports multiple addresses per wallet bool get supportsMultipleAddresses; @@ -107,6 +114,8 @@ abstract class ProtocolClass with ExplorerUrlMixin { /// Convert protocol back to JSON representation JsonMap toJson() => { ...config, + 'sub_class': subClass.toString().split('.').last, + 'is_custom_token': isCustomToken, if (supportedProtocols.isNotEmpty) 'other_types': supportedProtocols .map((p) => p.toString().split('.').last) @@ -130,4 +139,17 @@ abstract class ProtocolClass with ExplorerUrlMixin { ActivationParams defaultActivationParams() => ActivationParams.fromConfigJson(config); + + @override + List get props => [ + subClass, + supportedProtocols, + isCustomToken, + requiresHdWallet, + derivationPath, + isTestnet, + ]; + + @override + bool? get stringify => false; } diff --git a/packages/komodo_defi_types/lib/src/protocols/erc20/erc20_protocol.dart b/packages/komodo_defi_types/lib/src/protocols/erc20/erc20_protocol.dart index a274b3a5..a8410c83 100644 --- a/packages/komodo_defi_types/lib/src/protocols/erc20/erc20_protocol.dart +++ b/packages/komodo_defi_types/lib/src/protocols/erc20/erc20_protocol.dart @@ -6,12 +6,14 @@ class Erc20Protocol extends ProtocolClass { Erc20Protocol._({ required super.subClass, required super.config, + super.isCustomToken = false, }); factory Erc20Protocol.fromJson(JsonMap json) { _validateErc20Config(json); return Erc20Protocol._( subClass: CoinSubClass.parse(json.value('type')), + isCustomToken: json.valueOrNull('is_custom_token') ?? false, config: json, ); } @@ -67,4 +69,27 @@ class Erc20Protocol extends ProtocolClass { // TODO: Confirm if this is correct, or if it is only for 'ERC20' and 'ETH' // protocols as is in the legacy repository. bool get needs0xPrefix => true; + + Erc20Protocol copyWith({ + int? chainId, + List? nodes, + String? swapContractAddress, + String? fallbackSwapContract, + bool? isCustomToken, + }) { + return Erc20Protocol._( + subClass: subClass, + isCustomToken: isCustomToken ?? this.isCustomToken, + config: JsonMap.from(config) + ..addAll({ + if (chainId != null) 'chain_id': chainId, + if (nodes != null) + 'nodes': nodes.map((node) => node.toJson()).toList(), + if (swapContractAddress != null) + 'swap_contract_address': swapContractAddress, + if (fallbackSwapContract != null) + 'fallback_swap_contract': fallbackSwapContract, + }), + ); + } } diff --git a/packages/komodo_defi_types/lib/src/transactions/transaction.dart b/packages/komodo_defi_types/lib/src/transactions/transaction.dart index 974104f3..10c4c19d 100644 --- a/packages/komodo_defi_types/lib/src/transactions/transaction.dart +++ b/packages/komodo_defi_types/lib/src/transactions/transaction.dart @@ -92,6 +92,35 @@ class Transaction extends Equatable { if (fee != null) 'fee': fee!.toJson(), if (memo != null) 'memo': memo, }; + + Transaction copyWith({ + String? id, + String? internalId, + AssetId? assetId, + BalanceChanges? balanceChanges, + DateTime? timestamp, + int? confirmations, + int? blockHeight, + List? from, + List? to, + String? txHash, + FeeInfo? fee, + String? memo, + }) => + Transaction( + id: id ?? this.id, + internalId: internalId ?? this.internalId, + assetId: assetId ?? this.assetId, + balanceChanges: balanceChanges ?? this.balanceChanges, + timestamp: timestamp ?? this.timestamp, + confirmations: confirmations ?? this.confirmations, + blockHeight: blockHeight ?? this.blockHeight, + from: from ?? this.from, + to: to ?? this.to, + txHash: txHash ?? this.txHash, + fee: fee ?? this.fee, + memo: memo ?? this.memo, + ); } extension TransactionInfoExtension on TransactionInfo { diff --git a/packages/komodo_ui/lib/src/defi/asset/asset_icon.dart b/packages/komodo_ui/lib/src/defi/asset/asset_icon.dart index b1b89cff..a96db269 100644 --- a/packages/komodo_ui/lib/src/defi/asset/asset_icon.dart +++ b/packages/komodo_ui/lib/src/defi/asset/asset_icon.dart @@ -58,6 +58,29 @@ class AssetIcon extends StatelessWidget { _AssetIconResolver.clearCaches(); } + /// Registers a custom icon for a given coin abbreviation. + /// + /// The [imageProvider] will be used instead of the default asset or CDN images + /// when displaying the icon for the specified [assetId]. + /// + /// Example: + /// ```dart + /// // Register a custom icon from an asset + /// CoinIcon.registerCustomIcon( + /// 'MYCOIN', + /// AssetImage('assets/my_custom_coin.png'), + /// ); + /// + /// // Register a custom icon from memory + /// CoinIcon.registerCustomIcon( + /// 'MYCOIN', + /// MemoryImage(customIconBytes), + /// ); + /// ``` + static void registerCustomIcon(AssetId assetId, ImageProvider imageProvider) { + _AssetIconResolver.registerCustomIcon(assetId, imageProvider); + } + /// Pre-loads the asset icon image into the cache. /// /// This is useful when you know you'll need an icon soon and want to avoid @@ -93,10 +116,16 @@ class _AssetIconResolver extends StatelessWidget { static final Map _assetExistenceCache = {}; static final Map _cdnExistenceCache = {}; + static final Map _customIconsCache = {}; + + static void registerCustomIcon(AssetId assetId, ImageProvider imageProvider) { + _customIconsCache[assetId.symbol.configSymbol] = imageProvider; + } static void clearCaches() { _assetExistenceCache.clear(); _cdnExistenceCache.clear(); + _customIconsCache.clear(); } String get _sanitizedId => @@ -113,6 +142,23 @@ class _AssetIconResolver extends StatelessWidget { final sanitizedId = resolver._sanitizedId; try { + if (_customIconsCache.containsKey(asset.symbol.configSymbol)) { + if (context.mounted) { + await precacheImage( + _customIconsCache[asset.symbol.configSymbol]!, + context, + onError: (e, stackTrace) { + if (throwExceptions) { + throw Exception( + 'Failed to pre-cache custom image for coin $asset: $e', + ); + } + }, + ); + } + return; + } + bool? assetExists; bool? cdnExists; @@ -159,6 +205,17 @@ class _AssetIconResolver extends StatelessWidget { @override Widget build(BuildContext context) { + if (_customIconsCache.containsKey(_sanitizedId)) { + return Image( + image: _customIconsCache[_sanitizedId]!, + filterQuality: FilterQuality.high, + errorBuilder: (context, error, stackTrace) { + debugPrint('Error loading custom icon for $assetId: $error'); + return Icon(Icons.monetization_on_outlined, size: size); + }, + ); + } + _assetExistenceCache[_imagePath] = true; return Image.asset( _imagePath,