diff --git a/lib/app_config/app_config.dart b/lib/app_config/app_config.dart index e4fc6d4219..f4a192dfe1 100644 --- a/lib/app_config/app_config.dart +++ b/lib/app_config/app_config.dart @@ -21,9 +21,7 @@ const String coinsAssetsPath = 'packages/komodo_defi_framework/assets'; final Uri discordSupportChannelUrl = Uri.parse( 'mailto:info@gleec.com?subject=GLEEC%20Wallet%20Support', ); -final Uri discordInviteUrl = Uri.parse( - 'https://www.gleec.com/contact', -); +final Uri discordInviteUrl = Uri.parse('https://www.gleec.com/contact'); /// Const to define if Bitrefill integration is enabled in the app. const bool isBitrefillIntegrationEnabled = false; @@ -79,6 +77,7 @@ Map priorityCoinsAbbrMap = { 'USDT-ERC20': 80, 'USDT-PLG20': 80, 'USDT-BEP20': 80, + 'USDT-TRC20': 80, // Rank 4: XRP (~$145 billion) 'XRP': 70, @@ -119,6 +118,7 @@ const List unauthenticatedUserPriorityTickers = [ 'BTC', 'KMD', 'ETH', + 'TRX', 'BNB', 'LTC', 'DASH', diff --git a/lib/bloc/coins_bloc/asset_coin_extension.dart b/lib/bloc/coins_bloc/asset_coin_extension.dart index 9e8db56af9..af637c1bd8 100644 --- a/lib/bloc/coins_bloc/asset_coin_extension.dart +++ b/lib/bloc/coins_bloc/asset_coin_extension.dart @@ -68,6 +68,10 @@ extension AssetCoinExtension on Asset { extension CoinTypeExtension on CoinSubClass { CoinType toCoinType() { switch (this) { + case CoinSubClass.trx: + return CoinType.trx; + case CoinSubClass.trc20: + return CoinType.trc20; case CoinSubClass.base: return CoinType.base20; case CoinSubClass.ftm20: @@ -128,6 +132,9 @@ extension CoinTypeExtension on CoinSubClass { switch (this) { case CoinSubClass.base: return true; + case CoinSubClass.trx: + case CoinSubClass.trc20: + return false; case CoinSubClass.avx20: case CoinSubClass.bep20: case CoinSubClass.ftm20: @@ -158,6 +165,10 @@ extension CoinTypeExtension on CoinSubClass { extension CoinSubClassExtension on CoinType { CoinSubClass toCoinSubClass() { switch (this) { + case CoinType.trx: + return CoinSubClass.trx; + case CoinType.trc20: + return CoinSubClass.trc20; case CoinType.base20: return CoinSubClass.base; case CoinType.ftm20: diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart index e4a6d7242a..4120a7c1fb 100644 --- a/lib/bloc/coins_bloc/coins_repo.dart +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -21,6 +21,7 @@ import 'package:web_dex/generated/codegen_loader.g.dart'; import 'package:web_dex/mm2/mm2.dart'; import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; import 'package:web_dex/mm2/mm2_api/rpc/bloc_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/disable_coin/disable_coin_req.dart'; import 'package:web_dex/mm2/mm2_api/rpc/withdraw/withdraw_errors.dart'; import 'package:web_dex/mm2/mm2_api/rpc/withdraw/withdraw_request.dart'; import 'package:web_dex/model/cex_price.dart'; @@ -705,6 +706,74 @@ class CoinsRepo { _invalidateActivatedAssetsCache(); } + /// Performs a full rollback for preview-only asset activations. + /// + /// Unlike [deactivateCoinsSync], this disables the assets in MM2 so + /// temporary preview activations do not remain active for the rest of the + /// session. This should only be used for short-lived preview flows where a + /// real rollback is required. + Future rollbackPreviewAssets( + Iterable assets, { + Set deleteCustomTokens = const {}, + Set removeWalletMetadataAssets = const {}, + bool notifyListeners = false, + }) async { + final uniqueAssets = Map.fromEntries( + assets.map((asset) => MapEntry(asset.id, asset)), + ); + if (uniqueAssets.isEmpty) { + return; + } + + final orderedAssets = uniqueAssets.values.toList() + ..sort((a, b) { + final aPriority = a.id.parentId == null ? 1 : 0; + final bPriority = b.id.parentId == null ? 1 : 0; + return aPriority.compareTo(bPriority); + }); + + for (final asset in orderedAssets) { + await _balanceWatchers[asset.id]?.cancel(); + _balanceWatchers.remove(asset.id); + + try { + if (await isAssetActivated(asset.id, forceRefresh: true)) { + await _mm2.call(DisableCoinReq(coin: asset.id.id)); + } + } catch (e, s) { + _log.warning('Failed to disable preview asset ${asset.id.id}', e, s); + } + + if (notifyListeners) { + _broadcastAsset(asset.toCoin().copyWith(state: CoinState.inactive)); + } + } + + if (removeWalletMetadataAssets.isNotEmpty) { + try { + await _kdfSdk.removeActivatedCoins( + removeWalletMetadataAssets.map((assetId) => assetId.id).toList(), + ); + } catch (e, s) { + _log.warning( + 'Failed to remove preview assets from wallet metadata', + e, + s, + ); + } + } + + for (final assetId in deleteCustomTokens) { + try { + await _kdfSdk.deleteCustomToken(assetId); + } catch (e, s) { + _log.warning('Failed to delete preview custom token $assetId', e, s); + } + } + + _invalidateActivatedAssetsCache(); + } + double? getUsdPriceByAmount(String amount, String coinAbbr) { final Coin? coin = getCoin(coinAbbr); final double? parsedAmount = double.tryParse(amount); 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 index 4b526a42f2..b724a25393 100644 --- a/lib/bloc/custom_token_import/bloc/custom_token_import_bloc.dart +++ b/lib/bloc/custom_token_import/bloc/custom_token_import_bloc.dart @@ -14,8 +14,55 @@ import 'package:web_dex/bloc/custom_token_import/bloc/custom_token_import_event. 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/model/kdf_auth_metadata_extension.dart'; import 'package:web_dex/shared/utils/extensions/kdf_user_extensions.dart'; +class _CustomTokenPreviewSession { + const _CustomTokenPreviewSession({ + required this.platformAsset, + required this.wasPlatformAlreadyActivated, + required this.wasPlatformAlreadyInWalletMetadata, + this.tokenAsset, + this.wasTokenAlreadyActivated = false, + this.wasTokenAlreadyInWalletMetadata = false, + this.wasTokenAlreadyKnown = false, + }); + + final Asset platformAsset; + final bool wasPlatformAlreadyActivated; + final bool wasPlatformAlreadyInWalletMetadata; + final Asset? tokenAsset; + final bool wasTokenAlreadyActivated; + final bool wasTokenAlreadyInWalletMetadata; + final bool wasTokenAlreadyKnown; + + _CustomTokenPreviewSession copyWith({ + Asset? platformAsset, + bool? wasPlatformAlreadyActivated, + bool? wasPlatformAlreadyInWalletMetadata, + Asset? Function()? tokenAsset, + bool? wasTokenAlreadyActivated, + bool? wasTokenAlreadyInWalletMetadata, + bool? wasTokenAlreadyKnown, + }) { + return _CustomTokenPreviewSession( + platformAsset: platformAsset ?? this.platformAsset, + wasPlatformAlreadyActivated: + wasPlatformAlreadyActivated ?? this.wasPlatformAlreadyActivated, + wasPlatformAlreadyInWalletMetadata: + wasPlatformAlreadyInWalletMetadata ?? + this.wasPlatformAlreadyInWalletMetadata, + tokenAsset: tokenAsset != null ? tokenAsset() : this.tokenAsset, + wasTokenAlreadyActivated: + wasTokenAlreadyActivated ?? this.wasTokenAlreadyActivated, + wasTokenAlreadyInWalletMetadata: + wasTokenAlreadyInWalletMetadata ?? + this.wasTokenAlreadyInWalletMetadata, + wasTokenAlreadyKnown: wasTokenAlreadyKnown ?? this.wasTokenAlreadyKnown, + ); + } +} + class CustomTokenImportBloc extends Bloc { CustomTokenImportBloc( @@ -36,19 +83,21 @@ class CustomTokenImportBloc final KomodoDefiSdk _sdk; final AnalyticsBloc _analyticsBloc; final _log = Logger('CustomTokenImportBloc'); + _CustomTokenPreviewSession? _previewSession; - void _onResetFormStatus( + Future _onResetFormStatus( ResetFormStatusEvent event, Emitter emit, - ) { + ) async { + await _rollbackPreviewIfNeeded(); + final availableCoinTypes = CoinType.values.map( (CoinType type) => type.toCoinSubClass(), ); final items = CoinSubClass.values.where((CoinSubClass type) { - final isEvm = type.isEvmProtocol(); final isAvailable = availableCoinTypes.contains(type); final isSupported = _repository.getNetworkApiName(type) != null; - return isEvm && isAvailable && isSupported; + return isAvailable && isSupported; }).toList()..sort((a, b) => a.name.compareTo(b.name)); emit( @@ -57,7 +106,7 @@ class CustomTokenImportBloc formErrorMessage: '', importStatus: FormStatus.initial, importErrorMessage: '', - evmNetworks: items, + supportedNetworks: items, ), ); } @@ -85,21 +134,46 @@ class CustomTokenImportBloc ) async { emit(state.copyWith(formStatus: FormStatus.submitting)); - Asset? tokenData; try { - final networkAsset = _sdk.getSdkAsset(state.network.ticker); + final walletCoinIds = (await _sdk.getWalletCoinIds()).toSet(); + final platformAsset = _sdk.getSdkAsset(state.network.ticker); + final wasPlatformAlreadyActivated = await _coinsRepo.isAssetActivated( + platformAsset.id, + ); + _previewSession = _CustomTokenPreviewSession( + platformAsset: platformAsset, + wasPlatformAlreadyActivated: wasPlatformAlreadyActivated, + wasPlatformAlreadyInWalletMetadata: walletCoinIds.contains( + platformAsset.id.id, + ), + ); // Network (parent) asset must be active before attempting to fetch the // custom token data await _coinsRepo.activateAssetsSync( - [networkAsset], + [platformAsset], notifyListeners: false, addToWalletMetadata: false, ); - tokenData = await _repository.fetchCustomToken( - networkAsset.id, - state.address, + final tokenData = await _repository.fetchCustomToken( + network: state.network, + platformAsset: platformAsset, + address: state.address, + ); + final wasTokenAlreadyKnown = _sdk.assets.available.containsKey( + tokenData.id, + ); + final wasTokenAlreadyActivated = await _coinsRepo.isAssetActivated( + tokenData.id, + ); + _previewSession = _previewSession?.copyWith( + tokenAsset: () => tokenData, + wasTokenAlreadyActivated: wasTokenAlreadyActivated, + wasTokenAlreadyInWalletMetadata: walletCoinIds.contains( + tokenData.id.id, + ), + wasTokenAlreadyKnown: wasTokenAlreadyKnown, ); await _coinsRepo.activateAssetsSync( [tokenData], @@ -130,19 +204,14 @@ class CustomTokenImportBloc ); } catch (e, s) { _log.severe('Error fetching custom token', e, s); + await _rollbackPreviewIfNeeded(); emit( state.copyWith( formStatus: FormStatus.failure, tokenData: () => null, - formErrorMessage: e.toString(), + formErrorMessage: _formatImportError(e), ), ); - } finally { - if (tokenData != null) { - // Activate to get balance, then deactivate to avoid confusion if the user - // does not proceed with the import (exits the dialog). - await _coinsRepo.deactivateCoinsSync([tokenData.toCoin()]); - } } } @@ -177,6 +246,7 @@ class CustomTokenImportBloc try { await _repository.importCustomToken(state.coin!); + _previewSession = null; final walletType = (await _sdk.auth.currentUser)?.type ?? ''; _analyticsBloc.logEvent( @@ -198,14 +268,71 @@ class CustomTokenImportBloc emit( state.copyWith( importStatus: FormStatus.failure, - importErrorMessage: e.toString(), + importErrorMessage: _formatImportError(e), ), ); } } + String _formatImportError(Object error) { + return switch (error) { + final CustomTokenConflictException e => e.message, + final UnsupportedCustomTokenNetworkException e => e.message, + _ => error.toString(), + }; + } + + Future _rollbackPreviewIfNeeded() async { + final previewSession = _previewSession; + _previewSession = null; + + if (previewSession == null) { + return; + } + + final rollbackAssets = []; + final deleteCustomTokens = {}; + final removeWalletMetadataAssets = {}; + + final tokenAsset = previewSession.tokenAsset; + if (tokenAsset != null && !previewSession.wasTokenAlreadyActivated) { + rollbackAssets.add(tokenAsset); + if (!previewSession.wasTokenAlreadyInWalletMetadata) { + removeWalletMetadataAssets.add(tokenAsset.id); + } + if (!previewSession.wasTokenAlreadyKnown && + !previewSession.wasTokenAlreadyInWalletMetadata) { + deleteCustomTokens.add(tokenAsset.id); + } + } + + if (!previewSession.wasPlatformAlreadyActivated) { + rollbackAssets.add(previewSession.platformAsset); + if (!previewSession.wasPlatformAlreadyInWalletMetadata) { + removeWalletMetadataAssets.add(previewSession.platformAsset.id); + } + } + + if (rollbackAssets.isEmpty && + deleteCustomTokens.isEmpty && + removeWalletMetadataAssets.isEmpty) { + return; + } + + try { + await _coinsRepo.rollbackPreviewAssets( + rollbackAssets, + deleteCustomTokens: deleteCustomTokens, + removeWalletMetadataAssets: removeWalletMetadataAssets, + ); + } catch (e, s) { + _log.warning('Failed to rollback preview activation state', e, s); + } + } + @override Future close() async { + await _rollbackPreviewIfNeeded(); _repository.dispose(); await super.close(); } 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 index 3c523749aa..71d8174045 100644 --- a/lib/bloc/custom_token_import/bloc/custom_token_import_state.dart +++ b/lib/bloc/custom_token_import/bloc/custom_token_import_state.dart @@ -15,7 +15,7 @@ class CustomTokenImportState extends Equatable { required this.coin, required this.coinBalance, required this.coinBalanceUsd, - required this.evmNetworks, + required this.supportedNetworks, }); CustomTokenImportState.defaults({ @@ -26,9 +26,9 @@ class CustomTokenImportState extends Equatable { this.formErrorMessage = '', this.importErrorMessage = '', this.coin, - this.evmNetworks = const [], - }) : coinBalance = Decimal.zero, - coinBalanceUsd = Decimal.zero; + this.supportedNetworks = const [], + }) : coinBalance = Decimal.zero, + coinBalanceUsd = Decimal.zero; final FormStatus formStatus; final FormStatus importStatus; @@ -39,7 +39,7 @@ class CustomTokenImportState extends Equatable { final Asset? coin; final Decimal coinBalance; final Decimal coinBalanceUsd; - final Iterable evmNetworks; + final Iterable supportedNetworks; CustomTokenImportState copyWith({ FormStatus? formStatus, @@ -51,7 +51,7 @@ class CustomTokenImportState extends Equatable { Asset? Function()? tokenData, Decimal? tokenBalance, Decimal? tokenBalanceUsd, - Iterable? evmNetworks, + Iterable? supportedNetworks, }) { return CustomTokenImportState( formStatus: formStatus ?? this.formStatus, @@ -61,7 +61,7 @@ class CustomTokenImportState extends Equatable { formErrorMessage: formErrorMessage ?? this.formErrorMessage, importErrorMessage: importErrorMessage ?? this.importErrorMessage, coin: tokenData?.call() ?? coin, - evmNetworks: evmNetworks ?? this.evmNetworks, + supportedNetworks: supportedNetworks ?? this.supportedNetworks, coinBalance: tokenBalance ?? coinBalance, coinBalanceUsd: tokenBalanceUsd ?? coinBalanceUsd, ); @@ -69,14 +69,14 @@ class CustomTokenImportState extends Equatable { @override List get props => [ - formStatus, - importStatus, - network, - address, - formErrorMessage, - importErrorMessage, - coin, - coinBalance, - evmNetworks, - ]; + formStatus, + importStatus, + network, + address, + formErrorMessage, + importErrorMessage, + coin, + coinBalance, + supportedNetworks, + ]; } 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 index 2d2fc486c4..5d90a685d2 100644 --- a/lib/bloc/custom_token_import/data/custom_token_import_repository.dart +++ b/lib/bloc/custom_token_import/data/custom_token_import_repository.dart @@ -16,10 +16,15 @@ import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; /// Implementations should resolve token metadata and activate tokens so they /// become available to the user within the wallet. abstract class ICustomTokenImportRepository { - /// Fetch an [Asset] for a custom token on [network] using [address]. + /// Fetch an [Asset] for a custom token on [network] using [address] and the + /// resolved parent [platformAsset]. /// /// May return an existing known asset or construct a new one when absent. - Future fetchCustomToken(AssetId networkId, String address); + Future fetchCustomToken({ + required CoinSubClass network, + required Asset platformAsset, + required String address, + }); /// Import the provided custom token [asset] into the wallet (e.g. activate it). Future importCustomToken(Asset asset); @@ -46,64 +51,108 @@ class KdfCustomTokenImportRepository implements ICustomTokenImportRepository { final _log = Logger('KdfCustomTokenImportRepository'); @override - Future fetchCustomToken(AssetId networkId, String address) async { - final networkSubclass = networkId.subClass; + Future fetchCustomToken({ + required CoinSubClass network, + required Asset platformAsset, + required String address, + }) async { + _assertSupportedNetwork(network); + _assertPlatformAsset(network, platformAsset); + final convertAddressResponse = await _kdfSdk.client.rpc.address .convertAddress( from: address, - coin: networkSubclass.ticker, - toFormat: AddressFormat.fromCoinSubClass(CoinSubClass.erc20), + coin: platformAsset.id.id, + toFormat: AddressFormat.fromCoinSubClass(network), ); final contractAddress = convertAddressResponse.address; - final knownCoin = _kdfSdk.assets.available.values.firstWhereOrNull( - (asset) => - asset.contractAddress == contractAddress && - asset.id.subClass == networkSubclass, + final knownCoin = _findKnownAssetByContract( + network: network, + platformAsset: platformAsset, + contractAddress: contractAddress, ); - if (knownCoin == null) { - return _createNewCoin(contractAddress, networkId); + if (knownCoin != null) { + return knownCoin; } - return knownCoin; + return _createNewCoin( + contractAddress: contractAddress, + network: network, + platformAsset: platformAsset, + ); } - Future _createNewCoin( - String contractAddress, - AssetId networkId, - ) async { - final network = networkId.subClass; - + Future _createNewCoin({ + required String contractAddress, + required CoinSubClass network, + required Asset platformAsset, + }) async { _log.info('Creating new coin for $contractAddress on $network'); final response = await _kdfSdk.client.rpc.utility.getTokenInfo( contractAddress: contractAddress, - platform: network.ticker, - protocolType: - CoinSubClass.erc20.tokenStandardSuffix ?? - CoinSubClass.erc20.name.toUpperCase(), + platform: platformAsset.id.id, + protocolType: _protocolTypeFor(network), + ); + + final platformConfig = platformAsset.protocol.config; + final String ticker = response.info.symbol; + final int tokenDecimals = response.info.decimals; + final int? platformChainId = platformConfig.valueOrNull('chain_id'); + final coinId = '$ticker-${network.tokenStandardSuffix}'; + final conflictingAsset = _findExistingAssetByGeneratedId( + network: network, + platformAsset: platformAsset, + assetId: coinId, ); + if (conflictingAsset != null) { + if (_hasMatchingContract( + network: network, + existingContractAddress: conflictingAsset.contractAddress, + requestedContractAddress: contractAddress, + )) { + return conflictingAsset; + } - final platformAssets = _kdfSdk.assets.findAssetsByConfigId(network.ticker); - if (platformAssets.length != 1) { - throw Exception( - 'Platform asset not found. ${platformAssets.length} ' - 'results returned.', + throw CustomTokenConflictException( + assetId: coinId, + network: network, + existingContractAddress: conflictingAsset.contractAddress ?? '', + requestedContractAddress: contractAddress, ); } - final platformAsset = platformAssets.single; - final platformConfig = platformAsset.protocol.config; - final String ticker = response.info.symbol; final tokenApi = await fetchTokenInfoFromApi(network, contractAddress); - final platformChainId = int.parse( - platformAsset.id.chainId.formattedChainId, - ); - final coinId = '$ticker-${network.tokenStandardSuffix}'; + final String? logoImageUrl = tokenApi?['image']?['large'] ?? tokenApi?['image']?['small'] ?? tokenApi?['image']?['thumb']; _log.info('Creating new coin for $coinId on $network'); + final protocol = switch (network) { + CoinSubClass.trc20 => + _buildTrc20Protocol(platformConfig).copyWithProtocolData( + coin: coinId, + type: network.tokenStandardSuffix, + chainId: platformChainId, + decimals: tokenDecimals, + contractAddress: contractAddress, + platform: network.ticker, + logoImageUrl: logoImageUrl, + isCustomToken: true, + ), + _ => Erc20Protocol.fromJson(platformConfig).copyWithProtocolData( + coin: coinId, + type: network.tokenStandardSuffix, + chainId: platformChainId, + decimals: tokenDecimals, + contractAddress: contractAddress, + platform: network.ticker, + logoImageUrl: logoImageUrl, + isCustomToken: true, + ), + }; + final newCoin = Asset( signMessagePrefix: null, id: AssetId( @@ -114,21 +163,13 @@ class KdfCustomTokenImportRepository implements ICustomTokenImportRepository { coinGeckoId: tokenApi?['id'], coinPaprikaId: tokenApi?['id'], ), - chainId: platformAsset.id.chainId, + chainId: ChainId.parse(protocol.config), subClass: network, derivationPath: platformAsset.id.derivationPath, parentId: platformAsset.id, ), isWalletOnly: false, - protocol: Erc20Protocol.fromJson(platformConfig).copyWithProtocolData( - coin: coinId, - type: network.tokenStandardSuffix, - chainId: platformChainId, - contractAddress: contractAddress, - platform: network.ticker, - logoImageUrl: logoImageUrl, - isCustomToken: true, - ), + protocol: protocol, ); if (logoImageUrl != null && logoImageUrl.isNotEmpty) { @@ -138,6 +179,89 @@ class KdfCustomTokenImportRepository implements ICustomTokenImportRepository { return newCoin; } + void _assertSupportedNetwork(CoinSubClass network) { + if (network.tokenStandardSuffix == null || + getNetworkApiName(network) == null) { + throw UnsupportedCustomTokenNetworkException(network); + } + } + + void _assertPlatformAsset(CoinSubClass network, Asset platformAsset) { + if (!platformAsset.id.subClass.canBeParentOf(network)) { + throw ArgumentError.value( + platformAsset.id, + 'platformAsset', + 'is not a valid parent for ${network.formatted} tokens', + ); + } + } + + String _protocolTypeFor(CoinSubClass network) { + final protocolType = network.tokenStandardSuffix; + if (protocolType == null) { + throw UnsupportedCustomTokenNetworkException(network); + } + return protocolType; + } + + Asset? _findKnownAssetByContract({ + required CoinSubClass network, + required Asset platformAsset, + required String contractAddress, + }) { + return _kdfSdk.assets.available.values.firstWhereOrNull( + (asset) => + asset.id.subClass == network && + asset.id.parentId == platformAsset.id && + _hasMatchingContract( + network: network, + existingContractAddress: asset.contractAddress, + requestedContractAddress: contractAddress, + ), + ); + } + + Asset? _findExistingAssetByGeneratedId({ + required CoinSubClass network, + required Asset platformAsset, + required String assetId, + }) { + return _kdfSdk.assets.available.values.firstWhereOrNull( + (asset) => + asset.id.id == assetId && + asset.id.subClass == network && + asset.id.parentId == platformAsset.id, + ); + } + + bool _hasMatchingContract({ + required CoinSubClass network, + required String? existingContractAddress, + required String requestedContractAddress, + }) { + if (existingContractAddress == null) { + return false; + } + + return _normalizeContractAddress( + network: network, + contractAddress: existingContractAddress, + ) == + _normalizeContractAddress( + network: network, + contractAddress: requestedContractAddress, + ); + } + + String _normalizeContractAddress({ + required CoinSubClass network, + required String contractAddress, + }) { + return network == CoinSubClass.trc20 + ? contractAddress + : contractAddress.toLowerCase(); + } + @override Future importCustomToken(Asset asset) async { await _coinsRepo.activateAssetsSync([asset], maxRetryAttempts: 10); @@ -181,6 +305,8 @@ class KdfCustomTokenImportRepository implements ICustomTokenImportRepository { @override String? getNetworkApiName(CoinSubClass coinType) { switch (coinType) { + case CoinSubClass.trc20: + return 'tron'; // https://api.coingecko.com/api/v3/coins/tron/contract/TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t case CoinSubClass.erc20: return 'ethereum'; // https://api.coingecko.com/api/v3/coins/ethereum/contract/0x56072C95FAA701256059aa122697B133aDEd9279 case CoinSubClass.bep20: @@ -228,12 +354,14 @@ extension on Erc20Protocol { String? logoImageUrl, bool? isCustomToken, int? chainId, + int? decimals, }) { final currentConfig = JsonMap.from(config); currentConfig.addAll({ if (coin != null) 'coin': coin, if (type != null) 'type': type, if (chainId != null) 'chain_id': chainId, + if (decimals != null) 'decimals': decimals, if (platform != null) 'parent_coin': platform, if (logoImageUrl != null) 'logo_image_url': logoImageUrl, if (isCustomToken != null) 'is_custom_token': isCustomToken, @@ -249,3 +377,51 @@ extension on Erc20Protocol { return Erc20Protocol.fromJson(currentConfig); } } + +Trc20Protocol _buildTrc20Protocol(JsonMap platformConfig) { + final config = JsonMap.from(platformConfig); + config['protocol'] = { + 'type': 'TRC20', + 'protocol_data': { + 'platform': config.valueOrNull('coin') ?? CoinSubClass.trx.ticker, + 'contract_address': config.valueOrNull('contract_address') ?? '', + }, + }; + config['contract_address'] = + config.valueOrNull('contract_address') ?? ''; + return Trc20Protocol.fromJson(config); +} + +extension on Trc20Protocol { + Trc20Protocol copyWithProtocolData({ + String? coin, + String? type, + String? contractAddress, + String? platform, + String? logoImageUrl, + bool? isCustomToken, + int? chainId, + int? decimals, + }) { + final currentConfig = JsonMap.from(config); + currentConfig.addAll({ + if (coin != null) 'coin': coin, + if (type != null) 'type': type, + if (chainId != null) 'chain_id': chainId, + if (decimals != null) 'decimals': decimals, + if (platform != null) 'parent_coin': platform, + if (logoImageUrl != null) 'logo_image_url': logoImageUrl, + if (isCustomToken != null) 'is_custom_token': isCustomToken, + if (contractAddress != null) 'contract_address': contractAddress, + if (contractAddress != null || platform != null) + 'protocol': { + 'type': 'TRC20', + 'protocol_data': { + 'contract_address': contractAddress ?? this.contractAddress, + 'platform': platform ?? this.platform, + }, + }, + }); + return Trc20Protocol.fromJson(currentConfig); + } +} diff --git a/lib/bloc/fiat/banxa_fiat_provider.dart b/lib/bloc/fiat/banxa_fiat_provider.dart index 27c79fd05e..34c0118318 100644 --- a/lib/bloc/fiat/banxa_fiat_provider.dart +++ b/lib/bloc/fiat/banxa_fiat_provider.dart @@ -14,6 +14,28 @@ class BanxaFiatProvider extends BaseFiatProvider { final String apiEndpoint = '/api/v1/banxa'; static final _log = Logger('BanxaFiatProvider'); + bool _isUnsupportedCoinForChain(String coinCode, CoinType coinType) { + switch (coinCode) { + case 'AVAX': + case 'DOT': + case 'FIL': + case 'TRX': + return coinType == CoinType.bep20; + case 'TON': + return coinType == CoinType.erc20; + default: + return banxaUnsupportedCoinsList.contains(coinCode); + } + } + + bool _isUnsupportedCurrency(ICurrency target) { + if (target is! CryptoCurrency) { + return false; + } + + return _isUnsupportedCoinForChain(target.configSymbol, target.chainType); + } + @override String getProviderId() { return providerId; @@ -32,70 +54,57 @@ class BanxaFiatProvider extends BaseFiatProvider { String source, ICurrency target, { String? sourceAmount, - }) => - apiRequest( - 'GET', - apiEndpoint, - queryParams: { - 'endpoint': '/api/payment-methods', - 'source': source, - 'target': target.configSymbol, - }, - ); + }) => apiRequest( + 'GET', + apiEndpoint, + queryParams: { + 'endpoint': '/api/payment-methods', + 'source': source, + 'target': target.configSymbol, + }, + ); Future _getPricesWithPaymentMethod( String source, ICurrency target, String sourceAmount, FiatPaymentMethod paymentMethod, - ) => - apiRequest( - 'GET', - apiEndpoint, - queryParams: { - 'endpoint': '/api/prices', - 'source': source, - 'target': target.configSymbol, - 'source_amount': sourceAmount, - 'payment_method_id': paymentMethod.id, - }, - ); + ) => apiRequest( + 'GET', + apiEndpoint, + queryParams: { + 'endpoint': '/api/prices', + 'source': source, + 'target': target.configSymbol, + 'source_amount': sourceAmount, + 'payment_method_id': paymentMethod.id, + }, + ); Future _createOrder(Map payload) => apiRequest( - 'POST', - apiEndpoint, - queryParams: { - 'endpoint': '/api/orders', - }, - body: payload, - ); + 'POST', + apiEndpoint, + queryParams: {'endpoint': '/api/orders'}, + body: payload, + ); Future _getOrder(String orderId) => apiRequest( - 'GET', - apiEndpoint, - queryParams: { - 'endpoint': '/api/orders', - 'order_id': orderId, - }, - ); + 'GET', + apiEndpoint, + queryParams: {'endpoint': '/api/orders', 'order_id': orderId}, + ); Future _getFiats() => apiRequest( - 'GET', - apiEndpoint, - queryParams: { - 'endpoint': '/api/fiats', - 'orderType': 'buy', - }, - ); + 'GET', + apiEndpoint, + queryParams: {'endpoint': '/api/fiats', 'orderType': 'buy'}, + ); Future _getCoins() => apiRequest( - 'GET', - apiEndpoint, - queryParams: { - 'endpoint': '/api/coins', - 'orderType': 'buy', - }, - ); + 'GET', + apiEndpoint, + queryParams: {'endpoint': '/api/coins', 'orderType': 'buy'}, + ); // These will be in BLOC: @override @@ -107,12 +116,14 @@ class BanxaFiatProvider extends BaseFiatProvider { // message, but adds the challenge that we add further web-only code that // needs to be re-implemented for mobile/desktop. while (true) { - final response = await _getOrder(orderId) - .catchError((e) => Future.error('Error fetching order: $e')); + final response = await _getOrder( + orderId, + ).catchError((e) => Future.error('Error fetching order: $e')); _log.fine('Fiat order status response:\n${jsonEncode(response)}'); - final status = - _parseStatusFromResponse(response as Map? ?? {}); + final status = _parseStatusFromResponse( + response as Map? ?? {}, + ); final isCompleted = status == FiatOrderStatus.success || status == FiatOrderStatus.failed; @@ -153,19 +164,21 @@ class BanxaFiatProvider extends BaseFiatProvider { final List currencyList = []; for (final item in data) { final coinCode = item['coin_code'] as String; - if (banxaUnsupportedCoinsList.contains(coinCode)) { - _log.warning('Banxa does not support $coinCode'); - continue; - } - final coinName = item['coin_name'] as String; final blockchains = item['blockchains'] as List; for (final blockchain in blockchains) { - final coinType = getCoinType(blockchain['code'] as String); + final coinType = getCoinType( + blockchain['code'] as String, + coinSymbol: coinCode, + ); if (coinType == null) { continue; } + if (_isUnsupportedCoinForChain(coinCode, coinType)) { + _log.warning('Banxa does not support $coinCode on ${coinType.name}'); + continue; + } // Parse min_value which can be a string, int, or double final dynamic minValue = blockchain['min_value']; @@ -208,20 +221,24 @@ class BanxaFiatProvider extends BaseFiatProvider { String sourceAmount, ) async { try { - if (banxaUnsupportedCoinsList.contains(target.configSymbol)) { - _log.warning('Banxa does not support ${target.configSymbol}'); + if (_isUnsupportedCurrency(target)) { + _log.warning('Banxa does not support ${target.getAbbr()}'); return []; } - final response = - await _getPaymentMethods(source, target, sourceAmount: sourceAmount); - final List paymentMethods = (response['data'] - ['payment_methods'] as List) - .map( - (json) => - FiatPaymentMethod.fromJson(json as Map? ?? {}), - ) - .toList(); + final response = await _getPaymentMethods( + source, + target, + sourceAmount: sourceAmount, + ); + final List paymentMethods = + (response['data']['payment_methods'] as List) + .map( + (json) => FiatPaymentMethod.fromJson( + json as Map? ?? {}, + ), + ) + .toList(); final List> priceFutures = []; for (final paymentMethod in paymentMethods) { @@ -239,9 +256,7 @@ class BanxaFiatProvider extends BaseFiatProvider { // Combine price information with payment methods for (int i = 0; i < paymentMethods.length; i++) { - paymentMethods[i] = paymentMethods[i].copyWith( - priceInfo: prices[i], - ); + paymentMethods[i] = paymentMethods[i].copyWith(priceInfo: prices[i]); } return paymentMethods; @@ -259,12 +274,14 @@ class BanxaFiatProvider extends BaseFiatProvider { FiatPaymentMethod paymentMethod, ) async { try { - final response = await _getPricesWithPaymentMethod( - source, - target, - sourceAmount, - paymentMethod, - ) as Map? ?? + final response = + await _getPricesWithPaymentMethod( + source, + target, + sourceAmount, + paymentMethod, + ) + as Map? ?? {}; final responseData = response['data'] as Map? ?? {}; final prices = responseData['prices'] as List; diff --git a/lib/bloc/fiat/base_fiat_provider.dart b/lib/bloc/fiat/base_fiat_provider.dart index 841e125d03..5a01e8aa65 100644 --- a/lib/bloc/fiat/base_fiat_provider.dart +++ b/lib/bloc/fiat/base_fiat_provider.dart @@ -109,6 +109,9 @@ abstract class BaseFiatProvider { case CoinType.utxo: // BTC, BCH, DOGE, LTC return currency.configSymbol; + case CoinType.trx: + case CoinType.trc20: + return 'TRON'; case CoinType.erc20: return 'ETH'; case CoinType.bep20: @@ -200,7 +203,6 @@ abstract class BaseFiatProvider { // TERNOA // TERRA // TEZOS - // TRON // WAX // XCH // XDAI @@ -212,13 +214,19 @@ abstract class BaseFiatProvider { } // TODO: migrate to SDK [CoinSubClass] ticker/formatted getters - CoinType? getCoinType(String chain) { + CoinType? getCoinType(String chain, {String? coinSymbol}) { switch (chain) { case 'BTC': case 'BCH': case 'DOGE': case 'LTC': return CoinType.utxo; + case 'TRX': + case 'TRON': + if (coinSymbol == null || coinSymbol == 'TRX') { + return CoinType.trx; + } + return CoinType.trc20; case 'ETH': return CoinType.erc20; case 'BSC': diff --git a/lib/bloc/fiat/models/i_currency.dart b/lib/bloc/fiat/models/i_currency.dart index e5940eca80..46314a0186 100644 --- a/lib/bloc/fiat/models/i_currency.dart +++ b/lib/bloc/fiat/models/i_currency.dart @@ -124,12 +124,17 @@ class CryptoCurrency extends ICurrency { @override String getAbbr() { + if (symbol.contains('-')) { + return symbol; + } + // TODO: look into a better way to do this when migrating to the SDK // Providers return "ETH" with chain type "ERC20", resultning in abbr of // "ETH-ERC20", which is not how it is stored in our coins configuration // files. "ETH" is the expected abbreviation, which would just be `symbol`. if (chainType == CoinType.utxo || (chainType == CoinType.tendermint && symbol == 'ATOM') || + (chainType == CoinType.trx && symbol == 'TRX') || (chainType == CoinType.erc20 && symbol == 'ETH') || (chainType == CoinType.bep20 && symbol == 'BNB') || (chainType == CoinType.avx20 && symbol == 'AVAX') || diff --git a/lib/bloc/fiat/ramp/ramp_fiat_provider.dart b/lib/bloc/fiat/ramp/ramp_fiat_provider.dart index 6628d20ce1..cefe04c57a 100644 --- a/lib/bloc/fiat/ramp/ramp_fiat_provider.dart +++ b/lib/bloc/fiat/ramp/ramp_fiat_provider.dart @@ -118,7 +118,7 @@ class RampFiatProvider extends BaseFiatProvider { return config.assets .map((asset) { - final coinType = getCoinType(asset.chain); + final coinType = getCoinType(asset.chain, coinSymbol: asset.symbol); if (coinType == null) { return null; } diff --git a/lib/bloc/withdraw_form/withdraw_form_bloc.dart b/lib/bloc/withdraw_form/withdraw_form_bloc.dart index 7ea4140b17..3712f4076e 100644 --- a/lib/bloc/withdraw_form/withdraw_form_bloc.dart +++ b/lib/bloc/withdraw_form/withdraw_form_bloc.dart @@ -530,6 +530,9 @@ class WithdrawFormBloc extends Bloc { throw Exception('Gas limit must be greater than 0'); } }, + tron: (_) { + throw Exception('Custom TRON fees are not supported'); + }, sia: (sia) { if (sia.amount <= Decimal.zero) { throw Exception('Fee amount must be greater than 0'); diff --git a/lib/model/coin.dart b/lib/model/coin.dart index 19323f5d2e..af463870a9 100644 --- a/lib/model/coin.dart +++ b/lib/model/coin.dart @@ -121,9 +121,11 @@ class Coin extends Equatable { bool get isTxMemoSupported => type == CoinType.tendermint || type == CoinType.tendermintToken; - bool get isCustomFeeSupported { - return type != CoinType.tendermintToken && type != CoinType.tendermint; - } + bool get isCustomFeeSupported => + type != CoinType.tendermintToken && + type != CoinType.tendermint && + type != CoinType.trx && + type != CoinType.trc20; static bool checkSegwitByAbbr(String abbr) => abbr.contains('-segwit'); static String normalizeAbbr(String abbr) => abbr.replaceAll('-segwit', ''); diff --git a/lib/model/coin_type.dart b/lib/model/coin_type.dart index 4c94bd4c8c..8cf60087d3 100644 --- a/lib/model/coin_type.dart +++ b/lib/model/coin_type.dart @@ -7,6 +7,8 @@ // anchor: protocols support enum CoinType { utxo, + trx, + trc20, smartChain, etc, erc20, diff --git a/lib/model/coin_utils.dart b/lib/model/coin_utils.dart index da01f38630..cee15bc2ba 100644 --- a/lib/model/coin_utils.dart +++ b/lib/model/coin_utils.dart @@ -141,6 +141,10 @@ String getCoinTypeName(CoinType type, [String? symbol]) { return 'Native'; } switch (type) { + case CoinType.trx: + return 'TRON'; + case CoinType.trc20: + return 'TRC-20'; case CoinType.erc20: return 'ERC-20'; case CoinType.bep20: @@ -190,6 +194,10 @@ String getCoinTypeName(CoinType type, [String? symbol]) { bool isParentCoin(CoinType type, String symbol) { switch (type) { + case CoinType.trx: + return symbol == 'TRX'; + case CoinType.trc20: + return false; case CoinType.utxo: case CoinType.tendermint: return true; diff --git a/lib/shared/utils/utils.dart b/lib/shared/utils/utils.dart index 0044f665ec..93f45e4e6c 100644 --- a/lib/shared/utils/utils.dart +++ b/lib/shared/utils/utils.dart @@ -332,6 +332,7 @@ String abbr2Ticker(String abbr) { if (!abbr.contains('-') && !abbr.contains('_')) return abbr; const List filteredSuffixes = [ + 'TRC20', 'ERC20', 'BEP20', 'QRC20', @@ -389,6 +390,9 @@ final Map _abbr2TickerCache = {}; Color getProtocolColor(CoinType type) { switch (type) { + case CoinType.trx: + case CoinType.trc20: + return const Color.fromRGBO(236, 4, 38, 1); case CoinType.zhtlc: case CoinType.utxo: return const Color.fromRGBO(233, 152, 60, 1); @@ -442,6 +446,9 @@ bool hasTxHistorySupport(Coin coin) { case CoinType.ubiq: case CoinType.hrc20: return false; + case CoinType.trx: + case CoinType.trc20: + return true; case CoinType.krc20: case CoinType.tendermint: case CoinType.tendermintToken: @@ -471,6 +478,9 @@ String getNativeExplorerUrlByCoin(Coin coin, String? address) { assert(!hasSupport); switch (coin.type) { + case CoinType.trx: + case CoinType.trc20: + return '${coin.explorerUrl}address/$coinAddress'; case CoinType.sbch: case CoinType.tendermint: return '${coin.explorerUrl}address/$coinAddress'; diff --git a/lib/views/custom_token_import/custom_token_import_dialog.dart b/lib/views/custom_token_import/custom_token_import_dialog.dart index 06abc9ee8f..e72bfc7d34 100644 --- a/lib/views/custom_token_import/custom_token_import_dialog.dart +++ b/lib/views/custom_token_import/custom_token_import_dialog.dart @@ -58,12 +58,8 @@ class CustomTokenImportDialogState extends State { controller: _pageController, physics: const NeverScrollableScrollPhysics(), children: [ - ImportFormPage( - onNextPage: goToNextPage, - ), - ImportSubmitPage( - onPreviousPage: goToPreviousPage, - ), + ImportFormPage(onNextPage: goToNextPage), + ImportSubmitPage(onPreviousPage: goToPreviousPage), ], ), ), @@ -105,12 +101,7 @@ class BasePage extends StatelessWidget { splashRadius: 20, ), if (onBackPressed != null) const SizedBox(width: 16), - Text( - title, - style: const TextStyle( - fontSize: 18, - ), - ), + Text(title, style: const TextStyle(fontSize: 18)), const Spacer(), IconButton( icon: const Icon(Icons.close), @@ -173,9 +164,7 @@ class ImportFormPage extends StatelessWidget { Expanded( child: Text( LocaleKeys.importTokenWarning.tr(), - style: const TextStyle( - fontSize: 14, - ), + style: const TextStyle(fontSize: 14), ), ), ], @@ -183,13 +172,13 @@ class ImportFormPage extends StatelessWidget { ), const SizedBox(height: 24), DropdownButtonFormField( - value: state.network, + initialValue: state.network, isExpanded: true, decoration: InputDecoration( labelText: LocaleKeys.selectNetwork.tr(), border: const OutlineInputBorder(), ), - items: state.evmNetworks.map((CoinSubClass coinSubClass) { + items: state.supportedNetworks.map((CoinSubClass coinSubClass) { return DropdownMenuItem( value: coinSubClass, child: Text(coinSubClass.formatted), @@ -198,9 +187,9 @@ class ImportFormPage extends StatelessWidget { onChanged: !initialState ? null : (CoinSubClass? value) { - context - .read() - .add(UpdateNetworkEvent(value)); + context.read().add( + UpdateNetworkEvent(value), + ); }, ), const SizedBox(height: 24), @@ -208,9 +197,9 @@ class ImportFormPage extends StatelessWidget { controller: addressController, enabled: initialState, onChanged: (value) { - context - .read() - .add(UpdateAddressEvent(value)); + context.read().add( + UpdateAddressEvent(value), + ); }, decoration: InputDecoration( labelText: LocaleKeys.tokenContractAddress.tr(), @@ -221,9 +210,9 @@ class ImportFormPage extends StatelessWidget { UiPrimaryButton( onPressed: isSubmitEnabled ? () { - context - .read() - .add(const SubmitFetchCustomTokenEvent()); + context.read().add( + const SubmitFetchCustomTokenEvent(), + ); } : null, child: state.formStatus == FormStatus.initial @@ -259,16 +248,17 @@ class ImportSubmitPage extends StatelessWidget { final newCoinUsdBalance = '\$${formatAmt(state.coinBalanceUsd.toDouble())}'; - final isSubmitEnabled = state.importStatus != FormStatus.submitting && + final isSubmitEnabled = + state.importStatus != FormStatus.submitting && state.importStatus != FormStatus.success && newCoin != null; return BasePage( title: LocaleKeys.importCustomToken.tr(), onBackPressed: () { - context - .read() - .add(const ResetFormStatusEvent()); + context.read().add( + const ResetFormStatusEvent(), + ); onPreviousPage(); }, child: Column( @@ -285,9 +275,7 @@ class ImportSubmitPage extends StatelessWidget { height: 250, filterQuality: FilterQuality.high, ), - Text( - LocaleKeys.tokenNotFound.tr(), - ), + Text(LocaleKeys.tokenNotFound.tr()), ], ), ), @@ -298,10 +286,7 @@ class ImportSubmitPage extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - AssetLogo.ofId( - newCoin.id, - size: 80, - ), + AssetLogo.ofId(newCoin.id, size: 80), const SizedBox(height: 12), Text( newCoin.id.id, @@ -310,10 +295,9 @@ class ImportSubmitPage extends StatelessWidget { const SizedBox(height: 32), Text( LocaleKeys.balance.tr(), - style: - Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Colors.grey, - ), + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: Colors.grey), ), const SizedBox(height: 8), Text( @@ -338,12 +322,13 @@ class ImportSubmitPage extends StatelessWidget { UiPrimaryButton( onPressed: isSubmitEnabled ? () { - context - .read() - .add(const SubmitImportCustomTokenEvent()); + context.read().add( + const SubmitImportCustomTokenEvent(), + ); } : null, - child: state.importStatus == FormStatus.submitting || + child: + state.importStatus == FormStatus.submitting || state.importStatus == FormStatus.success ? const UiSpinner(color: Colors.white) : Text(LocaleKeys.importToken.tr()), diff --git a/sdk b/sdk index b563bdf9f6..47b69e0f75 160000 --- a/sdk +++ b/sdk @@ -1 +1 @@ -Subproject commit b563bdf9f642cf0d9992324ff7c91ec3abdbbd5a +Subproject commit 47b69e0f75f430385712722f402cf7bba0cf33ed diff --git a/test_units/tests/custom_token_import/custom_token_import_bloc_test.dart b/test_units/tests/custom_token_import/custom_token_import_bloc_test.dart new file mode 100644 index 0000000000..ed7e1594dc --- /dev/null +++ b/test_units/tests/custom_token_import/custom_token_import_bloc_test.dart @@ -0,0 +1,501 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:komodo_defi_local_auth/komodo_defi_local_auth.dart'; +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_sdk/src/assets/asset_manager.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; +import 'package:web_dex/bloc/analytics/analytics_repo.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/bloc/custom_token_import_state.dart'; +import 'package:web_dex/bloc/custom_token_import/data/custom_token_import_repository.dart'; +import 'package:web_dex/bloc/settings/settings_repository.dart'; +import 'package:web_dex/model/stored_settings.dart'; +import 'package:web_dex/services/storage/base_storage.dart'; + +Map _trxConfig() => { + 'coin': 'TRX', + 'type': 'TRX', + 'name': 'TRON', + 'fname': 'TRON', + 'wallet_only': true, + 'mm2': 1, + 'decimals': 6, + 'required_confirmations': 1, + 'derivation_path': "m/44'/195'", + 'protocol': { + 'type': 'TRX', + 'protocol_data': {'network': 'Mainnet'}, + }, + 'nodes': >[], +}; + +Map _trc20Config({ + required String coin, + required String contractAddress, +}) => { + 'coin': coin, + 'type': 'TRC-20', + 'name': 'Tether', + 'fname': 'Tether', + 'wallet_only': true, + 'mm2': 1, + 'decimals': 6, + 'derivation_path': "m/44'/195'", + 'protocol': { + 'type': 'TRC20', + 'protocol_data': {'platform': 'TRX', 'contract_address': contractAddress}, + }, + 'contract_address': contractAddress, + 'parent_coin': 'TRX', + 'nodes': >[], +}; + +class _MemoryStorage implements BaseStorage { + final Map _store = {}; + + @override + Future delete(String key) async { + _store.remove(key); + return true; + } + + @override + Future read(String key) async => _store[key]; + + @override + Future write(String key, dynamic data) async { + _store[key] = data; + return true; + } +} + +class _FakeAnalyticsRepo implements AnalyticsRepo { + final List queuedEvents = []; + + @override + Future activate() async {} + + @override + Future deactivate() async {} + + @override + Future dispose() async {} + + @override + bool get isEnabled => true; + + @override + bool get isInitialized => true; + + @override + Future loadPersistedQueue() async {} + + @override + Future persistQueue() async {} + + @override + Future queueEvent(AnalyticsEventData data) async { + queuedEvents.add(data); + } + + @override + Future retryInitialization(dynamic settings) async {} + + @override + Future sendData(AnalyticsEventData data) async { + queuedEvents.add(data); + } +} + +class _FakeAssetManager implements AssetManager { + _FakeAssetManager(this._available); + + final Map _available; + + @override + Map get available => _available; + + void addAsset(Asset asset) { + _available[asset.id] = asset; + } + + void removeAsset(AssetId assetId) { + _available.remove(assetId); + } + + @override + Set findAssetsByConfigId(String ticker) { + return _available.values.where((asset) => asset.id.id == ticker).toSet(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeAuth implements KomodoDefiLocalAuth { + _FakeAuth({required this.user}); + + KdfUser? user; + + @override + Future get currentUser async => user; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeSdk implements KomodoDefiSdk { + _FakeSdk({required this.assets, required this.auth}); + + @override + final _FakeAssetManager assets; + + @override + final KomodoDefiLocalAuth auth; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _RollbackCall { + const _RollbackCall({ + required this.assets, + required this.deleteCustomTokens, + required this.removeWalletMetadataAssets, + }); + + final List assets; + final Set deleteCustomTokens; + final Set removeWalletMetadataAssets; +} + +class _FakeCoinsRepo implements CoinsRepo { + _FakeCoinsRepo({required this.assetManager, this.balanceInfo}); + + final _FakeAssetManager assetManager; + final List> activateCalls = []; + final List<_RollbackCall> rollbackCalls = []; + final Set activeAssetIds = {}; + final kdf_rpc.BalanceInfo? balanceInfo; + + @override + Future activateAssetsSync( + List assets, { + bool notifyListeners = true, + bool addToWalletMetadata = true, + int maxRetryAttempts = 15, + Duration initialRetryDelay = const Duration(milliseconds: 500), + Duration maxRetryDelay = const Duration(seconds: 10), + }) async { + activateCalls.add(List.from(assets)); + for (final asset in assets) { + activeAssetIds.add(asset.id); + assetManager.addAsset(asset); + } + } + + @override + double? getUsdPriceByAmount(String amount, String coinAbbr) => 12.5; + + @override + Future isAssetActivated( + AssetId assetId, { + bool forceRefresh = false, + }) async { + return activeAssetIds.contains(assetId); + } + + @override + Future rollbackPreviewAssets( + Iterable assets, { + Set deleteCustomTokens = const {}, + Set removeWalletMetadataAssets = const {}, + bool notifyListeners = false, + }) async { + final assetList = assets.toList(); + rollbackCalls.add( + _RollbackCall( + assets: assetList, + deleteCustomTokens: deleteCustomTokens, + removeWalletMetadataAssets: removeWalletMetadataAssets, + ), + ); + + for (final asset in assetList) { + activeAssetIds.remove(asset.id); + } + for (final assetId in deleteCustomTokens) { + assetManager.removeAsset(assetId); + } + } + + @override + Future tryGetBalanceInfo(AssetId coinId) async { + return balanceInfo ?? kdf_rpc.BalanceInfo.zero(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeCustomTokenImportRepository implements ICustomTokenImportRepository { + Asset? fetchResult; + Object? fetchError; + int importCalls = 0; + + @override + void dispose() {} + + @override + Future fetchCustomToken({ + required CoinSubClass network, + required Asset platformAsset, + required String address, + }) async { + if (fetchError != null) { + throw fetchError!; + } + return fetchResult ?? (throw StateError('fetchResult not configured')); + } + + @override + String? getNetworkApiName(CoinSubClass coinType) { + return switch (coinType) { + CoinSubClass.trc20 => 'tron', + CoinSubClass.erc20 => 'ethereum', + _ => null, + }; + } + + @override + Future importCustomToken(Asset asset) async { + importCalls += 1; + } +} + +Future _setTrc20Input(CustomTokenImportBloc bloc) async { + bloc.add(const UpdateNetworkEvent(CoinSubClass.trc20)); + bloc.add(const UpdateAddressEvent('0x1234')); + await Future.delayed(Duration.zero); +} + +Future _fetchPreview(CustomTokenImportBloc bloc) async { + final successState = bloc.stream.firstWhere( + (state) => state.formStatus == FormStatus.success, + ); + bloc.add(const SubmitFetchCustomTokenEvent()); + return successState; +} + +AnalyticsBloc _createAnalyticsBloc(_FakeAnalyticsRepo analyticsRepo) { + return AnalyticsBloc( + analytics: analyticsRepo, + storedData: StoredSettings.initial(), + repository: SettingsRepository(storage: _MemoryStorage()), + ); +} + +void main() { + group('CustomTokenImportBloc preview lifecycle', () { + late Asset platformAsset; + late Asset tokenAsset; + late _FakeAssetManager assetManager; + late _FakeCoinsRepo coinsRepo; + late _FakeCustomTokenImportRepository repository; + late _FakeAnalyticsRepo analyticsRepo; + late AnalyticsBloc analyticsBloc; + late _FakeAuth auth; + late CustomTokenImportBloc bloc; + + setUp(() { + platformAsset = Asset.fromJson(_trxConfig(), knownIds: const {}); + tokenAsset = Asset.fromJson( + _trc20Config( + coin: 'USDT-TRC20', + contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + ), + knownIds: {platformAsset.id}, + ); + + assetManager = _FakeAssetManager({platformAsset.id: platformAsset}); + coinsRepo = _FakeCoinsRepo( + assetManager: assetManager, + balanceInfo: kdf_rpc.BalanceInfo.zero(), + ); + repository = _FakeCustomTokenImportRepository()..fetchResult = tokenAsset; + analyticsRepo = _FakeAnalyticsRepo(); + analyticsBloc = _createAnalyticsBloc(analyticsRepo); + auth = _FakeAuth( + user: KdfUser( + walletId: WalletId.fromName( + 'test-wallet', + const AuthOptions(derivationMethod: DerivationMethod.hdWallet), + ), + isBip39Seed: true, + metadata: const {'activated_coins': []}, + ), + ); + bloc = CustomTokenImportBloc( + repository, + coinsRepo, + _FakeSdk(assets: assetManager, auth: auth), + analyticsBloc, + ); + }); + + tearDown(() async { + if (!bloc.isClosed) { + await bloc.close(); + } + await analyticsBloc.close(); + }); + + test('successful preview does not roll back immediately', () async { + await _setTrc20Input(bloc); + + final successState = await _fetchPreview(bloc); + + expect(successState.coin, tokenAsset); + expect(coinsRepo.rollbackCalls, isEmpty); + expect( + coinsRepo.activeAssetIds, + containsAll({platformAsset.id, tokenAsset.id}), + ); + }); + + test('fetch failure rolls back preview-only platform activation', () async { + repository.fetchError = StateError('token lookup failed'); + await _setTrc20Input(bloc); + + final failureState = bloc.stream.firstWhere( + (state) => state.formStatus == FormStatus.failure, + ); + bloc.add(const SubmitFetchCustomTokenEvent()); + await failureState; + + expect(coinsRepo.rollbackCalls, hasLength(1)); + expect( + coinsRepo.rollbackCalls.single.assets.map((asset) => asset.id).toSet(), + {platformAsset.id}, + ); + expect(coinsRepo.rollbackCalls.single.deleteCustomTokens, isEmpty); + }); + + test( + 'reset rolls back preview token and parent, deleting new token', + () async { + await _setTrc20Input(bloc); + await _fetchPreview(bloc); + + final resetState = bloc.stream.firstWhere( + (state) => state.formStatus == FormStatus.initial, + ); + bloc.add(const ResetFormStatusEvent()); + await resetState; + + expect(coinsRepo.rollbackCalls, hasLength(1)); + expect( + coinsRepo.rollbackCalls.single.assets + .map((asset) => asset.id) + .toSet(), + {platformAsset.id, tokenAsset.id}, + ); + expect(coinsRepo.rollbackCalls.single.deleteCustomTokens, { + tokenAsset.id, + }); + expect(coinsRepo.rollbackCalls.single.removeWalletMetadataAssets, { + platformAsset.id, + tokenAsset.id, + }); + expect(assetManager.available.containsKey(tokenAsset.id), isFalse); + }, + ); + + test('close rolls back preview token and parent', () async { + await _setTrc20Input(bloc); + await _fetchPreview(bloc); + + await bloc.close(); + + expect(coinsRepo.rollbackCalls, hasLength(1)); + expect( + coinsRepo.rollbackCalls.single.assets.map((asset) => asset.id).toSet(), + {platformAsset.id, tokenAsset.id}, + ); + }); + + test('pre-existing preview asset is not deleted on reset', () async { + assetManager.addAsset(tokenAsset); + auth.user = auth.user!.copyWith( + metadata: { + 'activated_coins': [platformAsset.id.id, tokenAsset.id.id], + }, + ); + await _setTrc20Input(bloc); + await _fetchPreview(bloc); + + final resetState = bloc.stream.firstWhere( + (state) => state.formStatus == FormStatus.initial, + ); + bloc.add(const ResetFormStatusEvent()); + await resetState; + + expect(coinsRepo.rollbackCalls, hasLength(1)); + expect(coinsRepo.rollbackCalls.single.deleteCustomTokens, isEmpty); + expect( + coinsRepo.rollbackCalls.single.removeWalletMetadataAssets, + isEmpty, + ); + expect(assetManager.available.containsKey(tokenAsset.id), isTrue); + }); + + test('preview rollback preserves saved parent metadata', () async { + auth.user = auth.user!.copyWith( + metadata: { + 'activated_coins': [platformAsset.id.id], + }, + ); + await _setTrc20Input(bloc); + await _fetchPreview(bloc); + + final resetState = bloc.stream.firstWhere( + (state) => state.formStatus == FormStatus.initial, + ); + bloc.add(const ResetFormStatusEvent()); + await resetState; + + expect(coinsRepo.rollbackCalls, hasLength(1)); + expect(coinsRepo.rollbackCalls.single.deleteCustomTokens, { + tokenAsset.id, + }); + expect(coinsRepo.rollbackCalls.single.removeWalletMetadataAssets, { + tokenAsset.id, + }); + }); + + test( + 'import success keeps preview activation and skips rollback on close', + () async { + await _setTrc20Input(bloc); + await _fetchPreview(bloc); + + final importState = bloc.stream.firstWhere( + (state) => state.importStatus == FormStatus.success, + ); + bloc.add(const SubmitImportCustomTokenEvent()); + await importState; + + expect(repository.importCalls, 1); + expect(analyticsRepo.queuedEvents, hasLength(1)); + + await bloc.close(); + + expect(coinsRepo.rollbackCalls, isEmpty); + }, + ); + }); +} diff --git a/test_units/tests/custom_token_import/custom_token_import_repository_test.dart b/test_units/tests/custom_token_import/custom_token_import_repository_test.dart new file mode 100644 index 0000000000..1fa5765073 --- /dev/null +++ b/test_units/tests/custom_token_import/custom_token_import_repository_test.dart @@ -0,0 +1,272 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; +import 'package:komodo_defi_sdk/src/assets/asset_manager.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/bloc/custom_token_import/data/custom_token_import_repository.dart'; + +Map _trxConfig() => { + 'coin': 'TRX', + 'type': 'TRX', + 'name': 'TRON', + 'fname': 'TRON', + 'wallet_only': true, + 'mm2': 1, + 'decimals': 6, + 'required_confirmations': 1, + 'derivation_path': "m/44'/195'", + 'protocol': { + 'type': 'TRX', + 'protocol_data': {'network': 'Mainnet'}, + }, + 'nodes': >[], +}; + +Map _trc20Config({ + required String coin, + required String contractAddress, +}) => { + 'coin': coin, + 'type': 'TRC-20', + 'name': 'Tether', + 'fname': 'Tether', + 'wallet_only': true, + 'mm2': 1, + 'decimals': 6, + 'derivation_path': "m/44'/195'", + 'protocol': { + 'type': 'TRC20', + 'protocol_data': {'platform': 'TRX', 'contract_address': contractAddress}, + }, + 'contract_address': contractAddress, + 'parent_coin': 'TRX', + 'nodes': >[], +}; + +class _StubCoinsRepo implements CoinsRepo { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeAssetManager implements AssetManager { + _FakeAssetManager(this._available); + + final Map _available; + + @override + Map get available => _available; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeSdk implements KomodoDefiSdk { + _FakeSdk({required this.client, required this.assets}); + + @override + final ApiClient client; + + @override + final AssetManager assets; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeApiClient implements ApiClient { + _FakeApiClient({ + required this.convertedContractAddress, + required this.tokenSymbol, + required this.decimals, + }); + + final String convertedContractAddress; + final String tokenSymbol; + final int decimals; + int convertAddressCalls = 0; + int getTokenInfoCalls = 0; + Map? lastConvertAddressRequest; + Map? lastGetTokenInfoRequest; + + @override + FutureOr> executeRpc(Map request) { + final method = request['method'] as String?; + switch (method) { + case 'convertaddress': + convertAddressCalls += 1; + lastConvertAddressRequest = request; + return { + 'mmrpc': '2.0', + 'result': {'address': convertedContractAddress}, + }; + case 'get_token_info': + getTokenInfoCalls += 1; + lastGetTokenInfoRequest = request; + return { + 'mmrpc': '2.0', + 'result': { + 'type': request['params']['protocol']['type'], + 'info': {'symbol': tokenSymbol, 'decimals': decimals}, + }, + }; + default: + throw UnsupportedError('Unexpected RPC method: $method'); + } + } +} + +class _FakeHttpClient extends http.BaseClient { + _FakeHttpClient(this._body); + + final String _body; + + @override + Future send(http.BaseRequest request) async { + final bytes = utf8.encode(_body); + return http.StreamedResponse( + Stream.value(bytes), + 200, + request: request, + headers: {'content-type': 'application/json'}, + ); + } +} + +void main() { + group('KdfCustomTokenImportRepository', () { + late Asset platformAsset; + + setUp(() { + platformAsset = Asset.fromJson(_trxConfig(), knownIds: const {}); + }); + + test('TRC20 fetch preserves selected protocol context end-to-end', () async { + final apiClient = _FakeApiClient( + convertedContractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + tokenSymbol: 'USDT', + decimals: 18, + ); + final repository = KdfCustomTokenImportRepository( + _FakeSdk( + client: apiClient, + assets: _FakeAssetManager({platformAsset.id: platformAsset}), + ), + _StubCoinsRepo(), + httpClient: _FakeHttpClient( + jsonEncode({ + 'id': 'tether', + 'name': 'Tether USD', + 'image': {'large': 'https://example.com/usdt.png'}, + }), + ), + ); + + final asset = await repository.fetchCustomToken( + network: CoinSubClass.trc20, + platformAsset: platformAsset, + address: '0x1234', + ); + + expect(asset.id.subClass, CoinSubClass.trc20); + expect(asset.protocol, isA()); + expect(asset.id.parentId, platformAsset.id); + expect(asset.id.id, 'USDT-TRC20'); + expect( + asset.protocol.contractAddress, + 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + ); + expect( + apiClient.lastConvertAddressRequest?['coin'], + equals(platformAsset.id.id), + ); + expect( + apiClient.lastGetTokenInfoRequest?['params']['protocol']['type'], + equals('TRC20'), + ); + expect( + apiClient + .lastGetTokenInfoRequest?['params']['protocol']['protocol_data']['platform'], + equals('TRX'), + ); + expect(asset.id.chainId.decimals, 18); + expect(asset.protocol.config['decimals'], 18); + }); + + test('same-contract re-import returns the existing known asset', () async { + final existingAsset = Asset.fromJson( + _trc20Config( + coin: 'USDT-TRC20', + contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + ), + knownIds: {platformAsset.id}, + ); + final apiClient = _FakeApiClient( + convertedContractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + tokenSymbol: 'USDT', + decimals: 6, + ); + final repository = KdfCustomTokenImportRepository( + _FakeSdk( + client: apiClient, + assets: _FakeAssetManager({ + platformAsset.id: platformAsset, + existingAsset.id: existingAsset, + }), + ), + _StubCoinsRepo(), + httpClient: _FakeHttpClient('{}'), + ); + + final asset = await repository.fetchCustomToken( + network: CoinSubClass.trc20, + platformAsset: platformAsset, + address: '0x1234', + ); + + expect(asset, same(existingAsset)); + expect(apiClient.convertAddressCalls, 1); + expect(apiClient.getTokenInfoCalls, 0); + }); + + test( + 'same generated asset id with different contract throws conflict', + () async { + final existingAsset = Asset.fromJson( + _trc20Config( + coin: 'USDT-TRC20', + contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + ), + knownIds: {platformAsset.id}, + ); + final repository = KdfCustomTokenImportRepository( + _FakeSdk( + client: _FakeApiClient( + convertedContractAddress: 'TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj', + tokenSymbol: 'USDT', + decimals: 6, + ), + assets: _FakeAssetManager({ + platformAsset.id: platformAsset, + existingAsset.id: existingAsset, + }), + ), + _StubCoinsRepo(), + httpClient: _FakeHttpClient('{}'), + ); + + expect( + repository.fetchCustomToken( + network: CoinSubClass.trc20, + platformAsset: platformAsset, + address: '0x1234', + ), + throwsA(isA()), + ); + }, + ); + }); +} diff --git a/test_units/tests/fiat/tron_fiat_mapping_test.dart b/test_units/tests/fiat/tron_fiat_mapping_test.dart new file mode 100644 index 0000000000..7a588f8af6 --- /dev/null +++ b/test_units/tests/fiat/tron_fiat_mapping_test.dart @@ -0,0 +1,248 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_dex/bloc/fiat/banxa_fiat_provider.dart'; +import 'package:web_dex/bloc/fiat/base_fiat_provider.dart'; +import 'package:web_dex/bloc/fiat/fiat_order_status.dart'; +import 'package:web_dex/bloc/fiat/models/models.dart'; +import 'package:web_dex/bloc/fiat/ramp/ramp_fiat_provider.dart'; +import 'package:web_dex/model/coin_type.dart'; + +class _TestFiatProvider extends BaseFiatProvider { + @override + Future buyCoin( + String accountReference, + String source, + ICurrency target, + String walletAddress, + String paymentMethodId, + String sourceAmount, + String returnUrlOnSuccess, + ) { + throw UnimplementedError(); + } + + @override + Future> getFiatList() { + throw UnimplementedError(); + } + + @override + Future> getCoinList() { + throw UnimplementedError(); + } + + @override + Future getPaymentMethodPrice( + String source, + ICurrency target, + String sourceAmount, + FiatPaymentMethod paymentMethod, + ) { + throw UnimplementedError(); + } + + @override + Future> getPaymentMethodsList( + String source, + ICurrency target, + String sourceAmount, + ) { + throw UnimplementedError(); + } + + @override + String getProviderId() => 'test'; + + @override + String get providerIconPath => ''; + + @override + Stream watchOrderStatus(String orderId) { + throw UnimplementedError(); + } +} + +class _TestBanxaFiatProvider extends BanxaFiatProvider { + _TestBanxaFiatProvider(this._coinsResponse); + + final Map _coinsResponse; + + @override + Future apiRequest( + String method, + String endpoint, { + Map? queryParams, + Map? body, + }) async { + if (queryParams?['endpoint'] == '/api/coins') { + return _coinsResponse; + } + + throw UnimplementedError('Unexpected Banxa API request'); + } +} + +class _TestBanxaPaymentMethodsProvider extends BanxaFiatProvider { + int paymentMethodsRequests = 0; + + @override + Future apiRequest( + String method, + String endpoint, { + Map? queryParams, + Map? body, + }) async { + if (queryParams?['endpoint'] == '/api/payment-methods') { + paymentMethodsRequests += 1; + return { + 'data': {'payment_methods': >[]}, + }; + } + + throw UnimplementedError('Unexpected Banxa API request'); + } +} + +void main() { + group('TRON fiat mapping', () { + final provider = _TestFiatProvider(); + + test('native TRX resolves to trx coin type', () { + expect(provider.getCoinType('TRX', coinSymbol: 'TRX'), CoinType.trx); + expect(provider.getCoinType('TRX'), CoinType.trx); + }); + + test('TRON tokens resolve to trc20 coin type', () { + expect(provider.getCoinType('TRON', coinSymbol: 'USDT'), CoinType.trc20); + }); + + test('native TRX abbreviation stays unchanged', () { + final currency = CryptoCurrency( + symbol: 'TRX', + name: 'TRON', + chainType: CoinType.trx, + minPurchaseAmount: Decimal.zero, + ); + + expect(currency.getAbbr(), 'TRX'); + }); + + test('TRC20 token abbreviation gets TRC20 suffix', () { + final currency = CryptoCurrency( + symbol: 'USDT', + name: 'Tether', + chainType: CoinType.trc20, + minPurchaseAmount: Decimal.zero, + ); + + expect(currency.getAbbr(), 'USDT-TRC20'); + }); + + test('Ramp asset codes use the TRON prefix', () { + final ramp = RampFiatProvider(); + + expect( + ramp.getFullCoinCode( + CryptoCurrency( + symbol: 'TRX', + name: 'TRON', + chainType: CoinType.trx, + minPurchaseAmount: Decimal.zero, + ), + ), + 'TRON_TRX', + ); + expect( + ramp.getFullCoinCode( + CryptoCurrency( + symbol: 'USDT', + name: 'Tether', + chainType: CoinType.trc20, + minPurchaseAmount: Decimal.zero, + ), + ), + 'TRON_USDT', + ); + }); + + test( + 'Banxa keeps native TRX while filtering unsupported BEP20 TRX', + () async { + final provider = _TestBanxaFiatProvider({ + 'data': { + 'coins': [ + { + 'coin_code': 'TRX', + 'coin_name': 'TRON', + 'blockchains': [ + {'code': 'TRX', 'min_value': '10'}, + {'code': 'BNB', 'min_value': '10'}, + ], + }, + ], + }, + }); + + final coins = await provider.getCoinList(); + + expect(coins, hasLength(1)); + expect(coins.single.symbol, 'TRX'); + expect(coins.single.chainType, CoinType.trx); + }, + ); + + test('Banxa payment methods allow native TRX', () async { + final provider = _TestBanxaPaymentMethodsProvider(); + + final methods = await provider.getPaymentMethodsList( + 'USD', + CryptoCurrency( + symbol: 'TRX', + name: 'TRON', + chainType: CoinType.trx, + minPurchaseAmount: Decimal.zero, + ), + '100', + ); + + expect(methods, isEmpty); + expect(provider.paymentMethodsRequests, 1); + }); + + test('Banxa payment methods allow native AVAX', () async { + final provider = _TestBanxaPaymentMethodsProvider(); + + final methods = await provider.getPaymentMethodsList( + 'USD', + CryptoCurrency( + symbol: 'AVAX', + name: 'Avalanche', + chainType: CoinType.avx20, + minPurchaseAmount: Decimal.zero, + ), + '100', + ); + + expect(methods, isEmpty); + expect(provider.paymentMethodsRequests, 1); + }); + + test('Banxa payment methods still block unsupported BEP20 TRX', () async { + final provider = _TestBanxaPaymentMethodsProvider(); + + final methods = await provider.getPaymentMethodsList( + 'USD', + CryptoCurrency( + symbol: 'TRX', + name: 'TRON (BEP20)', + chainType: CoinType.bep20, + minPurchaseAmount: Decimal.zero, + ), + '100', + ); + + expect(methods, isEmpty); + expect(provider.paymentMethodsRequests, 0); + }); + }); +}