diff --git a/packages/komodo_coin_updates/lib/src/coins_config/custom_token_storage.dart b/packages/komodo_coin_updates/lib/src/coins_config/custom_token_storage.dart index 3a85f8aa..58622532 100644 --- a/packages/komodo_coin_updates/lib/src/coins_config/custom_token_storage.dart +++ b/packages/komodo_coin_updates/lib/src/coins_config/custom_token_storage.dart @@ -38,6 +38,7 @@ class CustomTokenStorage implements CustomTokenStore { Future storeCustomToken(Asset asset) async { _log.fine('Storing custom token ${asset.id.id}'); final box = await _openCustomTokensBox(); + await _validateCanStoreAsset(box, asset); await box.put(asset.id.id, asset); } @@ -45,6 +46,10 @@ class CustomTokenStorage implements CustomTokenStore { Future storeCustomTokens(List assets) async { _log.fine('Storing ${assets.length} custom tokens'); final box = await _openCustomTokensBox(); + _validateBatchCollisions(assets); + for (final asset in assets) { + await _validateCanStoreAsset(box, asset); + } final putMap = {for (final a in assets) a.id.id: a}; await box.putAll(putMap); } @@ -127,7 +132,9 @@ class CustomTokenStorage implements CustomTokenStore { @override Future upsertCustomToken(Asset asset) async { final box = await _openCustomTokensBox(); - final existed = box.containsKey(asset.id.id); + final existingAsset = await box.get(asset.id.id); + final existed = existingAsset != null; + _assertNoConflict(existingAsset, asset); await box.put(asset.id.id, asset); if (existed) { @@ -142,7 +149,9 @@ class CustomTokenStorage implements CustomTokenStore { @override Future addCustomTokenIfNotExists(Asset asset) async { final box = await _openCustomTokensBox(); - if (box.containsKey(asset.id.id)) { + final existingAsset = await box.get(asset.id.id); + if (existingAsset != null) { + _assertNoConflict(existingAsset, asset); _log.fine('Custom token ${asset.id.id} already exists, skipping'); return false; } @@ -185,6 +194,72 @@ class CustomTokenStorage implements CustomTokenStore { return _customTokensBox!; } + Future _validateCanStoreAsset(LazyBox box, Asset asset) async { + final existingAsset = await box.get(asset.id.id); + _assertNoConflict(existingAsset, asset); + } + + void _validateBatchCollisions(List assets) { + final assetsById = {}; + for (final asset in assets) { + final existingAsset = assetsById[asset.id.id]; + _assertNoConflict(existingAsset, asset); + assetsById[asset.id.id] = asset; + } + } + + void _assertNoConflict(Asset? existingAsset, Asset requestedAsset) { + if (existingAsset == null) { + return; + } + + if (_hasMatchingContract(existingAsset, requestedAsset)) { + return; + } + + throw CustomTokenConflictException( + assetId: requestedAsset.id.id, + network: requestedAsset.id.subClass, + existingContractAddress: existingAsset.protocol.contractAddress ?? '', + requestedContractAddress: requestedAsset.protocol.contractAddress ?? '', + ); + } + + bool _hasMatchingContract(Asset existingAsset, Asset requestedAsset) { + final hasMatchingIdentity = + existingAsset.id.subClass == requestedAsset.id.subClass && + existingAsset.id.chainId.formattedChainId == + requestedAsset.id.chainId.formattedChainId && + existingAsset.id.parentId == requestedAsset.id.parentId; + if (!hasMatchingIdentity) { + return false; + } + + final existingContractAddress = existingAsset.protocol.contractAddress; + final requestedContractAddress = requestedAsset.protocol.contractAddress; + if (existingContractAddress == null || requestedContractAddress == null) { + return false; + } + + return _normalizeContractAddress( + existingAsset.id.subClass, + existingContractAddress, + ) == + _normalizeContractAddress( + requestedAsset.id.subClass, + requestedContractAddress, + ); + } + + String _normalizeContractAddress( + CoinSubClass network, + String contractAddress, + ) { + return network == CoinSubClass.trc20 + ? contractAddress + : contractAddress.toLowerCase(); + } + ProtocolClass _markCustomToken(ProtocolClass protocol) { return switch (protocol) { final Erc20Protocol p => p.copyWith(isCustomToken: true), diff --git a/packages/komodo_coin_updates/lib/src/coins_config/custom_token_store.dart b/packages/komodo_coin_updates/lib/src/coins_config/custom_token_store.dart index 2fc2dbb9..40149489 100644 --- a/packages/komodo_coin_updates/lib/src/coins_config/custom_token_store.dart +++ b/packages/komodo_coin_updates/lib/src/coins_config/custom_token_store.dart @@ -6,11 +6,15 @@ abstract class CustomTokenStore { Future init(); /// Stores a single custom token. - /// If a token with the same AssetId already exists, it will be overwritten. + /// If a token with the same storage key already exists, it is overwritten + /// only when it represents the same contract on the same network. + /// Otherwise, [CustomTokenConflictException] is thrown. Future storeCustomToken(Asset asset); /// Stores multiple custom tokens atomically (all-or-nothing). - /// Existing tokens with the same AssetIds will be overwritten. + /// Existing tokens with the same storage key are overwritten only when they + /// represent the same contract on the same network. + /// Otherwise, [CustomTokenConflictException] is thrown. /// Implementations should throw on partial failure. Future storeCustomTokens(List assets); @@ -39,11 +43,14 @@ abstract class CustomTokenStore { Future hasCustomTokens(); /// Upserts a custom token: updates if it exists, inserts otherwise. - /// Returns true if updated, false if inserted. + /// Returns true if updated, false if inserted. Throws + /// [CustomTokenConflictException] for same-key different-contract writes. Future upsertCustomToken(Asset asset); /// Adds a custom token to storage if it doesn't already exist. - /// Returns true if the token was added, false if it already existed. + /// Returns true if the token was added, false if the same contract already + /// existed. Throws [CustomTokenConflictException] for same-key + /// different-contract writes. Future addCustomTokenIfNotExists(Asset asset); /// Returns the number of custom tokens in storage. diff --git a/packages/komodo_coin_updates/test/custom_token_storage_test.dart b/packages/komodo_coin_updates/test/custom_token_storage_test.dart new file mode 100644 index 00000000..f10a6f66 --- /dev/null +++ b/packages/komodo_coin_updates/test/custom_token_storage_test.dart @@ -0,0 +1,180 @@ +import 'package:komodo_coin_updates/komodo_coin_updates.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:test/test.dart'; + +import 'hive/test_harness.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 name, + required String contractAddress, +}) => { + 'coin': coin, + 'type': 'TRC-20', + 'name': name, + 'fname': name, + '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': >[], +}; + +Asset _buildTrc20Asset({ + required Asset platformAsset, + required String coin, + required String name, + required String contractAddress, +}) { + return Asset.fromJson( + _trc20Config(coin: coin, name: name, contractAddress: contractAddress), + knownIds: {platformAsset.id}, + ); +} + +void main() { + group('CustomTokenStorage', () { + late HiveTestEnv hiveEnv; + late CustomTokenStorage storage; + late Asset platformAsset; + + setUp(() async { + hiveEnv = HiveTestEnv(); + await hiveEnv.setup(); + storage = CustomTokenStorage(customTokensBoxName: 'custom_tokens_test'); + await storage.init(); + platformAsset = Asset.fromJson(_trxConfig(), knownIds: const {}); + }); + + tearDown(() async { + await storage.dispose(); + await hiveEnv.dispose(); + }); + + test( + 'upsert allows replacing an existing token with the same contract', + () async { + final original = _buildTrc20Asset( + platformAsset: platformAsset, + coin: 'USDT-TRC20', + name: 'Tether USD', + contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + ); + final updated = _buildTrc20Asset( + platformAsset: platformAsset, + coin: 'USDT-TRC20', + name: 'Tether USD Updated', + contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + ); + + await storage.storeCustomToken(original); + final didUpdate = await storage.upsertCustomToken(updated); + final stored = await storage.getCustomToken(updated.id); + + expect(didUpdate, isTrue); + expect(stored?.id.name, updated.id.name); + expect( + stored?.protocol.contractAddress, + updated.protocol.contractAddress, + ); + }, + ); + + test('store rejects same-id different-contract collisions', () async { + final original = _buildTrc20Asset( + platformAsset: platformAsset, + coin: 'USDT-TRC20', + name: 'Tether USD', + contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + ); + final conflicting = _buildTrc20Asset( + platformAsset: platformAsset, + coin: 'USDT-TRC20', + name: 'Another USDT', + contractAddress: 'TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj', + ); + + await storage.storeCustomToken(original); + + await expectLater( + storage.storeCustomToken(conflicting), + throwsA(isA()), + ); + + final stored = await storage.getCustomToken(original.id); + expect(await storage.getCustomTokenCount(), 1); + expect( + stored?.protocol.contractAddress, + original.protocol.contractAddress, + ); + }); + + test( + 'addCustomTokenIfNotExists is idempotent for the same contract', + () async { + final asset = _buildTrc20Asset( + platformAsset: platformAsset, + coin: 'USDT-TRC20', + name: 'Tether USD', + contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + ); + + final firstInsert = await storage.addCustomTokenIfNotExists(asset); + final secondInsert = await storage.addCustomTokenIfNotExists(asset); + + expect(firstInsert, isTrue); + expect(secondInsert, isFalse); + expect(await storage.getCustomTokenCount(), 1); + }, + ); + + test( + 'storeCustomTokens fails atomically for conflicting batch entries', + () async { + final first = _buildTrc20Asset( + platformAsset: platformAsset, + coin: 'USDT-TRC20', + name: 'Tether USD', + contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + ); + final conflicting = _buildTrc20Asset( + platformAsset: platformAsset, + coin: 'USDT-TRC20', + name: 'Another USDT', + contractAddress: 'TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj', + ); + + await expectLater( + storage.storeCustomTokens([first, conflicting]), + throwsA(isA()), + ); + + expect(await storage.getCustomTokenCount(), 0); + expect(await storage.getCustomToken(first.id), isNull); + }, + ); + }); +} diff --git a/packages/komodo_defi_framework/app_build/build_config.json b/packages/komodo_defi_framework/app_build/build_config.json index 0228d580..420ae0b3 100644 --- a/packages/komodo_defi_framework/app_build/build_config.json +++ b/packages/komodo_defi_framework/app_build/build_config.json @@ -68,7 +68,7 @@ "coins": { "fetch_at_build_enabled": true, "update_commit_on_build": true, - "bundled_coins_repo_commit": "e027082339558cc79d653d0e871f0d211562fe2f", + "bundled_coins_repo_commit": "f74c985339ff45de8768ab6c8be02ed8e2d0f49a", "coins_repo_api_url": "https://api.github.com/repos/GLEECBTC/coins", "coins_repo_content_url": "https://raw.githubusercontent.com/GLEECBTC/coins", "coins_repo_branch": "master", diff --git a/packages/komodo_defi_sdk/lib/src/withdrawals/withdrawal_manager.dart b/packages/komodo_defi_sdk/lib/src/withdrawals/withdrawal_manager.dart index febe40f0..89e5615c 100644 --- a/packages/komodo_defi_sdk/lib/src/withdrawals/withdrawal_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/withdrawals/withdrawal_manager.dart @@ -9,6 +9,40 @@ import 'package:komodo_defi_sdk/src/fees/fee_manager.dart'; import 'package:komodo_defi_sdk/src/withdrawals/legacy_withdrawal_manager.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; +/// Shared fee-estimation strategy classification used by withdrawal flows. +enum FeeEstimationSupport { + /// Ethereum-style gas estimation. + evmGas, + + /// UTXO fee-per-kilobyte estimation. + utxoPerKbyte, + + /// Tendermint gas estimation. + tendermint, + + /// QTUM gas estimation with UTXO fallback. + qtum, + + /// ZHTLC fixed-fee estimation. + zhtlc, + + /// No fee estimation support is available. + unsupported, +} + +/// Returns the fee-estimation support level for [protocol]. +FeeEstimationSupport feeEstimationSupportForProtocol(ProtocolClass protocol) { + return switch (protocol) { + Erc20Protocol() => FeeEstimationSupport.evmGas, + UtxoProtocol() => FeeEstimationSupport.utxoPerKbyte, + TendermintProtocol() => FeeEstimationSupport.tendermint, + QtumProtocol() => FeeEstimationSupport.qtum, + ZhtlcProtocol() => FeeEstimationSupport.zhtlc, + TrxProtocol() || Trc20Protocol() => FeeEstimationSupport.unsupported, + _ => FeeEstimationSupport.unsupported, + }; +} + /// Manages cryptocurrency asset withdrawals to external addresses. /// /// The [WithdrawalManager] provides functionality for: @@ -27,9 +61,9 @@ import 'package:komodo_defi_types/komodo_defi_types.dart'; /// 3. Broadcasting to the network /// 4. Status tracking /// -/// **Note:** Fee estimation features are currently disabled as the API endpoints -/// are not yet available. Set `_feeEstimationEnabled` to `true` when the API -/// endpoints become available. +/// **Note:** Fee estimation features are currently disabled as the API +/// endpoints are not yet available. Set `_feeEstimationEnabled` to `true` +/// when the API endpoints become available. /// /// Usage example: /// ```dart @@ -208,8 +242,8 @@ class WithdrawalManager { final protocol = asset.protocol; // Handle different protocol types - switch (protocol.runtimeType) { - case Erc20Protocol: + switch (feeEstimationSupportForProtocol(protocol)) { + case FeeEstimationSupport.evmGas: // Ethereum-based protocols use gas estimation final estimation = await _feeManager.getEthEstimatedFeePerGas( assetId, @@ -248,7 +282,7 @@ class WithdrawalManager { ), ); - case UtxoProtocol: + case FeeEstimationSupport.utxoPerKbyte: // UTXO-based protocols use per-kbyte fee estimation final estimation = await _feeManager.getUtxoEstimatedFee(assetId); return WithdrawalFeeOptions( @@ -279,7 +313,7 @@ class WithdrawalManager { ), ); - case TendermintProtocol: + case FeeEstimationSupport.tendermint: // Tendermint/Cosmos protocols use gas price and gas limit final estimation = await _feeManager.getTendermintEstimatedFee( assetId, @@ -315,7 +349,7 @@ class WithdrawalManager { ), ); - case QtumProtocol: + case FeeEstimationSupport.qtum: // QTUM uses similar gas model to Ethereum but with different fee structure try { final estimation = await _feeManager.getEthEstimatedFeePerGas( @@ -383,7 +417,7 @@ class WithdrawalManager { ); } - case ZhtlcProtocol: + case FeeEstimationSupport.zhtlc: // ZHTLC (Zcash) uses UTXO-style fees final estimation = await _feeManager.getUtxoEstimatedFee(assetId); return WithdrawalFeeOptions( @@ -414,8 +448,7 @@ class WithdrawalManager { ), ); - default: - // For unknown protocols, return null to indicate unsupported + case FeeEstimationSupport.unsupported: log('Fee options not supported for protocol ${protocol.runtimeType}'); return null; } @@ -929,8 +962,8 @@ class WithdrawalManager { final priority = params.feePriority ?? WithdrawalFeeLevel.medium; FeeInfo? fee; - switch (protocol.runtimeType) { - case Erc20Protocol: + switch (feeEstimationSupportForProtocol(protocol)) { + case FeeEstimationSupport.evmGas: // Ethereum-based protocols (ETH, ERC20 tokens) use gas estimation final estimation = await _feeManager.getEthEstimatedFeePerGas( asset.id.id, @@ -943,7 +976,7 @@ class WithdrawalManager { gas: _defaultEthGasLimit, ); - case UtxoProtocol: + case FeeEstimationSupport.utxoPerKbyte: // UTXO-based protocols use per-kbyte fee estimation final estimation = await _feeManager.getUtxoEstimatedFee(asset.id.id); final selectedLevel = _getUtxoFeeLevel(estimation, priority); @@ -952,7 +985,7 @@ class WithdrawalManager { amount: selectedLevel.feePerKbyte, ); - case TendermintProtocol: + case FeeEstimationSupport.tendermint: // Tendermint/Cosmos protocols use gas price and gas limit final estimation = await _feeManager.getTendermintEstimatedFee( asset.id.id, @@ -964,7 +997,7 @@ class WithdrawalManager { gasLimit: selectedLevel.gasLimit, ); - case QtumProtocol: + case FeeEstimationSupport.qtum: // QTUM uses similar gas model to Ethereum but different fee structure try { final estimation = await _feeManager.getEthEstimatedFeePerGas( @@ -988,7 +1021,7 @@ class WithdrawalManager { ); } - case ZhtlcProtocol: + case FeeEstimationSupport.zhtlc: // ZHTLC (Zcash) uses UTXO-style fees final estimation = await _feeManager.getUtxoEstimatedFee(asset.id.id); final selectedLevel = _getUtxoFeeLevel(estimation, priority); @@ -999,26 +1032,11 @@ class WithdrawalManager { Decimal.fromInt(250), // Assume ~250 bytes ); - default: - // For unknown protocols, attempt ETH estimation as fallback - try { - final estimation = await _feeManager.getEthEstimatedFeePerGas( - asset.id.id, - ); - final selectedLevel = _getEthFeeLevel(estimation, priority); - fee = FeeInfo.ethGasEip1559( - coin: asset.id.id, - maxFeePerGas: selectedLevel.maxFeePerGas, - maxPriorityFeePerGas: selectedLevel.maxPriorityFeePerGas, - gas: _defaultEthGasLimit, - ); - } catch (e) { - log( - 'No fee estimation available for protocol ${protocol.runtimeType}', - ); - // Return original parameters without fee - return params; - } + case FeeEstimationSupport.unsupported: + log( + 'No fee estimation available for protocol ${protocol.runtimeType}', + ); + return params; } return WithdrawParameters( diff --git a/packages/komodo_defi_sdk/test/withdrawals/withdrawal_manager_fee_support_test.dart b/packages/komodo_defi_sdk/test/withdrawals/withdrawal_manager_fee_support_test.dart new file mode 100644 index 00000000..c042d223 --- /dev/null +++ b/packages/komodo_defi_sdk/test/withdrawals/withdrawal_manager_fee_support_test.dart @@ -0,0 +1,88 @@ +import 'package:komodo_defi_sdk/src/withdrawals/withdrawal_manager.dart'; +import 'package:komodo_defi_types/komodo_defi_types.dart'; +import 'package:test/test.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() => { + 'coin': 'USDT-TRC20', + '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': 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + }, + }, + 'contract_address': 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + 'parent_coin': 'TRX', + 'nodes': >[], +}; + +Map _erc20Config() => { + 'coin': 'ETH', + 'type': 'ETH', + 'name': 'Ethereum', + 'fname': 'Ethereum', + 'wallet_only': false, + 'mm2': 1, + 'chain_id': 1, + 'required_confirmations': 3, + 'protocol': { + 'type': 'ETH', + 'protocol_data': {'chain_id': 1}, + }, + 'nodes': >[], +}; + +void main() { + group('feeEstimationSupportForProtocol', () { + test('TRX platform assets are explicitly unsupported', () { + final protocol = ProtocolClass.fromJson(_trxConfig()); + + expect( + feeEstimationSupportForProtocol(protocol), + FeeEstimationSupport.unsupported, + ); + }); + + test('TRC20 tokens are explicitly unsupported', () { + final protocol = ProtocolClass.fromJson(_trc20Config()); + + expect( + feeEstimationSupportForProtocol(protocol), + FeeEstimationSupport.unsupported, + ); + }); + + test('supported EVM protocols still classify as gas-estimated', () { + final protocol = ProtocolClass.fromJson(_erc20Config()); + + expect( + feeEstimationSupportForProtocol(protocol), + FeeEstimationSupport.evmGas, + ); + }); + }); +} diff --git a/packages/komodo_defi_types/lib/komodo_defi_types.dart b/packages/komodo_defi_types/lib/komodo_defi_types.dart index 279499c9..87319019 100644 --- a/packages/komodo_defi_types/lib/komodo_defi_types.dart +++ b/packages/komodo_defi_types/lib/komodo_defi_types.dart @@ -9,6 +9,7 @@ export 'src/api/api_client.dart'; export 'src/assets/asset.dart'; export 'src/assets/asset_cache_key.dart'; export 'src/assets/asset_id.dart'; +export 'src/assets/custom_token_exceptions.dart'; export 'src/auth/auth_result.dart'; // export 'src/auth/exceptions/incorrect_password_exception.dart'; export 'src/auth/exceptions/auth_exception.dart'; diff --git a/packages/komodo_defi_types/lib/src/assets/custom_token_exceptions.dart b/packages/komodo_defi_types/lib/src/assets/custom_token_exceptions.dart new file mode 100644 index 00000000..04646748 --- /dev/null +++ b/packages/komodo_defi_types/lib/src/assets/custom_token_exceptions.dart @@ -0,0 +1,35 @@ +import 'package:komodo_defi_types/src/coin_classes/coin_subclasses.dart'; + +class CustomTokenConflictException implements Exception { + const CustomTokenConflictException({ + required this.assetId, + required this.network, + required this.existingContractAddress, + required this.requestedContractAddress, + }); + + final String assetId; + final CoinSubClass network; + final String existingContractAddress; + final String requestedContractAddress; + + String get message => + 'A different ${network.formatted} token with id "$assetId" is already ' + 'stored. Existing contract: $existingContractAddress. Requested ' + 'contract: $requestedContractAddress.'; + + @override + String toString() => message; +} + +class UnsupportedCustomTokenNetworkException implements Exception { + const UnsupportedCustomTokenNetworkException(this.network); + + final CoinSubClass network; + + String get message => + 'Custom token import is not supported for ${network.formatted}.'; + + @override + String toString() => message; +} diff --git a/packages/komodo_defi_types/lib/src/types.dart b/packages/komodo_defi_types/lib/src/types.dart index f1376720..2ce8aea6 100644 --- a/packages/komodo_defi_types/lib/src/types.dart +++ b/packages/komodo_defi_types/lib/src/types.dart @@ -12,6 +12,7 @@ export 'assets/asset.dart'; export 'assets/asset_cache_key.dart'; export 'assets/asset_id.dart'; export 'assets/asset_symbol.dart'; +export 'assets/custom_token_exceptions.dart'; export 'auth/auth_options.dart'; export 'auth/auth_result.dart'; export 'auth/exceptions/auth_exception.dart';