Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-10 16:53:28 +04:00
commit b654c6ea6e
35 changed files with 1279 additions and 165 deletions

View file

@ -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,
)

View file

@ -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(

View file

@ -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(

View file

@ -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,
)

View file

@ -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"
}
}

View file

@ -131,6 +131,7 @@
<string name="address_book_no_contacts">Noch keine Kontakte</string>
<string name="address_book_no_contacts_description">Die von Ihnen hinzugefügten Kontakte werden hier angezeigt</string>
<string name="address_book_remove_address">Adresse entfernen</string>
<string name="address_book_save_address">Adresse speichern</string>
<string name="address_book_save_contact">Kontakt speichern</string>
<string name="address_book_save_to_wallet_title">In Wallet speichern</string>
<string name="address_book_save_wallet_to_description">Dieser Kontakt wird mit dem Adressbuch dieser Wallet verknüpft.</string>
@ -638,6 +639,8 @@
<string name="express_exchange_notification_long_transaction_time_title">Lange Transaktionszeit</string>
<string name="express_exchange_notification_refund_text">Der Transaktionsbetrag wurde aufgrund von OKX- oder Bridge-Regeln in %1$s auf deine Wallet zurückerstattet. %2$s</string>
<string name="express_exchange_notification_refund_title">Der Betrag wurde in %1$s (%2$s Netzwerk) zurückerstattet.</string>
<string name="express_exchange_notification_refunded_in_text">Ihr Guthaben wurde gemäß den Regeln der OKX-Börse in %1$s an Ihre Wallet im %2$s -Netzwerk zurückerstattet.</string>
<string name="express_exchange_notification_refunded_in_title">Rückerstattung erfolgt in %s</string>
<string name="express_exchange_notification_verification_text">Besuche die Website des Anbieters zur Überprüfung</string>
<string name="express_exchange_notification_verification_title">KYC-Überprüfung durch den Anbieter erforderlich</string>
<string name="express_exchange_status_bought">Kauf abgeschlossen</string>
@ -1307,7 +1310,9 @@
<string name="promo_campaign_enroll_success_title">Sie sind erfolgreich eingeschrieben in %1$s</string>
<string name="promo_campaign_not_active_subtitle">Diese Aktion existiert nicht mehr oder ist abgelaufen.</string>
<string name="promo_campaign_not_active_title">Kampagne nicht aktiv</string>
<string name="promo_campaign_reactivation_summary_description">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.</string>
<string name="promo_campaign_select_cashback_account">Cashback-Konto auswählen</string>
<string name="promo_campaign_select_token">Token auswählen</string>
<string name="promo_campaign_summary_title">Melden Sie sich an %1$s</string>
<string name="promo_campaign_terms_agreement">Ich stimme zu, dass %1$s</string>
<string name="promo_campaign_terms_agreement_android">Ich stimme zu, dass</string>

View file

@ -256,7 +256,7 @@
<string name="common_action_failed">%s falló</string>
<string name="common_activate">Activar</string>
<string name="common_add">Agregar</string>
<string name="common_add_funds">Agregar fondos</string>
<string name="common_add_funds">Depositar</string>
<string name="common_add_to_portfolio">Añadir al portafolio</string>
<string name="common_add_token">Agregar token</string>
<string name="common_add_tokens">Añada tokens</string>
@ -347,8 +347,9 @@
<string name="common_from">De</string>
<string name="common_from_wallet_name">De %s</string>
<string name="common_generate_addresses">Sincronizar direcciones</string>
<string name="common_get">Comprar</string>
<string name="common_get_started">Comenzar</string>
<string name="common_get_token">Obtener token</string>
<string name="common_get_token">Comprar token</string>
<string name="common_go_to_provider">Ir al proveedor</string>
<string name="common_go_to_token">Ir al token</string>
<string name="common_got_it">Entendido</string>
@ -1241,6 +1242,8 @@
<string name="onramp_title_available_from">Disponible desde</string>
<string name="onramp_title_available_up_to">Disponible hasta</string>
<string name="onramp_title_you_get">Obtiene</string>
<string name="onramp_token_is_not_supported_banner_subtitle">Este token no está soportado. Por favor, elija otro token para comprar.</string>
<string name="onramp_token_is_not_supported_banner_title">%s no está soportado</string>
<string name="onramp_tos_external_providers">El servicio es proporcionado por un proveedor externo. \nTangem no es responsable.</string>
<string name="onramp_transaction_status_footer_text">Puede comprobar el estado de la transacción desde la página detallada del token</string>
<string name="onramp_up_to_rate">Hasta</string>
@ -2505,7 +2508,7 @@
<string name="yield_module_approve_sheet_fee_note">Se le descontará la comisión y se volverán a suministrar sus activos.</string>
<string name="yield_module_approve_sheet_subtitle">Para seguir generando rendimiento, se requiere aprobación.</string>
<string name="yield_module_approve_sheet_title">Confirmar aprobación</string>
<string name="yield_module_average_apy">APY promedio %1$s%%</string>
<string name="yield_module_average_apy">APY actual %1$s%%</string>
<string name="yield_module_balance_info_sheet_subtitle">Sus fondos se suministran actualmente al protocolo Aave, pero puede gestionarlos en cualquier momento.</string>
<string name="yield_module_balance_info_sheet_title">Su %s está depositado en Aave</string>
<string name="yield_module_chart_loading_error">No se puede cargar el gráfico...</string>

