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,