diff --git a/assets/logo/not_found.png b/assets/logo/not_found.png new file mode 100644 index 0000000000..90c3ea4d8f Binary files /dev/null and b/assets/logo/not_found.png differ diff --git a/assets/translations/en.json b/assets/translations/en.json index 56e134650f..400e62aebe 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -606,6 +606,13 @@ "mmBotFirstTradePreview": "Preview of the first order", "mmBotFirstTradeEstimate": "First trade estimate", "mmBotFirstOrderVolume": "This is an estimate of the first order only. Following orders will be placed automatically using the configured volume of the available {} balance.", + "importCustomToken": "Import Custom Token", + "importTokenWarning": "Ensure the token is trustworthy before you import it.", + "importToken": "Import Token", + "selectNetwork": "Select Network", + "tokenContractAddress": "Token Contract Address", + "tokenNotFound": "Token is not found.", + "decimals": "Decimals", "onlySendToThisAddress": "Only send {} to this address", "scanTheQrCode": "Scan the QR code on any mobile device wallet", "swapAddress": "Swap Address", diff --git a/lib/bloc/auth_bloc/auth_bloc.dart b/lib/bloc/auth_bloc/auth_bloc.dart index 234c9b864b..b6fbe57ba4 100644 --- a/lib/bloc/auth_bloc/auth_bloc.dart +++ b/lib/bloc/auth_bloc/auth_bloc.dart @@ -6,6 +6,7 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:web_dex/blocs/wallets_repository.dart'; import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/shared/utils/utils.dart'; diff --git a/lib/bloc/cex_market_data/mockup/generate_demo_data.dart b/lib/bloc/cex_market_data/mockup/generate_demo_data.dart index 84138e7f9e..c6d07d1a24 100644 --- a/lib/bloc/cex_market_data/mockup/generate_demo_data.dart +++ b/lib/bloc/cex_market_data/mockup/generate_demo_data.dart @@ -141,18 +141,7 @@ class DemoDataGenerator { final totalAmount = transaction.balanceChanges.totalAmount; adjustedTransactions.add( - Transaction( - id: transaction.id, - timestamp: transaction.timestamp, - assetId: transaction.assetId, - blockHeight: transaction.blockHeight, - from: transaction.from, - internalId: transaction.internalId, - confirmations: transaction.confirmations, - to: transaction.to, - txHash: transaction.txHash, - fee: transaction.fee, - memo: transaction.memo, + transaction.copyWith( balanceChanges: BalanceChanges( netChange: netChange * adjustmentFactor, receivedByMe: received * adjustmentFactor, diff --git a/lib/bloc/coins_bloc/asset_coin_extension.dart b/lib/bloc/coins_bloc/asset_coin_extension.dart index 3a5e4fec36..d8cb9b56cb 100644 --- a/lib/bloc/coins_bloc/asset_coin_extension.dart +++ b/lib/bloc/coins_bloc/asset_coin_extension.dart @@ -12,24 +12,23 @@ extension AssetCoinExtension on Asset { contractAddress: '', ); - final CoinType? type = _getCoinTypeFromProtocol(protocol); - if (type == null) { - throw ArgumentError.value( - protocol.subClass, - 'protocol type', - 'Unsupported protocol type', - ); - } - + final CoinType type = protocol.subClass.toCoinType(); // temporary measure to get metadata, like `wallet_only`, that isn't exposed // by the SDK (and might be phased out completely later on) + // TODO: Remove this once the SDK exposes all the necessary metadata final config = protocol.config; + final logoImageUrl = config.valueOrNull('logo_image_url'); + final isCustomToken = + (config.valueOrNull('is_custom_token') ?? false) || + logoImageUrl != null; return Coin( type: type, abbr: id.id, id: id, name: id.name, + logoImageUrl: logoImageUrl ?? '', + isCustomCoin: isCustomToken, explorerUrl: config.valueOrNull('explorer_url') ?? '', explorerTxUrl: config.valueOrNull('explorer_tx_url') ?? '', explorerAddressUrl: @@ -49,13 +48,17 @@ extension AssetCoinExtension on Asset { ); } - CoinType? _getCoinTypeFromProtocol(ProtocolClass protocol) { - switch (protocol.subClass) { + String? get contractAddress => protocol.config + .valueOrNull('protocol', 'protocol_data', 'contract_address'); +} + +extension CoinTypeExtension on CoinSubClass { + CoinType toCoinType() { + switch (this) { case CoinSubClass.ftm20: return CoinType.ftm20; case CoinSubClass.arbitrum: return CoinType.arb20; - // ignore: deprecated_member_use case CoinSubClass.slp: return CoinType.slp; case CoinSubClass.qrc20: @@ -94,6 +97,29 @@ extension AssetCoinExtension on Asset { return CoinType.utxo; } } + + bool isEvmProtocol() { + switch (this) { + case CoinSubClass.avx20: + case CoinSubClass.bep20: + case CoinSubClass.ftm20: + case CoinSubClass.matic: + case CoinSubClass.hrc20: + case CoinSubClass.arbitrum: + case CoinSubClass.moonriver: + case CoinSubClass.moonbeam: + case CoinSubClass.ethereumClassic: + case CoinSubClass.ubiq: + case CoinSubClass.krc20: + case CoinSubClass.ewt: + case CoinSubClass.hecoChain: + case CoinSubClass.rskSmartBitcoin: + case CoinSubClass.erc20: + return true; + default: + return false; + } + } } extension CoinSubClassExtension on CoinType { diff --git a/lib/bloc/coins_bloc/coins_bloc.dart b/lib/bloc/coins_bloc/coins_bloc.dart index 134b2e6d31..ed4b505c51 100644 --- a/lib/bloc/coins_bloc/coins_bloc.dart +++ b/lib/bloc/coins_bloc/coins_bloc.dart @@ -11,6 +11,7 @@ import 'package:web_dex/blocs/trezor_coins_bloc.dart'; import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; import 'package:web_dex/model/cex_price.dart'; import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -265,24 +266,22 @@ class CoinsBloc extends Bloc { changed = true; // Create new coin instance with updated price coins[entry.key] = coin.copyWith(usdPrice: usdPrice); - - // Update wallet coins if exists - if (state.walletCoins.containsKey(coin.abbr)) { - emit( - state.copyWith( - walletCoins: { - ...state.walletCoins, - coin.abbr: - state.walletCoins[entry.key]!.copyWith(usdPrice: usdPrice), - }, - ), - ); - } } } if (changed) { - emit(state.copyWith(coins: coins)); + final newWalletCoins = state.walletCoins.map( + (String coinId, Coin coin) => MapEntry( + coinId, + coin.copyWith(usdPrice: coins[coinId]!.usdPrice), + ), + ); + emit( + state.copyWith( + coins: coins, + walletCoins: {...state.walletCoins, ...newWalletCoins}, + ), + ); } log('CEX prices updated', path: 'coins_bloc => updateCoinsCexPrices') @@ -353,7 +352,12 @@ class CoinsBloc extends Bloc { try { await _kdfSdk.addActivatedCoins(coins); await Future.wait( - coins.map((coin) => _currentWalletBloc.addCoin(state.coins[coin]!)), + coins.map((coin) async { + final sdkCoin = state.coins[coin] ?? _coinsRepo.getCoin(coin); + if (sdkCoin != null) { + await _currentWalletBloc.addCoin(sdkCoin); + } + }), ); } catch (e, s) { log( @@ -366,7 +370,12 @@ class CoinsBloc extends Bloc { return []; } - final enableFutures = coins.map((coin) => _activateCoin(coin)).toList(); + final enabledAssets = await _kdfSdk.assets.getEnabledCoins(); + final coinsToActivate = + coins.where((coin) => !enabledAssets.contains(coin)); + + final enableFutures = + coinsToActivate.map((coin) => _activateCoin(coin)).toList(); final results = []; await for (final coin in Stream.fromFutures(enableFutures).asBroadcastStream()) { @@ -389,10 +398,13 @@ class CoinsBloc extends Bloc { final activatingCoins = Map.fromIterable( coins .map( - (coin) => state.coins[coin]?.copyWith( - state: CoinState.activating, - enabledType: _currentUserCache?.wallet.config.type, - ), + (coin) { + final sdkCoin = state.coins[coin] ?? _coinsRepo.getCoin(coin); + return sdkCoin?.copyWith( + state: CoinState.activating, + enabledType: _currentUserCache?.wallet.config.type, + ); + }, ) .where((coin) => coin != null) .cast(), @@ -407,35 +419,50 @@ class CoinsBloc extends Bloc { } Future _activateCoin(String coinId) async { - Coin coin = state.coins[coinId]!; - final isLoggedIn = _currentUserCache != null; - if (!isLoggedIn || coin.isActive) { - return coin; + Coin? coin = state.coins[coinId] ?? _coinsRepo.getCoin(coinId); + if (coin == null) { + throw ArgumentError.value(coinId, 'coinId', 'Coin not found'); } - switch (_currentUserCache?.wallet.config.type) { - case WalletType.iguana: - case WalletType.hdwallet: - coin = await _activateIguanaCoin(coin); - case WalletType.trezor: - final asset = _kdfSdk.assets.available[coin.id]; - if (asset == null) { - log('Failed to find asset for coin: ${coin.id}', isError: true); - return coin.copyWith(state: CoinState.suspended); - } - final accounts = await _trezorBloc.activateCoin(asset); - final state = - accounts.isNotEmpty ? CoinState.active : CoinState.suspended; - coin = coin.copyWith(state: state, accounts: accounts); - case WalletType.metamask: - case WalletType.keplr: - case null: - break; + try { + final isLoggedIn = _currentUserCache != null; + if (!isLoggedIn || coin.isActive) { + return coin; + } + + switch (_currentUserCache?.wallet.config.type) { + case WalletType.iguana: + case WalletType.hdwallet: + coin = await _activateIguanaCoin(coin); + case WalletType.trezor: + coin = await _activateTrezorCoin(coin, coinId); + case WalletType.metamask: + case WalletType.keplr: + case null: + break; + } + } catch (e, s) { + log( + 'Error activating coin ${coin!.id.toString()}', + isError: true, + trace: s, + ); } return coin; } + Future _activateTrezorCoin(Coin coin, String coinId) async { + final asset = _kdfSdk.assets.available[coin.id]; + if (asset == null) { + log('Failed to find asset for coin: ${coin.id}', isError: true); + return coin.copyWith(state: CoinState.suspended); + } + final accounts = await _trezorBloc.activateCoin(asset); + final state = accounts.isNotEmpty ? CoinState.active : CoinState.suspended; + return coin.copyWith(state: state, accounts: accounts); + } + Future _activateIguanaCoin(Coin coin) async { try { log('Enabling a ${coin.name}', path: 'coins_bloc => enable').ignore(); @@ -460,28 +487,38 @@ class CoinsBloc extends Bloc { return List.empty(); } - final List coins = currentWallet.config.activatedCoins - .map((abbr) => state.coins[abbr]) - .whereType() - .map((coin) => coin.abbr) - .toList(); - - return _activateCoins(coins, emit); + return _activateCoins(currentWallet.config.activatedCoins, emit); } Stream> _reActivateSuspended( Emitter emit, { int attempts = 1, }) async* { + final List coinsToBeActivated = []; + for (int i = 0; i < attempts; i++) { final List suspended = state.walletCoins.values .where((coin) => coin.isSuspended) .map((coin) => coin.abbr) .toList(); - if (suspended.isEmpty) return; - yield await _activateCoins(suspended, emit); + coinsToBeActivated.addAll(suspended); + coinsToBeActivated.addAll(_getUnactivatedWalletCoins()); + + if (coinsToBeActivated.isEmpty) return; + yield await _activateCoins(coinsToBeActivated, emit); + } + } + + List _getUnactivatedWalletCoins() { + final Wallet? currentWallet = _currentUserCache?.wallet; + if (currentWallet == null) { + return List.empty(); } + + return currentWallet.config.activatedCoins + .where((coinId) => !state.walletCoins.containsKey(coinId)) + .toList(); } /// yields one coin at a time to provide visual feedback to the user as @@ -507,10 +544,13 @@ class CoinsBloc extends Bloc { yield coin.copyWith(state: CoinState.suspended); } - for (final Coin apiCoin in await _coinsRepo.getEnabledCoins()) { - if (!walletCoins.containsKey(apiCoin.abbr)) { + for (final String apiCoinId in await _kdfSdk.assets.getEnabledCoins()) { + if (!walletCoins.containsKey(apiCoinId)) { // enabled on api side, but not on gui side - enable on gui side - yield apiCoin; + final apiCoin = await _coinsRepo.getEnabledCoin(apiCoinId); + if (apiCoin != null) { + yield apiCoin; + } } } } diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index ee5b98ba3e..755733b54e 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -2,11 +2,13 @@ import 'dart:async'; import 'dart:convert'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:komodo_defi_rpc_methods/komodo_defi_rpc_methods.dart' as kdf_rpc; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/blocs/trezor_coins_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; @@ -73,12 +75,12 @@ class CoinsRepo { } List getKnownCoins() { - final assets = _kdfSdk.assets.available; + final Map assets = _kdfSdk.assets.available; return assets.values.map(_assetToCoinWithoutAddress).toList(); } Map getKnownCoinsMap() { - final assets = _kdfSdk.assets.available; + final Map assets = _kdfSdk.assets.available; return Map.fromEntries( assets.values.map( (asset) => MapEntry(asset.id.id, _assetToCoinWithoutAddress(asset)), @@ -96,9 +98,18 @@ class CoinsRepo { 'This uses the deprecated assetsFromTicker method that uses a separate ' 'cache that does not update with custom token activation.') Coin? getCoin(String coinId) { + if (coinId.isEmpty) return null; + try { - final asset = _kdfSdk.assets.assetsFromTicker(coinId).single; - return _assetToCoinWithoutAddress(asset); + final assets = _kdfSdk.assets.assetsFromTicker(coinId); + if (assets.isEmpty || assets.length > 1) { + log( + 'Coin "$coinId" not found. ${assets.length} results returned', + isError: true, + ).ignore(); + return null; + } + return _assetToCoinWithoutAddress(assets.single); } catch (_) { return null; } @@ -110,20 +121,29 @@ class CoinsRepo { return []; } - final activatedCoins = await _kdfSdk.assets.getEnabledCoins(); - final knownCoins = getKnownCoinsMap(); + final activatedCoins = await _kdfSdk.assets.getActivatedAssets(); return activatedCoins - .map((String coinId) => knownCoins[coinId]) - .where((Coin? coin) => coin != null) - .cast() + .map((Asset asset) => _assetToCoinWithoutAddress(asset)) .toList(); } Future getEnabledCoin(String coinId) async { - final enabledAssets = await getEnabledCoinsMap(); - final coin = enabledAssets[coinId]; - if (coin == null) return null; - return coin; + final enabledAssets = _kdfSdk.assets.assetsFromTicker(coinId); + if (enabledAssets.length != 1) { + return null; + } + final currentUser = await _kdfSdk.auth.currentUser; + if (currentUser == null) { + return null; + } + + final coin = _assetToCoinWithoutAddress(enabledAssets.single); + final coinAddress = await getFirstPubkey(coin.abbr); + return coin.copyWith( + address: coinAddress, + state: CoinState.active, + enabledType: currentUser.wallet.config.type, + ); } Future> getEnabledCoins() async { @@ -205,16 +225,44 @@ class CoinsRepo { } } - Future activateCoinsSync(List coins) async { - if (!await _kdfSdk.auth.isSignedIn()) return; - final enabledAssets = await getEnabledCoinsMap(); - - for (final coin in coins) { + Future activateAssetsSync(List assets) async { + for (final asset in assets) { + final coin = asset.toCoin(); try { - if (enabledAssets.containsKey(coin.abbr)) { - continue; + await _broadcastAsset(coin.copyWith(state: CoinState.activating)); + + // ignore: deprecated_member_use + final progress = await _kdfSdk.assets.activateAsset(assets.single).last; + if (!progress.isSuccess) { + throw StateError('Failed to activate coin ${asset.id.id}'); } + await _broadcastAsset(coin.copyWith(state: CoinState.active)); + } catch (e, s) { + log( + 'Error activating coin: ${asset.id.id} \n$e', + isError: true, + trace: s, + ).ignore(); + await _broadcastAsset( + asset.toCoin().copyWith(state: CoinState.suspended), + ); + } finally { + // Register outside of the try-catch to ensure icon is available even + // in a suspended or failing activation status. + if (coin.logoImageUrl?.isNotEmpty == true) { + CoinIcon.registerCustomIcon( + coin.id.id, + NetworkImage(coin.logoImageUrl!), + ); + } + } + } + } + + Future activateCoinsSync(List coins) async { + for (final coin in coins) { + try { final asset = _kdfSdk.assets.available[coin.id]; if (asset == null) { log( @@ -223,11 +271,9 @@ class CoinsRepo { ).ignore(); continue; } + await _broadcastAsset(coin.copyWith(state: CoinState.activating)); - if (coin.parentCoin != null) { - await _activateParentAsset(coin); - } // ignore: deprecated_member_use final progress = await _kdfSdk.assets.activateAsset(asset).last; if (!progress.isSuccess) { @@ -242,25 +288,19 @@ class CoinsRepo { trace: s, ).ignore(); await _broadcastAsset(coin.copyWith(state: CoinState.suspended)); + } finally { + // Register outside of the try-catch to ensure icon is available even + // in a suspended or failing activation status. + if (coin.logoImageUrl?.isNotEmpty == true) { + CoinIcon.registerCustomIcon( + coin.id.id, + NetworkImage(coin.logoImageUrl!), + ); + } } } } - Future _activateParentAsset(Coin coin) async { - final parentAsset = _kdfSdk.assets.available[coin.parentCoin!.id]; - if (parentAsset == null) { - throw ArgumentError('Parent asset ${coin.parentCoin!.id} not found'); - } - - await _broadcastAsset( - coin.parentCoin!.copyWith(state: CoinState.activating), - ); - await _kdfSdk.assets.activateAsset(parentAsset).last; - await _broadcastAsset( - coin.parentCoin!.copyWith(state: CoinState.active), - ); - } - Future deactivateCoinsSync(List coins) async { if (!await _kdfSdk.auth.isSignedIn()) return; @@ -423,7 +463,7 @@ class CoinsRepo { // Coins with the same coingeckoId supposedly have same usd price // (e.g. KMD == KMD-BEP20) final Iterable samePriceCoins = - getKnownCoins().where((coin) => coin.coingeckoId == coingeckoId); + (getKnownCoins()).where((coin) => coin.coingeckoId == coingeckoId); for (final Coin coin in samePriceCoins) { prices[coin.abbr] = CexPrice( diff --git a/lib/bloc/coins_manager/coins_manager_bloc.dart b/lib/bloc/coins_manager/coins_manager_bloc.dart index e6ea09035f..e1d38d412b 100644 --- a/lib/bloc/coins_manager/coins_manager_bloc.dart +++ b/lib/bloc/coins_manager/coins_manager_bloc.dart @@ -7,6 +7,7 @@ import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/model/coin.dart'; import 'package:web_dex/model/coin_type.dart'; import 'package:web_dex/model/coin_utils.dart'; +import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/router/state/wallet_state.dart'; diff --git a/lib/bloc/custom_token_import/bloc/custom_token_import_bloc.dart b/lib/bloc/custom_token_import/bloc/custom_token_import_bloc.dart new file mode 100644 index 0000000000..2063f53ed5 --- /dev/null +++ b/lib/bloc/custom_token_import/bloc/custom_token_import_bloc.dart @@ -0,0 +1,149 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/bloc/custom_token_import/bloc/custom_token_import_event.dart'; +import 'package:web_dex/bloc/custom_token_import/bloc/custom_token_import_state.dart'; +import 'package:web_dex/bloc/custom_token_import/data/custom_token_import_repository.dart'; +import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class CustomTokenImportBloc + extends Bloc { + final ICustomTokenImportRepository repository; + final CoinsRepo _coinsRepo; + + CustomTokenImportBloc(this.repository, this._coinsRepo) + : super(CustomTokenImportState.defaults()) { + on(_onUpdateAsset); + on(_onUpdateAddress); + on(_onSubmitImportCustomToken); + on(_onSubmitFetchCustomToken); + on(_onResetFormStatus); + } + + void _onResetFormStatus( + ResetFormStatusEvent event, + Emitter emit, + ) { + final availableCoinTypes = + CoinType.values.map((CoinType type) => type.toCoinSubClass()); + final items = CoinSubClass.values + .where( + (CoinSubClass type) => + type.isEvmProtocol() && availableCoinTypes.contains(type), + ) + .toList() + ..sort((a, b) => a.name.compareTo(b.name)); + + emit( + state.copyWith( + formStatus: FormStatus.initial, + formErrorMessage: '', + importStatus: FormStatus.initial, + importErrorMessage: '', + evmNetworks: items, + ), + ); + } + + void _onUpdateAsset( + UpdateNetworkEvent event, + Emitter emit, + ) { + if (event.network == null) { + return; + } + emit(state.copyWith(network: event.network)); + } + + void _onUpdateAddress( + UpdateAddressEvent event, + Emitter emit, + ) { + emit(state.copyWith(address: event.address)); + } + + Future _onSubmitFetchCustomToken( + SubmitFetchCustomTokenEvent event, + Emitter emit, + ) async { + emit(state.copyWith(formStatus: FormStatus.submitting)); + + try { + final networkAsset = _coinsRepo.getCoin(state.network.ticker); + if (networkAsset == null) { + throw Exception('Network asset ${state.network.formatted} not found'); + } + + await _coinsRepo.activateCoinsSync([networkAsset]); + final tokenData = + await repository.fetchCustomToken(state.network, state.address); + await _coinsRepo.activateAssetsSync([tokenData]); + + final balanceInfo = await _coinsRepo.tryGetBalanceInfo(tokenData.id); + final balance = balanceInfo.spendable; + final usdBalance = + _coinsRepo.getUsdPriceByAmount(balance.toString(), tokenData.id.id); + + emit( + state.copyWith( + formStatus: FormStatus.success, + tokenData: () => tokenData, + tokenBalance: balance, + tokenBalanceUsd: + Decimal.tryParse(usdBalance?.toString() ?? '0.0') ?? Decimal.zero, + formErrorMessage: '', + ), + ); + + await _coinsRepo.deactivateCoinsSync([tokenData.toCoin()]); + } catch (e, s) { + log( + 'Error fetching custom token: ${e.toString()}', + path: 'CustomTokenImportBloc._onSubmitFetchCustomToken', + isError: true, + trace: s, + ); + emit( + state.copyWith( + formStatus: FormStatus.failure, + tokenData: () => null, + formErrorMessage: e.toString(), + ), + ); + } + } + + Future _onSubmitImportCustomToken( + SubmitImportCustomTokenEvent event, + Emitter emit, + ) async { + emit(state.copyWith(importStatus: FormStatus.submitting)); + + try { + await repository.importCustomToken(state.coin!); + + emit( + state.copyWith( + importStatus: FormStatus.success, + importErrorMessage: '', + ), + ); + } catch (e, s) { + log( + 'Error importing custom token: ${e.toString()}', + path: 'CustomTokenImportBloc._onSubmitImportCustomToken', + isError: true, + trace: s, + ); + emit( + state.copyWith( + importStatus: FormStatus.failure, + importErrorMessage: e.toString(), + ), + ); + } + } +} diff --git a/lib/bloc/custom_token_import/bloc/custom_token_import_event.dart b/lib/bloc/custom_token_import/bloc/custom_token_import_event.dart new file mode 100644 index 0000000000..ef129aca08 --- /dev/null +++ b/lib/bloc/custom_token_import/bloc/custom_token_import_event.dart @@ -0,0 +1,39 @@ +import 'package:equatable/equatable.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +abstract class CustomTokenImportEvent extends Equatable { + const CustomTokenImportEvent(); + + @override + List get props => []; +} + +class UpdateNetworkEvent extends CustomTokenImportEvent { + final CoinSubClass? network; + + const UpdateNetworkEvent(this.network); + + @override + List get props => [network]; +} + +class UpdateAddressEvent extends CustomTokenImportEvent { + final String address; + + const UpdateAddressEvent(this.address); + + @override + List get props => [address]; +} + +class SubmitImportCustomTokenEvent extends CustomTokenImportEvent { + const SubmitImportCustomTokenEvent(); +} + +class SubmitFetchCustomTokenEvent extends CustomTokenImportEvent { + const SubmitFetchCustomTokenEvent(); +} + +class ResetFormStatusEvent extends CustomTokenImportEvent { + const ResetFormStatusEvent(); +} diff --git a/lib/bloc/custom_token_import/bloc/custom_token_import_state.dart b/lib/bloc/custom_token_import/bloc/custom_token_import_state.dart new file mode 100644 index 0000000000..3c523749aa --- /dev/null +++ b/lib/bloc/custom_token_import/bloc/custom_token_import_state.dart @@ -0,0 +1,82 @@ +import 'package:decimal/decimal.dart'; +import 'package:equatable/equatable.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; + +enum FormStatus { initial, submitting, success, failure } + +class CustomTokenImportState extends Equatable { + const CustomTokenImportState({ + required this.formStatus, + required this.importStatus, + required this.network, + required this.address, + required this.formErrorMessage, + required this.importErrorMessage, + required this.coin, + required this.coinBalance, + required this.coinBalanceUsd, + required this.evmNetworks, + }); + + CustomTokenImportState.defaults({ + this.network = CoinSubClass.erc20, + this.address = '', + this.formStatus = FormStatus.initial, + this.importStatus = FormStatus.initial, + this.formErrorMessage = '', + this.importErrorMessage = '', + this.coin, + this.evmNetworks = const [], + }) : coinBalance = Decimal.zero, + coinBalanceUsd = Decimal.zero; + + final FormStatus formStatus; + final FormStatus importStatus; + final CoinSubClass network; + final String address; + final String formErrorMessage; + final String importErrorMessage; + final Asset? coin; + final Decimal coinBalance; + final Decimal coinBalanceUsd; + final Iterable evmNetworks; + + CustomTokenImportState copyWith({ + FormStatus? formStatus, + FormStatus? importStatus, + CoinSubClass? network, + String? address, + String? formErrorMessage, + String? importErrorMessage, + Asset? Function()? tokenData, + Decimal? tokenBalance, + Decimal? tokenBalanceUsd, + Iterable? evmNetworks, + }) { + return CustomTokenImportState( + formStatus: formStatus ?? this.formStatus, + importStatus: importStatus ?? this.importStatus, + network: network ?? this.network, + address: address ?? this.address, + formErrorMessage: formErrorMessage ?? this.formErrorMessage, + importErrorMessage: importErrorMessage ?? this.importErrorMessage, + coin: tokenData?.call() ?? coin, + evmNetworks: evmNetworks ?? this.evmNetworks, + coinBalance: tokenBalance ?? coinBalance, + coinBalanceUsd: tokenBalanceUsd ?? coinBalanceUsd, + ); + } + + @override + List get props => [ + formStatus, + importStatus, + network, + address, + formErrorMessage, + importErrorMessage, + coin, + coinBalance, + evmNetworks, + ]; +} diff --git a/lib/bloc/custom_token_import/data/custom_token_import_repository.dart b/lib/bloc/custom_token_import/data/custom_token_import_repository.dart new file mode 100644 index 0000000000..5888044056 --- /dev/null +++ b/lib/bloc/custom_token_import/data/custom_token_import_repository.dart @@ -0,0 +1,181 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/coin_icon.dart'; + +abstract class ICustomTokenImportRepository { + Future fetchCustomToken(CoinSubClass network, String address); + Future importCustomToken(Asset asset); +} + +class KdfCustomTokenImportRepository implements ICustomTokenImportRepository { + KdfCustomTokenImportRepository(this._kdfSdk, this._coinsRepo); + + final CoinsRepo _coinsRepo; + final KomodoDefiSdk _kdfSdk; + + @override + Future fetchCustomToken(CoinSubClass network, String address) async { + final convertAddressResponse = + await _kdfSdk.client.rpc.address.convertAddress( + from: address, + coin: network.ticker, + toFormat: AddressFormat.fromCoinSubClass(CoinSubClass.erc20), + ); + final contractAddress = convertAddressResponse.address; + final knownCoin = _kdfSdk.assets.available.values.firstWhereOrNull( + (asset) => asset.contractAddress == contractAddress, + ); + if (knownCoin == null) { + return await _createNewCoin( + contractAddress, + network, + address, + ); + } + + return knownCoin; + } + + Future _createNewCoin( + String contractAddress, + CoinSubClass network, + String address, + ) async { + final response = await _kdfSdk.client.rpc.utility.getTokenInfo( + contractAddress: contractAddress, + platform: network.ticker, + protocolType: CoinSubClass.erc20.formatted, + ); + + final platformAssets = _kdfSdk.assets.findAssetsByTicker(network.ticker); + if (platformAssets.length != 1) { + throw Exception('Platform asset not found. ${platformAssets.length} ' + 'results returned.'); + } + final platformAsset = platformAssets.single; + final platformConfig = platformAsset.protocol.config; + final String ticker = response.info.symbol; + final tokenApi = await fetchTokenInfoFromApi(network, contractAddress); + + final coinId = '$ticker-${network.ticker}'; + final logoImageUrl = tokenApi?['image']?['large'] ?? + tokenApi?['image']?['small'] ?? + tokenApi?['image']?['thumb']; + final newCoin = Asset( + id: AssetId( + id: coinId, + name: tokenApi?['name'] ?? ticker, + symbol: AssetSymbol( + assetConfigId: '$ticker-${network.ticker}', + coinGeckoId: tokenApi?['id'], + coinPaprikaId: tokenApi?['id'], + ), + chainId: AssetChainId(chainId: 0), + subClass: network, + derivationPath: '', + ), + protocol: Erc20Protocol.fromJson({ + 'type': network.formatted, + 'chain_id': 0, + 'nodes': [], + 'swap_contract_address': + platformConfig.valueOrNull('swap_contract_address'), + 'fallback_swap_contract': + platformConfig.valueOrNull('fallback_swap_contract'), + 'protocol': { + 'protocol_data': { + 'platform': network.ticker, + 'contract_address': address, + }, + }, + 'logo_image_url': logoImageUrl, + 'explorer_url': platformConfig.valueOrNull('explorer_url'), + 'explorer_url_tx': + platformConfig.valueOrNull('explorer_url_tx'), + 'explorer_url_address': + platformConfig.valueOrNull('explorer_url_address'), + }).copyWith(isCustomToken: true), + ); + + CoinIcon.registerCustomIcon( + newCoin.id.id, + NetworkImage( + tokenApi?['image']?['large'] ?? + 'assets/coin_icons/png/${ticker.toLowerCase()}.png', + ), + ); + + return newCoin; + } + + @override + Future importCustomToken(Asset asset) async { + await _coinsRepo.activateAssetsSync([asset]); + } + + Future?> fetchTokenInfoFromApi( + CoinSubClass coinType, + String contractAddress, + ) async { + final platform = getNetworkApiName(coinType); + if (platform == null) { + log('Unsupported Image URL Network: $coinType'); + return null; + } + + final url = Uri.parse( + 'https://api.coingecko.com/api/v3/coins/$platform/' + 'contract/$contractAddress', + ); + + try { + final response = await http.get(url); + final data = jsonDecode(response.body); + return data; + } catch (e) { + log('Error fetching token image URL: $e'); + return null; + } + } + + // this does not appear to match the coingecko id field in the coins config. + // notable differences are bep20, matic, and hrc20 + // these could possibly be mapped with another field, or it should be changed + // to the subclass formatted/ticker fields + String? getNetworkApiName(CoinSubClass coinType) { + switch (coinType) { + case CoinSubClass.erc20: + return 'ethereum'; + case CoinSubClass.bep20: + return 'binance-smart-chain'; + case CoinSubClass.qrc20: + return 'qtum'; + case CoinSubClass.ftm20: + return 'fantom'; + case CoinSubClass.arbitrum: + return 'arbitrum-one'; + case CoinSubClass.avx20: + return 'avalanche'; + case CoinSubClass.moonriver: + return 'moonriver'; + case CoinSubClass.hecoChain: + return 'huobi-token'; + case CoinSubClass.matic: + return 'polygon-pos'; + case CoinSubClass.hrc20: + return 'harmony-shard-0'; + case CoinSubClass.krc20: + return 'kcc'; + default: + return null; + } + } +} diff --git a/lib/bloc/fiat/base_fiat_provider.dart b/lib/bloc/fiat/base_fiat_provider.dart index 80b29ca157..827bfe32b0 100644 --- a/lib/bloc/fiat/base_fiat_provider.dart +++ b/lib/bloc/fiat/base_fiat_provider.dart @@ -211,6 +211,7 @@ abstract class BaseFiatProvider { // ZILLIQA } + // TODO: migrate to SDK [CoinSubClass] ticker/formatted getters CoinType? getCoinType(String chain) { switch (chain) { case 'BTC': diff --git a/lib/bloc/nft_withdraw/nft_withdraw_bloc.dart b/lib/bloc/nft_withdraw/nft_withdraw_bloc.dart index 161cef61a0..3b179e26c6 100644 --- a/lib/bloc/nft_withdraw/nft_withdraw_bloc.dart +++ b/lib/bloc/nft_withdraw/nft_withdraw_bloc.dart @@ -3,13 +3,14 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/bloc/nft_withdraw/nft_withdraw_repo.dart'; import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; -import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/convert_address/convert_address_request.dart'; import 'package:web_dex/mm2/mm2_api/rpc/errors.dart'; import 'package:web_dex/mm2/mm2_api/rpc/nft/withdraw/withdraw_nft_response.dart'; import 'package:web_dex/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transaction_response.dart'; @@ -24,11 +25,11 @@ class NftWithdrawBloc extends Bloc { NftWithdrawBloc({ required NftWithdrawRepo repo, required NftToken nft, - required Mm2Api mm2Api, + required KomodoDefiSdk kdfSdk, required CoinsRepo coinsRepository, }) : _repo = repo, _coinsRepository = coinsRepository, - _mm2Api = mm2Api, + _kdfSdk = kdfSdk, super(NftWithdrawFillState.initial(nft)) { on(_onAddressChanged); on(_onAmountChanged); @@ -40,7 +41,7 @@ class NftWithdrawBloc extends Bloc { } final NftWithdrawRepo _repo; - final Mm2Api _mm2Api; + final KomodoDefiSdk _kdfSdk; final CoinsRepo _coinsRepository; Future _onSend( @@ -51,12 +52,14 @@ class NftWithdrawBloc extends Bloc { if (state is! NftWithdrawFillState) return; if (state.isSending) return; - emit(state.copyWith( - isSending: () => true, - addressError: () => null, - amountError: () => null, - sendError: () => null, - )); + emit( + state.copyWith( + isSending: () => true, + addressError: () => null, + amountError: () => null, + sendError: () => null, + ), + ); final NftToken nft = state.nft; final String address = state.address; final int? amount = state.amount; @@ -68,11 +71,13 @@ class NftWithdrawBloc extends Bloc { final BaseError? amountError = _validateAmount(amount, int.parse(nft.amount), nft.contractType); if (addressError != null || amountError != null) { - emit(state.copyWith( - isSending: () => false, - addressError: () => addressError, - amountError: () => amountError, - )); + emit( + state.copyWith( + isSending: () => false, + addressError: () => addressError, + amountError: () => amountError, + ), + ); return; } @@ -82,12 +87,14 @@ class NftWithdrawBloc extends Bloc { final NftTransactionDetails result = response.result; - emit(NftWithdrawConfirmState( - nft: state.nft, - isSending: false, - txDetails: result, - sendError: null, - )); + emit( + NftWithdrawConfirmState( + nft: state.nft, + isSending: false, + txDetails: result, + sendError: null, + ), + ); } on ApiError catch (e) { emit(state.copyWith(sendError: () => e, isSending: () => false)); } on TransportError catch (e) { @@ -101,14 +108,18 @@ class NftWithdrawBloc extends Bloc { } Future _onConfirmSend( - NftWithdrawConfirmSendEvent event, Emitter emit) async { + NftWithdrawConfirmSendEvent event, + Emitter emit, + ) async { final state = this.state; if (state is! NftWithdrawConfirmState) return; - emit(state.copyWith( - isSending: () => true, - sendError: () => null, - )); + emit( + state.copyWith( + isSending: () => true, + sendError: () => null, + ), + ); final txDetails = state.txDetails; final SendRawTransactionResponse response = @@ -116,30 +127,38 @@ class NftWithdrawBloc extends Bloc { final BaseError? responseError = response.error; final String? txHash = response.txHash; if (txHash == null) { - emit(state.copyWith( - isSending: () => false, - sendError: () => - responseError ?? TextError(error: LocaleKeys.somethingWrong), - )); + emit( + state.copyWith( + isSending: () => false, + sendError: () => + responseError ?? TextError(error: LocaleKeys.somethingWrong), + ), + ); } else { - emit(NftWithdrawSuccessState( - txHash: txHash, - nft: state.nft, - timestamp: txDetails.timestamp, - to: txDetails.to.first, - )); + emit( + NftWithdrawSuccessState( + txHash: txHash, + nft: state.nft, + timestamp: txDetails.timestamp, + to: txDetails.to.first, + ), + ); } } void _onAddressChanged( - NftWithdrawAddressChanged event, Emitter emit) { + NftWithdrawAddressChanged event, + Emitter emit, + ) { final state = this.state; if (state is! NftWithdrawFillState) return; - emit(state.copyWith( - address: () => event.address, - addressError: () => null, - sendError: () => null, - )); + emit( + state.copyWith( + address: () => event.address, + addressError: () => null, + sendError: () => null, + ), + ); } void _onAmountChanged( @@ -149,11 +168,13 @@ class NftWithdrawBloc extends Bloc { final state = this.state; if (state is! NftWithdrawFillState) return; - emit(state.copyWith( - amount: () => event.amount, - amountError: () => null, - sendError: () => null, - )); + emit( + state.copyWith( + amount: () => event.amount, + amountError: () => null, + sendError: () => null, + ), + ); } Future _validateAddress( @@ -192,22 +213,27 @@ class NftWithdrawBloc extends Bloc { } if (amount > totalAmount) { return TextError( - error: LocaleKeys.maxCount.tr(args: [totalAmount.toString()])); + error: LocaleKeys.maxCount.tr(args: [totalAmount.toString()]), + ); } return null; } FutureOr _onShowFillForm( - NftWithdrawShowFillStep event, Emitter emit) { + NftWithdrawShowFillStep event, + Emitter emit, + ) { final state = this.state; if (state is NftWithdrawConfirmState) { - emit(NftWithdrawFillState( - address: state.txDetails.to.first, - amount: int.tryParse(state.txDetails.amount), - isSending: false, - nft: state.nft, - )); + emit( + NftWithdrawFillState( + address: state.txDetails.to.first, + amount: int.tryParse(state.txDetails.amount), + isSending: false, + nft: state.nft, + ), + ); } else { emit(NftWithdrawFillState.initial(state.nft)); } @@ -223,20 +249,28 @@ class NftWithdrawBloc extends Bloc { } Future _onConvertAddress( - NftWithdrawConvertAddress event, Emitter emit) async { + NftWithdrawConvertAddress event, + Emitter emit, + ) async { final state = this.state; if (state is! NftWithdrawFillState) return; - final result = await _mm2Api.convertLegacyAddress( - ConvertAddressRequest( - coin: state.nft.parentCoin.abbr, + try { + final subclass = state.nft.parentCoin.type.toCoinSubClass(); + final result = await _kdfSdk.client.rpc.address.convertAddress( from: state.address, - isErc: state.nft.parentCoin.isErcType, - ), - ); - if (result == null) return; - - add(NftWithdrawAddressChanged(result)); + coin: subclass.ticker, + toFormat: AddressFormat.fromCoinSubClass(subclass), + ); + add(NftWithdrawAddressChanged(result.address)); + } catch (e) { + emit( + state.copyWith( + address: () => '', + addressError: () => TextError(error: e.toString()), + ), + ); + } } Future _activateParentCoinIfNeeded(NftToken nft) async { diff --git a/lib/bloc/transaction_history/transaction_history_repo.dart b/lib/bloc/transaction_history/transaction_history_repo.dart index f8fe97ecb3..19208222c5 100644 --- a/lib/bloc/transaction_history/transaction_history_repo.dart +++ b/lib/bloc/transaction_history/transaction_history_repo.dart @@ -24,7 +24,6 @@ class SdkTransactionHistoryRepository implements TransactionHistoryRepo { } try { - final asset = _sdk.assets.available[assetId]!; final transactionHistory = await _sdk.transactions.getTransactionHistory( asset, pagination: fromId == null diff --git a/lib/bloc/trezor_bloc/trezor_repo.dart b/lib/bloc/trezor_bloc/trezor_repo.dart index d875eadaac..e0e0c8b6bf 100644 --- a/lib/bloc/trezor_bloc/trezor_repo.dart +++ b/lib/bloc/trezor_bloc/trezor_repo.dart @@ -29,6 +29,7 @@ import 'package:web_dex/model/hd_account/hd_account.dart'; import 'package:web_dex/model/hw_wallet/trezor_connection_status.dart'; import 'package:web_dex/model/hw_wallet/trezor_status.dart'; import 'package:web_dex/model/hw_wallet/trezor_task.dart'; +import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -57,17 +58,21 @@ class TrezorRepo { } Future sendPin(String pin, TrezorTask trezorTask) async { - await _api.pin(TrezorPinRequest( - pin: pin, - task: trezorTask, - )); + await _api.pin( + TrezorPinRequest( + pin: pin, + task: trezorTask, + ), + ); } Future sendPassphrase(String passphrase, TrezorTask trezorTask) async { - await _api.passphrase(TrezorPassphraseRequest( - passphrase: passphrase, - task: trezorTask, - )); + await _api.passphrase( + TrezorPassphraseRequest( + passphrase: passphrase, + task: trezorTask, + ), + ); } Future initCancel(int taskId) async { diff --git a/lib/bloc/trezor_init_bloc/trezor_init_bloc.dart b/lib/bloc/trezor_init_bloc/trezor_init_bloc.dart index 1600f93f2e..8eb1614615 100644 --- a/lib/bloc/trezor_init_bloc/trezor_init_bloc.dart +++ b/lib/bloc/trezor_init_bloc/trezor_init_bloc.dart @@ -14,6 +14,7 @@ import 'package:web_dex/model/hw_wallet/init_trezor.dart'; import 'package:web_dex/model/hw_wallet/trezor_status.dart'; import 'package:web_dex/model/hw_wallet/trezor_status_error.dart'; import 'package:web_dex/model/hw_wallet/trezor_task.dart'; +import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; import 'package:web_dex/model/text_error.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/shared/utils/utils.dart'; @@ -84,10 +85,12 @@ class TrezorInitBloc extends Bloc { isError: true, trace: s, ).ignore(); - emit(state.copyWith( - error: () => TextError(error: LocaleKeys.somethingWrong.tr()), - inProgress: () => false, - )); + emit( + state.copyWith( + error: () => TextError(error: LocaleKeys.somethingWrong.tr()), + inProgress: () => false, + ), + ); return; } @@ -96,27 +99,33 @@ class TrezorInitBloc extends Bloc { final InitTrezorResult? responseResult = response.result; if (responseError != null) { - emit(state.copyWith( - error: () => TextError(error: responseError), - inProgress: () => false, - )); + emit( + state.copyWith( + error: () => TextError(error: responseError), + inProgress: () => false, + ), + ); await _logout(); return; } if (responseResult == null) { - emit(state.copyWith( - error: () => TextError(error: LocaleKeys.somethingWrong.tr()), - inProgress: () => false, - )); + emit( + state.copyWith( + error: () => TextError(error: LocaleKeys.somethingWrong.tr()), + inProgress: () => false, + ), + ); await _logout(); return; } add(const TrezorInitSubscribeStatus()); - emit(state.copyWith( - taskId: () => responseResult.taskId, - inProgress: () => false, - )); + emit( + state.copyWith( + taskId: () => responseResult.taskId, + inProgress: () => false, + ), + ); } Future _onSubscribeStatus( @@ -154,9 +163,12 @@ class TrezorInitBloc extends Bloc { final InitTrezorStatusData? initTrezorStatus = response.result; if (initTrezorStatus == null) { - emit(state.copyWith( + emit( + state.copyWith( error: () => - TextError(error: 'Something went wrong. Empty init status.'))); + TextError(error: 'Something went wrong. Empty init status.'), + ), + ); await _logout(); return; @@ -241,11 +253,13 @@ class TrezorInitBloc extends Bloc { await _trezorRepo.initCancel(taskId); } _logout(); - emit(state.copyWith( - taskId: () => null, - status: () => null, - error: () => null, - )); + emit( + state.copyWith( + taskId: () => null, + status: () => null, + error: () => null, + ), + ); } FutureOr _onAuthModeChange( @@ -257,9 +271,10 @@ class TrezorInitBloc extends Bloc { /// KDF has to be running with a seed/wallet to init a trezor, so this signs /// into a static 'hidden' wallet to init trezor - Future _loginToTrezorWallet( - {String walletName = 'My Trezor', - String password = 'hidden-login'}) async { + Future _loginToTrezorWallet({ + String walletName = 'My Trezor', + String password = 'hidden-login', + }) async { final bool mm2SignedIn = await _kdfSdk.auth.isSignedIn(); if (state.kdfUser != null && mm2SignedIn) { return; diff --git a/lib/blocs/current_wallet_bloc.dart b/lib/blocs/current_wallet_bloc.dart index 83bfe1dc28..4b16698f51 100644 --- a/lib/blocs/current_wallet_bloc.dart +++ b/lib/blocs/current_wallet_bloc.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:web_dex/blocs/bloc_base.dart'; import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/kdf_auth_metadata_extension.dart'; import 'package:web_dex/model/wallet.dart'; import 'package:web_dex/services/file_loader/file_loader.dart'; import 'package:web_dex/shared/utils/encryption_tool.dart'; @@ -76,7 +77,9 @@ class CurrentWalletBloc implements BlocBase { final mnemonic = await _kdfSdk.auth.getMnemonicPlainText(password); wallet.config.seedPhrase = await _encryptionTool.encryptData( - password, mnemonic.plaintextMnemonic ?? ''); + password, + mnemonic.plaintextMnemonic ?? '', + ); } final String data = jsonEncode(wallet.config); diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index a9a7be46c4..cca4555a09 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -601,6 +601,13 @@ abstract class LocaleKeys { static const mmBotFirstTradePreview = 'mmBotFirstTradePreview'; static const mmBotFirstTradeEstimate = 'mmBotFirstTradeEstimate'; static const mmBotFirstOrderVolume = 'mmBotFirstOrderVolume'; + static const importCustomToken = 'importCustomToken'; + static const importTokenWarning = 'importTokenWarning'; + static const importToken = 'importToken'; + static const selectNetwork = 'selectNetwork'; + static const tokenNotFound = 'tokenNotFound'; + static const tokenContractAddress = 'tokenContractAddress'; + static const decimals = 'decimals'; static const onlySendToThisAddress = 'onlySendToThisAddress'; static const scanTheQrCode = 'scanTheQrCode'; static const swapAddress = 'swapAddress'; diff --git a/lib/mm2/mm2.dart b/lib/mm2/mm2.dart index b03a35a81d..43085502f0 100644 --- a/lib/mm2/mm2.dart +++ b/lib/mm2/mm2.dart @@ -10,7 +10,16 @@ final MM2 mm2 = MM2(); final class MM2 { MM2() { - _kdfSdk = KomodoDefiSdk(config: const KomodoDefiSdkConfig()); + _kdfSdk = KomodoDefiSdk( + config: const KomodoDefiSdkConfig( + // Syncing pre-activation coin states is not yet implemented, + // so we disable it for now. + // TODO: sync pre-activation of coins (show activating coins in list) + preActivateHistoricalAssets: false, + preActivateDefaultAssets: false, + preActivateCustomTokenAssets: true, + ), + ); } late final KomodoDefiSdk _kdfSdk; diff --git a/lib/mm2/mm2_api/mm2_api.dart b/lib/mm2/mm2_api/mm2_api.dart index 041a7faaa3..f9365014bb 100644 --- a/lib/mm2/mm2_api/mm2_api.dart +++ b/lib/mm2/mm2_api/mm2_api.dart @@ -12,7 +12,6 @@ import 'package:web_dex/mm2/mm2_api/rpc/active_swaps/active_swaps_request.dart'; import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders_request.dart'; import 'package:web_dex/mm2/mm2_api/rpc/cancel_order/cancel_order_request.dart'; -import 'package:web_dex/mm2/mm2_api/rpc/convert_address/convert_address_request.dart'; import 'package:web_dex/mm2/mm2_api/rpc/directly_connected_peers/get_directly_connected_peers.dart'; import 'package:web_dex/mm2/mm2_api/rpc/directly_connected_peers/get_directly_connected_peers_response.dart'; import 'package:web_dex/mm2/mm2_api/rpc/disable_coin/disable_coin_req.dart'; @@ -590,21 +589,6 @@ class Mm2Api { } } - Future convertLegacyAddress(ConvertAddressRequest request) async { - try { - final JsonMap responseJson = await _mm2.call(request); - return responseJson['result']?['address'] as String?; - } catch (e, s) { - log( - 'Convert address error: $e', - path: 'api => convertLegacyAddress', - trace: s, - isError: true, - ).ignore(); - return null; - } - } - Future stop() async { await _mm2.call(StopReq()); } diff --git a/lib/mm2/mm2_api/rpc/convert_address/convert_address_request.dart b/lib/mm2/mm2_api/rpc/convert_address/convert_address_request.dart deleted file mode 100644 index 47202ee021..0000000000 --- a/lib/mm2/mm2_api/rpc/convert_address/convert_address_request.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; - -class ConvertAddressRequest implements BaseRequest { - ConvertAddressRequest({ - required this.from, - required this.coin, - required this.isErc, - }); - - @override - final String method = 'convertaddress'; - @override - late String userpass; - - final String from; - final String coin; - final bool isErc; - - @override - Map toJson() { - return { - 'method': method, - 'userpass': userpass, - 'from': from, - 'coin': coin, - 'to_address_format': { - 'format': isErc ? 'mixedcase' : 'cashaddress', - if (coin == 'BCH') 'network': 'bitcoincash', - } - }; - } -} diff --git a/lib/mm2/mm2_api/rpc/max_maker_vol/max_maker_vol_response.dart b/lib/mm2/mm2_api/rpc/max_maker_vol/max_maker_vol_response.dart index 463e8db80e..1d6e685c70 100644 --- a/lib/mm2/mm2_api/rpc/max_maker_vol/max_maker_vol_response.dart +++ b/lib/mm2/mm2_api/rpc/max_maker_vol/max_maker_vol_response.dart @@ -4,14 +4,18 @@ class MaxMakerVolResponse { required this.balance, }); - final MaxMakerVolResponseValue volume; - final MaxMakerVolResponseValue balance; - factory MaxMakerVolResponse.fromJson(Map json) => MaxMakerVolResponse( - volume: MaxMakerVolResponseValue.fromJson(json['volume']), - balance: MaxMakerVolResponseValue.fromJson(json['balance']), + volume: MaxMakerVolResponseValue.fromJson( + Map.from(json['volume'] as Map? ?? {}), + ), + balance: MaxMakerVolResponseValue.fromJson( + Map.from(json['balance'] as Map? ?? {}), + ), ); + + final MaxMakerVolResponseValue volume; + final MaxMakerVolResponseValue balance; } class MaxMakerVolResponseValue { @@ -19,10 +23,10 @@ class MaxMakerVolResponseValue { required this.decimal, }); - final String decimal; - factory MaxMakerVolResponseValue.fromJson(Map json) => - MaxMakerVolResponseValue(decimal: json['decimal']); + MaxMakerVolResponseValue(decimal: json['decimal'] as String); + + final String decimal; Map toJson() => { 'decimal': decimal, diff --git a/lib/model/cex_price.dart b/lib/model/cex_price.dart index 8130765226..84d4b893b2 100644 --- a/lib/model/cex_price.dart +++ b/lib/model/cex_price.dart @@ -27,6 +27,34 @@ class CexPrice extends Equatable { return 'CexPrice(ticker: $ticker, price: $price)'; } + factory CexPrice.fromJson(Map json) { + return CexPrice( + ticker: json['ticker'] as String, + price: (json['price'] as num).toDouble(), + lastUpdated: json['lastUpdated'] == null + ? null + : DateTime.parse(json['lastUpdated'] as String), + priceProvider: cexDataProvider(json['priceProvider'] as String), + volume24h: (json['volume24h'] as num?)?.toDouble(), + volumeProvider: cexDataProvider(json['volumeProvider'] as String), + change24h: (json['change24h'] as num?)?.toDouble(), + changeProvider: cexDataProvider(json['changeProvider'] as String), + ); + } + + Map toJson() { + return { + 'ticker': ticker, + 'price': price, + 'lastUpdated': lastUpdated?.toIso8601String(), + 'priceProvider': priceProvider?.toString(), + 'volume24h': volume24h, + 'volumeProvider': volumeProvider?.toString(), + 'change24h': change24h, + 'changeProvider': changeProvider?.toString(), + }; + } + @override List get props => [ ticker, diff --git a/lib/model/coin.dart b/lib/model/coin.dart index f42b899c59..8676bae341 100644 --- a/lib/model/coin.dart +++ b/lib/model/coin.dart @@ -23,6 +23,7 @@ class Coin { required this.protocolType, required this.protocolData, required this.isTestCoin, + required this.logoImageUrl, required this.coingeckoId, required this.fallbackSwapContract, required this.priority, @@ -34,6 +35,7 @@ class Coin { this.usdPrice, this.coinpaprikaId, this.activeByDefault = false, + this.isCustomCoin = false, required String? swapContractAddress, required bool walletOnly, required this.mode, @@ -45,6 +47,7 @@ class Coin { final String abbr; final String name; final AssetId id; + final String? logoImageUrl; final String? coingeckoId; final String? coinpaprikaId; final CoinType type; @@ -58,6 +61,7 @@ class Coin { final int decimals; CexPrice? usdPrice; final bool isTestCoin; + bool isCustomCoin; String? address; List? accounts; final double _balance; @@ -222,6 +226,8 @@ class Coin { explorerAddressUrl: explorerAddressUrl, protocolType: protocolType, isTestCoin: isTestCoin, + isCustomCoin: isCustomCoin, + logoImageUrl: logoImageUrl, coingeckoId: coingeckoId, fallbackSwapContract: fallbackSwapContract, priority: priority, @@ -261,6 +267,7 @@ class Coin { String? explorerTxUrl, String? explorerAddressUrl, String? protocolType, + String? logoImageUrl, ProtocolData? protocolData, bool? isTestCoin, String? coingeckoId, @@ -281,12 +288,14 @@ class Coin { WalletType? enabledType, double? balance, double? sendableBalance, + bool? isCustomCoin, }) { return Coin( type: type ?? this.type, abbr: abbr ?? this.abbr, id: id ?? this.id, name: name ?? this.name, + logoImageUrl: logoImageUrl ?? this.logoImageUrl, explorerUrl: explorerUrl ?? this.explorerUrl, explorerTxUrl: explorerTxUrl ?? this.explorerTxUrl, explorerAddressUrl: explorerAddressUrl ?? this.explorerAddressUrl, @@ -308,6 +317,7 @@ class Coin { walletOnly: walletOnly ?? _walletOnly, mode: mode ?? this.mode, balance: balance ?? _balance, + isCustomCoin: isCustomCoin ?? this.isCustomCoin, ) ..address = address ?? this.address ..enabledType = enabledType ?? this.enabledType @@ -319,129 +329,6 @@ extension LegacyCoinToSdkAsset on Coin { Asset toSdkAsset(KomodoDefiSdk sdk) => getSdkAsset(sdk, abbr); } -CoinType? getCoinType(String? jsonType, String coinAbbr) { - // anchor: protocols support - for (CoinType value in CoinType.values) { - switch (value) { - case CoinType.utxo: - if (jsonType == 'UTXO') { - return value; - } else { - continue; - } - case CoinType.smartChain: - if (jsonType == 'Smart Chain') { - return value; - } else { - continue; - } - case CoinType.erc20: - if (jsonType == 'ERC-20') { - return value; - } else { - continue; - } - case CoinType.bep20: - if (jsonType == 'BEP-20') { - return value; - } else { - continue; - } - case CoinType.qrc20: - if (jsonType == 'QRC-20') { - return value; - } else { - continue; - } - case CoinType.ftm20: - if (jsonType == 'FTM-20') { - return value; - } else { - continue; - } - case CoinType.arb20: - if (jsonType == 'Arbitrum') { - return value; - } else { - continue; - } - case CoinType.etc: - if (jsonType == 'Ethereum Classic') { - return value; - } else { - continue; - } - case CoinType.avx20: - if (jsonType == 'AVX-20') { - return value; - } else { - continue; - } - case CoinType.mvr20: - if (jsonType == 'Moonriver') { - return value; - } else { - continue; - } - case CoinType.hco20: - if (jsonType == 'HecoChain') { - return value; - } else { - continue; - } - case CoinType.plg20: - if (jsonType == 'Matic') { - return value; - } else { - continue; - } - case CoinType.sbch: - if (jsonType == 'SmartBCH') { - return value; - } else { - continue; - } - case CoinType.ubiq: - if (jsonType == 'Ubiq') { - return value; - } else { - continue; - } - case CoinType.hrc20: - if (jsonType == 'HRC-20') { - return value; - } else { - continue; - } - case CoinType.krc20: - if (jsonType == 'KRC-20') { - return value; - } else { - continue; - } - case CoinType.cosmos: - if (jsonType == 'TENDERMINT' && coinAbbr != 'IRIS') { - return value; - } else { - continue; - } - case CoinType.iris: - if (jsonType == 'TENDERMINTTOKEN' || coinAbbr == 'IRIS') { - return value; - } else { - continue; - } - case CoinType.slp: - if (jsonType == 'SLP') { - return value; - } else { - continue; - } - } - } - return null; -} - class ProtocolData { ProtocolData({ required this.platform, diff --git a/lib/model/hd_account/hd_account.dart b/lib/model/hd_account/hd_account.dart index fdc4c2df94..23de67af28 100644 --- a/lib/model/hd_account/hd_account.dart +++ b/lib/model/hd_account/hd_account.dart @@ -17,6 +17,16 @@ class HdAccount { ); } + Map toJson() { + return { + 'account_index': accountIndex, + 'derivation_path': derivationPath, + 'total_balance': totalBalance?.toJson(), + 'addresses': + addresses.map((HdAddress address) => address.toJson()).toList(), + }; + } + final int accountIndex; final String? derivationPath; final HdBalance? totalBalance; @@ -40,6 +50,15 @@ class HdAddress { ); } + Map toJson() { + return { + 'address': address, + 'derivation_path': derivationPath, + 'chain': chain, + 'balance': balance.toJson(), + }; + } + final String address; final String derivationPath; final String chain; @@ -80,6 +99,13 @@ class HdBalance { ); } + Map toJson() { + return { + 'spendable': spendable, + 'unspendable': unspendable, + }; + } + double spendable; double unspendable; } diff --git a/lib/model/kdf_auth_metadata_extension.dart b/lib/model/kdf_auth_metadata_extension.dart new file mode 100644 index 0000000000..b862090ea1 --- /dev/null +++ b/lib/model/kdf_auth_metadata_extension.dart @@ -0,0 +1,43 @@ +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; +import 'package:web_dex/model/wallet.dart'; + +extension KdfAuthMetadataExtension on KomodoDefiSdk { + Future walletExists(String walletId) async { + final users = await auth.getUsers(); + return users.any((user) => user.walletId.name == walletId); + } + + Future currentWallet() async { + final user = await auth.currentUser; + return user?.wallet; + } + + Future addActivatedCoins(Iterable coins) async { + final existingCoins = (await auth.currentUser) + ?.metadata + .valueOrNull>('activated_coins') ?? + []; + + final mergedCoins = {...existingCoins, ...coins}.toList(); + await auth.setOrRemoveActiveUserKeyValue('activated_coins', mergedCoins); + } + + Future removeActivatedCoins(List coins) async { + final existingCoins = (await auth.currentUser) + ?.metadata + .valueOrNull>('activated_coins') ?? + []; + + existingCoins.removeWhere((coin) => coins.contains(coin)); + await auth.setOrRemoveActiveUserKeyValue('activated_coins', existingCoins); + } + + Future confirmSeedBackup({bool hasBackup = true}) async { + await auth.setOrRemoveActiveUserKeyValue('has_backup', true); + } + + Future setWalletType(WalletType type) async { + await auth.setOrRemoveActiveUserKeyValue('type', type.name); + } +} diff --git a/lib/model/wallet.dart b/lib/model/wallet.dart index b96b0de6e9..11d98a0214 100644 --- a/lib/model/wallet.dart +++ b/lib/model/wallet.dart @@ -1,6 +1,6 @@ import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; -import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_defi_types/komodo_defi_type_utils.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:uuid/uuid.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/shared/utils/encryption_tool.dart'; @@ -192,43 +192,3 @@ extension KdfSdkWalletExtension on KomodoDefiSdk { Future> get wallets async => (await auth.getUsers()).map((user) => user.wallet); } - -extension KdfAuthExtension on KomodoDefiSdk { - Future walletExists(String walletId) async { - final users = await auth.getUsers(); - return users.any((user) => user.walletId.name == walletId); - } - - Future currentWallet() async { - final user = await auth.currentUser; - return user?.wallet; - } - - Future addActivatedCoins(Iterable coins) async { - final existingCoins = (await auth.currentUser) - ?.metadata - .valueOrNull>('activated_coins') ?? - []; - - final mergedCoins = {...existingCoins, ...coins}.toList(); - await auth.setOrRemoveActiveUserKeyValue('activated_coins', mergedCoins); - } - - Future removeActivatedCoins(List coins) async { - final existingCoins = (await auth.currentUser) - ?.metadata - .valueOrNull>('activated_coins') ?? - []; - - existingCoins.removeWhere((coin) => coins.contains(coin)); - await auth.setOrRemoveActiveUserKeyValue('activated_coins', existingCoins); - } - - Future confirmSeedBackup({bool hasBackup = true}) async { - await auth.setOrRemoveActiveUserKeyValue('has_backup', true); - } - - Future setWalletType(WalletType type) async { - await auth.setOrRemoveActiveUserKeyValue('type', type.name); - } -} diff --git a/lib/views/custom_token_import/custom_token_import_button.dart b/lib/views/custom_token_import/custom_token_import_button.dart new file mode 100644 index 0000000000..5f042bdc50 --- /dev/null +++ b/lib/views/custom_token_import/custom_token_import_button.dart @@ -0,0 +1,38 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/bloc/custom_token_import/bloc/custom_token_import_bloc.dart'; +import 'package:web_dex/bloc/custom_token_import/bloc/custom_token_import_event.dart'; +import 'package:web_dex/bloc/custom_token_import/data/custom_token_import_repository.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/views/custom_token_import/custom_token_import_dialog.dart'; + +class CustomTokenImportButton extends StatelessWidget { + const CustomTokenImportButton({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return UiPrimaryButton( + onPressed: () { + final coinsRepo = RepositoryProvider.of(context); + final kdfSdk = RepositoryProvider.of(context); + showDialog( + context: context, + builder: (BuildContext context) { + return BlocProvider( + create: (context) => CustomTokenImportBloc( + KdfCustomTokenImportRepository(kdfSdk, coinsRepo), + coinsRepo, + )..add(const ResetFormStatusEvent()), + child: const CustomTokenImportDialog(), + ); + }, + ); + }, + child: Text(LocaleKeys.importCustomToken.tr()), + ); + } +} diff --git a/lib/views/custom_token_import/custom_token_import_dialog.dart b/lib/views/custom_token_import/custom_token_import_dialog.dart new file mode 100644 index 0000000000..d62c06d5e2 --- /dev/null +++ b/lib/views/custom_token_import/custom_token_import_dialog.dart @@ -0,0 +1,359 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; +import 'package:web_dex/bloc/custom_token_import/bloc/custom_token_import_bloc.dart'; +import 'package:web_dex/bloc/custom_token_import/bloc/custom_token_import_event.dart'; +import 'package:web_dex/bloc/custom_token_import/bloc/custom_token_import_state.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin_utils.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +class CustomTokenImportDialog extends StatefulWidget { + const CustomTokenImportDialog({Key? key}) : super(key: key); + + @override + CustomTokenImportDialogState createState() => CustomTokenImportDialogState(); +} + +class CustomTokenImportDialogState extends State { + final PageController _pageController = PageController(); + + Future navigateToPage(int pageIndex) async { + return _pageController.animateToPage( + pageIndex, + duration: const Duration(milliseconds: 100), + curve: Curves.easeInOut, + ); + } + + Future goToNextPage() async { + if (_pageController.page == null) return; + + await navigateToPage(_pageController.page!.toInt() + 1); + } + + Future goToPreviousPage() async { + if (_pageController.page == null) return; + + await navigateToPage(_pageController.page!.toInt() - 1); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + child: SizedBox( + width: 450, + height: 358, + child: PageView( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + children: [ + ImportFormPage( + onNextPage: goToNextPage, + ), + ImportSubmitPage( + onPreviousPage: goToPreviousPage, + ), + ], + ), + ), + ); + } +} + +class BasePage extends StatelessWidget { + final String title; + final Widget child; + final VoidCallback? onBackPressed; + + const BasePage({ + required this.title, + required this.child, + this.onBackPressed, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (onBackPressed != null) + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: onBackPressed, + iconSize: 36, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + splashRadius: 20, + ), + if (onBackPressed != null) const SizedBox(width: 16), + Text( + title, + style: const TextStyle( + fontSize: 18, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + Navigator.of(context).pop(); + }, + splashRadius: 20, + ), + ], + ), + const SizedBox(height: 12), + Flexible(child: child), + ], + ), + ); + } +} + +class ImportFormPage extends StatelessWidget { + final VoidCallback onNextPage; + + const ImportFormPage({required this.onNextPage, Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + // keep controller outside of bloc consumer to prevent user inputs from + // being hijacked by state updates + final addressController = TextEditingController(text: ''); + return BlocConsumer( + listenWhen: (previous, current) => + previous.formStatus != current.formStatus, + listener: (context, state) { + if (state.formStatus == FormStatus.success || + state.formStatus == FormStatus.failure) { + onNextPage(); + } + }, + builder: (context, state) { + final initialState = state.formStatus == FormStatus.initial; + + final isSubmitEnabled = initialState && state.address.isNotEmpty; + + return BasePage( + title: LocaleKeys.importCustomToken.tr(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.orange.shade300.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.shade300), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: Colors.orange.shade300), + const SizedBox(width: 8), + Expanded( + child: Text( + LocaleKeys.importTokenWarning.tr(), + style: const TextStyle( + fontSize: 14, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + DropdownButtonFormField( + value: state.network, + isExpanded: true, + decoration: InputDecoration( + labelText: LocaleKeys.selectNetwork.tr(), + border: const OutlineInputBorder(), + ), + items: state.evmNetworks.map((CoinSubClass coinType) { + return DropdownMenuItem( + value: coinType, + child: Text(getCoinTypeNameLong(coinType.toCoinType())), + ); + }).toList(), + onChanged: !initialState + ? null + : (CoinSubClass? value) { + context + .read() + .add(UpdateNetworkEvent(value)); + }, + ), + const SizedBox(height: 24), + TextFormField( + controller: addressController, + enabled: initialState, + onChanged: (value) { + context + .read() + .add(UpdateAddressEvent(value)); + }, + decoration: InputDecoration( + labelText: LocaleKeys.tokenContractAddress.tr(), + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 24), + UiPrimaryButton( + onPressed: isSubmitEnabled + ? () { + context + .read() + .add(const SubmitFetchCustomTokenEvent()); + } + : null, + child: state.formStatus == FormStatus.initial + ? Text(LocaleKeys.importToken.tr()) + : const UiSpinner(color: Colors.white), + ), + ], + ), + ); + }, + ); + } +} + +class ImportSubmitPage extends StatelessWidget { + final VoidCallback onPreviousPage; + + const ImportSubmitPage({required this.onPreviousPage, Key? key}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listenWhen: (previous, current) => + previous.importStatus != current.importStatus, + listener: (context, state) { + if (state.importStatus == FormStatus.success) { + Navigator.of(context).pop(); + } + }, + builder: (context, state) { + final newCoin = state.coin; + final newCoinBalance = formatAmt(state.coinBalance.toDouble()); + final newCoinUsdBalance = + '\$${formatAmt(state.coinBalanceUsd.toDouble())}'; + + final isSubmitEnabled = state.importStatus != FormStatus.submitting && + state.importStatus != FormStatus.success && + newCoin != null; + + return BasePage( + title: LocaleKeys.importCustomToken.tr(), + onBackPressed: () { + context + .read() + .add(const ResetFormStatusEvent()); + onPreviousPage(); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: newCoin == null + ? [ + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + '$assetsPath/logo/not_found.png', + height: 250, + filterQuality: FilterQuality.high, + ), + Text( + LocaleKeys.tokenNotFound.tr(), + ), + ], + ), + ), + ] + : [ + Flexible( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + CoinIcon.ofSymbol( + newCoin.id.id, + size: 80, + ), + const SizedBox(height: 12), + Text( + newCoin.id.id, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 32), + Text( + LocaleKeys.balance.tr(), + style: + Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Colors.grey, + ), + ), + const SizedBox(height: 8), + Text( + '$newCoinBalance ${newCoin.id.id} ($newCoinUsdBalance)', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + ], + ), + ), + if (state.importErrorMessage.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text( + state.importErrorMessage, + textAlign: TextAlign.start, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + UiPrimaryButton( + onPressed: isSubmitEnabled + ? () { + context + .read() + .add(const SubmitImportCustomTokenEvent()); + } + : null, + child: state.importStatus == FormStatus.submitting || + state.importStatus == FormStatus.success + ? const UiSpinner(color: Colors.white) + : Text(LocaleKeys.importToken.tr()), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/views/fiat/fiat_currency_list_tile.dart b/lib/views/fiat/fiat_currency_list_tile.dart index a10d707b6e..133b13d42f 100644 --- a/lib/views/fiat/fiat_currency_list_tile.dart +++ b/lib/views/fiat/fiat_currency_list_tile.dart @@ -36,7 +36,7 @@ class FiatCurrencyListTile extends StatelessWidget { // Use Expanded to let AutoScrollText take all available space Expanded( child: AutoScrollText( - text: '${currency.name}$coinType', + text: '${currency.name}${coinType.isEmpty ? '' : ' ($coinType)'}', ), ), // Align the text to the right diff --git a/lib/views/nfts/details_page/nft_details_page.dart b/lib/views/nfts/details_page/nft_details_page.dart index 0e3fd70b62..b5bba05302 100644 --- a/lib/views/nfts/details_page/nft_details_page.dart +++ b/lib/views/nfts/details_page/nft_details_page.dart @@ -3,6 +3,7 @@ import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/bloc/nft_withdraw/nft_withdraw_bloc.dart'; @@ -40,10 +41,13 @@ class NftDetailsPage extends StatelessWidget { } final nfts = context.read().state.nfts; final NftToken? nft = nfts.values - .firstWhereOrNull((list) => - list?.firstWhereOrNull((token) => token.uuid == uuid) != null) + .firstWhereOrNull( + (list) => + list?.firstWhereOrNull((token) => token.uuid == uuid) != null, + ) ?.firstWhereOrNull((token) => token.uuid == uuid); final mm2Api = RepositoryProvider.of(context); + final kdfSdk = RepositoryProvider.of(context); if (nft == null) { return Column( @@ -66,7 +70,7 @@ class NftDetailsPage extends StatelessWidget { create: (context) => NftWithdrawBloc( nft: nft, repo: NftWithdrawRepo(api: mm2Api), - mm2Api: mm2Api, + kdfSdk: kdfSdk, coinsRepository: RepositoryProvider.of(context), ), child: isMobile diff --git a/lib/views/wallet/coins_manager/coins_manager_controls.dart b/lib/views/wallet/coins_manager/coins_manager_controls.dart index 257c01e2be..0b32f69d36 100644 --- a/lib/views/wallet/coins_manager/coins_manager_controls.dart +++ b/lib/views/wallet/coins_manager/coins_manager_controls.dart @@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/coins_manager/coins_manager_bloc.dart'; import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/views/custom_token_import/custom_token_import_button.dart'; import 'package:web_dex/views/wallet/coins_manager/coins_manager_filters_dropdown.dart'; import 'package:web_dex/views/wallet/coins_manager/coins_manager_select_all_button.dart'; @@ -21,6 +22,8 @@ class CoinsManagerFilters extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildSearchField(context), + const SizedBox(height: 8), + const CustomTokenImportButton(), Padding( padding: const EdgeInsets.only(top: 14.0), child: Row( @@ -42,7 +45,7 @@ class CoinsManagerFilters extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( @@ -50,6 +53,13 @@ class CoinsManagerFilters extends StatelessWidget { height: 45, child: _buildSearchField(context), ), + const SizedBox(width: 20), + Container( + constraints: const BoxConstraints(maxWidth: 240), + height: 45, + child: const CustomTokenImportButton(), + ), + const Spacer(), CoinsManagerFiltersDropdown(), ], ), diff --git a/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart b/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart index e736fbf6e6..5f0f637f7e 100644 --- a/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart @@ -13,7 +13,6 @@ import 'package:web_dex/bloc/bridge_form/bridge_event.dart'; import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart'; import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart'; import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; -import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/bloc/taker_form/taker_bloc.dart'; import 'package:web_dex/bloc/taker_form/taker_event.dart'; import 'package:web_dex/common/screen.dart'; @@ -181,8 +180,8 @@ class _WalletMainState extends State final portfolioGrowthBloc = context.read(); final profitLossBloc = context.read(); final assetOverviewBloc = context.read(); - final coinsRepository = RepositoryProvider.of(context); - final walletCoins = await coinsRepository.getWalletCoins(); + final walletCoins = + context.read().state.walletCoins.values.toList(); portfolioGrowthBloc.add( PortfolioGrowthLoadRequested( @@ -311,7 +310,10 @@ class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate { @override Widget build( - BuildContext context, double shrinkOffset, bool overlapsContent) { + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { return WalletManageSection( withBalance: withBalance, onSearchChange: onSearchChange, diff --git a/lib/views/wallet/wallet_page/wallet_page.dart b/lib/views/wallet/wallet_page/wallet_page.dart index e0505a96aa..a8cb32eaf1 100644 --- a/lib/views/wallet/wallet_page/wallet_page.dart +++ b/lib/views/wallet/wallet_page/wallet_page.dart @@ -18,26 +18,28 @@ class WalletPage extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder(builder: (context, state) { - final Coin? coin = state.walletCoins[coinAbbr ?? '']; - if (coin != null && coin.enabledType != null) { - return CoinDetails( - key: Key(coin.abbr), - coin: coin, - onBackButtonPressed: _onBackButtonPressed, - ); - } + return BlocBuilder( + builder: (context, state) { + final Coin? coin = state.walletCoins[coinAbbr ?? '']; + if (coin != null) { + return CoinDetails( + key: Key(coin.abbr), + coin: coin, + onBackButtonPressed: _onBackButtonPressed, + ); + } - final action = this.action; - if (action != CoinsManagerAction.none) { - return CoinsManagerPage( - action: action, - closePage: _onBackButtonPressed, - ); - } + final action = this.action; + if (action != CoinsManagerAction.none) { + return CoinsManagerPage( + action: action, + closePage: _onBackButtonPressed, + ); + } - return const WalletMain(); - }); + return const WalletMain(); + }, + ); } void _onBackButtonPressed() { diff --git a/packages/komodo_ui_kit/lib/src/images/coin_icon.dart b/packages/komodo_ui_kit/lib/src/images/coin_icon.dart index 58cef221b5..2cbf40f50d 100644 --- a/packages/komodo_ui_kit/lib/src/images/coin_icon.dart +++ b/packages/komodo_ui_kit/lib/src/images/coin_icon.dart @@ -9,6 +9,7 @@ const mediaCdnUrl = 'https://komodoplatform.github.io/coins/icons/'; final Map _assetExistenceCache = {}; final Map _cdnExistenceCache = {}; +final Map _customIconsCache = {}; List? _cachedFileList; String _getImagePath(String abbr) { @@ -95,6 +96,41 @@ class CoinIcon extends StatelessWidget { final double size; final bool suspended; + /// 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 [coinAbbr]. + /// + /// 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(String coinAbbr, ImageProvider imageProvider) { + final normalizedAbbr = abbr2Ticker(coinAbbr).toLowerCase(); + _customIconsCache[normalizedAbbr] = imageProvider; + } + + /// Removes a custom icon registration for the specified coin abbreviation. + static void removeCustomIcon(String coinAbbr) { + final normalizedAbbr = abbr2Ticker(coinAbbr).toLowerCase(); + _customIconsCache.remove(normalizedAbbr); + } + + /// Clears all custom icon registrations. + static void clearCustomIcons() { + _customIconsCache.clear(); + } + @override Widget build(BuildContext context) { return Opacity( @@ -114,6 +150,7 @@ class CoinIcon extends StatelessWidget { _assetExistenceCache.clear(); _cdnExistenceCache.clear(); _cachedFileList = null; + _customIconsCache.clear(); } /// Pre-loads the coin icon image into the cache. @@ -129,6 +166,26 @@ class CoinIcon extends StatelessWidget { bool throwExceptions = false, }) async { try { + final normalizedAbbr = abbr2Ticker(abbr).toLowerCase(); + + // Check for custom icon first + if (_customIconsCache.containsKey(normalizedAbbr)) { + if (context.mounted) { + await precacheImage( + _customIconsCache[normalizedAbbr]!, + context, + onError: (e, stackTrace) { + if (throwExceptions) { + throw Exception( + 'Failed to pre-cache custom image for coin $abbr: $e', + ); + } + }, + ); + } + return; + } + bool? assetExists, cdnExists; final filePath = _getImagePath(abbr); @@ -184,10 +241,22 @@ class CoinIconResolverWidget extends StatelessWidget { @override Widget build(BuildContext context) { - // Check local asset first - final filePath = _getImagePath(coinAbbr); + final normalizedAbbr = abbr2Ticker(coinAbbr).toLowerCase(); + + // Check for custom icon first + if (_customIconsCache.containsKey(normalizedAbbr)) { + return Image( + image: _customIconsCache[normalizedAbbr]!, + filterQuality: FilterQuality.high, + errorBuilder: (context, error, stackTrace) { + debugPrint('Error loading custom icon for $coinAbbr: $error'); + return Icon(Icons.monetization_on_outlined, size: size); + }, + ); + } - // if (await checkIfAssetExists(coinAbbr)) { + // Check local asset + final filePath = _getImagePath(coinAbbr); _assetExistenceCache[filePath] = true; return Image.asset( @@ -209,7 +278,6 @@ class CoinIconResolverWidget extends StatelessWidget { ); } } - // DUPLICATED FROM MAIN PROJECT in `lib/shared/utils/utils.dart`. // NB: ENSURE IT STAYS IN SYNC. diff --git a/packages/komodo_ui_kit/pubspec.lock b/packages/komodo_ui_kit/pubspec.lock index f85054c62e..9dc17e1f88 100644 --- a/packages/komodo_ui_kit/pubspec.lock +++ b/packages/komodo_ui_kit/pubspec.lock @@ -95,7 +95,7 @@ packages: description: path: "packages/komodo_defi_rpc_methods" ref: dev - resolved-ref: "204fdfe48ca06bbe61252eaaed5901aee8ec9a75" + resolved-ref: "3b970367572516c6a1d734ff134d2bf5a30a4949" url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" source: git version: "0.2.0+0" @@ -104,7 +104,7 @@ packages: description: path: "packages/komodo_defi_types" ref: dev - resolved-ref: "204fdfe48ca06bbe61252eaaed5901aee8ec9a75" + resolved-ref: "3b970367572516c6a1d734ff134d2bf5a30a4949" url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" source: git version: "0.2.0+0" @@ -113,7 +113,7 @@ packages: description: path: "packages/komodo_ui" ref: dev - resolved-ref: "204fdfe48ca06bbe61252eaaed5901aee8ec9a75" + resolved-ref: "3b970367572516c6a1d734ff134d2bf5a30a4949" url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" source: git version: "0.2.0+0" diff --git a/pubspec.lock b/pubspec.lock index 55d0e46e29..7bbab3ee7b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -666,7 +666,7 @@ packages: description: path: "packages/komodo_coins" ref: dev - resolved-ref: "204fdfe48ca06bbe61252eaaed5901aee8ec9a75" + resolved-ref: "3b970367572516c6a1d734ff134d2bf5a30a4949" url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" source: git version: "0.2.0+0" @@ -675,7 +675,7 @@ packages: description: path: "packages/komodo_defi_framework" ref: dev - resolved-ref: "204fdfe48ca06bbe61252eaaed5901aee8ec9a75" + resolved-ref: "3b970367572516c6a1d734ff134d2bf5a30a4949" url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" source: git version: "0.2.0" @@ -684,7 +684,7 @@ packages: description: path: "packages/komodo_defi_local_auth" ref: dev - resolved-ref: "204fdfe48ca06bbe61252eaaed5901aee8ec9a75" + resolved-ref: "3b970367572516c6a1d734ff134d2bf5a30a4949" url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" source: git version: "0.2.0+0" @@ -693,7 +693,7 @@ packages: description: path: "packages/komodo_defi_rpc_methods" ref: dev - resolved-ref: "204fdfe48ca06bbe61252eaaed5901aee8ec9a75" + resolved-ref: "3b970367572516c6a1d734ff134d2bf5a30a4949" url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" source: git version: "0.2.0+0" @@ -702,7 +702,7 @@ packages: description: path: "packages/komodo_defi_sdk" ref: dev - resolved-ref: "204fdfe48ca06bbe61252eaaed5901aee8ec9a75" + resolved-ref: "3b970367572516c6a1d734ff134d2bf5a30a4949" url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" source: git version: "0.2.0+0" @@ -711,7 +711,7 @@ packages: description: path: "packages/komodo_defi_types" ref: dev - resolved-ref: "204fdfe48ca06bbe61252eaaed5901aee8ec9a75" + resolved-ref: "3b970367572516c6a1d734ff134d2bf5a30a4949" url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" source: git version: "0.2.0+0" @@ -727,7 +727,7 @@ packages: description: path: "packages/komodo_ui" ref: dev - resolved-ref: "204fdfe48ca06bbe61252eaaed5901aee8ec9a75" + resolved-ref: "3b970367572516c6a1d734ff134d2bf5a30a4949" url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" source: git version: "0.2.0+0" @@ -743,7 +743,7 @@ packages: description: path: "packages/komodo_wallet_build_transformer" ref: dev - resolved-ref: "204fdfe48ca06bbe61252eaaed5901aee8ec9a75" + resolved-ref: "3b970367572516c6a1d734ff134d2bf5a30a4949" url: "https://github.com/KomodoPlatform/komodo-defi-sdk-flutter.git" source: git version: "0.2.0+0" diff --git a/test_units/tests/utils/test_util.dart b/test_units/tests/utils/test_util.dart index 117f87dde2..0696fc9f13 100644 --- a/test_units/tests/utils/test_util.dart +++ b/test_units/tests/utils/test_util.dart @@ -26,6 +26,7 @@ Coin setCoin({ ), accounts: null, activeByDefault: true, + logoImageUrl: null, coingeckoId: "komodo", coinpaprikaId: "kmd-komodo", derivationPath: "m/44'/141'/0'",