View file

@ -355,6 +355,7 @@
<string name="common_from">送信元</string>
<string name="common_from_wallet_name">%sから</string>
<string name="common_generate_addresses">アドレスを同期する</string>
<string name="common_get">入手</string>
<string name="common_get_started">はじめる</string>
<string name="common_get_token">トークンを取得</string>
<string name="common_go_to_provider">プロバイダーへ移動</string>
@ -1249,6 +1250,20 @@
<string name="organize_tokens_sort_by_balance">残高順</string>
<string name="organize_tokens_title">トークンを整理する</string>
<string name="organize_tokens_ungroup">グループ解除</string>
<string name="promo_campaign_already_activated_subtitle">対象のキャッシュバックは次のアドレスに配布されます:</string>
<string name="promo_campaign_already_activated_title">すでに%1$sに参加しています</string>
<string name="promo_campaign_eligible_tokens">対象トークン</string>
<string name="promo_campaign_enroll">参加する</string>
<string name="promo_campaign_enroll_success_title">%1$sへの参加が完了しました</string>
<string name="promo_campaign_not_active_subtitle">このキャンペーンは終了したか、無効です</string>
<string name="promo_campaign_not_active_title">キャンペーンは実施されていません</string>
<string name="promo_campaign_reactivation_summary_description">500ドルを超えるスワップごとに0.5%のキャッシュバックを獲得できます。ステーブルコイン同士を除く、すべてのペアが対象です。キャッシュバックは1回のスワップあたり最大50ドルです。\n\n対象のスワップを5回完了すると、さらに10ドルのボーナスを獲得できます。\n\n報酬は毎週、ご指定のUSDTまたはUSDCアドレスにお支払いします。</string>
<string name="promo_campaign_select_cashback_account">キャッシュバックの受取口座を選択</string>
<string name="promo_campaign_select_token">トークンを選択</string>
<string name="promo_campaign_summary_title">%1$sに参加</string>
<string name="promo_campaign_terms_agreement">%1$sに同意します</string>
<string name="promo_campaign_terms_link">%1$s 利用規約</string>
<string name="promo_campaign_whale_swap_summary_description">7月末まで、累計1万ドル以上のスワップでキャッシュバックを獲得できます。\n\n累計スワップ額に応じて還元率がアップします。1万ドル以上で0.10%、2万ドル以上で0.20%、10万ドル以上で0.50%。\n\nキャッシュバックは、1回のスワップあたり最大500ドル、ウォレットごと・スワップ方向ごとにキャンペーン期間中、合計最大1万ドルです。ステーブルコイン同士のスワップは対象外です。\n\nキャッシュバックは毎週、ご指定のUSDTまたはUSDCアドレスにお支払いします。</string>
<string name="provider_name_support">%sサポート</string>
<string name="push_notification_settings_banner_description">プッシュ通知は有効ですが、許可するまで動作しません</string>
<string name="push_notification_settings_banner_title">通知を許可する</string>

View file

