diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.kt index bc41c68a0f..d244afa082 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.kt @@ -139,7 +139,7 @@ sealed class QuickActionUM( ) data object SwapAndSend : V2( - title = resourceReference(R.string.common_send_with_swap), + title = resourceReference(R.string.send_with_swap_confirm_title), description = resourceReference(R.string.quick_action_send_and_swap_description), icon = R.drawable.ic_exchange_mini_24, ) diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsBSContentUM.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsBSContentUM.kt index bcbc87b589..3bea816930 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsBSContentUM.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsBSContentUM.kt @@ -44,7 +44,7 @@ data class TokenActionsBSContentUM( iconRes = R.drawable.ic_exchange_horizontal_24, ), SendWithSwap( - text = resourceReference(R.string.common_send_with_swap), + text = resourceReference(R.string.send_with_swap_confirm_title), iconRes = R.drawable.ic_exchange_horizontal_24, ), Stake( diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTRuntimeStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTRuntimeStore.kt index 1efb779b34..6e10964419 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTRuntimeStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTRuntimeStore.kt @@ -121,8 +121,8 @@ internal class DefaultNFTRuntimeStore( }, ) } - val assetsCount = when (assets) { - is NFTCollection.Assets.Value -> assets.items.size + val assetsCount = when { + assets is NFTCollection.Assets.Value && assets.items.isNotEmpty() -> assets.items.size else -> data.count } data.copy( diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt index 6b3694be92..24effa1faa 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt @@ -3,6 +3,7 @@ package com.tangem.datasource.local.nft import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.dataStoreFile import com.squareup.moshi.Moshi import com.tangem.blockchain.nft.models.NFTCollection @@ -58,6 +59,7 @@ class NFTPersistenceStoreFactory @Inject constructor( types = types, defaultValue = defaultValue, ), + corruptionHandler = ReplaceFileCorruptionHandler { defaultValue }, produceFile = { context.dataStoreFile(fileName = fileName) }, scope = appScope, ) diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/local/nft/DefaultNFTRuntimeStoreTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/local/nft/DefaultNFTRuntimeStoreTest.kt new file mode 100644 index 0000000000..f09b40c825 --- /dev/null +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/local/nft/DefaultNFTRuntimeStoreTest.kt @@ -0,0 +1,136 @@ +package com.tangem.datasource.local.nft + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.network.Network +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.nft.models.NFTCollection +import com.tangem.domain.nft.models.NFTCollections +import com.tangem.domain.nft.models.NFTSalePrice +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.math.BigInteger + +class DefaultNFTRuntimeStoreTest { + + private val network = createNetwork() + + private val store = DefaultNFTRuntimeStore( + network = network, + collectionsRuntimeStore = RuntimeSharedStore(), + pricesRuntimeStore = RuntimeSharedStore(), + ) + + @Test + fun `GIVEN collection with empty loaded assets WHEN getCollections THEN collection is kept`() = runTest { + // Arrange + val collection = createCollection( + count = 1, + assets = NFTCollection.Assets.Value(items = emptyList(), source = StatusSource.ACTUAL), + ) + store.initialize(collections = createCollections(collection), prices = emptyMap()) + + // Act + val content = store.getCollections().first().content + + // Assert + assertThat(content).isInstanceOf(NFTCollections.Content.Collections::class.java) + content as NFTCollections.Content.Collections + val actual = content.collections.orEmpty().single() + assertThat(actual.count).isEqualTo(1) + assertThat(actual.assets).isInstanceOf(NFTCollection.Assets.Value::class.java) + } + + @Test + fun `GIVEN collection with loaded assets WHEN getCollections THEN count recalculated from assets`() = runTest { + // Arrange + val collection = createCollection( + count = 5, + assets = NFTCollection.Assets.Value(items = listOf(createAsset()), source = StatusSource.ACTUAL), + ) + store.initialize(collections = createCollections(collection), prices = emptyMap()) + + // Act + val content = store.getCollections().first().content + + // Assert + content as NFTCollections.Content.Collections + assertThat(content.collections.orEmpty().single().count).isEqualTo(1) + } + + @Test + fun `GIVEN collection with zero count and not loaded assets WHEN getCollections THEN collection filtered out`() = + runTest { + // Arrange + val collection = createCollection(count = 0, assets = NFTCollection.Assets.Empty) + store.initialize(collections = createCollections(collection), prices = emptyMap()) + + // Act + val content = store.getCollections().first().content + + // Assert + content as NFTCollections.Content.Collections + assertThat(content.collections.orEmpty()).isEmpty() + } + + private fun createCollections(vararg collections: NFTCollection) = NFTCollections( + network = network, + content = NFTCollections.Content.Collections( + collections = collections.toList(), + source = StatusSource.ACTUAL, + ), + ) + + private fun createCollection(count: Int, assets: NFTCollection.Assets) = NFTCollection( + id = NFTCollection.Identifier.EVM(tokenAddress = TOKEN_ADDRESS), + network = network, + name = "Test collection", + description = null, + logoUrl = null, + count = count, + assets = assets, + ) + + private fun createAsset(): NFTAsset { + val assetId = NFTAsset.Identifier.EVM( + tokenAddress = TOKEN_ADDRESS, + tokenId = BigInteger.ONE, + contractType = NFTAsset.Identifier.EVM.ContractType.ERC721, + ) + return NFTAsset( + id = assetId, + collectionId = NFTCollection.Identifier.EVM(tokenAddress = TOKEN_ADDRESS), + network = network, + contractType = "ERC721", + owner = null, + name = "Test asset", + description = null, + amount = null, + decimals = 0, + salePrice = NFTSalePrice.Empty(assetId), + rarity = null, + media = null, + traits = emptyList(), + source = StatusSource.ACTUAL, + ) + } + + private fun createNetwork() = Network( + id = Network.ID(rawId = Network.RawID("ethereum"), derivationPath = Network.DerivationPath.None), + name = "Ethereum", + currencySymbol = "ETH", + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.ERC20, + hasFiatFeeRate = true, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + + private companion object { + const val TOKEN_ADDRESS = "0x0000000000000000000000000000000000000001" + } +} \ No newline at end of file diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 5914434127..92c99b8dfa 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -131,6 +131,7 @@ Noch keine Kontakte Die von Ihnen hinzugefügten Kontakte werden hier angezeigt Adresse entfernen + Adresse speichern Kontakt speichern In Wallet speichern Dieser Kontakt wird mit dem Adressbuch dieser Wallet verknüpft. @@ -638,6 +639,8 @@ Lange Transaktionszeit Der Transaktionsbetrag wurde aufgrund von OKX- oder Bridge-Regeln in %1$s auf deine Wallet zurückerstattet. %2$s Der Betrag wurde in %1$s (%2$s Netzwerk) zurückerstattet. + Ihr Guthaben wurde gemäß den Regeln der OKX-Börse in %1$s an Ihre Wallet im %2$s -Netzwerk zurückerstattet. + Rückerstattung erfolgt in %s Besuche die Website des Anbieters zur Überprüfung KYC-Überprüfung durch den Anbieter erforderlich Kauf abgeschlossen @@ -1307,7 +1310,9 @@ Sie sind erfolgreich eingeschrieben in %1$s Diese Aktion existiert nicht mehr oder ist abgelaufen. Kampagne nicht aktiv + Erhalten Sie bei jedem Swap über 500 $ ein Cashback von 0,5% , und zwar für alle Währungspaare außer „Stablecoin zu Stablecoin“. Die maximale Auszahlung beträgt 50 $ pro Swap.\n\nFühren Sie fünf qualifizierende Swaps durch und sichern Sie sich einen zusätzlichen Bonus von 10 $.\n\nDie Prämien werden wöchentlich in USDT oder USDC an die ausgewählte Adresse ausgezahlt. Cashback-Konto auswählen + Token auswählen Melden Sie sich an %1$s Ich stimme zu, dass %1$s Ich stimme zu, dass diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index 60ae725cbf..d74f406f10 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -256,7 +256,7 @@ %s falló Activar Agregar - Agregar fondos + Depositar Añadir al portafolio Agregar token Añada tokens @@ -347,8 +347,9 @@ De De %s Sincronizar direcciones + Comprar Comenzar - Obtener token + Comprar token Ir al proveedor Ir al token Entendido @@ -1241,6 +1242,8 @@ Disponible desde Disponible hasta Obtiene + Este token no está soportado. Por favor, elija otro token para comprar. + %s no está soportado El servicio es proporcionado por un proveedor externo. \nTangem no es responsable. Puede comprobar el estado de la transacción desde la página detallada del token Hasta @@ -2505,7 +2508,7 @@ Se le descontará la comisión y se volverán a suministrar sus activos. Para seguir generando rendimiento, se requiere aprobación. Confirmar aprobación - APY promedio %1$s%% + APY actual %1$s%% Sus fondos se suministran actualmente al protocolo Aave, pero puede gestionarlos en cualquier momento. Su %s está depositado en Aave No se puede cargar el gráfico... diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 2c2737a84a..5038b93bf1 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -355,6 +355,7 @@ 送信元 %sから アドレスを同期する + 入手 はじめる トークンを取得 プロバイダーへ移動 @@ -1249,6 +1250,20 @@ 残高順 トークンを整理する グループ解除 + 対象のキャッシュバックは次のアドレスに配布されます: + すでに%1$sに参加しています + 対象トークン + 参加する + %1$sへの参加が完了しました + このキャンペーンは終了したか、無効です + キャンペーンは実施されていません + 500ドルを超えるスワップごとに0.5%のキャッシュバックを獲得できます。ステーブルコイン同士を除く、すべてのペアが対象です。キャッシュバックは1回のスワップあたり最大50ドルです。\n\n対象のスワップを5回完了すると、さらに10ドルのボーナスを獲得できます。\n\n報酬は毎週、ご指定のUSDTまたはUSDCアドレスにお支払いします。 + キャッシュバックの受取口座を選択 + トークンを選択 + %1$sに参加 + %1$sに同意します + %1$s 利用規約 + 7月末まで、累計1万ドル以上のスワップでキャッシュバックを獲得できます。\n\n累計スワップ額に応じて還元率がアップします。1万ドル以上で0.10%、2万ドル以上で0.20%、10万ドル以上で0.50%。\n\nキャッシュバックは、1回のスワップあたり最大500ドル、ウォレットごと・スワップ方向ごとにキャンペーン期間中、合計最大1万ドルです。ステーブルコイン同士のスワップは対象外です。\n\nキャッシュバックは毎週、ご指定のUSDTまたはUSDCアドレスにお支払いします。 %sサポート プッシュ通知は有効ですが、許可するまで動作しません 通知を許可する diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index d111e36499..ae70e6b4a6 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -377,6 +377,7 @@ Из Из %s Синхронизировать адреса + Приобрести Начать Получить токен К провайдеру @@ -2010,6 +2011,7 @@ Внесите USDC на счёт, чтобы покрыть комиссию Невозможно покрыть комиссию Перевыпустить карту? + Удалить аккаунт Мы устраняем техническую проблему. Пожалуйста, попробуйте позже. Сервис временно недоступен Не можем показать данные карты, но оплаты продолжают работать. @@ -2519,7 +2521,7 @@ Комиссия будет списана, и ваши активы снова начнут приносить доход. Чтобы продолжить зарабатывать, нужно выдать разрешение. Подтвердить разрешение - Средний APY %1$s%% + Текущий APY %1$s%% Ваши средства в данный момент размещены в протоколе Aave, но вы можете воспользоваться ими в любое время. Ваш %s внесён в Aave Невозможно загрузить график diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index ef6f3f2e6c..b2e532969f 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -356,6 +356,7 @@ 从 %s 同步地址 + 得到 开始 获取代币 前往服务提供商 @@ -1245,6 +1246,21 @@ 按余额 整理代币 取消分组 + 符合条件的返现将发放给: + 您已经注册了 %1$s + 符合条件的代币 + 注册 + 您已成功注册 %1$s + 此活动已结束或已过期。 + 活动未启动 + 每次兑换金额超过 500 美元,即可获得 0.5% 的返现,适用于除稳定币与稳定币之间的兑换以外的所有交易对。每次兑换的最高返现金额为 50 美元。\n\n完成五笔符合条件的兑换,即可解锁额外 10 美元的奖励。\n\n奖励每周以 USDT 或 USDC 形式支付至您选择的地址。 + 选择返现账户 + 选择代币 + 报名参加 %1$s + 我同意 %1$s + 我同意 + %1$s 条款 + 7月底前,每次1万美元及以上的兑换均可获得返现。\n\n返现率随交易金额递增:1万美元起为0.10% ,2万美元起为0.20% ,10万美元起为0.50% 。n\n最高奖励:每笔最少兑换500美元,且在活动期间内,每个钱包单向兑换方向的奖励上限为10,000美元。稳定币与稳定币之间的兑换不计入奖励范围。\n\n奖励每周发放至您指定的USDT或USDC地址。 %s 支持 推送通知已启用,但需要您在设备设置中允许通知才能正常工作。 允许通知 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index cea7f845ca..38b59392d3 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -928,6 +928,7 @@ In your portfolio Your portfolio **Token not supported**. This token is currently not supported in the wallet + Other eligible tokens Market Pulse Quick actions Clear all @@ -2624,7 +2625,7 @@ The fee will be deducted, and your assets will be resupplied. To continue generating yield, approval is required. Confirm approval - Average APY %1$s%% + Current APY %1$s%% Your funds are currently supplied to the Aave protocol, but you can manage them at any time. Your %s is supplied to Aave Unable to load chart... diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeaderLegacy.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeaderLegacy.kt index 18b34425eb..adcce89a2a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeaderLegacy.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeaderLegacy.kt @@ -47,7 +47,7 @@ fun TangemBottomSheetDraggableHeader() { height = TangemTheme.dimens2.x1, ) .background( - color = TangemTheme.colors2.graphic.neutral.primaryInverted, + color = TangemTheme.colors3.icon.tertiary, shape = RoundedCornerShape(TangemTheme.dimens2.x0_5), ), ) diff --git a/data/nft/build.gradle.kts b/data/nft/build.gradle.kts index 4f9f268fb9..da3bc13711 100644 --- a/data/nft/build.gradle.kts +++ b/data/nft/build.gradle.kts @@ -58,6 +58,7 @@ dependencies { testImplementation(deps.test.coroutine) testImplementation(deps.test.junit5) testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) testImplementation(projects.common.test) // endregion } \ No newline at end of file diff --git a/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt b/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt index d5e0ee4f95..dcca338ef7 100644 --- a/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt +++ b/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt @@ -12,6 +12,7 @@ import com.tangem.datasource.local.nft.NFTPersistenceStore import com.tangem.datasource.local.nft.NFTPersistenceStoreFactory import com.tangem.datasource.local.nft.NFTRuntimeStore import com.tangem.datasource.local.nft.NFTRuntimeStoreFactory +import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter import com.tangem.datasource.local.nft.converter.NFTSdkAssetIdentifierConverter import com.tangem.datasource.local.nft.converter.NFTSdkAssetSalePriceConverter import com.tangem.datasource.local.nft.converter.NFTSdkCollectionConverter @@ -181,29 +182,27 @@ internal class DefaultNFTRepository @Inject constructor( } } - getNFTPersistenceStore(userWalletId, network) - .getCollectionsSync() - ?.map { collection -> - if (collection.identifier == sdkCollectionId) { - collection.copy(assets = assets) - } else { - collection - } - } - ?.let { collections -> - saveCollectionsInRuntime( - userWalletId = userWalletId, - network = network, - collections = collections, - ) - saveCollectionsInPersistence( - userWalletId = userWalletId, - network = network, - collections = collections, - ) - } + saveAssetsInRuntime( + userWalletId = userWalletId, + network = network, + collectionId = collectionId, + assets = assets, + ) + + // local cache failures must not affect the runtime state which is already up to date + runSuspendCatching { + updateAssetsInPersistence( + userWalletId = userWalletId, + network = network, + sdkCollectionId = sdkCollectionId, + assets = assets, + ) + }.onFailure { error -> + TangemLogger.e("Failed to persist NFT assets for $network", error) + } }.onLeft { throwable -> if (throwable !is UnsupportedOperationException) { + TangemLogger.e("Failed to refresh NFT assets for $network", throwable) saveFailedStateInRuntime( userWalletId = userWalletId, network = network, @@ -269,18 +268,29 @@ internal class DefaultNFTRepository @Inject constructor( expireCollections(userWalletId, network) val collections = walletManagersFacade.getNFTCollections(userWalletId, network) - val mergedCollections = collections.mergeWithStoredAssets(userWalletId, network) + + // local cache failures must not affect successfully fetched collections + val mergedCollections = runSuspendCatching { + collections.mergeWithStoredAssets(userWalletId, network) + }.getOrElse { error -> + TangemLogger.e("Failed to merge NFT collections with stored assets for $network", error) + collections + } saveCollectionsInRuntime( userWalletId = userWalletId, network = network, collections = mergedCollections, ) - saveCollectionsInPersistence( - userWalletId = userWalletId, - network = network, - collections = mergedCollections, - ) + runSuspendCatching { + saveCollectionsInPersistence( + userWalletId = userWalletId, + network = network, + collections = mergedCollections, + ) + }.onFailure { error -> + TangemLogger.e("Failed to persist NFT collections for $network", error) + } if (refreshAssets) { mergedCollections.forEach { collection -> @@ -292,6 +302,7 @@ internal class DefaultNFTRepository @Inject constructor( } } }.onLeft { throwable -> + TangemLogger.e("Failed to refresh NFT collections for $network", throwable) saveFailedStateInRuntime( userWalletId = userWalletId, network = network, @@ -391,12 +402,72 @@ internal class DefaultNFTRepository @Inject constructor( } } + private suspend fun saveAssetsInRuntime( + userWalletId: UserWalletId, + network: Network, + collectionId: NFTCollection.Identifier, + assets: List, + ) { + val store = getNFTRuntimeStore(userWalletId, network) + val storedCollections = store.getCollectionsSync() + val content = storedCollections.content as? NFTCollections.Content.Collections ?: return + + val convertedAssets = assets + .map { asset -> NFTSdkAssetConverter.convert(network to asset) } + .filter { it.id !is NFTAsset.Identifier.Unknown } + + val updatedCollections = content.collections + ?.map { collection -> + if (collection.id == collectionId) { + collection.copy( + assets = NFTCollection.Assets.Value( + items = convertedAssets, + source = StatusSource.ACTUAL, + ), + ) + } else { + collection + } + } + + store.saveCollections( + storedCollections.copy(content = content.copy(collections = updatedCollections)), + ) + } + + private suspend fun updateAssetsInPersistence( + userWalletId: UserWalletId, + network: Network, + sdkCollectionId: SdkNFTCollection.Identifier, + assets: List, + ) { + val storedCollections = getNFTPersistenceStore(userWalletId, network).getCollectionsSync() ?: return + val updatedCollections = storedCollections.map { collection -> + if (collection.identifier == sdkCollectionId) { + collection.copy(assets = assets) + } else { + collection + } + } + saveCollectionsInPersistence(userWalletId, network, updatedCollections) + } + private suspend fun saveCollectionsInPersistence( userWalletId: UserWalletId, network: Network, collections: List, ) { - getNFTPersistenceStore(userWalletId, network).saveCollections(collections) + val serializableCollections = collections + .filter { it.identifier !is SdkNFTCollection.Identifier.Unknown } + .map { collection -> + collection.copy( + assets = collection.assets.filter { asset -> + asset.identifier !is SdkNFTAsset.Identifier.Unknown && + asset.collectionIdentifier !is SdkNFTCollection.Identifier.Unknown + }, + ) + } + getNFTPersistenceStore(userWalletId, network).saveCollections(serializableCollections) } private suspend fun saveSalePriceInRuntime(userWalletId: UserWalletId, network: Network, salePrice: NFTSalePrice) { diff --git a/data/nft/src/test/kotlin/com/tangem/data/nft/DefaultNFTRepositoryTest.kt b/data/nft/src/test/kotlin/com/tangem/data/nft/DefaultNFTRepositoryTest.kt new file mode 100644 index 0000000000..250cf17d20 --- /dev/null +++ b/data/nft/src/test/kotlin/com/tangem/data/nft/DefaultNFTRepositoryTest.kt @@ -0,0 +1,288 @@ +package com.tangem.data.nft + +import android.content.Context +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.datasource.local.nft.NFTPersistenceStore +import com.tangem.datasource.local.nft.NFTPersistenceStoreFactory +import com.tangem.datasource.local.nft.NFTRuntimeStore +import com.tangem.datasource.local.nft.NFTRuntimeStoreFactory +import com.tangem.datasource.local.nft.converter.NFTSdkCollectionIdentifierConverter +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.nft.models.NFTCollection +import com.tangem.domain.nft.models.NFTCollections +import com.tangem.domain.nft.models.NFTSalePrice +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.io.IOException +import java.math.BigInteger +import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset +import com.tangem.blockchain.nft.models.NFTCollection as SdkNFTCollection + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DefaultNFTRepositoryTest { + + private val nftPersistenceStoreFactory: NFTPersistenceStoreFactory = mockk() + private val nftRuntimeStoreFactory: NFTRuntimeStoreFactory = mockk() + private val walletManagersFacade: WalletManagersFacade = mockk() + private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val context: Context = mockk() + + private val userWalletId = UserWalletId("011") + private val userWallet = mockk { + every { walletId } returns userWalletId + } + + private val network: Network = MockCryptoCurrencyFactory().ethereum.network + + @BeforeEach + fun resetMocks() { + clearMocks(nftPersistenceStoreFactory, nftRuntimeStoreFactory, walletManagersFacade, userWalletsListRepository) + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) + every { context.resources } returns mockk() + } + + private fun createRepository() = DefaultNFTRepository( + nftPersistenceStoreFactory = nftPersistenceStoreFactory, + nftRuntimeStoreFactory = nftRuntimeStoreFactory, + walletManagersFacade = walletManagersFacade, + dispatchers = TestingCoroutineDispatcherProvider(), + userWalletsListRepository = userWalletsListRepository, + networkFactory = mockk(), + excludedBlockchains = ExcludedBlockchains(), + context = context, + ) + + @Test + fun `GIVEN collections fetched WHEN persistence write fails THEN runtime keeps actual data`() = runTest { + // Arrange + val runtimeStore = FakeNFTRuntimeStore(network) + val persistenceStore = mockk { + coEvery { getCollectionsSync() } returns null + coEvery { getSalePricesSync() } returns null + coEvery { saveCollections(any()) } throws IOException("Failed to write to disk") + } + every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceStore + every { nftRuntimeStoreFactory.provide(network) } returns runtimeStore + coEvery { walletManagersFacade.getNFTCollections(userWalletId, network) } returns listOf(createSdkCollection()) + + // Act + createRepository().refreshCollections(userWalletId, listOf(network)) + + // Assert + val content = runtimeStore.getCollectionsSync().content + assertThat(content).isInstanceOf(NFTCollections.Content.Collections::class.java) + content as NFTCollections.Content.Collections + assertThat(content.source).isEqualTo(StatusSource.ACTUAL) + assertThat(content.collections).hasSize(1) + } + + @Test + fun `GIVEN runtime has collection missing in persistence WHEN refreshAssets THEN assets saved to runtime`() = + runTest { + // Arrange + val sdkCollection = createSdkCollection() + val collectionId = NFTSdkCollectionIdentifierConverter.convert(sdkCollection.identifier) + val runtimeStore = FakeNFTRuntimeStore(network) + val persistenceStore = mockk { + coEvery { getCollectionsSync() } returns null + coEvery { getSalePricesSync() } returns null + coEvery { saveCollections(any()) } returns Unit + } + every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceStore + every { nftRuntimeStoreFactory.provide(network) } returns runtimeStore + coEvery { + walletManagersFacade.getNFTCollections(userWalletId, network) + } returns listOf(sdkCollection) + coEvery { + walletManagersFacade.getNFTAssets(userWalletId, network, sdkCollection.identifier) + } returns listOf(createSdkAsset()) + coEvery { walletManagersFacade.getNFTSalePrice(userWalletId, network, any(), any()) } returns null + + val repository = createRepository() + // seed runtime store with the fetched collection, persistence stays empty + repository.refreshCollections(userWalletId, listOf(network)) + + // Act + repository.refreshAssets(userWalletId, network, collectionId) + + // Assert + val content = runtimeStore.getCollectionsSync().content + assertThat(content).isInstanceOf(NFTCollections.Content.Collections::class.java) + content as NFTCollections.Content.Collections + val assets = content.collections.orEmpty().single().assets + assertThat(assets).isInstanceOf(NFTCollection.Assets.Value::class.java) + assets as NFTCollection.Assets.Value + assertThat(assets.items).hasSize(1) + } + + @Test + fun `GIVEN fetch returns no assets WHEN refreshAssets THEN empty loaded value saved to runtime`() = runTest { + // Arrange + val sdkCollection = createSdkCollection() + val collectionId = NFTSdkCollectionIdentifierConverter.convert(sdkCollection.identifier) + val runtimeStore = FakeNFTRuntimeStore(network) + val persistenceStore = mockk { + coEvery { getCollectionsSync() } returns null + coEvery { getSalePricesSync() } returns null + coEvery { saveCollections(any()) } returns Unit + } + every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceStore + every { nftRuntimeStoreFactory.provide(network) } returns runtimeStore + coEvery { + walletManagersFacade.getNFTCollections(userWalletId, network) + } returns listOf(sdkCollection) + coEvery { + walletManagersFacade.getNFTAssets(userWalletId, network, sdkCollection.identifier) + } returns emptyList() + + val repository = createRepository() + repository.refreshCollections(userWalletId, listOf(network)) + + // Act + repository.refreshAssets(userWalletId, network, collectionId) + + // Assert + val content = runtimeStore.getCollectionsSync().content + assertThat(content).isInstanceOf(NFTCollections.Content.Collections::class.java) + content as NFTCollections.Content.Collections + val assets = content.collections.orEmpty().single().assets + assertThat(assets).isInstanceOf(NFTCollection.Assets.Value::class.java) + assets as NFTCollection.Assets.Value + assertThat(assets.items).isEmpty() + } + + @Test + fun `GIVEN no cached collections WHEN fetch fails THEN error state saved to runtime`() = runTest { + // Arrange + val runtimeStore = FakeNFTRuntimeStore(network) + val persistenceStore = mockk { + coEvery { getCollectionsSync() } returns null + coEvery { getSalePricesSync() } returns null + } + every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceStore + every { nftRuntimeStoreFactory.provide(network) } returns runtimeStore + coEvery { walletManagersFacade.getNFTCollections(userWalletId, network) } throws IOException("HTTP 500") + + // Act + createRepository().refreshCollections(userWalletId, listOf(network)) + + // Assert + val content = runtimeStore.getCollectionsSync().content + assertThat(content).isInstanceOf(NFTCollections.Content.Error::class.java) + } + + @Test + fun `GIVEN cached collections WHEN fetch fails THEN cache marked as only cache`() = runTest { + // Arrange + val runtimeStore = FakeNFTRuntimeStore(network) + val persistenceStore = mockk { + coEvery { getCollectionsSync() } returns listOf(createSdkCollection()) + coEvery { getSalePricesSync() } returns null + } + every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceStore + every { nftRuntimeStoreFactory.provide(network) } returns runtimeStore + coEvery { walletManagersFacade.getNFTCollections(userWalletId, network) } throws IOException("HTTP 500") + + // Act + createRepository().refreshCollections(userWalletId, listOf(network)) + + // Assert + val content = runtimeStore.getCollectionsSync().content + assertThat(content).isInstanceOf(NFTCollections.Content.Collections::class.java) + content as NFTCollections.Content.Collections + assertThat(content.source).isEqualTo(StatusSource.ONLY_CACHE) + assertThat(content.collections).hasSize(1) + } + + private fun createSdkCollection(assets: List = emptyList()) = SdkNFTCollection( + identifier = SdkNFTCollection.Identifier.EVM(tokenAddress = TOKEN_ADDRESS), + blockchainId = Blockchain.Ethereum.id, + name = "Test collection", + description = null, + logoUrl = null, + count = 1, + assets = assets, + ) + + private fun createSdkAsset() = SdkNFTAsset( + identifier = SdkNFTAsset.Identifier.EVM( + tokenId = BigInteger.ONE, + tokenAddress = TOKEN_ADDRESS, + contractType = SdkNFTAsset.Identifier.EVM.ContractType.ERC721, + ), + collectionIdentifier = SdkNFTCollection.Identifier.EVM(tokenAddress = TOKEN_ADDRESS), + blockchainId = Blockchain.Ethereum.id, + contractType = "ERC721", + owner = null, + name = "Test asset", + description = null, + amount = BigInteger.ONE, + decimals = 0, + salePrice = null, + rarity = null, + media = null, + traits = emptyList(), + ) + + private class FakeNFTRuntimeStore(private val network: Network) : NFTRuntimeStore { + + private var collections: NFTCollections = NFTCollections.empty(network) + private var prices: Map = emptyMap() + + override suspend fun initialize(collections: NFTCollections, prices: Map) { + this.collections = collections + this.prices = prices + } + + override fun getCollections(): Flow = flowOf(collections) + + override suspend fun getCollectionsSync(): NFTCollections = collections + + override fun getAsset( + collectionId: NFTCollection.Identifier, + assetId: NFTAsset.Identifier, + ): Flow = flowOf(null) + + override fun getSalePrice(assetId: NFTAsset.Identifier): Flow = + flowOf(prices[assetId] ?: NFTSalePrice.Empty(assetId)) + + override suspend fun getSalePriceSync(assetId: NFTAsset.Identifier): NFTSalePrice = + prices[assetId] ?: NFTSalePrice.Empty(assetId) + + override suspend fun saveCollections(collections: NFTCollections) { + this.collections = collections + } + + override suspend fun saveSalePrice(salePrice: NFTSalePrice) { + prices = prices + (salePrice.assetId to salePrice) + } + + override suspend fun clear() { + collections = NFTCollections.empty(network) + prices = emptyMap() + } + } + + private companion object { + const val TOKEN_ADDRESS = "0x0000000000000000000000000000000000000001" + } +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt index 4cc9fc0ed5..0e1e27ace2 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/BaseActionsFactory.kt @@ -133,6 +133,23 @@ internal open class BaseActionsFactory( } } + /** + * Determines the unavailability reason for the BUY action + * + * @param userWallet the user's wallet + * @param currency the cryptocurrency to check + */ + protected fun getBuyUnavailabilityReason( + userWallet: UserWallet, + currency: CryptoCurrency, + ): ScenarioUnavailabilityReason { + return if (userWallet is UserWallet.Cold && userWallet.cardTypesResolver.isStart2Coin()) { + ScenarioUnavailabilityReason.BuyUnavailable(currency.name) + } else { + ScenarioUnavailabilityReason.None + } + } + /** Adds a "Buy" action to the builder based on the unavailability [reason] */ protected fun ActionAvailabilityBuilder.addBuyAction(reason: ScenarioUnavailabilityReason) { val action = ActionState.Buy(unavailabilityReason = reason) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt index f244724fd4..7d10d0735c 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt @@ -91,7 +91,12 @@ internal class CommonActionsFactory( // endregion // region Buy - addBuyAction(reason = ScenarioUnavailabilityReason.None) + addBuyAction( + reason = getBuyUnavailabilityReason( + userWallet = userWallet, + currency = cryptoCurrencyStatus.currency, + ), + ) // endregion // region Sell diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt index 111e4685a5..7973e82ecf 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/OutdatedDataActionsFactory.kt @@ -79,7 +79,12 @@ internal class OutdatedDataActionsFactory( // endregion // region Buy - addBuyAction(reason = ScenarioUnavailabilityReason.None) + addBuyAction( + reason = getBuyUnavailabilityReason( + userWallet = userWallet, + currency = cryptoCurrencyStatus.currency, + ), + ) // endregion // region Stake diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/UnreachableActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/UnreachableActionsFactory.kt index 23e188e3dd..6ecb09d774 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/UnreachableActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/UnreachableActionsFactory.kt @@ -44,7 +44,12 @@ internal class UnreachableActionsFactory( // endregion // region Buy - addBuyAction(reason = ScenarioUnavailabilityReason.None) + addBuyAction( + reason = getBuyUnavailabilityReason( + userWallet = userWallet, + currency = cryptoCurrencyStatus.currency, + ), + ) // endregion // region Receive diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactoryTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactoryTest.kt new file mode 100644 index 0000000000..ba34737ad5 --- /dev/null +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactoryTest.kt @@ -0,0 +1,108 @@ +package com.tangem.domain.tokens.actions + +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.card.CardTypesResolver +import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.exchange.RampStateManager +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason +import com.tangem.domain.tokens.model.TokenActionsState.ActionState +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.yield.supply.models.YieldSupplyAvailability +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class CommonActionsFactoryTest { + + private val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true) + private val rampStateManager: RampStateManager = mockk(relaxed = true) + + private val factory = CommonActionsFactory( + walletManagersFacade = walletManagersFacade, + rampStateManager = rampStateManager, + ) + + private val currency: CryptoCurrency = MockCryptoCurrencyFactory().ethereum + + private val cardTypesResolver: CardTypesResolver = mockk() + private val userWallet: UserWallet.Cold = mockk(relaxed = true) + private val cryptoCurrencyStatus: CryptoCurrencyStatus = mockk() + + @BeforeAll + fun setupStatic() { + // cardTypesResolver is a UserWallet.Cold extension property, so it is stubbed via its file class. + mockkStatic("com.tangem.domain.card.common.util.ScanResponseExtKt") + } + + @AfterAll + fun tearDownStatic() { + unmockkStatic("com.tangem.domain.card.common.util.ScanResponseExtKt") + } + + @BeforeEach + fun setup() { + clearMocks(walletManagersFacade, rampStateManager, cardTypesResolver, userWallet, cryptoCurrencyStatus) + + val value = mockk(relaxed = true) + every { cryptoCurrencyStatus.value } returns value + every { cryptoCurrencyStatus.currency } returns currency + + every { userWallet.cardTypesResolver } returns cardTypesResolver + every { userWallet.isMultiCurrency } returns false + + coEvery { rampStateManager.getSendUnavailabilityReason(any(), any()) } returns ScenarioUnavailabilityReason.None + coEvery { rampStateManager.availableForSell(any(), any(), any()) } returns Unit.right() + } + + @Test + fun `GIVEN Start2Coin cold wallet WHEN create THEN buy action is unavailable`() = runTest { + // Arrange + every { cardTypesResolver.isStart2Coin() } returns true + + // Act + val buyAction = createBuyAction() + + // Assert + assertThat(buyAction.unavailabilityReason) + .isEqualTo(ScenarioUnavailabilityReason.BuyUnavailable(currency.name)) + } + + @Test + fun `GIVEN non-Start2Coin cold wallet WHEN create THEN buy action is available`() = runTest { + // Arrange + every { cardTypesResolver.isStart2Coin() } returns false + + // Act + val buyAction = createBuyAction() + + // Assert + assertThat(buyAction.unavailabilityReason).isEqualTo(ScenarioUnavailabilityReason.None) + } + + private suspend fun createBuyAction(): ActionState.Buy { + val actions = factory.create( + userWallet = userWallet, + cryptoCurrencyStatus = cryptoCurrencyStatus, + stakingAvailability = StakingAvailability.Unavailable, + yieldSupplyAvailability = YieldSupplyAvailability.Unavailable, + shouldShowSwapStories = false, + ) + return actions.filterIsInstance().single() + } +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index fa252262e5..2e424aeb67 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -1581,6 +1581,7 @@ internal class SwapInteractorImpl @Inject constructor( feeValue = nativeFee, selectedFeeToken = fee.selectedFeeToken, provider = state.swapProvider, + txType = state.txType, ) val currencyCheck = manageWarnings( fromSwapCurrencyStatus = fromSwapCurrencyStatus, @@ -1621,6 +1622,7 @@ internal class SwapInteractorImpl @Inject constructor( * from-currencies but "fee > native balance" for Token from-currencies is resolved here * by consulting `isBalanceEnough` (amount-alone check) directly. */ + @Suppress("LongParameterList") private suspend fun computeBalanceStatus( fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, @@ -1628,9 +1630,10 @@ internal class SwapInteractorImpl @Inject constructor( feeValue: BigDecimal, selectedFeeToken: CryptoCurrencyStatus?, provider: SwapProvider, + txType: ExpressTxType?, ): SwapBalanceStatus { - when (provider.type) { - ExchangeProviderType.CEX -> { + when (resolveQuoteFlow(provider, txType)) { + ResolvedFlow.CexLike -> { val includeStatus = getIncludeFeeInAmountInternal( fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, @@ -1642,9 +1645,7 @@ internal class SwapInteractorImpl @Inject constructor( return SwapBalanceStatus.FeeAdjustedAmount(adjustedAmount = includeStatus.amountSubtractFee) } } - ExchangeProviderType.DEX, - ExchangeProviderType.DEX_BRIDGE, - -> Unit + ResolvedFlow.DexLike -> Unit } val isAmountAlone = isBalanceEnough(fromSwapCurrencyStatus, amount, fee = feeValue) diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeMatrixTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeMatrixTest.kt index 9f7b14673e..67c30c7fdd 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeMatrixTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeMatrixTest.kt @@ -14,6 +14,7 @@ import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.feature.swap.domain.fee.TransactionFeeResult import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.ExpressTxType import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus import com.tangem.feature.swap.domain.models.ui.* @@ -214,6 +215,76 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest } } + // ========================================================================= + // Section A2: DEX provider re-routed to the CEX-like flow via txType=SEND ([REDACTED_TASK_KEY]) + // ========================================================================= + + @Nested + inner class `DEX provider with SEND txType follows CEX semantics` { + + /** + * [REDACTED_TASK_KEY]: a DEX-typed provider (e.g. Moonpay trade) whose quote returned txType=SEND + * executes as a plain transfer built by the app, so the fee must be folded into the amount + * exactly like for a CEX provider. + * + * GIVEN ExchangeProviderType.DEX, txType = SEND + * fromToken is Coin, amount = full native balance (max amount), fee = 0.01 + * WHEN applySwapFee runs + * THEN balanceStatus == FeeAdjustedAmount with adjustedAmount = balance - fee + * (NOT InsufficientAmount — the pre-fix behavior that showed "Insufficient funds") + */ + @Test + fun `applySwapFee DEX with SEND txType — max amount returns FeeAdjustedAmount like CEX`() = runTest { + coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin + coEvery { + walletManagersFacade.getNativeTokenBalance(any(), any(), any()) + } returns BigDecimal("1.0") + + val state = buildQuotesLoadedState( + providerType = ExchangeProviderType.DEX, + fromAmount = SwapAmount(BigDecimal("1.0"), 18), + isCoin = true, + fromBalance = BigDecimal("1.0"), + txType = ExpressTxType.SEND, + ) + val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.01")) + + val result = sut.applySwapFee(state, fee, lastReducedBalanceBy) + + val balanceStatus = result.preparedSwapConfigState.balanceStatus + assertThat(balanceStatus).isInstanceOf(SwapBalanceStatus.FeeAdjustedAmount::class.java) + assertThat((balanceStatus as SwapBalanceStatus.FeeAdjustedAmount).adjustedAmount.value) + .isEqualTo(BigDecimal("0.99")) + } + + /** + * Twin guard: the same max-amount scenario with txType = SWAP keeps the DEX invariant — + * the fee is never deducted from the amount, and the amount alone exceeding + * balance-with-fee yields InsufficientAmount. + */ + @Test + fun `applySwapFee DEX with SWAP txType — max amount keeps DEX semantics without fee deduction`() = runTest { + coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin + coEvery { + walletManagersFacade.getNativeTokenBalance(any(), any(), any()) + } returns BigDecimal("1.0") + + val state = buildQuotesLoadedState( + providerType = ExchangeProviderType.DEX, + fromAmount = SwapAmount(BigDecimal("1.0"), 18), + isCoin = true, + fromBalance = BigDecimal("1.0"), + txType = ExpressTxType.SWAP, + ) + val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.01")) + + val result = sut.applySwapFee(state, fee, lastReducedBalanceBy) + + assertThat(result.preparedSwapConfigState.balanceStatus) + .isInstanceOf(SwapBalanceStatus.InsufficientAmount::class.java) + } + } + // ========================================================================= // Section B: FeePaidCurrency.Token (gasless-token) paths // ========================================================================= @@ -749,6 +820,7 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest fromAmount: SwapAmount, isCoin: Boolean, fromBalance: BigDecimal, + txType: ExpressTxType? = null, ): SwapState.QuotesLoadedState { val from = buildSwapCurrencyStatus( networkRawId = ethNetwork, @@ -778,6 +850,7 @@ internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTest validationResult = null, minAdaValue = null, swapProvider = buildSwapProvider(providerType), + txType = txType, ) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 18803e9f8f..1fffe4938d 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -25,6 +25,7 @@ import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNet import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.ExpressTxType import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus import com.tangem.feature.swap.domain.models.ui.PermissionDataState import com.tangem.feature.swap.domain.models.ui.PriceImpact @@ -327,11 +328,16 @@ internal class SwapNotificationsFactory( val shouldShowCoverWarning = quoteModel.permissionState !is PermissionDataState.PermissionLoading && feeCryptoCurrencyStatus.currency != fromCurrency - val isCEXProvider = quoteModel.swapProvider.type == ExchangeProviderType.CEX + // A DEX-typed provider whose quote returned txType=SEND executes as a CEX-style transfer, + // so it must follow the same gasless suppression rule as a real CEX provider. + val isCexLikeFlow = quoteModel.swapProvider.type == ExchangeProviderType.CEX || + quoteModel.txType == ExpressTxType.SEND - val isNotEnoughFee = insufficientFee != null && !isCEXProvider + val isNotEnoughFee = insufficientFee != null - val isGaslessAvailable = isGaslessFeeSupportedForNetwork(fromCurrency.network) && isCEXProvider + // Suppress only when the user can actually switch the fee to a token via the gasless + // selector; on networks without gasless support the warning must show for CEX too. + val isGaslessAvailable = isGaslessFeeSupportedForNetwork(fromCurrency.network) && isCexLikeFlow if (shouldShowCoverWarning && !isGaslessAvailable && isNotEnoughFee) { add( if (fromCurrency.id == feeCryptoCurrencyStatus.currency.id) { diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapNotificationsFactoryTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapNotificationsFactoryTest.kt new file mode 100644 index 0000000000..52c26f5da8 --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapNotificationsFactoryTest.kt @@ -0,0 +1,276 @@ +package com.tangem.feature.swap.model + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.routing.AppRouter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.ExpressTxType +import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState +import com.tangem.feature.swap.domain.models.domain.RateType +import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus +import com.tangem.feature.swap.domain.models.domain.SwapProvider +import com.tangem.feature.swap.domain.models.ui.PermissionDataState +import com.tangem.feature.swap.domain.models.ui.PriceImpact +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo +import com.tangem.feature.swap.models.UiActions +import com.tangem.feature.swap.models.states.SwapNotificationUM +import com.tangem.utils.Provider +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +/** + * Tests for [SwapNotificationsFactory.getConfirmationStateNotifications], focused on the + * `UnableToCoverFeeWarning` gating ([REDACTED_TASK_KEY]): + * + * | flow | gasless network | expected for InsufficientFee | + * |-------------------------------|-----------------|--------------------------------| + * | CEX | no | warning shown (the bug fix) | + * | CEX | yes | suppressed (fee → token) | + * | DEX (txType=null) | yes | warning shown (DEX unchanged) | + * | DEX + txType=SEND (CEX-like) | yes | suppressed like a real CEX | + * | DEX + txType=SEND (CEX-like) | no | warning shown | + */ +internal class SwapNotificationsFactoryTest { + + private val actions: UiActions = mockk(relaxed = true) + private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + private val appCurrencyProvider: Provider = Provider { AppCurrency.Default } + private val appRouter: AppRouter = mockk() + + private val factory = SwapNotificationsFactory( + actions = actions, + isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, + appCurrencyProvider = appCurrencyProvider, + ) + + private val userWalletId = UserWalletId(stringValue = "deadbeef") + private val userWallet: UserWallet = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + @BeforeEach + fun resetMocks() { + clearMocks(isGaslessFeeSupportedForNetwork, appRouter) + every { appRouter.stack } returns emptyList() + } + + @Test + fun `GIVEN CEX and no gasless support WHEN insufficient fee THEN cover fee warning shown`() { + // Arrange + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val quoteModel = buildQuotesLoadedState(providerType = ExchangeProviderType.CEX) + + // Act + val notifications = factory.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = buildCoinFeeStatus(), + swapFee = null, + feeError = null, + appRouter = appRouter, + ) + + // Assert + val warning = notifications.filterIsInstance().single() + assertThat(warning.currencyName).isEqualTo("Ethereum") + assertThat(warning.currencySymbol).isEqualTo("ETH") + } + + @Test + fun `GIVEN CEX and gasless support WHEN insufficient fee THEN cover fee warning suppressed`() { + // Arrange + every { isGaslessFeeSupportedForNetwork(any()) } returns true + val quoteModel = buildQuotesLoadedState(providerType = ExchangeProviderType.CEX) + + // Act + val notifications = factory.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = buildCoinFeeStatus(), + swapFee = null, + feeError = null, + appRouter = appRouter, + ) + + // Assert + assertThat(notifications.filterIsInstance()).isEmpty() + } + + @Test + fun `GIVEN DEX and gasless support WHEN insufficient fee THEN cover fee warning shown`() { + // Arrange + every { isGaslessFeeSupportedForNetwork(any()) } returns true + val quoteModel = buildQuotesLoadedState(providerType = ExchangeProviderType.DEX) + + // Act + val notifications = factory.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = buildCoinFeeStatus(), + swapFee = null, + feeError = null, + appRouter = appRouter, + ) + + // Assert + assertThat(notifications.filterIsInstance()).hasSize(1) + } + + @Test + fun `GIVEN DEX with SEND txType and gasless support WHEN insufficient fee THEN warning suppressed like CEX`() { + // Arrange + every { isGaslessFeeSupportedForNetwork(any()) } returns true + val quoteModel = buildQuotesLoadedState( + providerType = ExchangeProviderType.DEX, + txType = ExpressTxType.SEND, + ) + + // Act + val notifications = factory.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = buildCoinFeeStatus(), + swapFee = null, + feeError = null, + appRouter = appRouter, + ) + + // Assert + assertThat(notifications.filterIsInstance()).isEmpty() + } + + @Test + fun `GIVEN DEX with SEND txType and no gasless support WHEN insufficient fee THEN warning shown`() { + // Arrange + every { isGaslessFeeSupportedForNetwork(any()) } returns false + val quoteModel = buildQuotesLoadedState( + providerType = ExchangeProviderType.DEX, + txType = ExpressTxType.SEND, + ) + + // Act + val notifications = factory.getConfirmationStateNotifications( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = buildCoinFeeStatus(), + swapFee = null, + feeError = null, + appRouter = appRouter, + ) + + // Assert + assertThat(notifications.filterIsInstance()).hasSize(1) + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private fun buildEthNetwork(): Network = mockk(relaxed = true) { + every { rawId } returns "ethereum" + every { name } returns "Ethereum" + every { currencySymbol } returns "ETH" + } + + /** Token from-currency, so the fee is paid in a different (native coin) currency. */ + private fun buildTokenFromStatus(): SwapCurrencyStatus { + val network = buildEthNetwork() + val currency = mockk(relaxed = true) { + every { this@mockk.network } returns network + every { symbol } returns "USDT" + every { name } returns "Tether" + every { decimals } returns 6 + } + val statusValue = mockk(relaxed = true) { + every { amount } returns BigDecimal("100") + every { pendingTransactions } returns emptySet() + } + return SwapCurrencyStatus( + userWallet = userWallet, + status = CryptoCurrencyStatus(currency = currency, value = statusValue), + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + ) + } + + private fun buildCoinFeeStatus(): CryptoCurrencyStatus { + val network = buildEthNetwork() + val currency = mockk(relaxed = true) { + every { this@mockk.network } returns network + every { symbol } returns "ETH" + every { name } returns "Ethereum" + every { decimals } returns 18 + } + val statusValue = mockk(relaxed = true) { + every { amount } returns BigDecimal.ZERO + } + return CryptoCurrencyStatus(currency = currency, value = statusValue) + } + + private fun buildQuotesLoadedState( + providerType: ExchangeProviderType, + txType: ExpressTxType? = null, + ): SwapState.QuotesLoadedState { + val toStatusValue = mockk(relaxed = true) { + every { amount } returns BigDecimal("1") + } + val toCurrency = mockk(relaxed = true) { + every { network } returns buildEthNetwork() + every { symbol } returns "BTC" + every { decimals } returns 8 + } + val toSwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = CryptoCurrencyStatus(currency = toCurrency, value = toStatusValue), + account = Account.CryptoPortfolio.createMainAccount(userWalletId), + ) + return SwapState.QuotesLoadedState( + fromTokenInfo = TokenSwapInfo( + tokenAmount = SwapAmount(BigDecimal("50"), 6), + swapCurrencyStatus = buildTokenFromStatus(), + amountFiat = BigDecimal.ZERO, + ), + toTokenInfo = TokenSwapInfo( + tokenAmount = SwapAmount(BigDecimal("0.5"), 8), + swapCurrencyStatus = toSwapCurrencyStatus, + amountFiat = BigDecimal.ZERO, + ), + priceImpact = PriceImpact.Empty, + preparedSwapConfigState = PreparedSwapConfigState( + balanceStatus = SwapBalanceStatus.InsufficientFee( + feeCurrencyName = "Ethereum", + feeCurrencySymbol = "ETH", + ), + hasOutgoingTransaction = false, + ), + permissionState = PermissionDataState.Empty, + swapDataModel = null, + currencyCheck = null, + validationResult = null, + minAdaValue = null, + swapProvider = buildProvider(providerType), + txType = txType, + ) + } + + private fun buildProvider(type: ExchangeProviderType): SwapProvider = SwapProvider( + providerId = "p", + rateTypes = listOf(RateType.FLOAT), + name = "Provider", + type = type, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + isRecommended = false, + slippage = null, + isExtraIdSupported = false, + ) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 2b5f382a09..0f1d44f855 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -29,7 +29,6 @@ import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.account.TangemPayCustomerTariffPlan import com.tangem.domain.models.account.VirtualAccountOnramp import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.pay.TangemPayCardFrozenState import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.model.TangemPayTopUpData @@ -129,7 +128,6 @@ internal class TangemPayDetailsModel @Inject constructor( private val refreshStateJobHolder = JobHolder() private val addToWalletBannerJobHolder = JobHolder() - private val frozenStateJobHolder = JobHolder() val bottomSheetNavigation: SlotNavigation = SlotNavigation() @@ -162,9 +160,6 @@ internal class TangemPayDetailsModel @Inject constructor( isMuted = !state.isFresh, ) uiState.update { balanceTransformer.transform(stateFactory.getLoadedState(state)) } - state.cards.firstOrNull()?.let { card -> - subscribeToCardFrozenState(card.id) - } } else -> uiState.update { stateFactory.getLoadingState() } } @@ -195,33 +190,11 @@ internal class TangemPayDetailsModel @Inject constructor( fun isRedesignEnabled(): Boolean = tangemPayFeatureToggles.isRedesignEnabled - private fun subscribeToCardFrozenState(cardId: String) { - frozenStateJobHolder.cancel() - cardDetailsRepository - .cardFrozenState(cardId) - .onEach { frozenState -> - // Mirror getLoadedState gating so a live freeze update can't re-enable actions on stale data. - val isFresh = currentStatus.value.ifLoadedOrNull { it.isFresh } == true - val isUnfrozen = frozenState == TangemPayCardFrozenState.Unfrozen - val areActionButtonsEnabled = isFresh && isUnfrozen - val hasWithdrawableBalance = currentStatus.value.balanceOrNull()?.hasWithdrawableAmount == true - uiState.update( - TangemPayActionButtonsTransformer( - stateFactory.getActionButtonsConfig( - isAddFundsEnabled = areActionButtonsEnabled, - isWithdrawEnabled = areActionButtonsEnabled && hasWithdrawableBalance, - ), - ), - ) - } - .launchIn(modelScope) - .saveIn(frozenStateJobHolder) - } - override fun onClickAddFunds() { analytics.send(TangemPayAnalyticsEvents.AddFundsClicked()) val balance = currentStatus.value.balanceOrNull() - if (balance == null) { + val address = currentStatus.value.ifLoadedOrNull { it.depositAddress } + if (balance == null || address.isNullOrEmpty()) { showBottomSheetError(TangemPayDetailsErrorType.Receive) } else { bottomSheetNavigation.activate( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueCardContentV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueCardContentV2.kt index b7f1861b7b..e2889fc5ff 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueCardContentV2.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueCardContentV2.kt @@ -178,7 +178,11 @@ private fun FeeInfoRow(titleRes: Int, value: String, showDivider: Boolean = fals }, valueSlot = { if (value.isEmpty()) { - TangemShimmer(style = TangemTheme.typography3.body.medium) + TangemShimmer( + modifier = Modifier.width(80.dp), + style = TangemTheme.typography3.body.medium, + textAlign = TextAlign.End, + ) } else { TangemRowText( text = value, @@ -299,7 +303,7 @@ private fun ReissueCardSheetPreview(state: TangemPayReissueCardUM) { private class TangemPayReissueCardUMPreviewProvider : CollectionPreviewParameterProvider( collection = listOf( - TangemPayReissueCardUM.stub(error = null), + TangemPayReissueCardUM.stub(error = null, feeAmount = ""), TangemPayReissueCardUM.stub( error = TangemPayReissueCardError.InsufficientFunds, cardBalance = "$0.05", diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt index ba1ecb9e4c..3c1a59dcec 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt @@ -1,11 +1,8 @@ package com.tangem.features.tangempay.utils -import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.PaymentAccountStatusValue -import com.tangem.domain.models.account.findCardWithId import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.wallet.UserWalletId internal val AccountStatus.Payment.userWalletId: UserWalletId @@ -22,14 +19,12 @@ internal val AccountStatus.Payment.isDeactivated: Boolean get() = value is PaymentAccountStatusValue.Deactivated internal val PaymentAccountStatusValue.Loaded.isFresh: Boolean - get() = source == StatusSource.ACTUAL && error == null + get() = source.isActual() && error == null internal fun AccountStatus.Payment.requireLoaded(): PaymentAccountStatusValue.Loaded = value as? PaymentAccountStatusValue.Loaded ?: error("Card-detail subflow requires Loaded status, got ${value::class.simpleName}") -internal fun AccountStatus.Payment.firstCard(): TangemPayCard = requireLoaded().cards.first() - internal inline fun AccountStatus.Payment.ifLoadedOrNull(call: (PaymentAccountStatusValue.Loaded) -> T): T? { val value = value return if (value is PaymentAccountStatusValue.Loaded) { @@ -46,20 +41,4 @@ internal fun AccountStatus.Payment.balanceOrNull(): PaymentAccountStatusValue.Ba } internal val PaymentAccountStatusValue.Balance.hasWithdrawableAmount: Boolean - get() = availableForWithdrawal.signum() > 0 - -internal fun AccountStatus.Payment.findCard( - initialCardId: String, - initialStatus: AccountStatus.Payment, -): TangemPayCard? { - val value = value - - if (value !is PaymentAccountStatusValue.Loaded || value.source != StatusSource.ACTUAL) return null - - val initialCard = value.findCardWithId(initialCardId) - val newCards = initialStatus.ifLoadedOrNull { status -> - val initialCardIds = status.cards.mapTo(mutableSetOf()) { it.id } - value.cards.filterNot { it.id in initialCardIds } - } - return initialCard ?: newCards?.firstOrNull() -} \ No newline at end of file + get() = availableForWithdrawal.signum() > 0 \ No newline at end of file diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt index 4b96fb8183..18e4532c16 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt @@ -37,27 +37,6 @@ internal class TangemPayDetailsModelTest { private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk() private val cardDetailsRepository: TangemPayCardDetailsRepository = mockk(relaxed = true) - @ParameterizedTest - @MethodSource("provideFreezeCases") - fun `GIVEN frozen state and balance WHEN status loaded THEN action buttons gated accordingly`( - case: FreezeCase, - ) = runTest { - // Arrange + Act - val model = createModel( - testScope = this, - statusSource = case.statusSource, - frozenState = case.frozenState, - availableForWithdrawal = case.availableForWithdrawal, - ) - advanceUntilIdle() - - // Assert - val state = model.uiState.value - assertThat(state.addFundsButton.isEnabled).isEqualTo(case.expectedAddFundsEnabled) - assertThat(state.withdrawButton.isEnabled).isEqualTo(case.expectedWithdrawEnabled) - model.onDestroy() - } - @ParameterizedTest @MethodSource("provideMutedCases") fun `GIVEN status source WHEN status loaded THEN balance is muted only when cached`(case: MutedCase) = runTest { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt index 42605a81a8..3d6772d33a 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt @@ -31,7 +31,10 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull @Suppress("LongParameterList") internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( @@ -83,10 +86,7 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( } } - // Refresh the portfolio before searching so a token just added on the backend is present locally. - refreshAccountsIfNeeded(userWallet) - - val cryptoCurrency = findCryptoCurrency(userWallet = userWallet, networkId = networkId, tokenId = tokenId) + val cryptoCurrency = resolveCryptoCurrency(userWallet, networkId, tokenId) if (cryptoCurrency == null) { TangemLogger.e( @@ -130,6 +130,28 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( } } + /** + * Resolves the target currency for the deeplink: refreshes the portfolio when needed and searches for the token. + * + * A multi-currency link needs both [networkId] and [tokenId] to match a token; a malformed link can never match, + * so we skip the refresh/await entirely to avoid wasted backend work and return immediately for the redirect. + */ + private suspend fun resolveCryptoCurrency( + userWallet: UserWallet, + networkId: String?, + tokenId: String?, + ): CryptoCurrency? { + if (userWallet.isMultiCurrency && (networkId.isNullOrBlank() || tokenId.isNullOrBlank())) return null + + val wasRefreshed = refreshAccountsIfNeeded(userWallet) + return findCryptoCurrency( + userWallet = userWallet, + networkId = networkId, + tokenId = tokenId, + awaitOnMiss = wasRefreshed, + ) + } + /** * Refreshes wallet accounts so a token just added on the backend appears in the local portfolio. * @@ -137,12 +159,17 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( * on cold start the fresh list is already loaded by the regular auth flow, and single-currency * wallets have a fixed token. The fetch is best-effort — on failure we fall through and try the * current cache, so existing tokens (e.g. swap/onramp pushes) still open without regression. + * + * @return `true` only when a refresh was actually performed and succeeded. Waiting for the refreshed + * list (see [awaitCryptoCurrency]) makes sense only in that case; otherwise there is nothing to wait for. */ - private suspend fun refreshAccountsIfNeeded(userWallet: UserWallet) { + private suspend fun refreshAccountsIfNeeded(userWallet: UserWallet): Boolean { if (isFromOnNewIntent && userWallet.isMultiCurrency) { - singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId = userWallet.walletId)) + return singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId = userWallet.walletId)) .onLeft { TangemLogger.e("Error on refreshing wallet accounts", it) } + .isRight() } + return false } private suspend fun fetchCurrency(userWallet: UserWallet, cryptoCurrency: CryptoCurrency) { @@ -158,26 +185,42 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( } } - private suspend fun findCryptoCurrency(userWallet: UserWallet, networkId: String?, tokenId: String?) = - if (userWallet.isMultiCurrency) { - val derivationPath = queryParams[DERIVATION_PATH_KEY] - - getCryptoCurrencies(userWalletId = userWallet.walletId)?.firstOrNull { currency -> - val isNetwork = currency.network.rawId.equals(networkId, ignoreCase = true) - val isCurrency = currency.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true - - val isDefaultDerivation = currency.network.derivationPath is Network.DerivationPath.Card - val isCustomDerivation = derivationPath?.equals(currency.network.derivationPath.value) == true - val isCorrectDerivation = isDefaultDerivation || isCustomDerivation - isNetwork && isCurrency && isCorrectDerivation - } - } else { - singleAccountListSupplier.getSyncOrNull(userWalletId = userWallet.walletId) + private suspend fun findCryptoCurrency( + userWallet: UserWallet, + networkId: String?, + tokenId: String?, + awaitOnMiss: Boolean, + ): CryptoCurrency? { + if (!userWallet.isMultiCurrency) { + return singleAccountListSupplier.getSyncOrNull(userWalletId = userWallet.walletId) ?.mainAccount?.cryptoCurrencies?.first() } - private suspend fun getCryptoCurrencies(userWalletId: UserWalletId): List? { - return singleAccountListSupplier.getSyncOrNull(userWalletId)?.flattenCurrencies() + val derivationPath = queryParams[DERIVATION_PATH_KEY] + val matches = { currency: CryptoCurrency -> currency.matches(networkId, tokenId, derivationPath) } + + return singleAccountListSupplier.getSyncOrNull(userWallet.walletId)?.flattenCurrencies()?.firstOrNull(matches) + // getSyncOrNull returns the stale SharedFlow replay just after a fetch; wait for the refreshed list. + // Only when a refresh actually ran and succeeded — otherwise a missing token would block for the full + // timeout before the fall-through redirect. + ?: if (awaitOnMiss) awaitCryptoCurrency(userWallet.walletId, matches) else null + } + + private suspend fun awaitCryptoCurrency( + userWalletId: UserWalletId, + matches: (CryptoCurrency) -> Boolean, + ): CryptoCurrency? = withTimeoutOrNull(TOKEN_APPEARANCE_TIMEOUT_MILLIS) { + singleAccountListSupplier(userWalletId) + .mapNotNull { accountList -> accountList.flattenCurrencies().firstOrNull(matches) } + .firstOrNull() + } + + private fun CryptoCurrency.matches(networkId: String?, tokenId: String?, derivationPath: String?): Boolean { + val isNetwork = network.rawId.equals(networkId, ignoreCase = true) + val isCurrency = id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true + val isDefaultDerivation = network.derivationPath is Network.DerivationPath.Card + val isCustomDerivation = derivationPath?.equals(network.derivationPath.value) == true + return isNetwork && isCurrency && (isDefaultDerivation || isCustomDerivation) } @AssistedFactory @@ -188,4 +231,8 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( isFromOnNewIntent: Boolean, ): DefaultTokenDetailsDeepLinkHandler } + + private companion object { + const val TOKEN_APPEARANCE_TIMEOUT_MILLIS = 3_000L + } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/QuickTopUpBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/QuickTopUpBlock.kt index 18eac82d3c..f826bee2ad 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/QuickTopUpBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/QuickTopUpBlock.kt @@ -1,12 +1,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui import android.content.res.Configuration -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -103,7 +98,6 @@ private fun QuickTopUpBlock_Preview() { ), ), ), - modifier = Modifier.padding(TangemTheme.dimens2.x3), ) } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index 0760de9790..5fb601334f 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -226,7 +226,7 @@ private fun TokenDetailsBody( item(key = "quick_top_up_block") { QuickTopUpBlock( state = quickTopUpBlock, - modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x0), + modifier = itemModifier.padding(top = 8.dp), ) } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/TransferBottomSheetContent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/TransferBottomSheetContent.kt index 99a2cc8b34..907b472f7c 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/TransferBottomSheetContent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/TransferBottomSheetContent.kt @@ -1,10 +1,6 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -87,7 +83,7 @@ private fun SwapAndSendActionRow(state: TransferUM) { if (state is TransferUM.Content && row == null) return ActionRow( iconRes = CoreR.drawable.ic_exchange_mini_24, - title = resourceReference(CoreR.string.common_send_with_swap), + title = resourceReference(CoreR.string.send_with_swap_confirm_title), description = resourceReference(CoreR.string.quick_action_send_and_swap_description), row = row, isLoading = state is TransferUM.Loading, diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt index 3301f14437..2cef14c1e9 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt @@ -31,6 +31,7 @@ import com.tangem.utils.logging.TangemLogger import io.mockk.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach @@ -532,6 +533,9 @@ class DefaultTokenDetailsDeepLinkHandlerTest { mockMultiCurrencyWallet(userWalletId) mockSelectWallet(userWalletId) coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns null + every { singleAccountListSupplier.invoke(userWalletId) } returns MutableStateFlow( + AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptyList()), + ) createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true) advanceUntilIdle() @@ -565,6 +569,108 @@ class DefaultTokenDetailsDeepLinkHandlerTest { } } + @Test + fun `GIVEN token appears only after refresh WHEN handle deeplink THEN push new route`() = runTest { + val userWalletId = UserWalletId("011") + val cryptoCurrency = mockCryptoCurrency() + mockMultiCurrencyWallet(userWalletId) + mockSelectWallet(userWalletId) + + val staleList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptyList()) + val freshList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = listOf(cryptoCurrency)) + val accountListFlow = MutableStateFlow(staleList) + + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns staleList + every { singleAccountListSupplier.invoke(userWalletId) } returns accountListFlow + coEvery { singleAccountListFetcher.invoke(SingleAccountListFetcher.Params(userWalletId)) } answers { + accountListFlow.value = freshList + Either.Right(Unit) + } + every { + cryptoCurrencyBalanceFetcher.invoke(userWalletId = userWalletId, currency = cryptoCurrency) + } just Runs + val expectedRoute = AppRoute.CurrencyDetails(userWalletId = userWalletId, currency = cryptoCurrency) + + createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true) + advanceUntilIdle() + + verify { appRouter.push(route = expectedRoute, onComplete = any()) } + verify(exactly = 0) { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) } + } + + @Test + fun `GIVEN cold start AND token missing WHEN handle deeplink THEN redirect to main without awaiting`() = runTest { + // Arrange + val userWalletId = UserWalletId("011") + mockMultiCurrencyWallet(userWalletId) + mockSelectWallet(userWalletId) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = emptyList(), + ) + + // Act + createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = false) + advanceUntilIdle() + + // Assert + verify { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) } + coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) } + verify(exactly = 0) { singleAccountListSupplier.invoke(any()) } + } + + @Test + fun `GIVEN refresh failed AND token missing WHEN handle deeplink THEN redirect to main without awaiting`() = + runTest { + // Arrange + val userWalletId = UserWalletId("011") + mockMultiCurrencyWallet(userWalletId) + mockSelectWallet(userWalletId) + coEvery { + singleAccountListFetcher.invoke(SingleAccountListFetcher.Params(userWalletId)) + } returns Either.Left(IllegalStateException("service unavailable")) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = emptyList(), + ) + + // Act + createHandler(scope = this, defaultQueryParams(), isFromOnNewIntent = true) + advanceUntilIdle() + + // Assert + verify { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) } + verify(exactly = 0) { singleAccountListSupplier.invoke(any()) } + } + + @Test + fun `GIVEN malformed deeplink AND refresh succeeded WHEN handle deeplink THEN redirect to main without awaiting`() = + runTest { + // Arrange + val userWalletId = UserWalletId("011") + mockMultiCurrencyWallet(userWalletId) + mockSelectWallet(userWalletId) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = emptyList(), + ) + val queryParams = mapOf( + WALLET_ID_KEY to "011", + NETWORK_ID_KEY to "123", + DERIVATION_PATH_KEY to "777", + // TOKEN_ID_KEY is missing + ) + + // Act + createHandler(scope = this, queryParams, isFromOnNewIntent = true) + advanceUntilIdle() + + // Assert + verify { appRouter.popTo(route = AppRoute.Wallet, onComplete = any()) } + verify(exactly = 0) { singleAccountListSupplier.invoke(any()) } + coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) } + } + private fun defaultQueryParams() = mapOf( WALLET_ID_KEY to "011", NETWORK_ID_KEY to "123", diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt index b3be6cc73a..542935b215 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt @@ -129,8 +129,8 @@ internal sealed class WalletNotificationUM(val messageUM: TangemMessageUM, val t data class BackupError(val onClick: () -> Unit) : WalletNotificationUM( messageUM = TangemMessageUM( id = "BackupErrorNotification", - title = resourceReference(id = R.string.warning_backup_errors_title), - subtitle = resourceReference(id = R.string.warning_backup_errors_message), + title = resourceReference(id = R.string.warning_incomplete_backup_notification_title), + subtitle = resourceReference(id = R.string.warning_incomplete_backup_notification_message), messageEffect = TangemMessageEffect.Warning, iconUM = TangemIconUM.Icon( iconRes = R.drawable.ic_attention_default_24, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/EarnApyConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/EarnApyConverter.kt index c7826b72c9..22e8abd72f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/EarnApyConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/EarnApyConverter.kt @@ -12,9 +12,9 @@ import com.tangem.domain.models.currency.yieldSupplyKey import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.staking.model.StakingOption -import com.tangem.domain.staking.model.optionOrNull import com.tangem.domain.staking.model.common.RewardInfo import com.tangem.domain.staking.model.common.RewardType +import com.tangem.domain.staking.model.optionOrNull import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.converter.Converter import java.math.BigDecimal @@ -34,7 +34,7 @@ internal class EarnApyConverter( ) }?.value if (yieldSupplyApy != null) { - val isActive = value.value.yieldSupplyStatus?.isActive == false + val isActive = value.value.yieldSupplyStatus?.isActive == true return EarnApyInfo( text = resourceReference( R.string.yield_module_earn_badge,