@ -377,6 +377,7 @@
<string name="common_from">Из</string>
<string name="common_from_wallet_name">Из %s</string>
<string name="common_generate_addresses">Синхронизировать адреса</string>
<string name="common_get">Приобрести</string>
<string name="common_get_started">Начать</string>
<string name="common_get_token">Получить токен</string>
<string name="common_go_to_provider">К провайдеру</string>
@ -2010,6 +2011,7 @@
<string name="tangempay_reissue_card_insufficient_funds_subtitle">Внесите USDC на счёт, чтобы покрыть комиссию</string>
<string name="tangempay_reissue_card_insufficient_funds_title">Невозможно покрыть комиссию</string>
<string name="tangempay_reissue_card_title">Перевыпустить карту?</string>
<string name="tangempay_remove_account">Удалить аккаунт</string>
<string name="tangempay_service_unavailable_description">Мы устраняем техническую проблему. Пожалуйста, попробуйте позже.</string>
<string name="tangempay_service_unavailable_title">Сервис временно недоступен</string>
<string name="tangempay_service_unreachable_try_later">Не можем показать данные карты, но оплаты продолжают работать.</string>
@ -2519,7 +2521,7 @@
<string name="yield_module_approve_sheet_fee_note">Комиссия будет списана, и ваши активы снова начнут приносить доход.</string>
<string name="yield_module_approve_sheet_subtitle">Чтобы продолжить зарабатывать, нужно выдать разрешение.</string>
<string name="yield_module_approve_sheet_title">Подтвердить разрешение</string>
<string name="yield_module_average_apy">Средний APY %1$s%%</string>
<string name="yield_module_average_apy">Текущий APY %1$s%%</string>
<string name="yield_module_balance_info_sheet_subtitle">Ваши средства в данный момент размещены в протоколе Aave, но вы можете воспользоваться ими в любое время.</string>
<string name="yield_module_balance_info_sheet_title">Ваш %s внесён в Aave</string>
<string name="yield_module_chart_loading_error">Невозможно загрузить график</string>

View file

@ -356,6 +356,7 @@
<string name="common_from"></string>
<string name="common_from_wallet_name">从 %s</string>
<string name="common_generate_addresses">同步地址</string>
<string name="common_get">得到</string>
<string name="common_get_started">开始</string>
<string name="common_get_token">获取代币</string>
<string name="common_go_to_provider">前往服务提供商</string>
@ -1245,6 +1246,21 @@
<string name="organize_tokens_sort_by_balance">按余额</string>
<string name="organize_tokens_title">整理代币</string>
<string name="organize_tokens_ungroup">取消分组</string>
<string name="promo_campaign_already_activated_subtitle">符合条件的返现将发放给:</string>
<string name="promo_campaign_already_activated_title">您已经注册了 %1$s</string>
<string name="promo_campaign_eligible_tokens">符合条件的代币</string>
<string name="promo_campaign_enroll">注册</string>
<string name="promo_campaign_enroll_success_title">您已成功注册 %1$s</string>
<string name="promo_campaign_not_active_subtitle">此活动已结束或已过期。</string>
<string name="promo_campaign_not_active_title">活动未启动</string>
<string name="promo_campaign_reactivation_summary_description">每次兑换金额超过 500 美元,即可获得 0.5% 的返现,适用于除稳定币与稳定币之间的兑换以外的所有交易对。每次兑换的最高返现金额为 50 美元。\n\n完成五笔符合条件的兑换即可解锁额外 10 美元的奖励。\n\n奖励每周以 USDT 或 USDC 形式支付至您选择的地址。</string>
<string name="promo_campaign_select_cashback_account">选择返现账户</string>
<string name="promo_campaign_select_token">选择代币</string>
<string name="promo_campaign_summary_title">报名参加 %1$s</string>
<string name="promo_campaign_terms_agreement">我同意 %1$s</string>
<string name="promo_campaign_terms_agreement_android">我同意</string>
<string name="promo_campaign_terms_link">%1$s 条款</string>
<string name="promo_campaign_whale_swap_summary_description">7月底前每次1万美元及以上的兑换均可获得返现。\n\n返现率随交易金额递增1万美元起为0.10% 2万美元起为0.20% 10万美元起为0.50% 。n\n最高奖励每笔最少兑换500美元且在活动期间内每个钱包单向兑换方向的奖励上限为10,000美元。稳定币与稳定币之间的兑换不计入奖励范围。\n\n奖励每周发放至您指定的USDT或USDC地址。</string>
<string name="provider_name_support">%s 支持</string>
<string name="push_notification_settings_banner_description">推送通知已启用,但需要您在设备设置中允许通知才能正常工作。</string>
<string name="push_notification_settings_banner_title">允许通知</string>

View file

@ -928,6 +928,7 @@
<string name="markets_portfolio_block_subtitle">In your portfolio</string>
<string name="markets_portfolio_block_title">Your portfolio</string>
<string name="markets_portfolio_block_token_unsupported">**Token not supported**. This token is currently not supported in the wallet</string>
<string name="markets_portfolio_eligible_block_title">Other eligible tokens</string>
<string name="markets_pulse_common_title">Market Pulse</string>
<string name="markets_quick_actions">Quick actions</string>
<string name="markets_search_clear_all_hints">Clear all</string>
@ -2624,7 +2625,7 @@
<string name="yield_module_approve_sheet_fee_note">The fee will be deducted, and your assets will be resupplied.</string>
<string name="yield_module_approve_sheet_subtitle">To continue generating yield, approval is required.</string>
<string name="yield_module_approve_sheet_title">Confirm approval</string>
<string name="yield_module_average_apy">Average APY %1$s%%</string>
<string name="yield_module_average_apy">Current APY %1$s%%</string>
<string name="yield_module_balance_info_sheet_subtitle">Your funds are currently supplied to the Aave protocol, but you can manage them at any time.</string>
<string name="yield_module_balance_info_sheet_title">Your %s is supplied to Aave</string>
<string name="yield_module_chart_loading_error">Unable to load chart...</string>

View file

@ -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),
),
)

View file

@ -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
}

View file

@ -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<SdkNFTAsset>,
) {
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<SdkNFTAsset>,
) {
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<SdkNFTCollection>,
) {
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) {

View file

@ -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<UserWallet.Hot> {
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<NFTPersistenceStore> {
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<NFTPersistenceStore> {
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<NFTPersistenceStore> {
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<NFTPersistenceStore> {
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<NFTPersistenceStore> {
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<SdkNFTAsset> = 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<NFTAsset.Identifier, NFTSalePrice> = emptyMap()
override suspend fun initialize(collections: NFTCollections, prices: Map<NFTAsset.Identifier, NFTSalePrice>) {
this.collections = collections
this.prices = prices
}
override fun getCollections(): Flow<NFTCollections> = flowOf(collections)
override suspend fun getCollectionsSync(): NFTCollections = collections
override fun getAsset(
collectionId: NFTCollection.Identifier,
assetId: NFTAsset.Identifier,
): Flow<NFTAsset?> = flowOf(null)
override fun getSalePrice(assetId: NFTAsset.Identifier): Flow<NFTSalePrice> =
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"
}
}

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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<CryptoCurrencyStatus.Value>(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<ActionState.Buy>().single()
}
}

View file

@ -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)

View file

@ -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,
)
}

View file

@ -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) {

View file

@ -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<AppCurrency> = 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<SwapNotificationUM.Error.UnableToCoverFeeWarning>().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<SwapNotificationUM.Error.UnableToCoverFeeWarning>()).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<SwapNotificationUM.Error.UnableToCoverFeeWarning>()).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<SwapNotificationUM.Error.UnableToCoverFeeWarning>()).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<SwapNotificationUM.Error.UnableToCoverFeeWarning>()).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<CryptoCurrency.Token>(relaxed = true) {
every { this@mockk.network } returns network
every { symbol } returns "USDT"
every { name } returns "Tether"
every { decimals } returns 6
}
val statusValue = mockk<CryptoCurrencyStatus.Loaded>(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<CryptoCurrency.Coin>(relaxed = true) {
every { this@mockk.network } returns network
every { symbol } returns "ETH"
every { name } returns "Ethereum"
every { decimals } returns 18
}
val statusValue = mockk<CryptoCurrencyStatus.Loaded>(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<CryptoCurrencyStatus.Loaded>(relaxed = true) {
every { amount } returns BigDecimal("1")
}
val toCurrency = mockk<CryptoCurrency.Coin>(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,
)
}

View file

@ -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<TangemPayDetailsNavigation> = 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(

View file

@ -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<TangemPayReissueCardUM>(
collection = listOf(
TangemPayReissueCardUM.stub(error = null),
TangemPayReissueCardUM.stub(error = null, feeAmount = ""),
TangemPayReissueCardUM.stub(
error = TangemPayReissueCardError.InsufficientFunds,
cardBalance = "$0.05",

View file

@ -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 <T> 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()
}
get() = availableForWithdrawal.signum() > 0

View file

@ -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 {

View file

@ -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<CryptoCurrency>? {
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
}
}

View file

@ -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),
)
}
}

View file

@ -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),
)
}
}

View file

@ -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,

View file

@ -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<UserWalletId>()) }
}
@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<UserWalletId>()) }
}
@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<UserWalletId>()) }
coVerify(exactly = 0) { singleAccountListFetcher.invoke(any()) }
}
private fun defaultQueryParams() = mapOf(
WALLET_ID_KEY to "011",
NETWORK_ID_KEY to "123",

View file

@ -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,

View file

@ -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,