From 5cae804a2dd72cca782dd6bb82d821da0e1b1345 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 2 Jun 2026 23:42:42 +0500 Subject: [PATCH 01/36] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 6741f7e5f8..7c3b140b4c 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.38-1523" +tangemBlockchainSdk = "releases-5.38-1542" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-5.38-615" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 2de0b0683dc9cde153b4b177e9b799c368be8545 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 3 Jun 2026 15:57:31 +0500 Subject: [PATCH 02/36] Updated on 2026-08-14 --- .../DefaultPaymentAccountStatusProducer.kt | 21 +++++++++++++------ .../pay/store/PaymentAccountStatusesStore.kt | 13 +++--------- gradle/tangem_dependencies.toml | 2 +- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusProducer.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusProducer.kt index 153f0d327d..b8530171db 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusProducer.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusProducer.kt @@ -15,8 +15,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onEmpty +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart private const val TAG = "PaymentAccountStatusProducer" @@ -38,10 +37,20 @@ internal class DefaultPaymentAccountStatusProducer @AssistedInject constructor( logger.i("[${params.userWalletId}] produce() called") return paymentAccountStatusesStore.get(userWalletId = params.userWalletId) .onStart { logger.i("[${params.userWalletId}] flow subscribed to store") } - .onEach { logger.i("[${params.userWalletId}] flow emits statusType=${it.value::class.simpleName}") } - .onEmpty { - logger.i("[${params.userWalletId}] onEmpty triggered: emitting NotCreated fallback") - emit(value = AccountStatus.Payment(account, PaymentAccountStatusValue.NotCreated)) + .map { status -> + if (status != null) { + logger.i("[${params.userWalletId}] flow emits statusType=${status.value::class.simpleName}") + AccountStatus.Payment( + account = account, + value = status.value, + ) + } else { + logger.i("[${params.userWalletId}] status is null: emitting Empty fallback") + AccountStatus.Payment( + account = account, + value = PaymentAccountStatusValue.Empty, + ) + } } .flowOn(dispatchers.default) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt index fd60f231b4..c9ff2fd990 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt @@ -13,11 +13,7 @@ import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch internal typealias WalletIdWithPaymentStatus = Map @@ -65,7 +61,7 @@ internal class PaymentAccountStatusesStore( } } - fun get(userWalletId: UserWalletId): Flow { + fun get(userWalletId: UserWalletId): Flow { return runtimeStore.get() .onStart { logger.i("get($userWalletId): subscribed to runtimeStore") } .onEach { map -> @@ -74,10 +70,7 @@ internal class PaymentAccountStatusesStore( "hasEntry=${map.containsKey(userWalletId.stringValue)}", ) } - .mapNotNull { it[userWalletId.stringValue] } - .onEach { status -> - logger.i("get($userWalletId): emitting statusType=${status.value::class.simpleName}") - } + .map { it[userWalletId.stringValue] } } suspend fun getSyncOrNull(userWalletId: UserWalletId): AccountStatus.Payment? { diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 7c3b140b4c..149b415b2f 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.38-1542" +tangemBlockchainSdk = "releases-5.38-1543" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-5.38-615" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From d873d6425d4a7a3597a09b32a3e31b052da08755 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 3 Jun 2026 19:34:07 +0500 Subject: [PATCH 03/36] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- .../src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 149b415b2f..21992c6251 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.38-1543" +tangemBlockchainSdk = "releases-5.38-1547" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-5.38-615" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt index 74d6248b75..5c08e72030 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt @@ -66,6 +66,7 @@ object BlockchainFeeUtils { private fun Fee.increaseGasLimitBy(percentage: Int): Fee { if (this !is Fee.Ethereum) return this val gasLimit = this.gasLimit + if (gasLimit == BigInteger.ZERO) return this val increasedGasPrice = this.amount.value?.movePointRight(this.amount.decimals) ?.divide(gasLimit.toBigDecimal(), RoundingMode.HALF_UP) val increasedGasLimit = gasLimit From d6a69a6b8cbbecc470d1ceccd298f7322b50f67c Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 3 Jun 2026 21:38:39 +0500 Subject: [PATCH 04/36] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 21992c6251..962be99d3f 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.38-1547" +tangemBlockchainSdk = "releases-5.38-1551" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-5.38-615" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 259953954a0601dd6bf03281f5ef6222f5354930 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 3 Jun 2026 18:47:59 +0200 Subject: [PATCH 05/36] Updated on 2026-08-14 --- .../ui/tokens/TokenItemStateConverter.kt | 14 +- .../staking/DefaultP2PEthPoolRepository.kt | 4 +- ...ultP2PEthPoolRepositoryAvailabilityTest.kt | 120 ++++++++++++++++++ .../staking/model/P2PEthPoolIntegration.kt | 2 +- .../staking/model/StakingAvailability.kt | 18 ++- .../model/P2PEthPoolIntegrationTest.kt | 45 +++---- .../tokens/actions/BaseActionsFactory.kt | 1 + .../deeplink/DefaultStakingDeepLinkHandler.kt | 5 +- .../tokendetails/model/TokenDetailsModel.kt | 3 +- .../TokenDetailsStakingInfoConverter.kt | 34 +++++ .../UpdateStakingNotificationTransformer.kt | 22 ++++ ...pdateStakingNotificationTransformerTest.kt | 62 ++++++++- .../converter/EarnApyConverter.kt | 14 +- 13 files changed, 303 insertions(+), 41 deletions(-) create mode 100644 data/staking/src/test/kotlin/com/tangem/data/staking/DefaultP2PEthPoolRepositoryAvailabilityTest.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt index b350ff74b9..68aec719b3 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt @@ -25,6 +25,7 @@ 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.lib.crypto.BlockchainUtils @@ -244,14 +245,21 @@ class TokenItemStateConverter( currencyStatus: CryptoCurrencyStatus, stakingApyMap: Map, ): StakingLocalInfo { - val stakingAvailability = stakingApyMap[currencyStatus.currency] as? StakingAvailability.Available + val availability = stakingApyMap[currencyStatus.currency] + val option = availability?.optionOrNull ?: return StakingLocalInfo(rate = null, isActive = false, rewardType = null) val stakingBalance = currencyStatus.value.stakingBalance as? StakingBalance.Data val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit val p2pEthPoolBalance = stakingBalance as? StakingBalance.Data.P2PEthPool + val isActive = stakeKitBalance != null || p2pEthPoolBalance != null - val rateInfo = when (val stakingOptions = stakingAvailability.option) { + // Full = no free capacity: show the badge only for tokens that already have a stake. + if (availability is StakingAvailability.Full && !isActive) { + return StakingLocalInfo(rate = null, isActive = false, rewardType = null) + } + + val rateInfo = when (val stakingOptions = option) { is StakingOption.P2PEthPool -> { RewardInfo( rate = stakingOptions.apy, @@ -282,7 +290,7 @@ class TokenItemStateConverter( return StakingLocalInfo( rate = rateInfo?.rate, - isActive = stakeKitBalance != null || p2pEthPoolBalance != null, + isActive = isActive, rewardType = rateInfo?.type, ) } diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt index 37502feb87..5bbadc6b84 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt @@ -201,7 +201,7 @@ internal class DefaultP2PEthPoolRepository( else -> { val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits) if (integration.areAllTargetsFull) { - StakingAvailability.Unavailable + StakingAvailability.Full(StakingOption.P2PEthPool(vaults)) } else { StakingAvailability.Available(StakingOption.P2PEthPool(vaults)) } @@ -216,7 +216,7 @@ internal class DefaultP2PEthPoolRepository( val limits = getVaultLimitsSyncOrNull() ?: return StakingAvailability.TemporaryUnavailable val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits) return if (integration.areAllTargetsFull) { - StakingAvailability.Unavailable + StakingAvailability.Full(StakingOption.P2PEthPool(vaults)) } else { StakingAvailability.Available(StakingOption.P2PEthPool(vaults)) } diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/DefaultP2PEthPoolRepositoryAvailabilityTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/DefaultP2PEthPoolRepositoryAvailabilityTest.kt new file mode 100644 index 0000000000..314965ca01 --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/DefaultP2PEthPoolRepositoryAvailabilityTest.kt @@ -0,0 +1,120 @@ +package com.tangem.data.staking + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.ethpool.P2PEthPoolApi +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.local.token.P2PEthPoolVaultsStore +import com.tangem.datasource.local.token.P2PVaultLimitsStore +import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault +import com.tangem.domain.staking.model.ethpool.VaultLimitInfo +import com.tangem.domain.staking.toggles.StakingFeatureToggles +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultP2PEthPoolRepositoryAvailabilityTest { + + private val api = mockk(relaxed = true) + private val vaultsStore = mockk(relaxed = true) + private val limitsStore = mockk(relaxed = true) + private val tangemTechApi = mockk(relaxed = true) + private val featureToggles = mockk(relaxed = true) + + private val repository = DefaultP2PEthPoolRepository( + p2pEthPoolApi = api, + p2pEthPoolVaultsStore = vaultsStore, + p2pVaultLimitsStore = limitsStore, + tangemTechApi = tangemTechApi, + dispatchers = TestingCoroutineDispatcherProvider(), + stakingFeatureToggles = featureToggles, + ) + + private fun buildVault(address: String, totalAssets: String) = P2PEthPoolVault( + vaultAddress = address, + displayName = "Vault", + apy = BigDecimal("4.5"), + baseApy = BigDecimal("4.0"), + capacity = BigDecimal("1000"), + totalAssets = BigDecimal(totalAssets), + feePercent = BigDecimal("10"), + isPrivate = false, + isGenesis = false, + isSmoothingPool = true, + isErc20 = false, + tokenName = null, + tokenSymbol = null, + createdAt = 0L, + ) + + private fun limits(address: String, limit: String) = + mapOf(address.lowercase() to VaultLimitInfo(limit = BigDecimal(limit), coefficient = null)) + + @Test + fun `all vaults full - emits Full with option`() = runTest { + every { vaultsStore.get() } returns flowOf(listOf(buildVault("0xABC", totalAssets = "999.95"))) + every { limitsStore.get() } returns MutableStateFlow(limits("0xABC", limit = "1000")) // remaining 0.05 <= 0.1 + + val result = repository.getStakingAvailability().first() + + assertThat(result).isInstanceOf(StakingAvailability.Full::class.java) + } + + @Test + fun `capacity available - emits Available`() = runTest { + every { vaultsStore.get() } returns flowOf(listOf(buildVault("0xABC", totalAssets = "100"))) + every { limitsStore.get() } returns MutableStateFlow(limits("0xABC", limit = "1000")) // remaining 900 > 0.1 + + val result = repository.getStakingAvailability().first() + + assertThat(result).isInstanceOf(StakingAvailability.Available::class.java) + } + + @Test + fun `sync - all vaults full - returns Full with option`() = runTest { + coEvery { vaultsStore.getSync() } returns listOf(buildVault("0xABC", totalAssets = "999.95")) + coEvery { limitsStore.getSyncOrNull() } returns limits("0xABC", limit = "1000") // remaining 0.05 <= 0.1 + + val result = repository.getStakingAvailabilitySync() + + assertThat(result).isInstanceOf(StakingAvailability.Full::class.java) + } + + @Test + fun `sync - capacity available - returns Available`() = runTest { + coEvery { vaultsStore.getSync() } returns listOf(buildVault("0xABC", totalAssets = "100")) + coEvery { limitsStore.getSyncOrNull() } returns limits("0xABC", limit = "1000") // remaining 900 > 0.1 + + val result = repository.getStakingAvailabilitySync() + + assertThat(result).isInstanceOf(StakingAvailability.Available::class.java) + } + + @Test + fun `sync - empty vaults - returns TemporaryUnavailable`() = runTest { + coEvery { vaultsStore.getSync() } returns emptyList() + + val result = repository.getStakingAvailabilitySync() + + assertThat(result).isInstanceOf(StakingAvailability.TemporaryUnavailable::class.java) + } + + @Test + fun `sync - limits not loaded - returns TemporaryUnavailable`() = runTest { + coEvery { vaultsStore.getSync() } returns listOf(buildVault("0xABC", totalAssets = "100")) + coEvery { limitsStore.getSyncOrNull() } returns null + + val result = repository.getStakingAvailabilitySync() + + assertThat(result).isInstanceOf(StakingAvailability.TemporaryUnavailable::class.java) + } +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt index a1baf275df..77a4f09876 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/P2PEthPoolIntegration.kt @@ -108,7 +108,7 @@ class P2PEthPoolIntegration( private const val MAX_AMOUNT_SCALE = 1 private val DEFAULT_MINIMUM_STAKE = BigDecimal("0.01") private val DEFAULT_MINIMUM_UNSTAKE = BigDecimal("0.01") - private val AVAILABILITY_THRESHOLD = BigDecimal("2") + private val AVAILABILITY_THRESHOLD = BigDecimal("0.1") private const val TERMS_OF_SERVICE_URL = "https://www.p2p.org/terms-of-use" private const val PRIVACY_POLICY_URL = "https://www.p2p.org/privacy-policy" diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingAvailability.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingAvailability.kt index b8afc62152..6b0a5c1ffc 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingAvailability.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingAvailability.kt @@ -4,7 +4,23 @@ sealed class StakingAvailability { data class Available(val option: StakingOption) : StakingAvailability() + /** + * Integration exists and APY is known, but there is no free capacity (all vaults full). + * Existing stakes stay visible; new stakes are not offered. P2P ETH only. + */ + data class Full(val option: StakingOption) : StakingAvailability() + data object Unavailable : StakingAvailability() data object TemporaryUnavailable : StakingAvailability() -} \ No newline at end of file +} + +/** Staking option if the integration is known (Available or Full), else null. */ +val StakingAvailability.optionOrNull: StakingOption? + get() = when (this) { + is StakingAvailability.Available -> option + is StakingAvailability.Full -> option + StakingAvailability.Unavailable, + StakingAvailability.TemporaryUnavailable, + -> null + } \ No newline at end of file diff --git a/domain/staking/src/test/kotlin/com/tangem/domain/staking/model/P2PEthPoolIntegrationTest.kt b/domain/staking/src/test/kotlin/com/tangem/domain/staking/model/P2PEthPoolIntegrationTest.kt index 2d32510e90..2bc2331aff 100644 --- a/domain/staking/src/test/kotlin/com/tangem/domain/staking/model/P2PEthPoolIntegrationTest.kt +++ b/domain/staking/src/test/kotlin/com/tangem/domain/staking/model/P2PEthPoolIntegrationTest.kt @@ -67,24 +67,6 @@ internal class P2PEthPoolIntegrationTest { assertThat(integration.enterArgs!!.amountRequirement!!.maximum).isNull() } - @Test - fun `vault with exactly 2 ETH remaining - not available, max is null`() { - val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "48")) - val limits = buildLimits("0xABC" to BigDecimal("50")) - val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits) - - assertThat(integration.enterArgs!!.amountRequirement!!.maximum).isNull() - } - - @Test - fun `vault with less than 2 ETH remaining - not available, max is null`() { - val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "48.5")) - val limits = buildLimits("0xABC" to BigDecimal("50")) - val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits) - - assertThat(integration.enterArgs!!.amountRequirement!!.maximum).isNull() - } - @Test fun `multiple available vaults - uses minimum remaining space`() { val vault1 = buildVault("0xA", capacity = "100", totalAssets = "10") @@ -99,27 +81,36 @@ internal class P2PEthPoolIntegrationTest { @Nested inner class Availability { @Test - fun `all vaults full - areAllTargetsFull is true`() { + fun `vault absent from limits - areAllTargetsFull is true`() { val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "48")) - val limits = buildLimits("0xABC" to BigDecimal("50")) + val limits = emptyMap() val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits) assertThat(integration.areAllTargetsFull).isTrue() } @Test - fun `vault with remaining between 0_1 and 2 ETH - also considered full`() { - val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "48.5")) - val limits = buildLimits("0xABC" to BigDecimal("50")) + fun `vault with exactly 0_1 ETH remaining - not available`() { + val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "49.9")) + val limits = buildLimits("0xABC" to BigDecimal("50")) // remaining = 0.1 val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits) assertThat(integration.areAllTargetsFull).isTrue() } + @Test + fun `vault with remaining just above 0_1 ETH - available (regression [REDACTED_TASK_KEY])`() { + val vaults = listOf(buildVault("0xABC", capacity = "400", totalAssets = "321.895202388313423922")) + val limits = buildLimits("0xABC" to BigDecimal("322.1")) // remaining ≈ 0.2048 > 0.1 + val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits) + + assertThat(integration.areAllTargetsFull).isFalse() + } + @Test fun `at least one vault available - areAllTargetsFull is false`() { - val vault1 = buildVault("0xA", capacity = "100", totalAssets = "48.5") // full (1.5 remaining < 2) - val vault2 = buildVault("0xB", capacity = "100", totalAssets = "10") // available (40 remaining > 2) + val vault1 = buildVault("0xA", capacity = "100", totalAssets = "49.95") // full (0.05 remaining < 0.1) + val vault2 = buildVault("0xB", capacity = "100", totalAssets = "10") // available (40 remaining > 0.1) val limits = buildLimits("0xa" to BigDecimal("50"), "0xb" to BigDecimal("50")) val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, listOf(vault1, vault2), limits) @@ -128,8 +119,8 @@ internal class P2PEthPoolIntegrationTest { @Test fun `preferred targets only contains available vaults`() { - val vault1 = buildVault("0xA", capacity = "100", totalAssets = "48.5") // full - val vault2 = buildVault("0xB", capacity = "100", totalAssets = "10") // available + val vault1 = buildVault("0xA", capacity = "100", totalAssets = "49.95") // full (0.05 remaining ≤ 0.1) + val vault2 = buildVault("0xB", capacity = "100", totalAssets = "10") // available (40 remaining > 0.1) val limits = buildLimits("0xa" to BigDecimal("50"), "0xb" to BigDecimal("50")) val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, listOf(vault1, vault2), limits) 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 8e58cc711c..f023d7fea9 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 @@ -184,6 +184,7 @@ internal open class BaseActionsFactory( unavailabilityReason = ScenarioUnavailabilityReason.StakingUnavailable(currency.name), option = null, ) + is StakingAvailability.Full -> null StakingAvailability.Unavailable -> null } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt index 648df734f4..01f549c2f7 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt @@ -8,6 +8,7 @@ import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.GetStakingAvailabilityUseCase import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.optionOrNull import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase @@ -70,12 +71,12 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( return@launch } - val availability = getStakingAvailabilityUseCase.invokeSync( + val availability: StakingAvailability? = getStakingAvailabilityUseCase.invokeSync( userWalletId = selectedUserWalletId, cryptoCurrency = cryptoCurrency, ).getOrNull() - val option = (availability as? StakingAvailability.Available)?.option + val option = availability?.optionOrNull if (option == null) { TangemLogger.e("Staking is unavailable for ${cryptoCurrency.name}") return@launch diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 1ee997f1dc..2a2ba67098 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -68,6 +68,7 @@ import com.tangem.domain.onramp.model.OnrampSource import com.tangem.domain.staking.GetStakingAvailabilityUseCase import com.tangem.domain.staking.GetStakingEntryInfoUseCase import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.optionOrNull import com.tangem.domain.tokens.* import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.tokens.model.TokenActionsState @@ -1148,7 +1149,7 @@ internal class TokenDetailsModel @Inject constructor( modelScope.launch { getStakingAvailabilityUseCase.invokeSync(userWalletId, cryptoCurrency) .onRight { availability -> - val option = (availability as? StakingAvailability.Available)?.option + val option = availability.optionOrNull if (option != null) { router.openStaking( userWalletId = userWalletId, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt index 61415883f5..845d22c4e1 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt @@ -52,6 +52,7 @@ internal class TokenDetailsStakingInfoConverter( return when (stakingAvailability) { StakingAvailability.TemporaryUnavailable -> StakingBlockUM.TemporaryUnavailable StakingAvailability.Unavailable -> null + is StakingAvailability.Full -> getStakedBlockOrNull(status) is StakingAvailability.Available -> getStakingInfoBlock(status, state) } } @@ -107,6 +108,39 @@ internal class TokenDetailsStakingInfoConverter( } } + private fun getStakedBlockOrNull(status: CryptoCurrencyStatus): StakingBlockUM? { + val stakingBalance = status.value.stakingBalance as? StakingBalance.Data + val stakingCryptoAmount = stakingBalance?.getTotalStakingBalance(status.currency.network.rawId) + val hasPendingBalances = when (stakingBalance) { + is StakingBalance.Data.StakeKit -> stakingBalance.balance.items.isNotEmpty() + is StakingBalance.Data.P2PEthPool -> !stakingBalance.unstakingAmount.isNullOrZero() + null -> false + } + return when { + !stakingCryptoAmount.isNullOrZero() -> getStakedBlockWithFiatAmount( + status = status, + stakingAmount = stakingCryptoAmount, + rewardAmount = when (stakingBalance) { + is StakingBalance.Data.StakeKit -> stakingBalance.getRewardStakingBalance() + is StakingBalance.Data.P2PEthPool -> stakingBalance.totalRewards + else -> BigDecimal.ZERO + }, + ) + // Pending-only path: reachable for StakeKit (pending items are not part of the total); + // for P2PEthPool the unstaking amount is already included in getTotalStakingBalance above. + hasPendingBalances -> getStakedBlockWithFiatAmount( + status = status, + stakingAmount = when (stakingBalance) { + is StakingBalance.Data.StakeKit -> stakingBalance.balance.items.sumOf { it.amount } + is StakingBalance.Data.P2PEthPool -> stakingBalance.unstakingAmount + null -> BigDecimal.ZERO + }, + rewardAmount = null, + ) + else -> null + } + } + private fun isStakingButtonEnabled(status: CryptoCurrencyStatus): Boolean { return status.value is CryptoCurrencyStatus.Loaded || status.value is CryptoCurrencyStatus.NoQuote || diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt index 35258d56cd..2933e23648 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt @@ -50,6 +50,7 @@ internal class UpdateStakingNotificationTransformer( return when (val availability = stakingAvailability) { StakingAvailability.TemporaryUnavailable -> buildTemporaryUnavailable() StakingAvailability.Unavailable -> null + is StakingAvailability.Full -> buildActiveBlockOrNull(isBalanceHidden) is StakingAvailability.Available -> getStakingInfoBlock(availability, isBalanceHidden) } } @@ -106,6 +107,27 @@ internal class UpdateStakingNotificationTransformer( } } + private fun buildActiveBlockOrNull(isBalanceHidden: Boolean): EarnBlockUM? { + val status = cryptoCurrencyStatus + val stakingBalance = status.value.stakingBalance as? StakingBalance.Data + val stakingCryptoAmount = stakingBalance?.getTotalStakingBalance(status.currency.network.rawId) + return when { + !stakingCryptoAmount.isNullOrZero() -> buildActiveBlock( + stakingAmount = stakingCryptoAmount, + rewardAmount = stakingBalance.getRewardAmount(), + isBalanceHidden = isBalanceHidden, + ) + // Pending-only path: reachable for StakeKit (pending items are not part of the total); + // for P2PEthPool the unstaking amount is already included in getTotalStakingBalance above. + stakingBalance.hasPendingBalances() -> buildActiveBlock( + stakingAmount = stakingBalance.getPendingAmount(), + rewardAmount = null, + isBalanceHidden = isBalanceHidden, + ) + else -> null + } + } + private fun isStakingButtonEnabled(status: CryptoCurrencyStatus): Boolean { return status.value is CryptoCurrencyStatus.Loaded || status.value is CryptoCurrencyStatus.NoQuote || diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerTest.kt index 31b1d632f2..e994349df1 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerTest.kt @@ -84,11 +84,39 @@ class UpdateStakingNotificationTransformerTest { assertThat(content.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Button::class.java) } + @Test + fun `GIVEN Full AND no active stake WHEN transform THEN earnBlockState is null`() { + val transformer = createTransformer( + availability = fullOption(BigDecimal("4.2")), + entryInfo = null, + ) + + val result = transformer.transform(initialState()) + + assertThat(result.earnBlockState).isNull() + } + + @Test + fun `GIVEN Full AND active stake WHEN transform THEN active balance block`() { + val transformer = createTransformer( + availability = fullOption(BigDecimal("4.2")), + entryInfo = null, + status = buildStatusWithStake(stakedAmount = BigDecimal("5")), + ) + + val result = transformer.transform(initialState()) + + assertThat(result.earnBlockState).isInstanceOf(EarnBlockUM.Content::class.java) + val content = result.earnBlockState as EarnBlockUM.Content + assertThat(content.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Balance::class.java) + } + private fun createTransformer( availability: StakingAvailability, entryInfo: StakingEntryInfo?, + status: CryptoCurrencyStatus = buildStatus(), ) = UpdateStakingNotificationTransformer( - cryptoCurrencyStatus = buildStatus(), + cryptoCurrencyStatus = status, stakingAvailability = availability, stakingEntryInfo = entryInfo, appCurrency = AppCurrency.Default, @@ -122,6 +150,38 @@ class UpdateStakingNotificationTransformerTest { return StakingAvailability.Available(option = option) } + private fun fullOption(apy: BigDecimal): StakingAvailability.Full { + val option = mockk(relaxed = true) { + every { this@mockk.apy } returns apy + } + return StakingAvailability.Full(option = option) + } + + private fun buildStatusWithStake(stakedAmount: BigDecimal): CryptoCurrencyStatus { + val network = mockk(relaxed = true) { + every { rawId } returns "solana" + every { isTestnet } returns false + } + val currency = mockk(relaxed = true) { + every { symbol } returns "SOL" + every { decimals } returns 9 + every { this@mockk.network } returns network + every { id.isCoin } returns true + } + val stakingBalance = mockk(relaxed = true) { + every { totalStaked } returns stakedAmount + every { unstakingAmount } returns BigDecimal.ZERO + every { withdrawableAmount } returns BigDecimal.ZERO + every { totalRewards } returns BigDecimal.ZERO + } + val value = mockk(relaxed = true) { + every { this@mockk.stakingBalance } returns stakingBalance + every { fiatRate } returns BigDecimal.ONE + every { yieldSupplyStatus } returns null + } + return CryptoCurrencyStatus(currency = currency, value = value) + } + private fun initialState(): TokenDetailsUM = TokenDetailsUM( topAppBarUM = TokenDetailsTopAppBarUM( titleState = TitleState.Simple(tokenName = "Solana"), 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 050519fc12..c7826b72c9 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,6 +12,7 @@ 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.lib.crypto.BlockchainUtils @@ -77,14 +78,21 @@ internal class EarnApyConverter( currencyStatus: CryptoCurrencyStatus, stakingApyMap: Map, ): StakingLocalInfo { - val stakingAvailability = stakingApyMap[currencyStatus.currency] as? StakingAvailability.Available + val availability = stakingApyMap[currencyStatus.currency] + val option = availability?.optionOrNull ?: return StakingLocalInfo(rate = null, isActive = false, rewardType = null) val stakingBalance = currencyStatus.value.stakingBalance as? StakingBalance.Data val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit val p2pEthPoolBalance = stakingBalance as? StakingBalance.Data.P2PEthPool + val isActive = stakeKitBalance != null || p2pEthPoolBalance != null - val rateInfo = when (val stakingOptions = stakingAvailability.option) { + // Full = no free capacity: show the badge only for tokens that already have a stake. + if (availability is StakingAvailability.Full && !isActive) { + return StakingLocalInfo(rate = null, isActive = false, rewardType = null) + } + + val rateInfo = when (val stakingOptions = option) { is StakingOption.P2PEthPool -> { RewardInfo( rate = stakingOptions.apy, @@ -115,7 +123,7 @@ internal class EarnApyConverter( return StakingLocalInfo( rate = rateInfo?.rate, - isActive = stakeKitBalance != null || p2pEthPoolBalance != null, + isActive = isActive, rewardType = rateInfo?.type, ) } From e20384e8ea5e84ae80f57a05ca55764434805acc Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 4 Jun 2026 15:06:14 +0500 Subject: [PATCH 06/36] Updated on 2026-08-14 --- .../commonfeatures/impl/addfunds/model/AddFundsModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addfunds/model/AddFundsModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addfunds/model/AddFundsModel.kt index b3404a4490..556971fb7e 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addfunds/model/AddFundsModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addfunds/model/AddFundsModel.kt @@ -60,7 +60,7 @@ internal class AddFundsModel @Inject constructor( ).map { actionsState -> CryptoCurrencyData( userWallet = result.wallet, - status = result.currency, + status = actionsState.cryptoCurrencyStatus, actions = actionsState.states, isAccountMode = false, account = cryptoPortfolio, From 0bf1a5cfe097dde4e34b03ae9c0c88cc38455cfa Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 4 Jun 2026 14:43:01 +0400 Subject: [PATCH 07/36] Updated on 2026-08-14 --- core/res/src/main/res/values-de/strings.xml | 10 +++++++++- core/res/src/main/res/values-es/strings.xml | 2 ++ core/res/src/main/res/values-fr/strings.xml | 1 + core/res/src/main/res/values-ja/strings.xml | 2 +- core/res/src/main/res/values-pt-rBR/strings.xml | 10 +++++++++- core/res/src/main/res/values-ru/strings.xml | 3 +++ core/res/src/main/res/values-uk-rUA/strings.xml | 4 +++- core/res/src/main/res/values-zh-rCN/strings.xml | 2 +- core/res/src/main/res/values/strings.xml | 12 ++++++++++-- .../supply/impl/promo/ui/YieldSupplyPromoContent.kt | 5 +++-- 10 files changed, 42 insertions(+), 9 deletions(-) diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index f56c107dfd..20f3a81950 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -1609,6 +1609,8 @@ Web 3.0-kompatibel Zum Fortfahren ist eine eingehende Transaktion von mindestens %1$s erforderlich Unzureichende Mittel + Mail öffnen + Mail öffnen Durch die Genehmigung erlaubst Du dem Smart Contract, Deine Token in zukünftigen Transaktionen zu verwenden. Detaillierter Modus Fester Zinssatz @@ -1697,6 +1699,7 @@ MCC %s Andere PIN-Code + Kaufen Nicht nutzbar auf gerooteten Geräten Abgeschlossen Abgelehnt @@ -1705,6 +1708,8 @@ Bedingungen, Gebühren & Limits Bedingungen und Einschränkungen Die Bank hat diese Transaktionsanfrage abgelehnt. + Kategorie + MCC Eine Gebühr wird gemäß den Servicetarifen erhoben Die Transaktion wurde vom Händler teilweise oder vollständig storniert Nutze Dein Geld weiterhin. Du kannst es jederzeit einfrieren. @@ -1871,6 +1876,8 @@ Service vorübergehend nicht verfügbar Daten können derzeit nicht angezeigt werden, Kartenzahlungen funktionieren jedoch weiterhin. Satz \nPIN-Code + Neue PIN einrichten + PIN einstellen Konto geschlossen Ersetzen deine Karte Karte oder Ring verwenden, um die Sitzung zu verlängern @@ -2403,7 +2410,8 @@ Transaktionsverlauf für Details prüfen Bonus im Ertragsmodus ausgezahlt %1$s tage übrig, um dein Bonus freizuschalten - Du hast Anspruch auf einen 30-tägigen APY-Boost, es gelten die T&C, erfahren Sie mehr + Sie haben Anspruch auf 30 Tage APY-Boost + Mehr erfahren. Es gelten die Allgemeinen Geschäftsbedingungen. Aktiviere den Renditemodus zum ersten Mal und erhalte in den ersten 30 Tagen bis zu 3x Rendite Bonus für den ersten Monat APR Sie erhalten Marktrendite + Bonus. Der Bonus wird einmalig in USDT oder USDC innerhalb von 14 Tagen nach Ablauf der 30-Tage-Frist ausgezahlt. Verfügbar, solange das Promo-Budget reicht. Bedingungen und Konditionen gelten. diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index 24420b84c8..5d00ffa44e 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -1169,6 +1169,7 @@ No se encontraron tokens compatibles Este código QR contiene parámetros que no son reconocidos: %s. Si continúa, es posible que se pierdan algunos detalles de pago. Parámetros desconocidos + Recarga rápida No se requiere nota %1$s (%2$s) en la red %3$s %1$s en la red %2$s @@ -2263,6 +2264,7 @@ ¡Bono por primera activación! Oferta especial para el modo Rendimiento APY x3 + Puedes disfrutar de un APY mejorado durante 30 días Active el Modo Rendimiento por primera vez y obtenga hasta 3 veces más rendimiento durante sus primeros 30 días Bonificación del primer mes APR Usted obtiene rendimiento de mercado + Bonificación. La bonificación se paga una vez en USDT o USDC en un plazo de 14 días tras finalizar el periodo de 30 días. Disponible mientras dure el presupuesto promocional. Se aplican términos y condiciones diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 5858ef2704..f4eb9029c9 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -1127,6 +1127,7 @@ Aucun jeton pris en charge n\'a été trouvé Ce code QR contient des paramètres non reconnus : %s. Si vous continuez, certaines informations de paiement risquent d\'être perdues. Paramètres inconnus + Recharge rapide Aucun mémo requis %1$s (%2$s) sur le réseau %3$s %1$s sur le réseau %2$s diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index cd1d2c518a..305ff5cafd 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -2374,7 +2374,7 @@ 詳細は取引履歴をご確認ください 利息モードのボーナスが支払われました ボーナス獲得まであと%1$s日 - 30日間APYブーストの対象です。利用規約が適用されます。詳細はこちら。 + 30日間のAPYブーストをご利用いただけます 初めて利息モードを有効にすると、最初の30日間は最大3倍の利回りを獲得できます。 初月APRボーナス 市場利回りに加えてボーナスを獲得できます。ボーナスは30日間の期間終了後、14日以内にUSDTまたはUSDCで一度だけ支払われます。プロモーション予算がなくなり次第終了します。利用規約が適用されます diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index 5b10e64077..d9210b3a7b 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -1609,6 +1609,8 @@ Compatível com Web 3.0 Uma transação de entrada de pelo menos %1$s é necessário prosseguir Fundos insuficientes + Abra o e-mail + Abra o e-mail Ao aprovar, você permite que o contrato inteligente utilize seus tokens em transações futuras. Modo detalhado Taxa fixa @@ -1697,6 +1699,7 @@ MCC %s Outro Código PIN + Compra Não é possível usar em dispositivos com root. Concluído Recusado @@ -1705,6 +1708,8 @@ Termos, taxas e limites Termos e Limites O banco rejeitou esta solicitação de transação. + Categoria + MCC Uma taxa é cobrada de acordo com as tarifas de serviço A transação foi parcial ou totalmente revertida pelo comerciante. Continue usando seu dinheiro. Você pode congelar a qualquer momento. @@ -1871,6 +1876,8 @@ Serviço temporariamente indisponível Não foi possível exibir os detalhes. No entanto, os pagamentos com cartão ainda estão funcionando. Defina o código PIN. + Configurar novo PIN + Definir PIN Conta encerrada Substituindo seu cartão Use o cartão ou anel para renovar a sessão @@ -2403,7 +2410,8 @@ Consulte o histórico de transações para obter detalhes. Bônus do modo Yield pago %1$s Faltam poucos dias para desbloquear seu bônus. - Você tem direito a um aumento de APY por 30 dias. Aplicam-se os termos e condições. Saiba mais. + Você tem direito a uma oferta por tempo limitado para novos usuários. + Aplicam-se os Termos e Condições. Ative o Modo de Rendimento pela primeira vez e obtenha até 3 vezes mais rendimento nos seus primeiros 30 dias. Bônus de APR no primeiro mês Você recebe rendimento de mercado + bônus. O bônus é pago uma única vez em USDT ou USDC dentro de 14 dias após o término do período de 30 dias. Disponível enquanto durar o orçamento promocional. Aplicam-se os termos e condições. diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 8a14eae2d6..5e69a432b4 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -1168,6 +1168,7 @@ Tangem не будет иметь доступа к вашим личным данным, вы передаете их напрямую лицензированному провайдеру Выберите другой метод Согласно требованиям законодательства, %@ требует пройти верификацию личности. + Провайдер платежей требует подтверждения личности Верифицировать Что важно знать Пользуясь сервисом покупки, вы соглашаетесь с %1$s и %2$s @@ -1243,6 +1244,7 @@ Этот QR-код содержит параметры, которые не распознаны: %s. Некоторые данные платежа могут быть утеряны, если вы продолжите. Неизвестные параметры Поделиться адресом или QR кодом + Быстрое пополнение Memo не требуется %1$s (%2$s) в сети %3$s %1$s в %2$s сети @@ -1474,6 +1476,7 @@ APR APY Награда автоматически аккумулируется на вашем стейкинг балансе. + Вознаграждения реинвестируются в ваш баланс стейкинга. Заработано: %s Доступно Средння ставка вознаграждения Что такое Стейкинг? diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index 459ad0e281..eeced5a70b 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -1100,6 +1100,7 @@ Tangem не матиме доступу до ваших особистих даних, ви передаєте їх безпосередньо ліцензованому провайдеру Виберіть інший метод Згідно з вимогами законодавства, %@ вимагає пройти верифікацію особи. + Провайдер платежів вимагає підтвердження особи Верифікувати Що важливо знати Використовуючи сервіс покупки, ви погоджуєтесь з %1$s та %2$s @@ -1174,6 +1175,7 @@ Підтримуваних токенів не знайдено Цей QR-код містить нерозпізнані параметри: %s. Деякі деталі платежу можуть бути втрачені, якщо ви продовжите. Невідомі параметри + Швидке поповнення Memo не вимагається %1$s (%2$s) у мережі %3$s %1$s у мережі %2$s @@ -1394,7 +1396,7 @@ APR APY Винагороди автоматично накопичуються на вашому балансі щодня. - Винагороди реінвестуються у ваш баланс стейкінгу. Зароблено коштів: %s + Винагороди реінвестуються у ваш баланс стейкінгу. Зароблено: %s Доступно Середня ставка винагороди Що таке стейкінг? 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 ebf8761cf7..fe1fbff287 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -2359,7 +2359,7 @@ 收益模式特惠 APY x3 yield_apy_boost_block_activate - 您有资格获得 30 天的年利率提升,适用条款和条件,了解更多信息 + 您有资格获得 30 天的 APY 提升 首次激活收益模式,即可在前 30 天内获得高达 3 倍的收益。 首月年利率奖励 您将获得市场收益 + 奖励。奖励将在 30 天期限结束后 14 天内以 USDT 或 USDC 形式一次性发放。活动额度有限,售完即止。须遵守相关条款和条件。 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 4f4126c849..eab2377186 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1610,6 +1610,8 @@ Web 3.0 Compatible An incoming transaction of at least %1$s is required to proceed Insufficient funds + Open mail + Open mail By approving, you allow the smart contract to use your tokens in future transactions. Detailed mode Fixed Rate @@ -1698,6 +1700,7 @@ MCC %s Other PIN-code + Purchase Unable to use on rooted devices Completed Declined @@ -1706,6 +1709,8 @@ Terms, Fees & Limits Terms and fees The bank rejected this transaction request. + Category + MCC A fee is charged in accordance with the service tariffs The transaction was partially or fully reversed by the merchant Keep using your money. You can freeze anytime. @@ -1872,6 +1877,8 @@ Service temporarily unavailable The service is currently unreachable. Please try again later. Set \nPIN code + Set up new PIN + Set PIN Account closed Replacing your card Use your card or ring to renew session @@ -1884,7 +1891,7 @@ Send USDC Polygon to your account’s address From another wallet or exchange Use crypto from your wallet to top up your payment account - Swap from Tangem Wallet + From your Tangem Wallet USDC on Polygon network Funds from refunded purchases won’t be returned your on-chain Polygon balance or be available for withdrawal, but will stay on your card balance for purchases Please note @@ -2405,7 +2412,8 @@ Check transaction history for details Yield mode bonus paid out %1$s days left to unlock your bonus - You are eligible for 30 days APY boost, T&C apply, learn more + You are eligible for 30 days APY boost + Terms and Conditions apply Activate Yield Mode for the first time and get up to 3x yield for your first 30 days First month APR bonus You get market yield + Bonus. Bonus is paid once in USDT or USDC within 14 days after the 30-day period ends. Available while promo budget lasts. Terms and conditions apply diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/ui/YieldSupplyPromoContent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/ui/YieldSupplyPromoContent.kt index 28d299eb79..19fdece004 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/ui/YieldSupplyPromoContent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/ui/YieldSupplyPromoContent.kt @@ -40,6 +40,7 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.impl.promo.entity.YieldSupplyPromoUM import com.tangem.features.yield.supply.impl.promo.model.YieldSupplyPromoClickIntents +import com.tangem.utils.StringsSigns @Composable internal fun YieldSupplyPromoContent( @@ -257,11 +258,11 @@ private fun PromoBoostCard(baseApy: String, boostedApy: String, onLearnMoreClick append(boostedApy) } } - val learnMoreLabel = stringResourceSafe(R.string.common_learn_more).lowercase() + val learnMoreLabel = stringResourceSafe(R.string.yield_apy_boost_promo_terms_and_conditions) val eligibilityText = stringResourceSafe(R.string.yield_apy_boost_promo_eligibility_text) val subtitleAnnotated = buildAnnotatedString { append(eligibilityText) - append(" ") + append("${StringsSigns.COMA_SIGN} ") withLink( link = LinkAnnotation.Clickable( tag = "YIELD_BOOST_LEARN_MORE", From 22488e972680d95db509c684407cfb2d178aadec Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 4 Jun 2026 14:43:59 +0400 Subject: [PATCH 08/36] Updated on 2026-08-14 --- .../feature/swap/domain/SwapInteractor.kt | 5 ++ .../feature/swap/domain/SwapInteractorImpl.kt | 10 ++-- .../swap/domain/models/ui/SwapState.kt | 2 + .../SwapInteractorImplLoadSwapFeeTest.kt | 58 +++++++++++++++++++ .../tangem/feature/swap/model/SwapModel.kt | 43 +++++++++++--- 5 files changed, 104 insertions(+), 14 deletions(-) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index 01cd972d61..96a05a49ae 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -106,6 +106,10 @@ interface SwapInteractor { * Delegates to `DexSwapFeeCalculator` for DEX/DEX_BRIDGE or to `CexSwapFeeCalculator` for CEX, * then wraps the result in a [SwapFee]. * + * Flow is resolved by [txType], matching the quote-stage `resolveQuoteFlow`: a DEX/DEX_BRIDGE + * provider whose quote returned `txType=SEND` (swap-xyz native transfer) takes the CEX-style + * fee path even though [swapData] is `null`. `txType=SWAP`/`null` keeps the DEX path. + * * The DEX path consumes the pre-fetched [swapData] (which carries the `ExpressTransactionModel.DEX` payload); * the CEX path computes the fee directly from `amount`. * When [swapData] is `null` on the DEX path the call short-circuits to `Left(GetFeeError.UnknownError)` — @@ -130,5 +134,6 @@ interface SwapInteractor { swapData: SwapDataModel?, selectedFeeToken: CryptoCurrencyStatus?, isGasless: Boolean, + txType: ExpressTxType? = null, ): Either } \ 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 93eaea8fda..9ed0402e69 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 @@ -967,19 +967,18 @@ internal class SwapInteractorImpl @Inject constructor( swapData: SwapDataModel?, selectedFeeToken: CryptoCurrencyStatus?, isGasless: Boolean, + txType: ExpressTxType?, ): Either = either { if (amount.value.signum() == 0) { raise(GetFeeError.UnknownError) } - return when (provider.type) { - ExchangeProviderType.DEX, - ExchangeProviderType.DEX_BRIDGE, - -> loadDexSwapFee( + return when (resolveQuoteFlow(provider, txType)) { + ResolvedFlow.DexLike -> loadDexSwapFee( fromStatus = fromStatus, swapData = swapData, selectedFeeToken = selectedFeeToken, ) - ExchangeProviderType.CEX -> loadCexSwapFee( + ResolvedFlow.CexLike -> loadCexSwapFee( fromStatus = fromStatus, amount = amount, selectedFeeToken = selectedFeeToken, @@ -1306,6 +1305,7 @@ internal class SwapInteractorImpl @Inject constructor( feeValue = BigDecimal.ZERO, ), minAdaValue = null, + txType = quoteModel.txType, ) when (resolveQuoteFlow(provider, quoteModel.txType)) { diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index 861066f760..ccc1db514d 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -8,6 +8,7 @@ import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount +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.domain.SwapDataModel @@ -30,6 +31,7 @@ sealed interface SwapState { val validationResult: Throwable? = null, val minAdaValue: BigDecimal?, val swapProvider: SwapProvider, + val txType: ExpressTxType? = null, ) : SwapState data class Transfer( diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadSwapFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadSwapFeeTest.kt index e705f7c63e..51447a52f6 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadSwapFeeTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadSwapFeeTest.kt @@ -20,6 +20,7 @@ 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.ExpressTransactionModel +import com.tangem.feature.swap.domain.models.domain.ExpressTxType import com.tangem.feature.swap.domain.models.domain.SwapDataModel import com.tangem.feature.swap.domain.models.ui.FeeBucket import io.mockk.coEvery @@ -228,6 +229,63 @@ internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase() } } + @Test + fun `DEX provider with quote txType SEND and null swapData routes to CEX fee calculator`() = runTest { + // [REDACTED_TASK_KEY]: swap-xyz comes as provider.type=DEX but the quote returns txType=SEND, which + // re-routes to the CEX-style flow (no DEX swapData is built). Fee must load via the CEX + // calculator instead of short-circuiting to UnknownError. + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) + val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) + val extendedFee = mockk(relaxed = true) { + io.mockk.every { transactionFee } returns TransactionFee.Single(normal = mockk(relaxed = true)) + } + coEvery { + cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any()) + } returns CexFeeResult(transactionFee = TransactionFeeResult.LoadedExtended(extendedFee)).right() + + val result = sut.loadSwapFee( + provider = buildSwapProvider(ExchangeProviderType.DEX), + fromStatus = fromStatus, + toStatus = toStatus, + amount = SwapAmount(BigDecimal.ONE, 18), + swapData = null, + selectedFeeToken = null, + isGasless = false, + txType = ExpressTxType.SEND, + ) + + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { dexSwapFeeCalculator.calculate(any(), any(), any()) } + } + + @Test + fun `DEX_BRIDGE provider with quote txType SEND and null swapData routes to CEX fee calculator`() = runTest { + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) + val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) + val extendedFee = mockk(relaxed = true) { + io.mockk.every { transactionFee } returns TransactionFee.Single(normal = mockk(relaxed = true)) + } + coEvery { + cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any()) + } returns CexFeeResult(transactionFee = TransactionFeeResult.LoadedExtended(extendedFee)).right() + + val result = sut.loadSwapFee( + provider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE), + fromStatus = fromStatus, + toStatus = toStatus, + amount = SwapAmount(BigDecimal.ONE, 18), + swapData = null, + selectedFeeToken = null, + isGasless = false, + txType = ExpressTxType.SEND, + ) + + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { cexSwapFeeCalculator.calculate(any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { dexSwapFeeCalculator.calculate(any(), any(), any()) } + } + @Test fun `DEX calculator Left ExpressDataError maps to Wrapped Left GetFeeError`() = runTest { val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 9772880a1c..a69376aedb 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -2135,6 +2135,28 @@ internal class SwapModel @Inject constructor( override val forceUpdateState = MutableSharedFlow() + /** + * Resolves the `swapData` to hand to [SwapInteractor.loadSwapFee] for the native (non-gasless) + * fee load. A DEX/DEX_BRIDGE provider whose quote returned `txType=SEND` (swap-xyz native + * transfer) re-routes to the CEX-style flow without DEX swapData → returns `null`. A real DEX + * quote without resolved swapData is an error. + */ + private fun resolveDexSwapDataForFee( + quoteState: SwapState.QuotesLoadedState, + ): Either { + return when (quoteState.swapProvider.type) { + ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { + if (quoteState.txType == ExpressTxType.SEND) { + Either.Right(null) + } else { + quoteState.swapDataModel?.let { Either.Right(it) } + ?: Either.Left(GetFeeError.UnknownError) + } + } + ExchangeProviderType.CEX -> Either.Right(null) + } + } + override suspend fun loadFee(): Either { val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) @@ -2165,12 +2187,8 @@ internal class SwapModel @Inject constructor( val amountDecimal = lastAmount.value.replace(",", ".").toBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError) val swapAmount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) - val swapDataForCall = when (quoteState.swapProvider.type) { - ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { - quoteState.swapDataModel ?: return Either.Left(GetFeeError.UnknownError) - } - ExchangeProviderType.CEX -> null - } + val swapDataForCall = resolveDexSwapDataForFee(quoteState) + .getOrElse { return Either.Left(it) } return swapInteractor.loadSwapFee( provider = quoteState.swapProvider, fromStatus = fromSwapCurrencyStatus, @@ -2179,6 +2197,7 @@ internal class SwapModel @Inject constructor( swapData = swapDataForCall, selectedFeeToken = null, isGasless = false, + txType = quoteState.txType, ).map { swapFee -> when (val res = swapFee.transactionFeeResult) { is TransactionFeeResult.LoadedExtended -> res.fee.transactionFee @@ -2216,11 +2235,16 @@ internal class SwapModel @Inject constructor( val amountDecimal = lastAmount.value.parseBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError) val swapAmount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) - // DEX path requires a SwapDataModel. + // DEX path requires a SwapDataModel and does not support gasless yet. swap-xyz native + // transfers (txType=SEND) re-route to the CEX-style flow, so they take the CEX fee path. val swapDataForCall = when (quoteState.swapProvider.type) { ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { - // TODO support gasless in DEX/DEX_BRIDGE - return Either.Left(GetFeeError.GaslessError.NetworkIsNotSupported) + if (quoteState.txType == ExpressTxType.SEND) { + null + } else { + // TODO support gasless in DEX/DEX_BRIDGE + return Either.Left(GetFeeError.GaslessError.NetworkIsNotSupported) + } } ExchangeProviderType.CEX -> null } @@ -2233,6 +2257,7 @@ internal class SwapModel @Inject constructor( swapData = swapDataForCall, selectedFeeToken = selectedToken, isGasless = true, + txType = quoteState.txType, ).map { swapFee -> // The fee selector block consumes TransactionFeeExtended; build one when // `transactionFeeResult` is LoadedExtended, else wrap the native fee in a From 275433607d82f18781363b436b586c203e209011 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 4 Jun 2026 15:14:55 +0200 Subject: [PATCH 09/36] Updated on 2026-08-14 --- .../com/tangem/feature/rating/ui/RatingBlock.kt | 3 +++ .../feature/swap/DefaultSwapFeedbackRepository.kt | 14 ++++++++------ .../tokendetails/DefaultTokenDetailsComponent.kt | 2 +- .../model/ExpressTransactionsModel.kt | 15 +++++++-------- .../tokendetails/ui/TokenDetailsScreen.kt | 8 +++++++- 5 files changed, 26 insertions(+), 16 deletions(-) diff --git a/features/rating/impl/src/main/java/com/tangem/feature/rating/ui/RatingBlock.kt b/features/rating/impl/src/main/java/com/tangem/feature/rating/ui/RatingBlock.kt index bb8b986ee3..afc04a3d23 100644 --- a/features/rating/impl/src/main/java/com/tangem/feature/rating/ui/RatingBlock.kt +++ b/features/rating/impl/src/main/java/com/tangem/feature/rating/ui/RatingBlock.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign import com.tangem.core.ui.R import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.extensions.stringResourceSafe @@ -54,6 +55,7 @@ private fun UnratedState(state: RatingUM.RatingState.Unrated, onRatingSelect: (I text = stringResourceSafe(R.string.swapping_rate_experience_title), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8)) StarRow( @@ -69,6 +71,7 @@ private fun AlreadyRatedState(rating: Int) { text = stringResourceSafe(R.string.swapping_rate_experience_title), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8)) StarRow( diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapFeedbackRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapFeedbackRepository.kt index 51bba4a201..6365de5546 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapFeedbackRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapFeedbackRepository.kt @@ -56,12 +56,14 @@ internal class DefaultSwapFeedbackRepository( add(SurveySparrowAnswerDto(feedbackQuestionId, params.feedback)) } }, - variables = mapOf( - "tx_external_id" to params.txExternalId, - "provider_name" to params.providerName, - "tx_url" to params.txUrl, - "user_wallet_id" to params.userWalletIdHash, - ), + variables = buildMap { + put("tx_external_id", params.txExternalId) + put("provider_name", params.providerName) + if (params.txUrl.isNotEmpty()) { + put("tx_url", params.txUrl) + } + put("user_wallet_id", params.userWalletIdHash) + }, ), ) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt index a193fd3f49..493c4674eb 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt @@ -113,13 +113,13 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( if (LocalRedesignEnabled.current) { val tokenDetailsUM by model.redesignUiState.collectAsStateWithLifecycle() - // TODO [REDACTED_TASK_KEY]: wire ratingSlotState into TokenDetailsScreen when redesign is ready TokenDetailsScreen( tokenDetailsUM = tokenDetailsUM, tokenMarketBlockComponent = tokenMarketBlockComponent, yieldSupplyComponent = yieldSupplyComponent, txHistoryComponent = txHistoryComponent, expressTransactionsComponent = expressTransactionsComponent, + ratingComponent = ratingSlotState.child?.instance, modifier = modifier, ) } else { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt index d77b48b150..adce5c754b 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt @@ -113,14 +113,13 @@ internal class ExpressTransactionsModel @Inject constructor( ?: return internalUiState.value = expressStatusFactory.getStateWithExpressStatusBottomSheet(expressTxState) if (expressTxState is ExchangeUM) { - expressTxState.info.txExternalId?.let { txExternalId -> - params.onRatingRequested?.invoke( - txExternalId, - expressTxState.provider.name, - expressTxState.info.txExternalUrl.orEmpty(), - expressTxState.fromUserWalletId.stringValue, - ) - } + val ratingTxId = expressTxState.info.txExternalId ?: expressTxState.info.txId + params.onRatingRequested?.invoke( + ratingTxId, + expressTxState.provider.name, + expressTxState.info.txExternalUrl.orEmpty(), + expressTxState.fromUserWalletId.stringValue, + ) } } 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 1f5913e64b..c1b6963d6a 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 @@ -59,6 +59,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.ZeroBalan import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlock import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.ZeroBalanceActionsBlock import com.tangem.features.markets.token.block.TokenMarketBlockComponent +import com.tangem.features.rating.RatingComponent import com.tangem.features.tokendetails.ExpressTransactionsComponent import com.tangem.features.txhistory.component.TxHistoryComponent import com.tangem.features.txhistory.entity.TxHistoryItemsUM @@ -72,6 +73,7 @@ import kotlinx.coroutines.flow.StateFlow private val TopBarHeight: Dp = 64.dp private val MarketBlockHorizontalPadding: Dp = 14.dp +@Suppress("LongParameterList") @Composable internal fun TokenDetailsScreen( tokenDetailsUM: TokenDetailsUM, @@ -79,6 +81,7 @@ internal fun TokenDetailsScreen( yieldSupplyComponent: YieldSupplyComponent, txHistoryComponent: TxHistoryComponent, expressTransactionsComponent: ExpressTransactionsComponent, + ratingComponent: RatingComponent?, modifier: Modifier = Modifier, ) { val expressState by expressTransactionsComponent.state.collectAsStateWithLifecycle() @@ -126,7 +129,9 @@ internal fun TokenDetailsScreen( ) } - expressState.bottomSheetSlot?.content(null) + expressState.bottomSheetSlot?.content( + ratingComponent?.let { comp -> { comp.Content(modifier = Modifier.fillMaxWidth()) } }, + ) } } @@ -303,6 +308,7 @@ private fun TokenDetailsScreen_Preview() { override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryItemsUM) = Unit }, expressTransactionsComponent = PreviewExpressTransactionsComponent, + ratingComponent = null, ) } } From fa55c16871ab233dcbe50417d0569c2478b35469 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 4 Jun 2026 19:31:34 +0400 Subject: [PATCH 10/36] Updated on 2026-08-14 --- .../impl/active/model/BoostBlockState.kt | 7 +++++- .../impl/active/model/BoostBlockStateTest.kt | 25 +++++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/BoostBlockState.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/BoostBlockState.kt index 1eda6da853..a2f2a2ffb9 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/BoostBlockState.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/BoostBlockState.kt @@ -1,6 +1,10 @@ package com.tangem.features.yield.supply.impl.active.model import kotlinx.datetime.Instant +import kotlin.time.Duration.Companion.days + +/** How long the awaiting-payout copy stays visible after the qualification period ends, before the block is hidden. */ +private val AWAITING_PAYOUT_WINDOW = 14.days /** What the boost block on the active screen should display, derived solely from the qualification end date. */ internal sealed interface BoostBlockState { @@ -11,12 +15,13 @@ internal sealed interface BoostBlockState { /** Qualification period is over — show the awaiting-payout copy. */ data object AwaitingPayout : BoostBlockState - /** No qualification end date — show nothing. */ + /** No qualification end date, or the awaiting-payout window has elapsed — show nothing. */ data object Hidden : BoostBlockState } internal fun resolveBoostBlockState(qualificationEndDate: Instant?, now: Instant): BoostBlockState = when { qualificationEndDate == null -> BoostBlockState.Hidden + now >= qualificationEndDate + AWAITING_PAYOUT_WINDOW -> BoostBlockState.Hidden now >= qualificationEndDate -> BoostBlockState.AwaitingPayout else -> BoostBlockState.DaysLeft(days = (qualificationEndDate - now).inWholeDays.toInt()) } \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/BoostBlockStateTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/BoostBlockStateTest.kt index 9d2dd5f854..63580acb2c 100644 --- a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/BoostBlockStateTest.kt +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/BoostBlockStateTest.kt @@ -43,12 +43,33 @@ internal class BoostBlockStateTest { } @Test - fun `GIVEN past qualificationEndDate WHEN resolve THEN AwaitingPayout`() { + fun `GIVEN qualificationEndDate passed within 14 days WHEN resolve THEN AwaitingPayout`() { val result = resolveBoostBlockState( - qualificationEndDate = Instant.parse("2026-05-01T00:00:00Z"), + // 13d 23h 59m 59s ago — just inside the 14-day window + qualificationEndDate = Instant.parse("2026-05-14T00:00:01Z"), now = now, ) assertThat(result).isEqualTo(BoostBlockState.AwaitingPayout) } + + @Test + fun `GIVEN qualificationEndDate passed exactly 14 days ago WHEN resolve THEN Hidden`() { + val result = resolveBoostBlockState( + qualificationEndDate = Instant.parse("2026-05-14T00:00:00Z"), + now = now, + ) + + assertThat(result).isEqualTo(BoostBlockState.Hidden) + } + + @Test + fun `GIVEN qualificationEndDate passed more than 14 days ago WHEN resolve THEN Hidden`() { + val result = resolveBoostBlockState( + qualificationEndDate = Instant.parse("2026-05-01T00:00:00Z"), + now = now, + ) + + assertThat(result).isEqualTo(BoostBlockState.Hidden) + } } \ No newline at end of file From 85b00c4c658e658b0f25f3c6a7d24a7765bf1ba6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 5 Jun 2026 12:32:13 +0400 Subject: [PATCH 11/36] Updated on 2026-08-14 --- .../DefaultTransactionSignerFactory.kt | 57 +++++- .../di/domain/WalletConnectDomainModule.kt | 1 + .../TransactionSignerFactoryModule.kt | 10 +- .../com/tangem/tap/domain/TangemSigner.kt | 4 + .../DefaultTransactionSignerFactoryTest.kt | 163 ++++++++++++++++++ .../card/DefaultCardSdkConfigRepository.kt | 10 +- .../data/card/TransactionSignerFactory.kt | 8 +- .../repository/CardSdkConfigRepository.kt | 10 +- .../usecase/AssociateAssetUseCase.kt | 5 +- .../usecase/OpenTrustlineUseCase.kt | 5 +- .../usecase/PrepareAndSignUseCase.kt | 1 + .../usecase/PrepareForSendUseCase.kt | 1 + .../RetryIncompleteTransactionUseCase.kt | 5 +- .../SendLargeSolanaTransactionUseCase.kt | 1 + .../usecase/SendTransactionUseCase.kt | 1 + .../usecase/SignCloreMessageUseCase.kt | 1 + .../domain/transaction/usecase/SignUseCase.kt | 1 + .../CreateAndSendGaslessTransactionUseCase.kt | 1 + 18 files changed, 269 insertions(+), 16 deletions(-) create mode 100644 app/src/test/kotlin/com/tangem/tap/common/libs/blockchainsdk/DefaultTransactionSignerFactoryTest.kt diff --git a/app/src/main/java/com/tangem/tap/common/libs/blockchainsdk/DefaultTransactionSignerFactory.kt b/app/src/main/java/com/tangem/tap/common/libs/blockchainsdk/DefaultTransactionSignerFactory.kt index fced2e9ee9..ef9712b6d6 100644 --- a/app/src/main/java/com/tangem/tap/common/libs/blockchainsdk/DefaultTransactionSignerFactory.kt +++ b/app/src/main/java/com/tangem/tap/common/libs/blockchainsdk/DefaultTransactionSignerFactory.kt @@ -1,5 +1,6 @@ package com.tangem.tap.common.libs.blockchainsdk +import androidx.annotation.VisibleForTesting import com.tangem.Message import com.tangem.TangemSdk import com.tangem.blockchain.common.TransactionSigner @@ -7,22 +8,70 @@ import com.tangem.core.analytics.models.Basic.TransactionSent.WalletForm import com.tangem.core.analytics.store.LastSignedWalletFormStore import com.tangem.data.card.TransactionSignerFactory import com.tangem.domain.card.models.TwinKey +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.update +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.tap.domain.TangemSigner +import com.tangem.tap.domain.TangemSignerResponse +import com.tangem.utils.coroutines.AppCoroutineScope +import kotlinx.coroutines.launch internal class DefaultTransactionSignerFactory( private val lastSignedWalletFormStore: LastSignedWalletFormStore, + private val userWalletsListRepository: UserWalletsListRepository, + private val coroutineScope: AppCoroutineScope, ) : TransactionSignerFactory { - override fun createTransactionSigner(cardId: String?, sdk: TangemSdk, twinKey: TwinKey?): TransactionSigner { + override fun createTransactionSigner( + cardId: String?, + sdk: TangemSdk, + twinKey: TwinKey?, + userWalletId: UserWalletId, + ): TransactionSigner { return TangemSigner( cardId = cardId, tangemSdk = sdk, initialMessage = Message(), twinKey = twinKey, ) { signResponse -> - lastSignedWalletFormStore.update( - if (signResponse.isRing) WalletForm.Ring else WalletForm.Card, - ) + onSignerResponse(userWalletId, signResponse) } } + + @VisibleForTesting + internal fun onSignerResponse(userWalletId: UserWalletId, signResponse: TangemSignerResponse) { + lastSignedWalletFormStore.update( + if (signResponse.isRing) WalletForm.Ring else WalletForm.Card, + ) + + coroutineScope.launch { + userWalletsListRepository.update(userWalletId) { userWallet -> + userWallet.updateSignedHashes(signResponse) + } + } + } + + private fun UserWallet.updateSignedHashes(signResponse: TangemSignerResponse): UserWallet { + if (this !is UserWallet.Cold) return this + + return copy( + scanResponse = scanResponse.copy( + card = scanResponse.card.copy( + wallets = scanResponse.card.wallets.map { wallet -> + if (wallet.publicKey.contentEquals(signResponse.signedWalletPublicKey)) { + wallet.copy( + // Keep previously known counters if the signer response does not provide them, + // otherwise we would regress the UI counters to null. + totalSignedHashes = signResponse.totalSignedHashes ?: wallet.totalSignedHashes, + remainingSignatures = signResponse.remainingSignatures ?: wallet.remainingSignatures, + ) + } else { + wallet + } + }, + ), + ), + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/WalletConnectDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/WalletConnectDomainModule.kt index 780bea965e..981dcb1dd5 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/WalletConnectDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/WalletConnectDomainModule.kt @@ -51,6 +51,7 @@ internal object WalletConnectDomainModule { cardSdkConfigRepository.getCommonSigner( cardId = card.cardId.takeIf { isCardNotBackedUp }, twinKey = TwinKey.getOrNull(scanResponse = wallet.scanResponse), + userWalletId = wallet.walletId, ) } } diff --git a/app/src/main/java/com/tangem/tap/di/libs/blockchainsdk/TransactionSignerFactoryModule.kt b/app/src/main/java/com/tangem/tap/di/libs/blockchainsdk/TransactionSignerFactoryModule.kt index badf261801..c554ab14d1 100644 --- a/app/src/main/java/com/tangem/tap/di/libs/blockchainsdk/TransactionSignerFactoryModule.kt +++ b/app/src/main/java/com/tangem/tap/di/libs/blockchainsdk/TransactionSignerFactoryModule.kt @@ -2,7 +2,9 @@ package com.tangem.tap.di.libs.blockchainsdk import com.tangem.core.analytics.store.LastSignedWalletFormStore import com.tangem.data.card.TransactionSignerFactory +import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.tap.common.libs.blockchainsdk.DefaultTransactionSignerFactory +import com.tangem.utils.coroutines.AppCoroutineScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -20,7 +22,13 @@ internal class TransactionSignerFactoryModule { @Singleton fun provideTransactionSignerFactory( lastSignedWalletFormStore: LastSignedWalletFormStore, + userWalletsListRepository: UserWalletsListRepository, + appCoroutineScope: AppCoroutineScope, ): TransactionSignerFactory { - return DefaultTransactionSignerFactory(lastSignedWalletFormStore) + return DefaultTransactionSignerFactory( + lastSignedWalletFormStore = lastSignedWalletFormStore, + userWalletsListRepository = userWalletsListRepository, + coroutineScope = appCoroutineScope, + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/TangemSigner.kt b/app/src/main/java/com/tangem/tap/domain/TangemSigner.kt index 67e1652a6e..033e09dd1c 100644 --- a/app/src/main/java/com/tangem/tap/domain/TangemSigner.kt +++ b/app/src/main/java/com/tangem/tap/domain/TangemSigner.kt @@ -40,6 +40,7 @@ class TangemSigner( totalSignedHashes = result.data.totalSignedHashes, remainingSignatures = result.data.remainingSignatures, isRing = result.data.batchId?.let(::isRing) == true, + signedWalletPublicKey = publicKey.seedKey, ), ) if (continuation.isActive) { @@ -86,6 +87,7 @@ class TangemSigner( totalSignedHashes = result.data.totalSignedHashes, remainingSignatures = result.data.remainingSignatures, isRing = result.data.batchId?.let(::isRing) == true, + signedWalletPublicKey = publicKey.seedKey, ), ) if (continuation.isActive) { @@ -102,8 +104,10 @@ class TangemSigner( } } +@Suppress("ArrayInDataClass") data class TangemSignerResponse( val totalSignedHashes: Int?, val remainingSignatures: Int?, val isRing: Boolean, + val signedWalletPublicKey: ByteArray, ) \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/common/libs/blockchainsdk/DefaultTransactionSignerFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/common/libs/blockchainsdk/DefaultTransactionSignerFactoryTest.kt new file mode 100644 index 0000000000..58f444bf5d --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/common/libs/blockchainsdk/DefaultTransactionSignerFactoryTest.kt @@ -0,0 +1,163 @@ +package com.tangem.tap.common.libs.blockchainsdk + +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.TestAppCoroutineScope +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.core.analytics.models.Basic.TransactionSent.WalletForm +import com.tangem.core.analytics.store.LastSignedWalletFormStore +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.tap.domain.TangemSignerResponse +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +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 DefaultTransactionSignerFactoryTest { + + private val lastSignedWalletFormStore = mockk(relaxed = true) + private val userWalletsListRepository = mockk() + + private val factory = DefaultTransactionSignerFactory( + lastSignedWalletFormStore = lastSignedWalletFormStore, + userWalletsListRepository = userWalletsListRepository, + coroutineScope = TestAppCoroutineScope(), + ) + + private val baseWallet = MockUserWalletFactory.create() + + /** Wallet that will be the target of the signing operation. */ + private val walletA = baseWallet.scanResponse.card.wallets.first().copy( + publicKey = PUBLIC_KEY_A, + totalSignedHashes = 0, + remainingSignatures = 100, + ) + + /** Another wallet that must stay untouched after signing with [walletA]'s key. */ + private val walletB = baseWallet.scanResponse.card.wallets.first().copy( + publicKey = PUBLIC_KEY_B, + totalSignedHashes = 7, + remainingSignatures = 50, + ) + + private val userWallet = baseWallet.copy( + scanResponse = baseWallet.scanResponse.copy( + card = baseWallet.scanResponse.card.copy(wallets = listOf(walletA, walletB)), + ), + ) + + private val savedWalletSlot = slot() + + @BeforeEach + fun setup() { + clearMocks(lastSignedWalletFormStore, userWalletsListRepository) + + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) + coEvery { userWalletsListRepository.saveWithoutLock(capture(savedWalletSlot), any()) } answers { + savedWalletSlot.captured.right() + } + } + + @Test + fun `updates signed hashes only for the wallet matching the signed public key`() { + factory.onSignerResponse( + userWalletId = userWallet.walletId, + signResponse = signerResponse( + signedWalletPublicKey = PUBLIC_KEY_A, + totalSignedHashes = 5, + remainingSignatures = 95, + ), + ) + + val savedWallets = (savedWalletSlot.captured as UserWallet.Cold).scanResponse.card.wallets + val savedA = savedWallets.first { it.publicKey.contentEquals(PUBLIC_KEY_A) } + val savedB = savedWallets.first { it.publicKey.contentEquals(PUBLIC_KEY_B) } + + assertThat(savedA.totalSignedHashes).isEqualTo(5) + assertThat(savedA.remainingSignatures).isEqualTo(95) + // The non-signed wallet must keep its original values. + assertThat(savedB.totalSignedHashes).isEqualTo(7) + assertThat(savedB.remainingSignatures).isEqualTo(50) + } + + @Test + fun `keeps previously known counters when the signer response has null values`() { + factory.onSignerResponse( + userWalletId = userWallet.walletId, + signResponse = signerResponse( + signedWalletPublicKey = PUBLIC_KEY_A, + totalSignedHashes = null, + remainingSignatures = null, + ), + ) + + val savedA = (savedWalletSlot.captured as UserWallet.Cold).scanResponse.card.wallets + .first { it.publicKey.contentEquals(PUBLIC_KEY_A) } + + // Null response values must not overwrite the known counters. + assertThat(savedA.totalSignedHashes).isEqualTo(0) + assertThat(savedA.remainingSignatures).isEqualTo(100) + } + + @Test + fun `leaves all wallets untouched when no public key matches`() { + factory.onSignerResponse( + userWalletId = userWallet.walletId, + signResponse = signerResponse( + signedWalletPublicKey = UNKNOWN_PUBLIC_KEY, + totalSignedHashes = 5, + remainingSignatures = 95, + ), + ) + + val savedWallets = (savedWalletSlot.captured as UserWallet.Cold).scanResponse.card.wallets + assertThat(savedWallets.first { it.publicKey.contentEquals(PUBLIC_KEY_A) }.totalSignedHashes).isEqualTo(0) + assertThat(savedWallets.first { it.publicKey.contentEquals(PUBLIC_KEY_B) }.totalSignedHashes).isEqualTo(7) + } + + @Test + fun `updates last signed wallet form with Card for a non-ring response`() { + factory.onSignerResponse( + userWalletId = userWallet.walletId, + signResponse = signerResponse(signedWalletPublicKey = PUBLIC_KEY_A, isRing = false), + ) + + verify(exactly = 1) { lastSignedWalletFormStore.update(WalletForm.Card) } + } + + @Test + fun `updates last signed wallet form with Ring for a ring response`() { + factory.onSignerResponse( + userWalletId = userWallet.walletId, + signResponse = signerResponse(signedWalletPublicKey = PUBLIC_KEY_A, isRing = true), + ) + + verify(exactly = 1) { lastSignedWalletFormStore.update(WalletForm.Ring) } + } + + private fun signerResponse( + signedWalletPublicKey: ByteArray, + totalSignedHashes: Int? = 1, + remainingSignatures: Int? = 1, + isRing: Boolean = false, + ) = TangemSignerResponse( + totalSignedHashes = totalSignedHashes, + remainingSignatures = remainingSignatures, + isRing = isRing, + signedWalletPublicKey = signedWalletPublicKey, + ) + + private companion object { + val PUBLIC_KEY_A = byteArrayOf(1, 2, 3) + val PUBLIC_KEY_B = byteArrayOf(4, 5, 6) + val UNKNOWN_PUBLIC_KEY = byteArrayOf(9, 9, 9) + } +} \ No newline at end of file diff --git a/data/card/src/main/java/com/tangem/data/card/DefaultCardSdkConfigRepository.kt b/data/card/src/main/java/com/tangem/data/card/DefaultCardSdkConfigRepository.kt index 35c915ab68..d90486be25 100644 --- a/data/card/src/main/java/com/tangem/data/card/DefaultCardSdkConfigRepository.kt +++ b/data/card/src/main/java/com/tangem/data/card/DefaultCardSdkConfigRepository.kt @@ -9,6 +9,7 @@ import com.tangem.data.card.sdk.CardSdkProvider import com.tangem.domain.card.models.TwinKey import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.wallet.UserWalletId /** * Implementation of repository for managing of CardSDK config @@ -61,8 +62,13 @@ internal class DefaultCardSdkConfigRepository( } } - override fun getCommonSigner(cardId: String?, twinKey: TwinKey?): TransactionSigner { - return transactionSignerFactory.createTransactionSigner(cardId = cardId, sdk = sdk, twinKey = twinKey) + override fun getCommonSigner(cardId: String?, twinKey: TwinKey?, userWalletId: UserWalletId): TransactionSigner { + return transactionSignerFactory.createTransactionSigner( + cardId = cardId, + sdk = sdk, + twinKey = twinKey, + userWalletId = userWalletId, + ) } override fun isLinkedTerminal() = sdk.config.linkedTerminal diff --git a/data/card/src/main/java/com/tangem/data/card/TransactionSignerFactory.kt b/data/card/src/main/java/com/tangem/data/card/TransactionSignerFactory.kt index 4da2cdf41c..ff76712de0 100644 --- a/data/card/src/main/java/com/tangem/data/card/TransactionSignerFactory.kt +++ b/data/card/src/main/java/com/tangem/data/card/TransactionSignerFactory.kt @@ -3,11 +3,17 @@ package com.tangem.data.card import com.tangem.TangemSdk import com.tangem.blockchain.common.TransactionSigner import com.tangem.domain.card.models.TwinKey +import com.tangem.domain.models.wallet.UserWalletId /** [REDACTED_AUTHOR] */ interface TransactionSignerFactory { - fun createTransactionSigner(cardId: String?, sdk: TangemSdk, twinKey: TwinKey?): TransactionSigner + fun createTransactionSigner( + cardId: String?, + sdk: TangemSdk, + twinKey: TwinKey?, + userWalletId: UserWalletId, + ): TransactionSigner } \ No newline at end of file diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/repository/CardSdkConfigRepository.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/repository/CardSdkConfigRepository.kt index ecf06c78d8..e1291b947c 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/repository/CardSdkConfigRepository.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/repository/CardSdkConfigRepository.kt @@ -4,6 +4,7 @@ import com.tangem.TangemSdk import com.tangem.blockchain.common.TransactionSigner import com.tangem.domain.card.models.TwinKey import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.wallet.UserWalletId /** * Repository for managing with CardSDK config @@ -28,8 +29,13 @@ interface CardSdkConfigRepository { /** Update the card ID display format according to the [productType] of the scanned card */ fun updateCardIdDisplayFormat(productType: ProductType) - /** Get common signer by [cardId] */ - fun getCommonSigner(cardId: String?, twinKey: TwinKey?): TransactionSigner + /** + * Get common signer by [cardId]. + * + * @param userWalletId ID of the user wallet being signed. Used to persist the updated number of signed hashes + * back into the wallet after a successful signing operation. + */ + fun getCommonSigner(cardId: String?, twinKey: TwinKey?, userWalletId: UserWalletId): TransactionSigner /** Check if linked terminal is enabled */ fun isLinkedTerminal(): Boolean? diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/AssociateAssetUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/AssociateAssetUseCase.kt index 664a93a44f..0149832127 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/AssociateAssetUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/AssociateAssetUseCase.kt @@ -65,14 +65,15 @@ class AssociateAssetUseCase( private fun createSigner(userWallet: UserWallet): TransactionSigner { return when (userWallet) { is UserWallet.Hot -> getHotTransactionSigner(userWallet) - is UserWallet.Cold -> getColdSigner() + is UserWallet.Cold -> getColdSigner(userWallet) } } - private fun getColdSigner(): TransactionSigner { + private fun getColdSigner(userWallet: UserWallet.Cold): TransactionSigner { return cardSdkConfigRepository.getCommonSigner( cardId = null, twinKey = null, // use null here because no assets support for Twin cards + userWalletId = userWallet.walletId, ) } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/OpenTrustlineUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/OpenTrustlineUseCase.kt index 4f4a6c1374..7dc81d6d6f 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/OpenTrustlineUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/OpenTrustlineUseCase.kt @@ -62,14 +62,15 @@ class OpenTrustlineUseCase( private fun createSigner(userWallet: UserWallet): TransactionSigner { return when (userWallet) { is UserWallet.Hot -> getHotTransactionSigner(userWallet) - is UserWallet.Cold -> getColdSigner() + is UserWallet.Cold -> getColdSigner(userWallet) } } - private fun getColdSigner(): TransactionSigner { + private fun getColdSigner(userWallet: UserWallet.Cold): TransactionSigner { return cardSdkConfigRepository.getCommonSigner( cardId = null, twinKey = null, // use null here because no assets support for Twin cards + userWalletId = userWallet.walletId, ) } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrepareAndSignUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrepareAndSignUseCase.kt index 004f33b3bc..4b181f756c 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrepareAndSignUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrepareAndSignUseCase.kt @@ -70,6 +70,7 @@ class PrepareAndSignUseCase( val signer = cardSdkConfigRepository.getCommonSigner( cardId = card.cardId.takeIf { isCardNotBackedUp }, twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), + userWalletId = userWallet.walletId, ) return signer } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrepareForSendUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrepareForSendUseCase.kt index a6f1353437..fa6e2eb205 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrepareForSendUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrepareForSendUseCase.kt @@ -69,6 +69,7 @@ class PrepareForSendUseCase( val signer = cardSdkConfigRepository.getCommonSigner( cardId = card.cardId.takeIf { isCardNotBackedUp }, twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), + userWalletId = userWallet.walletId, ) return signer } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/RetryIncompleteTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/RetryIncompleteTransactionUseCase.kt index f4d661ee6c..baa59e7fab 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/RetryIncompleteTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/RetryIncompleteTransactionUseCase.kt @@ -58,14 +58,15 @@ class RetryIncompleteTransactionUseCase( private fun createSigner(userWallet: UserWallet): TransactionSigner { return when (userWallet) { is UserWallet.Hot -> getHotTransactionSigner(userWallet) - is UserWallet.Cold -> getColdSigner() + is UserWallet.Cold -> getColdSigner(userWallet) } } - private fun getColdSigner(): TransactionSigner { + private fun getColdSigner(userWallet: UserWallet.Cold): TransactionSigner { return cardSdkConfigRepository.getCommonSigner( cardId = null, twinKey = null, // use null here because no assets support for Twin cards + userWalletId = userWallet.walletId, ) } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendLargeSolanaTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendLargeSolanaTransactionUseCase.kt index 819faacd7b..301dd9f551 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendLargeSolanaTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendLargeSolanaTransactionUseCase.kt @@ -36,6 +36,7 @@ class SendLargeSolanaTransactionUseCase( val signer = cardSdkConfigRepository.getCommonSigner( cardId = card.cardId.takeIf { isCardNotBackedUp }, twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), + userWalletId = userWallet.walletId, ) val walletManager = walletManagersFacade diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt index 3ddfe06ed5..858562e17a 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt @@ -62,6 +62,7 @@ class SendTransactionUseCase( val coldSigner = cardSdkConfigRepository.getCommonSigner( cardId = card.cardId.takeIf { isCardNotBackedUp }, twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), + userWalletId = userWallet.walletId, ) coldSigner diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignCloreMessageUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignCloreMessageUseCase.kt index c4e4913822..f51cf1b5bb 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignCloreMessageUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignCloreMessageUseCase.kt @@ -43,6 +43,7 @@ class SignCloreMessageUseCase( cardSdkConfigRepository.getCommonSigner( cardId = card.cardId.takeIf { isCardNotBackedUp }, twinKey = null, + userWalletId = userWallet.walletId, ) } is UserWallet.Hot -> getHotWalletSigner(userWallet) diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignUseCase.kt index 07b302c99c..200e8c4faa 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignUseCase.kt @@ -44,6 +44,7 @@ class SignUseCase( return cardSdkConfigRepository.getCommonSigner( cardId = card.cardId.takeIf { isCardNotBackedUp }, twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), + userWalletId = userWallet.walletId, ) } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt index 90b0f2d731..ada361d701 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt @@ -235,6 +235,7 @@ class CreateAndSendGaslessTransactionUseCase( cardSdkConfigRepository.getCommonSigner( cardId = card.cardId.takeIf { isCardNotBackedUp }, twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), + userWalletId = userWallet.walletId, ) } is UserWallet.Hot -> getHotWalletSigner(userWallet) From 9b306f1bb184038aa963da99ec36581340a697b6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 5 Jun 2026 09:38:22 +0000 Subject: [PATCH 12/36] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 962be99d3f..0ab9e14901 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.38-1551" +tangemBlockchainSdk = "releases-5.39-1533" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.38-615" +tangemCardSdk = "releases-5.39-623" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 1c2c5a69bd633503482c8c4f3a95a78c6801810a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 5 Jun 2026 15:22:42 +0500 Subject: [PATCH 13/36] Updated on 2026-08-14 --- .../tangem/feature/swap/models/states/SwapNotificationUM.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt index 7896ea85ca..0769f7b5b7 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt @@ -76,12 +76,12 @@ internal object SwapNotificationUM { ), subtitle = resourceReference( R.string.warning_express_not_enough_fee_for_token_tx_description, - wrappedList(currencyName, currencySymbol), + wrappedList(feeCurrency.name, feeCurrency.symbol), ), iconResId = fromToken.networkIconResId, buttonState = onConfirmClick?.let { NotificationConfig.ButtonsState.SecondaryButtonConfig( - text = resourceReference(R.string.common_buy_currency, wrappedList(currencySymbol)), + text = resourceReference(R.string.common_buy_currency, wrappedList(feeCurrency.symbol)), onClick = onConfirmClick, ) }, From 0d7824736af6cc6a49a76ad40cab973d54cbd34e Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 5 Jun 2026 13:23:35 +0300 Subject: [PATCH 14/36] Updated on 2026-08-14 --- .../model/DynamicAddressesDelegate.kt | 9 ++- .../tokendetails/model/TokenDetailsModel.kt | 6 +- .../model/DynamicAddressesDelegateTest.kt | 64 ++++++++++++++----- 3 files changed, 57 insertions(+), 22 deletions(-) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt index 9e3ade71b0..ad8eef163e 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt @@ -1,10 +1,12 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.model +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.core.TangemSdkError import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.ResettableOneTimeEventSender import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.res.R import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.SnackbarMessage @@ -55,6 +57,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( private val analyticsEventHandler: AnalyticsEventHandler, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, private val dispatchers: CoroutineDispatcherProvider, + private val urlOpener: UrlOpener, @Assisted private val userWallet: UserWallet, @Assisted private val cryptoCurrencyStatusProvider: Provider, @Assisted private val appCurrencyProvider: Provider, @@ -77,7 +80,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( // region Entry point - fun onDynamicAddressesClick() { + fun openBottomSheet() { val currency = cryptoCurrencyStatusProvider()?.currency ?: return analyticsEventHandler.send(TokenDetailsAnalyticsEvent.DynamicAddressesScreenOpened(currency)) coroutineScope.launch(dispatchers.main) { @@ -313,7 +316,9 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( } private fun onReadMoreClick() { - // TODO: Replace with actual URL + coroutineScope.launch(dispatchers.main) { + urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.WhatIsTransactionFee)) + } } private fun onDisableClick() { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 2a2ba67098..b7066ce36d 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -680,11 +680,9 @@ internal class TokenDetailsModel @Inject constructor( openStaking() } - override fun onDynamicAddressesClick() = dynamicAddressesDelegate.onDynamicAddressesClick() + override fun onDynamicAddressesClick() = dynamicAddressesDelegate.openBottomSheet() - override fun onDynamicAddressesFundsFoundLearnMoreClick() { - // TODO: open "Learn more" URL once the destination is decided - } + override fun onDynamicAddressesFundsFoundLearnMoreClick() = dynamicAddressesDelegate.openBottomSheet() private fun onDynamicAddressesStateChanged() { updateTopBarMenu() diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegateTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegateTest.kt index beb9ad3931..5532624528 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegateTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegateTest.kt @@ -4,10 +4,12 @@ import arrow.core.left import arrow.core.right import com.google.common.truth.Truth.assertThat import com.tangem.blockchain.common.TransactionData +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.core.TangemSdkError import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.url.UrlOpener import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.dynamicaddresses.CreateConsolidationTransactionUseCase import com.tangem.domain.dynamicaddresses.IsDynamicAddressesConsolidationRequiredUseCase @@ -35,7 +37,9 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.slot +import io.mockk.unmockkObject import io.mockk.verify import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -50,6 +54,7 @@ private const val TEST_XPUB = "xpub-test-value" private const val TOKEN_SYMBOL = "ETH" private const val BLOCKCHAIN_NAME = "Ethereum" private const val TEST_ADDRESS = "0xTestAddress" +private const val TEST_BLOG_URL = "https://tangem.com/embed/blog/post/what-is-a-transaction-fee-and-why-do-we-need-it" @OptIn(ExperimentalCoroutinesApi::class) internal class DynamicAddressesDelegateTest { @@ -65,6 +70,7 @@ internal class DynamicAddressesDelegateTest { private val dynamicAddressesRepository: DynamicAddressesRepository = mockk(relaxed = true) private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase = mockk() private val uiMessageSender: UiMessageSender = mockk(relaxed = true) + private val urlOpener: UrlOpener = mockk(relaxed = true) private val network: Network = mockk(relaxed = true) { every { name } returns BLOCKCHAIN_NAME @@ -87,7 +93,7 @@ internal class DynamicAddressesDelegateTest { private val onDynamicAddressesStateChanged: () -> Unit = mockk(relaxed = true) @Test - fun `GIVEN currency is available WHEN onDynamicAddressesClick THEN DynamicAddressesScreenOpened event is sent`() = + fun `GIVEN currency is available WHEN openBottomSheet THEN DynamicAddressesScreenOpened event is sent`() = runTest { // GIVEN every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns @@ -99,7 +105,7 @@ internal class DynamicAddressesDelegateTest { every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit // WHEN - delegate.onDynamicAddressesClick() + delegate.openBottomSheet() // THEN val event = eventSlot.captured as TokenDetailsAnalyticsEvent.DynamicAddressesScreenOpened @@ -110,19 +116,19 @@ internal class DynamicAddressesDelegateTest { } @Test - fun `GIVEN no currency WHEN onDynamicAddressesClick THEN no event is sent`() = runTest { + fun `GIVEN no currency WHEN openBottomSheet THEN no event is sent`() = runTest { // GIVEN val delegate = createDelegate(cryptoCurrencyStatus = null) // WHEN - delegate.onDynamicAddressesClick() + delegate.openBottomSheet() // THEN verify(exactly = 0) { analyticsEventHandler.send(any()) } } @Test - fun `GIVEN DISABLED status AND conflicts WHEN onDynamicAddressesClick THEN Notice DynamicAddressesUnavailable is sent`() = + fun `GIVEN DISABLED status AND conflicts WHEN openBottomSheet THEN Notice DynamicAddressesUnavailable is sent`() = runTest { // GIVEN every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns @@ -131,7 +137,7 @@ internal class DynamicAddressesDelegateTest { val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) // WHEN - delegate.onDynamicAddressesClick() + delegate.openBottomSheet() // THEN verify { @@ -157,7 +163,7 @@ internal class DynamicAddressesDelegateTest { coEvery { enableDynamicAddressesUseCase(userWalletId, network, TEST_XPUB) } returns Unit.right() val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) - delegate.onDynamicAddressesClick() + delegate.openBottomSheet() // WHEN (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick() @@ -192,7 +198,7 @@ internal class DynamicAddressesDelegateTest { IllegalStateException("xpub fail").left() val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) - delegate.onDynamicAddressesClick() + delegate.openBottomSheet() // WHEN (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick() @@ -220,7 +226,7 @@ internal class DynamicAddressesDelegateTest { TangemSdkError.UserCancelled().left() val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) - delegate.onDynamicAddressesClick() + delegate.openBottomSheet() // WHEN (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick() @@ -244,7 +250,7 @@ internal class DynamicAddressesDelegateTest { EnableDynamicAddressesError.ServiceError(RuntimeException("boom")).left() val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) - delegate.onDynamicAddressesClick() + delegate.openBottomSheet() // WHEN (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick() @@ -268,7 +274,7 @@ internal class DynamicAddressesDelegateTest { coEvery { dynamicAddressesRepository.disable(userWalletId, network) } returns Unit val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) - delegate.onDynamicAddressesClick() + delegate.openBottomSheet() // WHEN (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation) @@ -289,6 +295,31 @@ internal class DynamicAddressesDelegateTest { } } + @Test + fun `GIVEN disable sheet WHEN read more clicked THEN transaction fee article is opened`() = runTest { + // GIVEN + every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns + flowOf(DynamicAddressesStatus.ENABLED) + coEvery { isConsolidationRequiredUseCase(userWalletId, network) } returns false.right() + mockkObject(TangemBlogUrlBuilder) + + try { + coEvery { TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.WhatIsTransactionFee) } returns TEST_BLOG_URL + + val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) + delegate.openBottomSheet() + + // WHEN + (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation) + .onReadMoreClick() + + // THEN + verify { urlOpener.openUrl(TEST_BLOG_URL) } + } finally { + unmockkObject(TangemBlogUrlBuilder) + } + } + @Test fun `GIVEN ENABLED status AND no consolidation WHEN menu tapped without confirmation THEN repository disable is NOT called`() = runTest { @@ -297,7 +328,7 @@ internal class DynamicAddressesDelegateTest { coEvery { isConsolidationRequiredUseCase(userWalletId, network) } returns false.right() val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) - delegate.onDynamicAddressesClick() + delegate.openBottomSheet() // The simple disable sheet must be shown but no backend write must happen yet. assertThat(delegate.bottomSheetConfig.value) @@ -318,7 +349,7 @@ internal class DynamicAddressesDelegateTest { // WHEN val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) - delegate.onDynamicAddressesClick() + delegate.openBottomSheet() // THEN val notEnoughFee = events @@ -341,7 +372,7 @@ internal class DynamicAddressesDelegateTest { val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) // WHEN: initial load + refresh - delegate.onDynamicAddressesClick() + delegate.openBottomSheet() (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithConsolidation) .onRefreshFee() @@ -365,7 +396,7 @@ internal class DynamicAddressesDelegateTest { coEvery { dynamicAddressesRepository.disable(userWalletId, network) } returns Unit val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) - delegate.onDynamicAddressesClick() + delegate.openBottomSheet() // WHEN (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithConsolidation) @@ -393,7 +424,7 @@ internal class DynamicAddressesDelegateTest { SendTransactionError.UserCancelledError.left() val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus) - delegate.onDynamicAddressesClick() + delegate.openBottomSheet() // WHEN (delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithConsolidation) @@ -444,6 +475,7 @@ internal class DynamicAddressesDelegateTest { getExtendedPublicKeyUseCase = getExtendedPublicKeyUseCase, analyticsEventHandler = analyticsEventHandler, uiMessageSender = uiMessageSender, + urlOpener = urlOpener, dispatchers = TestingCoroutineDispatcherProvider(), userWallet = userWallet, cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, From a3cac3b2c63debfe2dbc8c35ff143ac7c8e5c6a4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 5 Jun 2026 03:25:18 -0700 Subject: [PATCH 15/36] Updated on 2026-08-14 --- core/res/src/main/res/values-de/strings.xml | 4 ++-- core/res/src/main/res/values-es/strings.xml | 2 ++ core/res/src/main/res/values-fr/strings.xml | 3 +++ core/res/src/main/res/values-it/strings.xml | 2 ++ core/res/src/main/res/values-ja/strings.xml | 4 ++-- core/res/src/main/res/values-pt-rBR/strings.xml | 4 ++-- core/res/src/main/res/values-ru/strings.xml | 2 ++ core/res/src/main/res/values-uk-rUA/strings.xml | 2 ++ core/res/src/main/res/values-zh-rCN/strings.xml | 4 ++-- core/res/src/main/res/values-zh-rTW/strings.xml | 2 ++ 10 files changed, 21 insertions(+), 8 deletions(-) diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 20f3a81950..402efcc5b8 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -1855,8 +1855,8 @@ Zahlen Sie genau das, was Sie sehen Ein separates Zahlungskonto wird erstellt, ohne Ihre Adressen und Vermögenswerte offenzulegen Unerreichte Privatsphäre - Verknüpfen Sie eine Zahlungskarte - Wir richten eine Wallet ein. + Und verknüpfen Tangem Pay damit + Wir erstellen eine neue Wallet Holen Sie sich Ihre Tangem Pay Karte Pay-Betreuung Zahlungskonto diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index 5d00ffa44e..39c031cae2 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -1741,6 +1741,8 @@ Paga exactamente lo que ves Se creará una cuenta de pago separada sin divulgar tus direcciones y activos Privacidad inigualable + Y vincularemos Tangem Pay a esta wallet + Crearemos una nueva wallet Obtén tu tarjeta Tangem Pay en minutos Soporte Pay Cuenta de pago diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index f4eb9029c9..a10dc5191f 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -1683,6 +1683,8 @@ Payez exactement ce que vous voyez Un compte de paiement séparé sera créé sans divulguer vos adresses et actifs Confidentialité inégalée + Et associerons Tangem Pay à ce wallet + Nous allons créer un nouveau wallet Obtenez votre carte Tangem Pay en minutes Assistance Pay Compte de paiement @@ -2198,6 +2200,7 @@ Bonus de première activation! Offre spéciale pour le Mode de Rendement 3x APY + Vous pouvez bénéficier d\'un APY boosté pendant 30 jours Activez le Mode de Rendement pour la première fois et obtenez un rendement jusqu\'à 3 fois supérieur pour les 30 premiers jours Bonus APR du premier mois Vous percevez le rendement du marché + le bonus. Le bonus est versé en une fois en USDT ou USDC dans les 14 jours suivant la fin de la période de 30 jours. Disponible jusqu\'à épuisement du budget promotionnel. Conditions générales applicables. diff --git a/core/res/src/main/res/values-it/strings.xml b/core/res/src/main/res/values-it/strings.xml index 6f56516eb7..a53b78d38a 100644 --- a/core/res/src/main/res/values-it/strings.xml +++ b/core/res/src/main/res/values-it/strings.xml @@ -214,6 +214,8 @@ Paga esattamente quello che vedi Verrà creato un conto di pagamento separato senza divulgare i tuoi indirizzi e asset Privacy senza rivali + E collegheremo Tangem Pay al wallet + Creeremo un nuovo wallet Ottieni la tua carta Tangem Pay in pochi minuti Assistenza Pay Conto di pagamento diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 305ff5cafd..44fadcae59 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -1824,8 +1824,8 @@ 余計な費用なしで、表示金額のみを支払う あなたの住所や資産を開示することなく、個別の支払い用アカウントが作成されます。 他に類を見ないプライバシー - そして支払いカードを連携します - ウォレットを設定します + Tangem Payをこのウォレットに紐づけます + 新しいウォレットを作成します Tangem Pay カードをすぐに手に入れよう Payサポート 支払いアカウント diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index d9210b3a7b..b5aec57730 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -1855,8 +1855,8 @@ Pague exatamente o que você vê. Uma conta de pagamento separada será criada sem divulgar seus endereços e bens. Privacidade incomparável - E vincule um cartão de pagamento a ele. - Vamos configurar uma carteira. + E vincularemos o Tangem Pay a essa carteira + Vamos configurar uma carteira Obtenha seu cartão Tangem Pay em minutos Suporte de Pay Conta de pagamento diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 5e69a432b4..e088846758 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -1822,6 +1822,8 @@ Сколько видишь – столько платишь Мы создадим отдельный платежный счет без раскрытия ваших активов \nи их адресов Абсолютная приватность + И привяжем Tangem Pay к нему + Настроим новый кошелёк Откройте виртуальную\nTangem Pay Card Поддержка Pay Платежный аккаунт diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index eeced5a70b..5e68152a90 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -1736,6 +1736,8 @@ Платіть стільки, скільки бачите Буде створено окремий платіжний рахунок без розкриття ваших адрес та активів Неперевершена конфіденційність + І прив’яжемо Tangem Pay до нього + Ми створимо новий гаманець Отримайте картку Tangem Pay за лічені хвилини Підтримка Pay Платіжний акаунт 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 fe1fbff287..c4e92a8eaa 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -1813,8 +1813,8 @@ 实际支付金额与所示金额一致 将在不透露您的地址和资产的情况下创建一个单独的付款账户 无与伦比的隐私保护 - 并将其与支付卡关联。 - 我们将设置一个钱包。 + 并将 Tangem Pay 绑定到该钱包 + 我们将创建新钱包 立即获取你的 Tangem Pay 卡 支付支持 支付账户 diff --git a/core/res/src/main/res/values-zh-rTW/strings.xml b/core/res/src/main/res/values-zh-rTW/strings.xml index 0211276e64..64b9b01fe6 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -440,6 +440,8 @@ 所見即所付 將創建單獨的支付帳戶,且不會透露您的地址和資產 無與倫比的隱私 + 並將 Tangem Pay 綁定到該錢包 + 我們將建立新錢包 立即獲取你的 Tangem Pay 卡 Pay 客服 付款帳戶 From a78bfc619170412efa869f9b5a8d5f57f00768e0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 5 Jun 2026 14:36:11 +0400 Subject: [PATCH 16/36] Updated on 2026-08-14 --- app/src/main/assets/tangem-app-config | 2 +- .../converter/GeneratedEnvironmentConfigConverter.kt | 1 + gradle/tangem_dependencies.toml | 2 +- .../com/tangem/blockchainsdk/providers/ProviderTypeIdMapping.kt | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 3d111de2b3..de8d3eff2b 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 3d111de2b364a6191d213c138b1d2d11a999f779 +Subproject commit de8d3eff2b3a4d6b1d2794ce3c17945c86c449bd diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt index fa4a318031..c8583c6117 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt @@ -122,6 +122,7 @@ internal object GeneratedEnvironmentConfigConverter { etherscanApiKey = GeneratedEnvironmentConfig.etherscanApiKey, blinkApiKey = GeneratedEnvironmentConfig.blinkApiKey, tatumApiKey = GeneratedEnvironmentConfig.tatumApiKey, + alchemyApiKey = GeneratedEnvironmentConfig.alchemyApiKey, ) } diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 0ab9e14901..6a216e5a53 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.39-1533" +tangemBlockchainSdk = "releases-5.39-1556" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-5.39-623" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/providers/ProviderTypeIdMapping.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/providers/ProviderTypeIdMapping.kt index 51129751d3..e0ca6c1f7c 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/providers/ProviderTypeIdMapping.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/providers/ProviderTypeIdMapping.kt @@ -7,6 +7,7 @@ internal enum class ProviderTypeIdMapping(val id: String, val providerType: Prov NowNodes(id = "nownodes", providerType = ProviderType.NowNodes), GetBlock(id = "getblock", providerType = ProviderType.GetBlock), QuickNode(id = "quicknode", providerType = ProviderType.QuickNode), + Alchemy(id = "alchemy", providerType = ProviderType.Alchemy), BitcoinBlockchair(id = "blockchair", providerType = ProviderType.BitcoinLike.Blockchair), BitcoinBlockcypher(id = "blockcypher", providerType = ProviderType.BitcoinLike.Blockcypher), CardanoAdalite(id = "adalite", providerType = ProviderType.Cardano.Adalite), From 2ab80154d88b1308e10a40a16cd2e7a4111f746a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 5 Jun 2026 16:00:06 +0500 Subject: [PATCH 17/36] Updated on 2026-08-14 --- .../tangem/tap/routing/utils/ChildFactory.kt | 1 - .../com/tangem/common/routing/AppRoute.kt | 1 - .../com/tangem/features/swap/SwapComponent.kt | 1 - .../feature/swap/domain/SwapInteractor.kt | 6 + .../feature/swap/domain/SwapInteractorImpl.kt | 19 + .../domain/models/domain/SwapPairLeast.kt | 6 - .../SwapFilterTangemPayProvidersLogicTest.kt | 352 ++++++++++++++++++ ...teractorImplExtractFromSwapCurrencyTest.kt | 282 ++++++++++++++ .../domain/fee/CexSwapFeeCalculatorTest.kt | 70 ++-- .../feature/swap/DefaultSwapComponent.kt | 1 + .../tangem/feature/swap/model/SwapModel.kt | 52 ++- .../tangempay/model/TangemPayCardPageModel.kt | 1 - .../tangempay/model/TangemPayDetailsModel.kt | 2 - 13 files changed, 740 insertions(+), 54 deletions(-) create mode 100644 features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapFilterTangemPayProvidersLogicTest.kt create mode 100644 features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplExtractFromSwapCurrencyTest.kt diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 91d8e2b8d8..b11bbff6bb 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -334,7 +334,6 @@ internal class ChildFactory @Inject constructor( cryptoAmount = tangemPayInput.cryptoAmount, fiatAmount = tangemPayInput.fiatAmount, depositAddress = tangemPayInput.depositAddress, - isWithdrawal = tangemPayInput.isWithdrawal, ) }, ), diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 7c574717fe..79db7cf50a 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -219,7 +219,6 @@ sealed class AppRoute(val path: String) : Route { val cryptoAmount: SerializedBigDecimal, val fiatAmount: SerializedBigDecimal, val depositAddress: String, - val isWithdrawal: Boolean, ) @Serializable diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt index 6b8f708a35..b7b5ca195d 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt @@ -19,7 +19,6 @@ interface SwapComponent : ComposableContentComponent { val cryptoAmount: BigDecimal, val fiatAmount: BigDecimal, val depositAddress: String, - val isWithdrawal: Boolean, ) /** Preferred position of the pre-selected currency on the swap screen. */ diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index 96a05a49ae..2e12817fde 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -33,6 +33,12 @@ interface SwapInteractor { pairs: List, ): List + fun extractFromSwapCurrencyFromPair( + pair: SwapPairLeast, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + ): SwapCurrencyStatus? + @Throws(IllegalStateException::class) suspend fun findBestQuote( fromSwapCurrencyStatus: SwapCurrencyStatus, 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 9ed0402e69..4d57c736b2 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 @@ -154,6 +154,25 @@ internal class SwapInteractorImpl @Inject constructor( }?.providers.orEmpty() } + override fun extractFromSwapCurrencyFromPair( + pair: SwapPairLeast, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + ): SwapCurrencyStatus? { + return if (pair.from.network == fromSwapCurrencyStatus.currency.network.rawId && + pair.from.contractAddress == fromSwapCurrencyStatus.currency.getContractAddress() + ) { + fromSwapCurrencyStatus + } else if ( + pair.from.network == toSwapCurrencyStatus.currency.network.rawId && + pair.from.contractAddress == toSwapCurrencyStatus.currency.getContractAddress() + ) { + toSwapCurrencyStatus + } else { + null + } + } + override suspend fun findProvidersForPairWithCheck( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt index 9c1d643ad4..166682b781 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt @@ -2,7 +2,6 @@ package com.tangem.feature.swap.domain.models.domain import com.squareup.moshi.Json import com.squareup.moshi.JsonClass -import com.tangem.domain.models.currency.CryptoCurrencyStatus import java.math.BigDecimal /** @@ -18,11 +17,6 @@ data class SwapPairLeast( val providers: List, ) -data class CryptoCurrencySwapInfo( - val currencyStatus: CryptoCurrencyStatus, - val providers: List, -) - /** * Provider that could swap given cryptocurrencies * diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapFilterTangemPayProvidersLogicTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapFilterTangemPayProvidersLogicTest.kt new file mode 100644 index 0000000000..7570a1a891 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapFilterTangemPayProvidersLogicTest.kt @@ -0,0 +1,352 @@ +package com.tangem.feature.swap.domain + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.SwapPairLeast +import com.tangem.utils.extensions.filterIf +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** + * Tests for the Tangem Pay provider-filtering logic that lives in + * `SwapModel.filterTangemPayProviders` (private extension on `List`). + * + * Because `SwapModel` is a `@ModelScoped` Decompose class with ~30 constructor dependencies + * and requires a Decompose component context, it cannot be instantiated in a unit test. + * Instead, we verify the *algorithm* end-to-end: + * + * 1. [SwapInteractorImpl.extractFromSwapCurrencyFromPair] — resolves which + * [SwapCurrencyStatus] is the FROM side of a given pair. + * 2. `isTangemPayWithdrawal(status) = status?.account is Account.Payment` — the check. + * 3. `List.filterIf(isWithdrawal) { provider.type == CEX }` — the filtering. + * + * We exercise all three together in test-space so that every business rule of + * `filterTangemPayProviders` is covered, including all 9 edge cases from the task spec. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@DisplayName("filterTangemPayProviders — Payment-account provider filtering logic") +internal class SwapFilterTangemPayProvidersLogicTest : SwapInteractorImplTestBase() { + + private val ethNetwork = Blockchain.Ethereum.toNetworkId() + private val btcNetwork = Blockchain.Bitcoin.toNetworkId() + private val polygonNetwork = Blockchain.Polygon.toNetworkId() + private val userWalletId = UserWalletId(stringValue = "deadbeef") + + // ----------------------------------------------------------------------- + // Helpers — mirrors the private logic in SwapModel.filterTangemPayProviders + // ----------------------------------------------------------------------- + + /** + * Pure reimplementation of `SwapModel.filterTangemPayProviders` that delegates + * to the real [SwapInteractorImpl.extractFromSwapCurrencyFromPair] for the + * FROM-side resolution. This lets every unit test exercise the *exact same* + * algorithm as the production code without instantiating `SwapModel`. + */ + private fun List.applyTangemPayFilter( + fromStatus: SwapCurrencyStatus, + toStatus: SwapCurrencyStatus, + ): List = map { pair -> + val resolvedFrom = sut.extractFromSwapCurrencyFromPair( + pair = pair, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + val isTangemPayWithdrawal = resolvedFrom?.account is Account.Payment + val filterProviderTypes = if (isTangemPayWithdrawal) { + listOf(ExchangeProviderType.CEX) + } else { + emptyList() + } + pair.copy( + providers = pair.providers.filterIf(filterProviderTypes.isNotEmpty()) { provider -> + provider.type in filterProviderTypes + }, + ) + } + + // ----------------------------------------------------------------------- + // Builders + // ----------------------------------------------------------------------- + + private fun buildPaymentStatus( + networkRawId: String = ethNetwork, + contractAddress: String = "0", + isCoin: Boolean = true, + ): SwapCurrencyStatus = buildSwapCurrencyStatus( + networkRawId = networkRawId, + contractAddress = contractAddress, + isCoin = isCoin, + ).copy(account = Account.Payment(userWalletId)) + + private fun buildCryptoPortfolioStatus( + networkRawId: String = ethNetwork, + contractAddress: String = "0", + isCoin: Boolean = true, + ): SwapCurrencyStatus = buildSwapCurrencyStatus( + networkRawId = networkRawId, + contractAddress = contractAddress, + isCoin = isCoin, + ).copy(account = Account.CryptoPortfolio.createMainAccount(userWalletId)) + + private fun mixedProviders() = listOf( + buildSwapProvider(ExchangeProviderType.CEX, "cex-1"), + buildSwapProvider(ExchangeProviderType.DEX, "dex-1"), + buildSwapProvider(ExchangeProviderType.DEX_BRIDGE, "bridge-1"), + ) + + private fun cexOnlyProviders() = listOf( + buildSwapProvider(ExchangeProviderType.CEX, "cex-only"), + ) + + private fun dexOnlyProviders() = listOf( + buildSwapProvider(ExchangeProviderType.DEX, "dex-only"), + ) + + // ----------------------------------------------------------------------- + // Test cases + // ----------------------------------------------------------------------- + + @Nested + @DisplayName("Payment account FROM side — only CEX providers must remain") + inner class PaymentAccountFromSide { + + @Test + @DisplayName("should keep only CEX when FROM status is Payment account and providers are mixed") + fun `should keep only CEX when FROM status is Payment account and providers are mixed`() { + // given — FROM is a Payment account, pair.from matches FROM + val fromStatus = buildPaymentStatus(networkRawId = ethNetwork) + val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork) + val pair = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0", + toNetwork = btcNetwork, + toContract = "0", + providers = mixedProviders(), + ) + + // when + val result = listOf(pair).applyTangemPayFilter(fromStatus, toStatus) + + // then — only CEX survives + assertThat(result).hasSize(1) + assertThat(result[0].providers).hasSize(1) + assertThat(result[0].providers[0].type).isEqualTo(ExchangeProviderType.CEX) + } + + @Test + @DisplayName("should return empty providers when Payment account FROM and no CEX in list") + fun `should return empty providers when Payment account FROM and no CEX in list`() { + // given — FROM is Payment, no CEX provider exists + val fromStatus = buildPaymentStatus(networkRawId = ethNetwork) + val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork) + val pair = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0", + toNetwork = btcNetwork, + toContract = "0", + providers = dexOnlyProviders(), + ) + + // when + val result = listOf(pair).applyTangemPayFilter(fromStatus, toStatus) + + // then — all providers removed because none are CEX + assertThat(result[0].providers).isEmpty() + } + + @Test + @DisplayName("should leave list unchanged when Payment account FROM and all providers already CEX") + fun `should leave list unchanged when Payment account FROM and all providers already CEX`() { + // given — FROM is Payment, list is already all CEX + val fromStatus = buildPaymentStatus(networkRawId = ethNetwork) + val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork) + val pair = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0", + toNetwork = btcNetwork, + toContract = "0", + providers = cexOnlyProviders(), + ) + + // when + val result = listOf(pair).applyTangemPayFilter(fromStatus, toStatus) + + // then — single CEX provider still present, unchanged + assertThat(result[0].providers).hasSize(1) + assertThat(result[0].providers[0].type).isEqualTo(ExchangeProviderType.CEX) + } + } + + @Nested + @DisplayName("Non-Payment account — provider list must not be modified") + inner class NonPaymentAccount { + + @Test + @DisplayName("should not filter providers when FROM status is CryptoPortfolio account") + fun `should not filter providers when FROM status is CryptoPortfolio account`() { + // given — FROM is a CryptoPortfolio account (regression guard) + val fromStatus = buildCryptoPortfolioStatus(networkRawId = ethNetwork) + val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork) + val pair = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0", + toNetwork = btcNetwork, + toContract = "0", + providers = mixedProviders(), + ) + + // when + val result = listOf(pair).applyTangemPayFilter(fromStatus, toStatus) + + // then — all 3 providers survive untouched + assertThat(result[0].providers).hasSize(3) + assertThat(result[0].providers.map { it.type }) + .containsExactly(ExchangeProviderType.CEX, ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE) + } + } + + @Nested + @DisplayName("Null resolved status — no filtering applied") + inner class NullResolvedStatus { + + @Test + @DisplayName("should not filter when extractFromSwapCurrencyFromPair resolves null (unrelated pair)") + fun `should not filter when extractFromSwapCurrencyFromPair resolves null`() { + // given — pair.from is on an unrelated network (neither fromStatus nor toStatus) + val fromStatus = buildPaymentStatus(networkRawId = ethNetwork) + val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork) + val pair = buildSwapPairLeast( + fromNetwork = polygonNetwork, // matches neither + fromContract = "0", + toNetwork = ethNetwork, + toContract = "0", + providers = mixedProviders(), + ) + + // when + val result = listOf(pair).applyTangemPayFilter(fromStatus, toStatus) + + // then — null status → isTangemPayWithdrawal=false → no filter applied + assertThat(result[0].providers).hasSize(3) + } + } + + @Nested + @DisplayName("Empty inputs — no crash, stable output") + inner class EmptyInputs { + + @Test + @DisplayName("should return empty list when input pairs list is empty") + fun `should return empty list when input pairs list is empty`() { + // given + val fromStatus = buildPaymentStatus(networkRawId = ethNetwork) + val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork) + + // when + val result = emptyList().applyTangemPayFilter(fromStatus, toStatus) + + // then + assertThat(result).isEmpty() + } + + @Test + @DisplayName("should handle empty provider list on a pair without crashing") + fun `should handle empty provider list on a pair without crashing`() { + // given — Payment account FROM, but the pair already has an empty provider list + val fromStatus = buildPaymentStatus(networkRawId = ethNetwork) + val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork) + val pair = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0", + toNetwork = btcNetwork, + toContract = "0", + providers = emptyList(), + ) + + // when + val result = listOf(pair).applyTangemPayFilter(fromStatus, toStatus) + + // then — stays empty, no crash + assertThat(result[0].providers).isEmpty() + } + } + + @Nested + @DisplayName("Multiple pairs — filtering applied per-pair independently") + inner class MultiplePairs { + + @Test + @DisplayName("should filter only pairs whose resolved FROM is a Payment account") + fun `should filter only pairs whose resolved FROM is a Payment account`() { + // given — 2 pairs: + // pair1: pair.from == ethNetwork → fromStatus (Payment) → filter to CEX only + // pair2: pair.from == btcNetwork → toStatus (non-Payment) → no filter + val fromStatus = buildPaymentStatus(networkRawId = ethNetwork) + val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork) + + val pair1 = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0", + toNetwork = btcNetwork, + toContract = "0", + providers = mixedProviders(), + ) + val pair2 = buildSwapPairLeast( + fromNetwork = btcNetwork, // matches toStatus (CryptoPortfolio) + fromContract = "0", + toNetwork = ethNetwork, + toContract = "0", + providers = mixedProviders(), + ) + + // when + val result = listOf(pair1, pair2).applyTangemPayFilter(fromStatus, toStatus) + + // then + // pair1 resolved to Payment account → only CEX remains + assertThat(result[0].providers).hasSize(1) + assertThat(result[0].providers[0].type).isEqualTo(ExchangeProviderType.CEX) + + // pair2 resolved to CryptoPortfolio → all 3 providers intact + assertThat(result[1].providers).hasSize(3) + } + + @Test + @DisplayName("should filter all pairs when all resolved FROM statuses are Payment accounts") + fun `should filter all pairs when all resolved FROM statuses are Payment accounts`() { + // given — both pairs have their pair.from matching the Payment account + val fromStatus = buildPaymentStatus(networkRawId = ethNetwork) + val toStatus = buildCryptoPortfolioStatus(networkRawId = btcNetwork) + + val pair1 = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0", + toNetwork = btcNetwork, + toContract = "0", + providers = mixedProviders(), + ) + val pair2 = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0", + toNetwork = polygonNetwork, + toContract = "0", + providers = dexOnlyProviders(), + ) + + // when + val result = listOf(pair1, pair2).applyTangemPayFilter(fromStatus, toStatus) + + // then — pair1: CEX kept; pair2: DEX removed → empty + assertThat(result[0].providers).hasSize(1) + assertThat(result[0].providers[0].type).isEqualTo(ExchangeProviderType.CEX) + assertThat(result[1].providers).isEmpty() + } + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplExtractFromSwapCurrencyTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplExtractFromSwapCurrencyTest.kt new file mode 100644 index 0000000000..2beb4986b2 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplExtractFromSwapCurrencyTest.kt @@ -0,0 +1,282 @@ +package com.tangem.feature.swap.domain + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** + * Tests for [SwapInteractorImpl.extractFromSwapCurrencyFromPair]. + * + * This function resolves which of the two [com.tangem.domain.swap.models.SwapCurrencyStatus] + * arguments corresponds to the `from` side of a given [com.tangem.feature.swap.domain.models.domain.SwapPairLeast]. + * + * It is the building block behind the Tangem Pay provider-filtering logic in `SwapModel`: + * the resolved "from" currency status is inspected for an [com.tangem.domain.models.account.Account.Payment] + * account; when it belongs to a payment account, only CEX providers are kept for that pair. + * + * A pair is matched on both `network` (rawId) and `contractAddress` ("0" for coins, the token + * contract for tokens). The `from` side is checked first, then the `to` side, otherwise null. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapInteractorImplExtractFromSwapCurrencyTest : SwapInteractorImplTestBase() { + + private val ethNetwork = Blockchain.Ethereum.toNetworkId() + private val btcNetwork = Blockchain.Bitcoin.toNetworkId() + private val polygonNetwork = Blockchain.Polygon.toNetworkId() + + @Nested + inner class MatchesFromSide { + + @Test + fun `should return fromSwapCurrencyStatus when pair from matches the from coin by network and contract`() { + // Given — coin: getContractAddress() == "0" + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true) + val pair = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0", + toNetwork = btcNetwork, + toContract = "0", + ) + + // When + val result = sut.extractFromSwapCurrencyFromPair( + pair = pair, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + // Then + assertThat(result).isSameInstanceAs(fromStatus) + } + + @Test + fun `should return fromSwapCurrencyStatus when pair from matches the from token by network and contract`() { + // Given — token: getContractAddress() == contractAddress + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0xToken", + isCoin = false, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true) + val pair = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0xToken", + toNetwork = btcNetwork, + toContract = "0", + ) + + // When + val result = sut.extractFromSwapCurrencyFromPair( + pair = pair, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + // Then + assertThat(result).isSameInstanceAs(fromStatus) + } + } + + @Nested + inner class MatchesToSide { + + @Test + fun `should return toSwapCurrencyStatus when pair from matches the to side (reverse-direction pair)`() { + // Given — pair.from points at the toStatus currency, not the fromStatus + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true) + val pair = buildSwapPairLeast( + fromNetwork = btcNetwork, // matches toStatus + fromContract = "0", + toNetwork = ethNetwork, + toContract = "0", + ) + + // When + val result = sut.extractFromSwapCurrencyFromPair( + pair = pair, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + // Then + assertThat(result).isSameInstanceAs(toStatus) + } + + @Test + fun `should return toSwapCurrencyStatus when pair from matches to token by network and contract`() { + // Given + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true) + val toStatus = buildSwapCurrencyStatus( + networkRawId = polygonNetwork, + contractAddress = "0xUsdc", + isCoin = false, + ) + val pair = buildSwapPairLeast( + fromNetwork = polygonNetwork, // matches toStatus token + fromContract = "0xUsdc", + toNetwork = ethNetwork, + toContract = "0", + ) + + // When + val result = sut.extractFromSwapCurrencyFromPair( + pair = pair, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + // Then + assertThat(result).isSameInstanceAs(toStatus) + } + } + + @Nested + inner class NoMatch { + + @Test + fun `should return null when pair from matches neither from nor to`() { + // Given — pair.from is on an unrelated network + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true) + val pair = buildSwapPairLeast( + fromNetwork = polygonNetwork, // matches neither + fromContract = "0", + toNetwork = ethNetwork, + toContract = "0", + ) + + // When + val result = sut.extractFromSwapCurrencyFromPair( + pair = pair, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + // Then + assertThat(result).isNull() + } + + @Test + fun `should return null when network matches but contract address differs`() { + // Given — same eth network but different token contracts + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0xAaa", + isCoin = false, + ) + val toStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0xBbb", + isCoin = false, + ) + val pair = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0xCcc", // matches neither contract + toNetwork = ethNetwork, + toContract = "0xAaa", + ) + + // When + val result = sut.extractFromSwapCurrencyFromPair( + pair = pair, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + // Then + assertThat(result).isNull() + } + + @Test + fun `should return null when contract matches but network differs`() { + // Given — same contract address but on a different network than either status + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0xShared", + isCoin = false, + ) + val toStatus = buildSwapCurrencyStatus( + networkRawId = btcNetwork, + contractAddress = "0", + isCoin = true, + ) + val pair = buildSwapPairLeast( + fromNetwork = polygonNetwork, // contract matches fromStatus but network does not + fromContract = "0xShared", + toNetwork = ethNetwork, + toContract = "0", + ) + + // When + val result = sut.extractFromSwapCurrencyFromPair( + pair = pair, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + // Then + assertThat(result).isNull() + } + } + + @Nested + inner class Precedence { + + @Test + fun `should prefer from side when both from and to would match the pair from`() { + // Given — both statuses are the same network+contract; from must win (checked first) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true) + val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true) + val pair = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0", + toNetwork = ethNetwork, + toContract = "0", + ) + + // When + val result = sut.extractFromSwapCurrencyFromPair( + pair = pair, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + // Then — from side has precedence and is returned, not the to side + assertThat(result).isSameInstanceAs(fromStatus) + assertThat(result).isNotSameInstanceAs(toStatus) + } + + @Test + fun `pair providers are irrelevant to the resolution`() { + // Given — provider list should not affect which currency status is extracted + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true) + val pair = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0", + toNetwork = btcNetwork, + toContract = "0", + providers = listOf( + buildSwapProvider(ExchangeProviderType.DEX, "dex"), + buildSwapProvider(ExchangeProviderType.CEX, "cex"), + ), + ) + + // When + val result = sut.extractFromSwapCurrencyFromPair( + pair = pair, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + // Then + assertThat(result).isSameInstanceAs(fromStatus) + } + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt index 846dc194ee..93efa70ff3 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt @@ -98,7 +98,8 @@ internal class CexSwapFeeCalculatorTest { userWallet = fromStatus.userWallet, fromSwapCurrencyStatus = fromStatus, amount = BigDecimal("1.5"), - selectedFeeToken = null, isGasless = true, + selectedFeeToken = null, + isGasless = true, ) assertThat(result.isRight()).isTrue() @@ -131,7 +132,8 @@ internal class CexSwapFeeCalculatorTest { userWallet = fromStatus.userWallet, fromSwapCurrencyStatus = fromStatus, amount = BigDecimal("1.0"), - selectedFeeToken = null, isGasless = true, + selectedFeeToken = null, + isGasless = true, ) assertThat(result.isLeft()).isTrue() @@ -161,7 +163,8 @@ internal class CexSwapFeeCalculatorTest { userWallet = fromStatus.userWallet, fromSwapCurrencyStatus = fromStatus, amount = BigDecimal("2.0"), - selectedFeeToken = tokenStatus, isGasless = true, + selectedFeeToken = tokenStatus, + isGasless = true, ) assertThat(result.isRight()).isTrue() @@ -208,7 +211,8 @@ internal class CexSwapFeeCalculatorTest { userWallet = fromStatus.userWallet, fromSwapCurrencyStatus = fromStatus, amount = BigDecimal("3.0"), - selectedFeeToken = coinStatus, isGasless = true, + selectedFeeToken = coinStatus, + isGasless = true, ) assertThat(result.isRight()).isTrue() @@ -234,33 +238,33 @@ internal class CexSwapFeeCalculatorTest { } @Test - fun `GIVEN explicit native selectedFeeToken with non-Ethereum fee WHEN calculate THEN bump is a no-op`() = - runTest { - val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) - val coinCurrency = mockk(relaxed = true) - val coinStatus = mockk(relaxed = true) { - every { currency } returns coinCurrency - } - val rawFee = Fee.Common( - amount = Amount(currencySymbol = "BTC", value = BigDecimal("0.0001"), decimals = 8), - ) - coEvery { - estimateFeeUseCase(any(), any(), any()) - } returns TransactionFee.Single(normal = rawFee).right() - - val result = sut.calculate( - userWallet = fromStatus.userWallet, - fromSwapCurrencyStatus = fromStatus, - amount = BigDecimal("1.0"), - selectedFeeToken = coinStatus, isGasless = true, - ) - - result.onRight { cexResult -> - val loaded = cexResult.transactionFee as TransactionFeeResult.Loaded - val unchanged = (loaded.fee as TransactionFee.Single).normal as Fee.Common - assertThat(unchanged).isSameInstanceAs(rawFee) - } + fun `GIVEN explicit native selectedFeeToken with non-Ethereum fee WHEN calculate THEN bump is a no-op`() = runTest { + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val coinCurrency = mockk(relaxed = true) + val coinStatus = mockk(relaxed = true) { + every { currency } returns coinCurrency } + val rawFee = Fee.Common( + amount = Amount(currencySymbol = "BTC", value = BigDecimal("0.0001"), decimals = 8), + ) + coEvery { + estimateFeeUseCase(any(), any(), any()) + } returns TransactionFee.Single(normal = rawFee).right() + + val result = sut.calculate( + userWallet = fromStatus.userWallet, + fromSwapCurrencyStatus = fromStatus, + amount = BigDecimal("1.0"), + selectedFeeToken = coinStatus, + isGasless = true, + ) + + result.onRight { cexResult -> + val loaded = cexResult.transactionFee as TransactionFeeResult.Loaded + val unchanged = (loaded.fee as TransactionFee.Single).normal as Fee.Common + assertThat(unchanged).isSameInstanceAs(rawFee) + } + } @Test fun `GIVEN native path returns Left WHEN calculate THEN error is propagated`() = runTest { @@ -277,7 +281,8 @@ internal class CexSwapFeeCalculatorTest { userWallet = fromStatus.userWallet, fromSwapCurrencyStatus = fromStatus, amount = BigDecimal("1.0"), - selectedFeeToken = coinStatus, isGasless = true, + selectedFeeToken = coinStatus, + isGasless = true, ) assertThat(result.isLeft()).isTrue() @@ -322,7 +327,8 @@ internal class CexSwapFeeCalculatorTest { userWallet = fromStatus.userWallet, fromSwapCurrencyStatus = fromStatus, amount = BigDecimal("1.0"), - selectedFeeToken = coinStatus, isGasless = true, + selectedFeeToken = coinStatus, + isGasless = true, ) result.onRight { cexResult -> diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt index 16cb6b2bcb..6568a43cc5 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt @@ -153,6 +153,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( val feePaidCryptoCurrency by remember { derivedStateOf { dataState.feePaidCryptoCurrency } } val shouldHideBlock by remember { derivedStateOf { + // TODO collapse this and move to model val isAmountEmptyOrZero = dataState.amount?.parseBigDecimalOrNull().isNullOrZero() val isInsufficientFunds = model.uiState.isInsufficientFunds val isProviderMissing = dataState.selectedProvider == null diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index a69376aedb..45bc3613a8 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -110,6 +110,7 @@ import com.tangem.features.swap.SwapComponent import com.tangem.features.swap.SwapFeatureToggles import com.tangem.utils.Provider import com.tangem.utils.coroutines.* +import com.tangem.utils.extensions.filterIf import com.tangem.utils.isNullOrZero import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.NonCancellable @@ -546,7 +547,15 @@ internal class SwapModel @Inject constructor( fromSwapCurrencyStatus = newFromSwapCurrencyStatus, toSwapCurrencyStatus = newToSwapCurrencyStatus, pairs = dataState.pairs, - selectedPairProviders = dataState.selectedPairProviders, + selectedPairProviders = if (newFromSwapCurrencyStatus == null || newToSwapCurrencyStatus == null) { + emptyList() + } else { + swapInteractor.findProvidersForPairWithCheck( + fromSwapCurrencyStatus = newFromSwapCurrencyStatus, + toSwapCurrencyStatus = newToSwapCurrencyStatus, + pairs = dataState.pairs, + ) + }, ) filterTokensFromSelector() uiState = stateBuilder.updateCurrenciesState( @@ -614,11 +623,7 @@ internal class SwapModel @Inject constructor( swapInteractor.getPair( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, - filterProviderTypes = if (tangemPayInput?.isWithdrawal == true) { - listOf(ExchangeProviderType.CEX) - } else { - ExchangeProviderType.getSwapProviderTypes() - }, + filterProviderTypes = ExchangeProviderType.getSwapProviderTypes(), ).fold( ifLeft = { error -> uiState = stateBuilder.createInitialErrorState( @@ -629,7 +634,11 @@ internal class SwapModel @Inject constructor( ) TangemLogger.e("Error getting swap pair", error) }, - ifRight = { pairs -> + ifRight = { pairsRaw -> + val pairs = pairsRaw.filterTangemPayProviders( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) val providerList = swapInteractor.findProvidersForPairWithCheck( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, @@ -1187,7 +1196,7 @@ internal class SwapModel @Inject constructor( val isTangemPayWithdrawal = isTangemPayWithdrawal() if (swapFee == null && !isTangemPayWithdrawal) { - TangemLogger.e("onSwapClick: fee is null and isWithdrawal is ${tangemPayInput?.isWithdrawal}") + TangemLogger.e("onSwapClick: fee is null and isTangemPayWithdrawal is $isTangemPayWithdrawal") showAlert(resourceReference(R.string.swapping_fee_estimation_error_text)) modelScope.launch { delay(SWAP_IN_PROGRESS_DELAY) @@ -1950,8 +1959,31 @@ internal class SwapModel @Inject constructor( ) } - fun isTangemPayWithdrawal(): Boolean { - return tangemPayInput?.isWithdrawal == true || dataState.fromSwapCurrencyStatus?.account is Account.Payment + fun isTangemPayWithdrawal(fromSwapCurrencyStatus: SwapCurrencyStatus? = dataState.fromSwapCurrencyStatus): Boolean { + return fromSwapCurrencyStatus?.account is Account.Payment + } + + private fun List.filterTangemPayProviders( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + ) = map { pair -> + val isTangemPayWithdrawal = isTangemPayWithdrawal( + swapInteractor.extractFromSwapCurrencyFromPair( + pair = pair, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ), + ) + val filterProviderTypes = if (isTangemPayWithdrawal) { + listOf(ExchangeProviderType.CEX) + } else { + emptyList() + } + pair.copy( + providers = pair.providers.filterIf(filterProviderTypes.isNotEmpty()) { provider -> + provider.type in filterProviderTypes + }, + ) } private fun Map.getLastLoadedSuccessStates(): SuccessLoadedSwapData { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index 89584be4c6..07136a21aa 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -232,7 +232,6 @@ internal class TangemPayCardPageModel @Inject constructor( cryptoAmount = data.cryptoBalance, fiatAmount = data.fiatBalance, depositAddress = data.depositAddress, - isWithdrawal = false, ), ), ) 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 1304c613aa..d124201ef9 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 @@ -214,7 +214,6 @@ internal class TangemPayDetailsModel @Inject constructor( cryptoAmount = currentBalance.availableForWithdrawal, fiatAmount = currentBalance.availableForWithdrawal, depositAddress = depositAddress, - isWithdrawal = true, ), ), ) @@ -319,7 +318,6 @@ internal class TangemPayDetailsModel @Inject constructor( cryptoAmount = data.cryptoBalance, fiatAmount = data.fiatBalance, depositAddress = data.depositAddress, - isWithdrawal = false, ), ), ) From 7403ecdfe0c24b679d4b2764338ebd345067bb23 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 5 Jun 2026 15:52:28 +0300 Subject: [PATCH 18/36] Updated on 2026-08-14 --- .../wallet/presentation/wallet/domain/Wallet2CobrandImage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt index 719e0b1dc9..c19b0f14fc 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt @@ -378,7 +378,7 @@ internal enum class Wallet2CobrandImage( ElectraSea( cards2ResId = R.drawable.ill_electra_sea_card2_120_106, cards3ResId = R.drawable.ill_electra_sea_card3_120_106, - batchIds = setOf("AF990023", "AF990024", "AF990025"), + batchIds = setOf("AF990023", "AF990024", "AF990025", "AF990067", "AF990066", "AF990065"), ), Football( From 863a0b466765a741f1156fbdf3540ac5c2cae671 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 5 Jun 2026 17:43:52 +0400 Subject: [PATCH 19/36] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 6a216e5a53..2cf2bf4766 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.39-1556" +tangemBlockchainSdk = "releases-5.39-1557" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-5.39-623" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 59eeda5a0135799f3f768d6892aaceae0dba2a71 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 8 Jun 2026 14:48:28 +0400 Subject: [PATCH 20/36] Updated on 2026-08-14 --- .../domain/walletconnect/WcAnalyticEvents.kt | 11 +++++++++++ .../model/WcSendTransactionModel.kt | 18 ++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt index d7a799f3d5..62321d38b8 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt @@ -209,6 +209,17 @@ sealed class WcAnalyticEvents( ), ) + class WcSolanaMultiTxFailure( + rawRequest: WcSdkSessionRequest, + ) : WcAnalyticEvents( + event = "Solana Multi Transaction Failure", + params = mapOf( + AnalyticsParam.DAPP_NAME to rawRequest.dAppMetaData.name, + AnalyticsParam.DAPP_URL to rawRequest.dAppMetaData.url, + AnalyticsParam.METHOD_NAME to rawRequest.request.method, + ), + ) + class ButtonSign( rawRequest: WcSdkSessionRequest, ) : WcAnalyticEvents( diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt index 85ce399ac9..3921c67c53 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt @@ -5,6 +5,7 @@ import arrow.core.Either import arrow.core.Option import arrow.core.none import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.navigate import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.pushNew import com.domain.blockaid.models.dapp.CheckDAppResult @@ -13,6 +14,7 @@ import com.domain.blockaid.models.transaction.ValidationResult import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.TangemBlogUrlBuilder +import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -84,6 +86,7 @@ internal class WcSendTransactionModel @Inject constructor( private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val notificationsFactory: WcNotificationsFactory, private val analytics: AnalyticsEventHandler, + private val analyticsErrorHandler: AnalyticsErrorHandler, private val urlOpener: UrlOpener, ) : Model(), WcCommonTransactionModel, FeeSelectorModelCallback { @@ -182,12 +185,18 @@ internal class WcSendTransactionModel @Inject constructor( } private fun openMultipleTransaction() { - stackNavigation.pushNew(WcTransactionRoutes.MultipleTransactions) + stackNavigation.navigate { listOf(WcTransactionRoutes.Transaction, WcTransactionRoutes.MultipleTransactions) } } fun onMultiTransactionConfirm() { useCase.sign() - stackNavigation.pushNew(WcTransactionRoutes.TransactionProcess) + stackNavigation.navigate { + listOf( + WcTransactionRoutes.Transaction, + WcTransactionRoutes.MultipleTransactions, + WcTransactionRoutes.TransactionProcess, + ) + } } /** @@ -415,6 +424,11 @@ internal class WcSendTransactionModel @Inject constructor( onDismiss = { cancel(useCase) }, onRetry = { signFromAlert() }, ) + if (useCase is WcListTransactionUseCase) { + analyticsErrorHandler.sendErrorEvent( + event = WcAnalyticEvents.WcSolanaMultiTxFailure(rawRequest = useCase.rawSdkRequest), + ) + } stackNavigation.pushNew(WcTransactionRoutes.Alert(alertError)) false } From 8ecd77067e1370dc756afca3c6250ae38fe41435 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 8 Jun 2026 18:27:12 +0500 Subject: [PATCH 21/36] Updated on 2026-08-14 --- .../core/analytics/models/AnalyticsEvent.kt | 15 +++++- .../filter/AppsFlyerEventFilterRoutingTest.kt | 52 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 core/analytics/src/test/kotlin/com/tangem/core/analytics/filter/AppsFlyerEventFilterRoutingTest.kt diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsEvent.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsEvent.kt index d259b2ba00..d890ac79f6 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsEvent.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsEvent.kt @@ -6,11 +6,22 @@ package com.tangem.core.analytics.models open class AnalyticsEvent( val category: String, val event: String, - val params: Map = mapOf(), + params: Map = mapOf(), ) { + var params: Map = params + private set + val id: String = "[$category] $event" - fun withParams(newParams: Map) = AnalyticsEvent(category, event, newParams) + /** + * Enriches this event with [newParams] and returns the same instance, preserving the concrete + * runtime type so that marker interfaces survive enrichment and the event can still be routed + * by the analytics filters and handlers. + */ + fun withParams(newParams: Map): AnalyticsEvent { + params = newParams + return this + } override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/core/analytics/src/test/kotlin/com/tangem/core/analytics/filter/AppsFlyerEventFilterRoutingTest.kt b/core/analytics/src/test/kotlin/com/tangem/core/analytics/filter/AppsFlyerEventFilterRoutingTest.kt new file mode 100644 index 0000000000..120c6fbd1e --- /dev/null +++ b/core/analytics/src/test/kotlin/com/tangem/core/analytics/filter/AppsFlyerEventFilterRoutingTest.kt @@ -0,0 +1,52 @@ +package com.tangem.core.analytics.filter + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.analytics.api.AnalyticsHandler +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AppsFlyerIncludedEvent +import com.tangem.core.analytics.models.AppsFlyerOnlyEvent +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test + +internal class AppsFlyerEventFilterRoutingTest { + + private val filter = AppsFlyerEventFilter() + + private class AppsFlyerOnlyTestEvent : + AnalyticsEvent(category = "Test", event = "OnlyEvent"), AppsFlyerOnlyEvent + + private class AppsFlyerIncludedTestEvent : + AnalyticsEvent(category = "Test", event = "IncludedEvent"), AppsFlyerIncludedEvent + + @Test + fun `withParams keeps AppsFlyerOnlyEvent routable to AppsFlyer`() { + val event: AnalyticsEvent = AppsFlyerOnlyTestEvent() + + val enriched = event.withParams(mapOf("key" to "value")) + + assertThat(filter.canBeAppliedTo(enriched)).isTrue() + assertThat(enriched.params).containsEntry("key", "value") + } + + @Test + fun `withParams keeps AppsFlyerIncludedEvent routable to AppsFlyer`() { + val event: AnalyticsEvent = AppsFlyerIncludedTestEvent() + + val enriched = event.withParams(mapOf("key" to "value")) + + assertThat(filter.canBeAppliedTo(enriched)).isTrue() + assertThat(enriched.params).containsEntry("key", "value") + } + + @Test + fun `enriched AppsFlyerOnlyEvent is consumable only by the AppsFlyer handler`() { + val enriched = (AppsFlyerOnlyTestEvent() as AnalyticsEvent).withParams(emptyMap()) + + val appsFlyerHandler = mockk { every { id() } returns "AppsFlyer" } + val otherHandler = mockk { every { id() } returns "Amplitude" } + + assertThat(filter.canBeConsumedByHandler(appsFlyerHandler, enriched)).isTrue() + assertThat(filter.canBeConsumedByHandler(otherHandler, enriched)).isFalse() + } +} \ No newline at end of file From 3cd8553273ba165644b11f1aa301b176abd32a94 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 8 Jun 2026 19:07:05 +0300 Subject: [PATCH 22/36] Updated on 2026-08-14 --- .../transformers/SetInitialDataStateTransformer.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt index 5f75691356..b6105d4f37 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt @@ -1,6 +1,5 @@ package com.tangem.features.staking.impl.presentation.state.transformers -import com.tangem.blockchain.common.Blockchain import com.tangem.common.extensions.remove import com.tangem.common.ui.amountScreen.converters.AmountAccountConverter import com.tangem.common.ui.amountScreen.converters.AmountStateConverter @@ -37,6 +36,7 @@ import com.tangem.features.staking.impl.presentation.state.converters.RewardsVal import com.tangem.features.staking.impl.presentation.state.converters.YieldBalancesConverter import com.tangem.features.staking.impl.presentation.state.utils.getRewardScheduleText import com.tangem.features.staking.impl.presentation.state.utils.toTextReference +import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.Provider import com.tangem.utils.StringsSigns.DASH_SIGN import com.tangem.utils.isNullOrZero @@ -178,8 +178,8 @@ internal class SetInitialDataStateTransformer( cryptoCurrencyStatus: CryptoCurrencyStatus, ): RoundedListWithDividersItemData? { val minimumCryptoAmount = integration.enterMinimumAmount ?: return null - val blockchainId = cryptoCurrencyStatus.currency.network.rawId - if (!showMinimumRequirementInfo(blockchainId)) return null + val networkId = cryptoCurrencyStatus.currency.network.rawId + if (!showMinimumRequirementInfo(networkId)) return null val formattedAmount = minimumCryptoAmount.format { crypto(cryptoCurrencyStatus.currency) } @@ -301,8 +301,8 @@ internal class SetInitialDataStateTransformer( ) } - private fun showMinimumRequirementInfo(blockchainId: String): Boolean { - return blockchainId == Blockchain.Polkadot.id || blockchainId == Blockchain.Cardano.id + private fun showMinimumRequirementInfo(networkId: String): Boolean { + return BlockchainUtils.isPolkadot(networkId) || BlockchainUtils.isCardano(networkId) } private companion object { From 6b62d498e2950bc149638bf325fcbf254aa8e6cc Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 9 Jun 2026 13:11:06 +0400 Subject: [PATCH 23/36] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 2cf2bf4766..56087138c7 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.39-1557" +tangemBlockchainSdk = "releases-5.39-1558" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-5.39-623" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From be5e5bc181ac12e888ad628b7290fbc5d1c59e6f Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 9 Jun 2026 12:03:04 +0000 Subject: [PATCH 24/36] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 962be99d3f..56087138c7 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.38-1551" +tangemBlockchainSdk = "releases-5.39-1558" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.38-615" +tangemCardSdk = "releases-5.39-623" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 789b5546f199694053f2815333d87b6c1dff0fdc Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 9 Jun 2026 17:10:09 +0400 Subject: [PATCH 25/36] Updated on 2026-08-14 --- .../exchangeServices/moonpay/MoonpayBlockchainMapping.kt | 1 + .../com/tangem/common/ui/extensions/BlockchainIcons.kt | 2 ++ .../tangem/common/ui/extensions/BlockchainIconsTest.kt | 8 ++++++-- .../com/tangem/data/common/network/NetworkFactory.kt | 1 + .../data/onramp/legacy/MercuryoBlockchainMapping.kt | 1 + .../com/tangem/domain/card/common/extensions/CardSdk.kt | 4 +++- .../com/tangem/domain/card/configs/Wallet2CardConfig.kt | 2 ++ .../tangem/domain/card/configs/Wallet2CardConfigTest.kt | 2 ++ gradle/tangem_dependencies.toml | 2 +- .../java/com/tangem/blockchainsdk/utils/Blockchain.kt | 5 +++++ .../tangem/lib/crypto/derivation/AccountNodeRecognizer.kt | 2 ++ 11 files changed, 26 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt index 1ff346fa44..3a6c3d45c0 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt @@ -163,5 +163,6 @@ internal val Blockchain.moonPaySupportedCurrency: MoonPaySupportedCurrency? Linea, LineaTestnet -> null ArbitrumNova -> null Plasma, PlasmaTestnet -> null + SeiEvm, SeiEvmTestnet -> null Monad, MonadTestnet -> null } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/extensions/BlockchainIcons.kt b/common/ui/src/main/java/com/tangem/common/ui/extensions/BlockchainIcons.kt index 4b128eff63..a8bd621e00 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/extensions/BlockchainIcons.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/extensions/BlockchainIcons.kt @@ -225,6 +225,8 @@ private fun iconSetOf(blockchain: Blockchain): IconSet? = when (blockchain) { -> IconSet(active = R.drawable.img_scroll_22, greyedOut = R.drawable.ic_scroll_22) Blockchain.Sei, Blockchain.SeiTestnet, + Blockchain.SeiEvm, + Blockchain.SeiEvmTestnet, -> IconSet(active = R.drawable.img_sei_22, greyedOut = R.drawable.ic_sei_22) Blockchain.Shibarium, Blockchain.ShibariumTestnet, diff --git a/common/ui/src/test/kotlin/com/tangem/common/ui/extensions/BlockchainIconsTest.kt b/common/ui/src/test/kotlin/com/tangem/common/ui/extensions/BlockchainIconsTest.kt index 016c6ac306..8801424c6a 100644 --- a/common/ui/src/test/kotlin/com/tangem/common/ui/extensions/BlockchainIconsTest.kt +++ b/common/ui/src/test/kotlin/com/tangem/common/ui/extensions/BlockchainIconsTest.kt @@ -106,7 +106,9 @@ internal class BlockchainIconsTest { Blockchain.Radiant -> R.drawable.img_radiant_22 Blockchain.Ravencoin, Blockchain.RavencoinTestnet -> R.drawable.img_ravencoin_22 Blockchain.Scroll, Blockchain.ScrollTestnet -> R.drawable.img_scroll_22 - Blockchain.Sei, Blockchain.SeiTestnet -> R.drawable.img_sei_22 + Blockchain.Sei, Blockchain.SeiTestnet, + Blockchain.SeiEvm, Blockchain.SeiEvmTestnet, + -> R.drawable.img_sei_22 Blockchain.Shibarium, Blockchain.ShibariumTestnet -> R.drawable.img_shibarium_22 Blockchain.Solana, Blockchain.SolanaTestnet -> R.drawable.img_solana_22 Blockchain.Sonic, Blockchain.SonicTestnet -> R.drawable.img_sonic_22 @@ -226,7 +228,9 @@ internal class BlockchainIconsTest { Blockchain.Radiant -> R.drawable.ic_radiant_22 Blockchain.Ravencoin, Blockchain.RavencoinTestnet -> R.drawable.ic_ravencoin_22 Blockchain.Scroll, Blockchain.ScrollTestnet -> R.drawable.ic_scroll_22 - Blockchain.Sei, Blockchain.SeiTestnet -> R.drawable.ic_sei_22 + Blockchain.Sei, Blockchain.SeiTestnet, + Blockchain.SeiEvm, Blockchain.SeiEvmTestnet, + -> R.drawable.ic_sei_22 Blockchain.Shibarium, Blockchain.ShibariumTestnet -> R.drawable.ic_shibarium_22 Blockchain.Solana, Blockchain.SolanaTestnet -> R.drawable.ic_solana_16 Blockchain.Sonic, Blockchain.SonicTestnet -> R.drawable.ic_sonic_22 diff --git a/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt index 4d9c4d9cdc..948e3b63c9 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt @@ -375,6 +375,7 @@ class NetworkFactory @Inject constructor( Blockchain.Linea, Blockchain.LineaTestnet, Blockchain.ArbitrumNova, Blockchain.Plasma, Blockchain.PlasmaTestnet, + Blockchain.SeiEvm, Blockchain.SeiEvmTestnet, Blockchain.Monad, Blockchain.MonadTestnet, -> Network.TransactionExtrasType.NONE // endregion diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt b/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt index c1bf85879a..1f7d0d2c90 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt @@ -163,6 +163,7 @@ public val Blockchain.mercuryoNetwork: String? Blockchain.Linea, Blockchain.LineaTestnet -> null Blockchain.ArbitrumNova -> null Blockchain.Plasma, Blockchain.PlasmaTestnet -> null + Blockchain.SeiEvm, Blockchain.SeiEvmTestnet -> null Blockchain.Monad, Blockchain.MonadTestnet -> null } } \ No newline at end of file diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/common/extensions/CardSdk.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/common/extensions/CardSdk.kt index 4be35ce782..399cebd023 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/common/extensions/CardSdk.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/common/extensions/CardSdk.kt @@ -65,7 +65,9 @@ fun CardDTO.supportedBlockchains( */ private fun CardDTO.isBlockchainUnsupported(blockchain: Blockchain): Boolean { return when (blockchain) { - Blockchain.Quai, Blockchain.QuaiTestnet -> { + Blockchain.Quai, Blockchain.QuaiTestnet, + Blockchain.SeiEvm, Blockchain.SeiEvmTestnet, + -> { firmwareVersion <= FirmwareVersion.HDWalletAvailable } else -> false diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt index a545214f6f..549b8b6f2b 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/configs/Wallet2CardConfig.kt @@ -217,6 +217,8 @@ data object Wallet2CardConfig : CardConfig { Blockchain.ArbitrumNova -> EllipticCurve.Secp256k1 Blockchain.Plasma -> EllipticCurve.Secp256k1 Blockchain.PlasmaTestnet -> EllipticCurve.Secp256k1 + Blockchain.SeiEvm -> EllipticCurve.Secp256k1 + Blockchain.SeiEvmTestnet -> EllipticCurve.Secp256k1 Blockchain.Monad -> EllipticCurve.Secp256k1 Blockchain.MonadTestnet -> EllipticCurve.Secp256k1 } diff --git a/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt b/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt index 9ef3985637..7a0123ec08 100644 --- a/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt +++ b/domain/card/src/test/java/com/tangem/domain/card/configs/Wallet2CardConfigTest.kt @@ -173,6 +173,8 @@ class Wallet2CardConfigTest { Blockchain.ArbitrumNova to EllipticCurve.Secp256k1, Blockchain.Plasma to EllipticCurve.Secp256k1, Blockchain.PlasmaTestnet to EllipticCurve.Secp256k1, + Blockchain.SeiEvm to EllipticCurve.Secp256k1, + Blockchain.SeiEvmTestnet to EllipticCurve.Secp256k1, Blockchain.Monad to EllipticCurve.Secp256k1, Blockchain.MonadTestnet to EllipticCurve.Secp256k1, ) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 962be99d3f..ad8fd0d062 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.38-1551" +tangemBlockchainSdk = "releases-5.38-1560" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-5.38-615" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt index 216070afad..e708b13dda 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt @@ -174,6 +174,8 @@ fun Blockchain.Companion.fromNetworkId(networkId: String): Blockchain? { "arbitrum-nova" -> Blockchain.ArbitrumNova "plasma" -> Blockchain.Plasma "plasma/test" -> Blockchain.PlasmaTestnet + "sei-v2" -> Blockchain.SeiEvm + "sei-v2/test" -> Blockchain.SeiEvmTestnet "monad" -> Blockchain.Monad "monad/test" -> Blockchain.MonadTestnet else -> null @@ -347,6 +349,8 @@ fun Blockchain.toNetworkId(): String { Blockchain.ArbitrumNova -> "arbitrum-nova" Blockchain.Plasma -> "plasma" Blockchain.PlasmaTestnet -> "plasma/test" + Blockchain.SeiEvm -> "sei-v2" + Blockchain.SeiEvmTestnet -> "sei-v2/test" Blockchain.Monad -> "monad" Blockchain.MonadTestnet -> "monad/test" } @@ -457,6 +461,7 @@ fun Blockchain.toCoinId(): String { Blockchain.Linea, Blockchain.LineaTestnet -> "linea-ethereum" Blockchain.ArbitrumNova -> "arbitrum-nova-ethereum" Blockchain.Plasma, Blockchain.PlasmaTestnet -> "plasma" + Blockchain.SeiEvm, Blockchain.SeiEvmTestnet -> "sei-v2" Blockchain.Monad, Blockchain.MonadTestnet -> "monad" } } diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt index 026215cd7c..f5579d8077 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/AccountNodeRecognizer.kt @@ -177,6 +177,7 @@ class AccountNodeRecognizer(private val blockchain: Blockchain) { Blockchain.ArbitrumNova, Blockchain.Quai, Blockchain.Plasma, + Blockchain.SeiEvm, Blockchain.Monad, -> true Blockchain.Nexa, // unsupported network @@ -253,6 +254,7 @@ class AccountNodeRecognizer(private val blockchain: Blockchain) { Blockchain.QuaiTestnet, Blockchain.LineaTestnet, Blockchain.PlasmaTestnet, + Blockchain.SeiEvmTestnet, Blockchain.MonadTestnet, -> false // endregion From 9bf5707cacb3ee6cfc1446f25a07031d7ae1d439 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Jun 2026 16:28:47 +0300 Subject: [PATCH 26/36] Updated on 2026-08-14 --- .../core/abtests/di/ABTestsManagerModule.kt | 8 ++- .../core/abtests/manager/ABTestsManager.kt | 2 +- .../manager/impl/AmplitudeABTestsManager.kt | 58 +++++++++++++------ .../manager/impl/StubABTestsManager.kt | 2 +- .../swap/domain/GetSwapUiModeUseCaseTest.kt | 22 ++++--- .../plugin/configuration/model/BuildType.kt | 6 +- 6 files changed, 62 insertions(+), 36 deletions(-) diff --git a/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/di/ABTestsManagerModule.kt b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/di/ABTestsManagerModule.kt index ecc4cf0363..5747dedc9e 100644 --- a/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/di/ABTestsManagerModule.kt +++ b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/di/ABTestsManagerModule.kt @@ -27,7 +27,13 @@ internal object ABTestsManagerModule { return if (BuildConfig.AB_TESTS_ENABLED) { AmplitudeABTestsManager( application = application, - apiKey = environmentConfig.amplitudeApiKey, + apiKey = if (BuildConfig.TESTER_MENU_ENABLED) { + requireNotNull(environmentConfig.amplitudeApiKeyDev) { + "Amplitude api key not found in ${BuildConfig.BUILD_TYPE}" + } + } else { + environmentConfig.amplitudeApiKey + }, scope = appScope, ) } else { diff --git a/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/ABTestsManager.kt b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/ABTestsManager.kt index 07f9253ef9..e2cef74d35 100644 --- a/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/ABTestsManager.kt +++ b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/ABTestsManager.kt @@ -8,5 +8,5 @@ interface ABTestsManager { fun removeUserProperties() - fun getValue(key: String, defaultValue: String): String + suspend fun getValue(key: String, defaultValue: String): String } \ No newline at end of file diff --git a/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/AmplitudeABTestsManager.kt b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/AmplitudeABTestsManager.kt index 7869bdbc27..3e7baebb67 100644 --- a/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/AmplitudeABTestsManager.kt +++ b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/AmplitudeABTestsManager.kt @@ -9,7 +9,9 @@ import com.tangem.core.abtests.manager.ABTestsManager import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull internal class AmplitudeABTestsManager( val application: Application, @@ -19,9 +21,13 @@ internal class AmplitudeABTestsManager( private lateinit var client: ExperimentClient + private val variantsFetched = CompletableDeferred() + + private val logger = TangemLogger.withTag(TAG) + override fun init() { if (::client.isInitialized) { - TangemLogger.w("AB Tests manager already initialized, skipping") + logger.w("AB Tests manager already initialized, skipping") return } @@ -40,7 +46,9 @@ internal class AmplitudeABTestsManager( val allVariants = client.all() logAllVariants(allVariants) } catch (exception: Exception) { - TangemLogger.e("Failed to fetch AB test variants", exception) + logger.e("Failed to fetch AB test variants", exception) + } finally { + variantsFetched.complete(Unit) } } } @@ -64,31 +72,45 @@ internal class AmplitudeABTestsManager( client.setUser(ExperimentUser()) } - override fun getValue(key: String, defaultValue: String): String { + override suspend fun getValue(key: String, defaultValue: String): String { + if (!::client.isInitialized) return defaultValue + awaitVariantsFetched() return client.variant(key).value ?: defaultValue } - private fun logAllVariants(allVariants: Map) { - TangemLogger.d("=".repeat(SEPARATOR_LENGTH)) - TangemLogger.d("AB Tests: Fetched ${allVariants.size} variants") - TangemLogger.d("=".repeat(SEPARATOR_LENGTH)) + private suspend fun awaitVariantsFetched() { + if (variantsFetched.isCompleted) return + val completed = withTimeoutOrNull(FETCH_AWAIT_TIMEOUT_MILLIS) { + variantsFetched.await() + } + if (completed == null) { + logger.w("AB Tests variants not fetched within $FETCH_AWAIT_TIMEOUT_MILLIS ms, using default value") + // Prevent repeated blocking on subsequent calls; fetch can still complete in background. + variantsFetched.complete(Unit) + } + } - if (allVariants.isEmpty()) { - TangemLogger.d("No variants available") - } else { - allVariants.entries.forEachIndexed { index, (key, variant) -> - TangemLogger.d("[${index + 1}/${allVariants.size}] Key: $key") - TangemLogger.d(" → Value: ${variant.value ?: "null"}") - TangemLogger.d(" → Payload: ${variant.payload ?: "null"}") - TangemLogger.d(" → Key: ${variant.key ?: "null"}") - TangemLogger.d("-".repeat(SEPARATOR_LENGTH)) + private fun logAllVariants(allVariants: Map) { + val message = buildString { + appendLine("AB Tests: Fetched ${allVariants.size} variants") + if (allVariants.isEmpty()) { + append("No variants available") + } else { + allVariants.entries.forEachIndexed { index, (key, variant) -> + appendLine("[${index + 1}/${allVariants.size}] $key") + appendLine(" → value: ${variant.value ?: "null"}") + appendLine(" → key: ${variant.key ?: "null"}") + append(" → payload: ${variant.payload ?: "null"}") + if (index != allVariants.size - 1) appendLine() + } } } - TangemLogger.d("=".repeat(SEPARATOR_LENGTH)) + logger.i(message) } private companion object { - const val SEPARATOR_LENGTH = 50 + const val TAG = "AmplitudeABTestsManager" + const val FETCH_AWAIT_TIMEOUT_MILLIS = 3_000L } } \ No newline at end of file diff --git a/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/StubABTestsManager.kt b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/StubABTestsManager.kt index 64aec57be3..0ec5293034 100644 --- a/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/StubABTestsManager.kt +++ b/core/ab-tests/src/main/kotlin/com/tangem/core/abtests/manager/impl/StubABTestsManager.kt @@ -16,7 +16,7 @@ internal class StubABTestsManager : ABTestsManager { // intentionally do nothing } - override fun getValue(key: String, defaultValue: String): String { + override suspend fun getValue(key: String, defaultValue: String): String { return defaultValue } } \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/GetSwapUiModeUseCaseTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/GetSwapUiModeUseCaseTest.kt index 4d3f65e317..1e3d6a6c1c 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/GetSwapUiModeUseCaseTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/GetSwapUiModeUseCaseTest.kt @@ -7,9 +7,7 @@ import com.tangem.feature.swap.domain.models.domain.SwapUIMode import com.tangem.features.swap.SwapFeatureToggles import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test @@ -34,7 +32,7 @@ internal class GetSwapUiModeUseCaseTest { assertThat(actual).isEqualTo(SwapUIMode.Detailed) coVerify(exactly = 0) { swapRepository.getStoredSwapUiMode() } - verify(exactly = 0) { abTestsManager.getValue(any(), any()) } + coVerify(exactly = 0) { abTestsManager.getValue(any(), any()) } } @Test @@ -46,7 +44,7 @@ internal class GetSwapUiModeUseCaseTest { val actual = sut.invoke() assertThat(actual).isEqualTo(SwapUIMode.Detailed) - verify(exactly = 0) { abTestsManager.getValue(any(), any()) } + coVerify(exactly = 0) { abTestsManager.getValue(any(), any()) } } @Test @@ -58,7 +56,7 @@ internal class GetSwapUiModeUseCaseTest { val actual = sut.invoke() assertThat(actual).isEqualTo(SwapUIMode.Simple) - verify(exactly = 0) { abTestsManager.getValue(any(), any()) } + coVerify(exactly = 0) { abTestsManager.getValue(any(), any()) } } @Test @@ -66,24 +64,24 @@ internal class GetSwapUiModeUseCaseTest { runTest { coEvery { swapFeatureToggles.isSwapAbEnabled } returns true coEvery { swapRepository.getStoredSwapUiMode() } returns null - every { abTestsManager.getValue("swap_form_variant", "detailed") } returns "detailed" + coEvery { abTestsManager.getValue("swap_form_variant", "detailed") } returns "detailed" val actual = sut.invoke() assertThat(actual).isEqualTo(SwapUIMode.Detailed) - verify(exactly = 1) { abTestsManager.getValue("swap_form_variant", "detailed") } + coVerify(exactly = 1) { abTestsManager.getValue("swap_form_variant", "detailed") } } @Test fun `GIVEN toggle enabled and repository empty and AB returns simple WHEN invoke THEN returns Simple`() = runTest { coEvery { swapFeatureToggles.isSwapAbEnabled } returns true coEvery { swapRepository.getStoredSwapUiMode() } returns null - every { abTestsManager.getValue("swap_form_variant", "detailed") } returns "simple" + coEvery { abTestsManager.getValue("swap_form_variant", "detailed") } returns "simple" val actual = sut.invoke() assertThat(actual).isEqualTo(SwapUIMode.Simple) - verify(exactly = 1) { abTestsManager.getValue("swap_form_variant", "detailed") } + coVerify(exactly = 1) { abTestsManager.getValue("swap_form_variant", "detailed") } } @Test @@ -91,7 +89,7 @@ internal class GetSwapUiModeUseCaseTest { runTest { coEvery { swapFeatureToggles.isSwapAbEnabled } returns true coEvery { swapRepository.getStoredSwapUiMode() } returns null - every { abTestsManager.getValue("swap_form_variant", "detailed") } returns "SIMPLE" + coEvery { abTestsManager.getValue("swap_form_variant", "detailed") } returns "SIMPLE" val actual = sut.invoke() @@ -103,7 +101,7 @@ internal class GetSwapUiModeUseCaseTest { runTest { coEvery { swapFeatureToggles.isSwapAbEnabled } returns true coEvery { swapRepository.getStoredSwapUiMode() } returns null - every { abTestsManager.getValue("swap_form_variant", "detailed") } returns "something_else" + coEvery { abTestsManager.getValue("swap_form_variant", "detailed") } returns "something_else" val actual = sut.invoke() @@ -115,7 +113,7 @@ internal class GetSwapUiModeUseCaseTest { runTest { coEvery { swapFeatureToggles.isSwapAbEnabled } returns true coEvery { swapRepository.getStoredSwapUiMode() } returns null - every { abTestsManager.getValue("swap_form_variant", "detailed") } returns "" + coEvery { abTestsManager.getValue("swap_form_variant", "detailed") } returns "" val actual = sut.invoke() diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt index c15761c0d9..07e7b23df7 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt @@ -28,7 +28,7 @@ enum class BuildType( BuildConfigField.LogEnabled(isEnabled = true), BuildConfigField.TesterMenuAvailability(isEnabled = true), BuildConfigField.MockDataSource(isEnabled = false), - BuildConfigField.ABTestsEnabled(isEnabled = false), + BuildConfigField.ABTestsEnabled(isEnabled = true), ), ), @@ -74,7 +74,7 @@ enum class BuildType( BuildConfigField.LogEnabled(isEnabled = true), BuildConfigField.TesterMenuAvailability(isEnabled = true), BuildConfigField.MockDataSource(isEnabled = false), - BuildConfigField.ABTestsEnabled(isEnabled = false), + BuildConfigField.ABTestsEnabled(isEnabled = true), ), ), @@ -114,7 +114,7 @@ enum class BuildType( BuildConfigField.LogEnabled(isEnabled = false), BuildConfigField.TesterMenuAvailability(isEnabled = false), BuildConfigField.MockDataSource(isEnabled = false), - BuildConfigField.ABTestsEnabled(isEnabled = false), + BuildConfigField.ABTestsEnabled(isEnabled = true), ), ), ; From 319771b3b344a45c48de4ef0ea62a38328d290d2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Jun 2026 15:18:44 +0000 Subject: [PATCH 27/36] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index ad8fd0d062..56087138c7 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.38-1560" +tangemBlockchainSdk = "releases-5.39-1558" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.38-615" +tangemCardSdk = "releases-5.39-623" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From c933d44932e9a5030735885734dcfdc0b8140b32 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Jun 2026 19:47:47 +0400 Subject: [PATCH 28/36] Updated on 2026-08-14 --- .../txhistory/model/TxHistoryModel.kt | 8 +- .../state/TxHistoryStateController.kt | 16 ++- .../state/TxHistoryStateControllerTest.kt | 120 ++++++++++++++++++ 3 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/state/TxHistoryStateControllerTest.kt diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt index babda57ca1..b40657fc23 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt @@ -144,7 +144,13 @@ internal class TxHistoryModel @Inject constructor( private fun subscribeToUiItemChanges() { txHistoryListManager.uiItems - .onEach { snapshot -> stateController.setContent(snapshot = snapshot, loadMore = ::loadMoreItems) } + .onEach { snapshot -> + stateController.setContent( + snapshot = snapshot, + loadMore = ::loadMoreItems, + onExploreClick = ::openExplorer, + ) + } .launchIn(modelScope) txHistoryListManager.paginationStatus .onEach { paginationStatus -> handlePaginationStatus(paginationStatus) } diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/state/TxHistoryStateController.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/state/TxHistoryStateController.kt index ac79d77a9a..1c4661b41b 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/state/TxHistoryStateController.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/state/TxHistoryStateController.kt @@ -110,10 +110,15 @@ internal class TxHistoryStateController @Inject constructor( } } - fun setContent(snapshot: TxHistoryItemsSnapshot, loadMore: () -> Boolean) { + fun setContent(snapshot: TxHistoryItemsSnapshot, loadMore: () -> Boolean, onExploreClick: () -> Unit) { when (snapshot) { is TxHistoryItemsSnapshot.Items -> _uiState.update { state -> - if (state is TxHistoryItemsUM.Content) { + if (snapshot.items.none { it is TxHistoryItemsUM.TxHistoryItemUM.Transaction }) { + TxHistoryItemsUM.Empty( + isBalanceHidden = state.isBalanceHidden, + onExploreClick = onExploreClick, + ) + } else if (state is TxHistoryItemsUM.Content) { state.copy(items = snapshot.items) } else { TxHistoryItemsUM.Content( @@ -125,7 +130,12 @@ internal class TxHistoryStateController @Inject constructor( } } is TxHistoryItemsSnapshot.LegacyItems -> _legacyUiState.update { state -> - if (state is TxHistoryUM.Content) { + if (snapshot.items.none { it is TxHistoryUM.TxHistoryItemUM.Transaction }) { + TxHistoryUM.Empty( + isBalanceHidden = state.isBalanceHidden, + onExploreClick = onExploreClick, + ) + } else if (state is TxHistoryUM.Content) { state.copy(items = snapshot.items) } else { TxHistoryUM.Content( diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/state/TxHistoryStateControllerTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/state/TxHistoryStateControllerTest.kt new file mode 100644 index 0000000000..328bdf2d48 --- /dev/null +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/state/TxHistoryStateControllerTest.kt @@ -0,0 +1,120 @@ +package com.tangem.features.txhistory.state + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.DesignFeatureToggles +import com.tangem.core.ui.components.transactions.state.TransactionItemUM +import com.tangem.core.ui.components.transactions.state.TransactionState +import com.tangem.features.txhistory.entity.TxHistoryItemsUM +import com.tangem.features.txhistory.entity.TxHistoryUM +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class TxHistoryStateControllerTest { + + private val controller = TxHistoryStateController( + designFeatureToggles = mockk { every { isRedesignEnabled } returns true }, + ) + private val legacyController = TxHistoryStateController( + designFeatureToggles = mockk { every { isRedesignEnabled } returns false }, + ) + + @Test + fun `GIVEN empty items snapshot WHEN setContent THEN Empty state with explorer action`() { + val onExploreClick = {} + + controller.setContent( + snapshot = TxHistoryItemsSnapshot.Items(persistentListOf()), + loadMore = { true }, + onExploreClick = onExploreClick, + ) + + val state = controller.uiState.value + assertThat(state).isInstanceOf(TxHistoryItemsUM.Empty::class.java) + assertThat((state as TxHistoryItemsUM.Empty).onExploreClick).isEqualTo(onExploreClick) + } + + @Test + fun `GIVEN snapshot with only a group title WHEN setContent THEN Empty state`() { + controller.setContent( + snapshot = TxHistoryItemsSnapshot.Items( + persistentListOf( + TxHistoryItemsUM.TxHistoryItemUM.GroupTitle(title = "Today", itemKey = "0-Today"), + ), + ), + loadMore = { true }, + onExploreClick = {}, + ) + + assertThat(controller.uiState.value).isInstanceOf(TxHistoryItemsUM.Empty::class.java) + } + + @Test + fun `GIVEN snapshot with transactions WHEN setContent THEN Content state`() { + controller.setContent( + snapshot = TxHistoryItemsSnapshot.Items( + persistentListOf( + TxHistoryItemsUM.TxHistoryItemUM.GroupTitle(title = "Today", itemKey = "0-Today"), + TxHistoryItemsUM.TxHistoryItemUM.Transaction(TransactionItemUM.Loading("hash")), + ), + ), + loadMore = { true }, + onExploreClick = {}, + ) + + assertThat(controller.uiState.value).isInstanceOf(TxHistoryItemsUM.Content::class.java) + } + + @Test + fun `GIVEN Empty state WHEN empty snapshot arrives THEN Empty is not overridden by Content`() { + controller.setEmpty(onExploreClick = {}) + + controller.setContent( + snapshot = TxHistoryItemsSnapshot.Items(persistentListOf()), + loadMore = { true }, + onExploreClick = {}, + ) + + assertThat(controller.uiState.value).isInstanceOf(TxHistoryItemsUM.Empty::class.java) + } + + // region Legacy (e.g. Solana: probe reports HasTransactions but the mapped page is empty) + + @Test + fun `GIVEN legacy snapshot with only a title WHEN setContent THEN legacy Empty state with explorer`() { + val onExploreClick = {} + + legacyController.setContent( + snapshot = TxHistoryItemsSnapshot.LegacyItems( + persistentListOf(TxHistoryUM.TxHistoryItemUM.Title(onExploreClick = {})), + ), + loadMore = { true }, + onExploreClick = onExploreClick, + ) + + val state = legacyController.legacyUiState.value + assertThat(state).isInstanceOf(TxHistoryUM.Empty::class.java) + assertThat((state as TxHistoryUM.Empty).onExploreClick).isEqualTo(onExploreClick) + } + + @Test + fun `GIVEN legacy snapshot with transactions WHEN setContent THEN legacy Content state`() { + legacyController.setContent( + snapshot = TxHistoryItemsSnapshot.LegacyItems( + persistentListOf( + TxHistoryUM.TxHistoryItemUM.Title(onExploreClick = {}), + TxHistoryUM.TxHistoryItemUM.Transaction(TransactionState.Loading("hash")), + ), + ), + loadMore = { true }, + onExploreClick = {}, + ) + + assertThat(legacyController.legacyUiState.value).isInstanceOf(TxHistoryUM.Content::class.java) + } + + // endregion +} \ No newline at end of file From f8a9cac8016308fcdc1e6722dbb10a4300581b7e Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 10 Jun 2026 20:50:15 +0500 Subject: [PATCH 29/36] Updated on 2026-08-14 --- .../feature/swap/models/SwapStateHolder.kt | 2 +- .../tangem/feature/swap/ui/StateBuilder.kt | 26 ++++++++++--------- .../tangem/feature/swap/ui/TransactionCard.kt | 17 +++++++----- .../feature/swap/ui/TransactionCardSimple.kt | 22 ++++++++-------- .../ui/preview/SwapTransactionCardPreview.kt | 4 +-- .../ui/transfer/SwapTransferStateBuilder.kt | 9 ++++--- .../transfer/SwapTransferStateBuilderTest.kt | 2 +- 7 files changed, 44 insertions(+), 38 deletions(-) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index 24b806a74b..0feeebd886 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -59,7 +59,7 @@ sealed class SwapCardState { val tokenSymbol: TextReference, val amountEquivalent: TextReference?, val amountTextFieldValue: TextFieldValue?, - val balance: String, + val balance: TextReference, val isBalanceHidden: Boolean, ) : SwapCardState() diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index c2cce01561..955473a8f8 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -303,7 +303,7 @@ internal class StateBuilder( amountEquivalent = emptyAmountState.zeroAmountEquivalent, currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status), tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol), - balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), + balance = swapCurrencyStatus.status.getFormattedAmount(), isBalanceHidden = isBalanceHiddenProvider(), type = cardType, ) @@ -311,7 +311,7 @@ internal class StateBuilder( copy( currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status), tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol), - balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), + balance = swapCurrencyStatus.status.getFormattedAmount(), isBalanceHidden = isBalanceHiddenProvider(), type = cardType, ) @@ -350,7 +350,7 @@ internal class StateBuilder( amountEquivalent = emptyAmountState.zeroAmountEquivalent, currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status), tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol), - balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), + balance = swapCurrencyStatus.status.getFormattedAmount(), isBalanceHidden = isBalanceHiddenProvider(), ) } @@ -387,7 +387,7 @@ internal class StateBuilder( amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), currencyIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status), tokenSymbol = stringReference(fromSwapCurrencyStatus.currency.symbol), - balance = fromSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), + balance = fromSwapCurrencyStatus.status.getFormattedAmount(), isBalanceHidden = isBalanceHiddenProvider(), ), receiveCardData = SwapCardState.SwapCardData( @@ -400,7 +400,7 @@ internal class StateBuilder( amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status), tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol), - balance = toSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), + balance = toSwapCurrencyStatus.status.getFormattedAmount(), isBalanceHidden = isBalanceHiddenProvider(), ), notifications = notificationsFactory.getSwapNotSupportedNotifications(), @@ -516,7 +516,7 @@ internal class StateBuilder( amountEquivalent = uiStateHolder.sendCardData.amountEquivalent, currencyIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status), tokenSymbol = stringReference(fromSwapCurrencyStatus.currency.symbol), - balance = fromSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), + balance = fromSwapCurrencyStatus.status.getFormattedAmount(), isBalanceHidden = isBalanceHiddenProvider(), ), receiveCardData = SwapCardState.SwapCardData( @@ -552,7 +552,7 @@ internal class StateBuilder( }, currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status), tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol), - balance = toSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), + balance = toSwapCurrencyStatus.status.getFormattedAmount(), isBalanceHidden = isBalanceHiddenProvider(), ), isInsufficientFunds = isInsufficientFundsCondition(quoteModel), @@ -739,7 +739,7 @@ internal class StateBuilder( amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status), tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol), - balance = toToken.getFormattedAmount(isNeedSymbol = false), + balance = toToken.getFormattedAmount(), isBalanceHidden = isBalanceHiddenProvider(), ) } ?: SwapCardState.Empty( @@ -1214,10 +1214,12 @@ internal class StateBuilder( } } - private fun CryptoCurrencyStatus?.getFormattedAmount(isNeedSymbol: Boolean): String { - val amount = this?.value?.amount ?: return DASH_SIGN - val symbol = if (isNeedSymbol) currency.symbol else "" - return amount.format { crypto(symbol, currency.decimals) } + private fun CryptoCurrencyStatus?.getFormattedAmount(): TextReference { + if (this == null) return stringReference(DASH_SIGN) + return resourceReference( + R.string.common_balance, + wrappedList(this.value.amount.format { crypto(currency.symbol, currency.decimals) }), + ) } private fun getFormattedFiatAmount(amount: BigDecimal?): TextReference { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt index c4c9307346..8db4818844 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt @@ -102,10 +102,8 @@ private fun TransactionCardData( horizontalAlignment = Alignment.Start, ) { Header( - balance = stringResourceSafe( - R.string.common_balance, - cardState.balance, - ).orMaskWithStars(cardState.isBalanceHidden), + balance = cardState.balance, + isBalanceHidden = cardState.isBalanceHidden, type = cardState.type, ) @@ -276,7 +274,12 @@ private fun TransactionCardLoading(modifier: Modifier = Modifier) { } @Composable -private fun Header(type: TransactionCardType, balance: String, modifier: Modifier = Modifier) { +private fun Header( + type: TransactionCardType, + balance: TextReference, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { Column( modifier = modifier .fillMaxWidth() @@ -298,13 +301,13 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie textColor = titleColor, ) SpacerW16() - if (balance.isNotBlank()) { + if (balance != TextReference.EMPTY) { AnimatedContent( targetState = balance, label = "", ) { balanceText -> Text( - text = balanceText, + text = balanceText.resolveReference().orMaskWithStars(isBalanceHidden), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.body2, modifier = Modifier.testTag(SwapTokenScreenTestTags.BALANCE), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCardSimple.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCardSimple.kt index a807f078a3..7e57acc625 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCardSimple.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCardSimple.kt @@ -35,10 +35,7 @@ import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition -import com.tangem.core.ui.extensions.orMaskWithStars -import com.tangem.core.ui.extensions.resolveAnnotatedReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.SwapTokenScreenTestTags @@ -96,10 +93,8 @@ private fun SimpleTransactionCardData( horizontalAlignment = Alignment.Start, ) { SimpleHeader( - balance = stringResourceSafe( - R.string.common_balance, - cardState.balance, - ).orMaskWithStars(cardState.isBalanceHidden), + balance = cardState.balance, + isBalanceHidden = cardState.isBalanceHidden, type = cardState.type, ) @@ -264,7 +259,12 @@ private fun SimpleTransactionCardLoading(modifier: Modifier = Modifier) { } @Composable -private fun SimpleHeader(type: TransactionCardType, balance: String, modifier: Modifier = Modifier) { +private fun SimpleHeader( + type: TransactionCardType, + balance: TextReference, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { Column( modifier = modifier .fillMaxWidth() @@ -286,10 +286,10 @@ private fun SimpleHeader(type: TransactionCardType, balance: String, modifier: M textColor = titleColor, ) SpacerW16() - if (balance.isNotBlank()) { + if (balance != TextReference.EMPTY) { AnimatedContent(targetState = balance, label = "") { balanceText -> Text( - text = balanceText, + text = balanceText.resolveReference().orMaskWithStars(isBalanceHidden), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.body2, modifier = Modifier.testTag(SwapTokenScreenTestTags.BALANCE), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt index c64731f361..8e35541acd 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt @@ -30,7 +30,7 @@ internal object SwapTransactionCardPreview { amountEquivalent = stringReference("1 000 000"), currencyIconState = CurrencyIconState.Loading, tokenSymbol = stringReference("DAI"), - balance = "123123123.123123", + balance = stringReference("Balance: 123123123.123123 DAI"), isBalanceHidden = false, ) @@ -46,7 +46,7 @@ internal object SwapTransactionCardPreview { amountEquivalent = stringReference("1 000 000"), currencyIconState = CurrencyIconState.Loading, tokenSymbol = stringReference("DAI"), - balance = "33333", + balance = stringReference("Balance: 33333 DAI"), isBalanceHidden = false, ) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt index e9cd7a543b..97897c45c4 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt @@ -31,7 +31,6 @@ import com.tangem.feature.swap.models.states.SwapNotificationUM import com.tangem.feature.swap.presentation.R import com.tangem.features.send.v2.api.utils.formatFooterFiatFee import com.tangem.features.send.v2.api.utils.getTronTokenFeeSendingText -import com.tangem.utils.StringsSigns.DASH_SIGN import kotlinx.collections.immutable.ImmutableList import java.math.BigDecimal import javax.inject.Inject @@ -198,9 +197,11 @@ internal class SwapTransferStateBuilder @Inject constructor( ) } - private fun CryptoCurrencyStatus.getFormattedAmount(): String { - val amount = this.value.amount ?: return DASH_SIGN - return amount.format { crypto(symbol = "", decimals = currency.decimals) } + private fun CryptoCurrencyStatus.getFormattedAmount(): TextReference { + return resourceReference( + R.string.common_balance, + wrappedList(value.amount.format { crypto(currency.symbol, currency.decimals) }), + ) } private fun Account.toIconUM(): AccountIconUM { diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt index f9187e54f5..63e58ce5cf 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt @@ -592,7 +592,7 @@ internal class SwapTransferStateBuilderTest { tokenSymbol = stringReference(""), amountEquivalent = TextReference.EMPTY, amountTextFieldValue = initialAmountTextFieldValue, - balance = "", + balance = TextReference.EMPTY, isBalanceHidden = false, ), receiveCardData = SwapCardState.Loading( From f72c0f448e1866fc672ea4b9ca78b9ba68bf5eb3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 11 Jun 2026 11:40:39 +0200 Subject: [PATCH 30/36] Updated on 2026-08-14 --- .../converters/AmountStateConverter.kt | 2 - .../ui/amountScreen/models/AmountState.kt | 2 - .../amountScreen/ui/AmountFieldContainer.kt | 35 ++-- .../converters/AmountStateConverterTest.kt | 62 ------- .../analytics/StakingAnalyticsEvent.kt | 13 ++ .../impl/presentation/model/StakingModel.kt | 34 +++- .../state/events/StakingAlertUM.kt | 5 + .../SetInitialDataStateTransformer.kt | 2 - .../AmountRequirementStateTransformer.kt | 52 +++--- .../model/StakingModelP2PSumLimitTest.kt | 131 +++++++++++++++ .../model/StakingModelTestBase.kt | 4 +- .../state/events/StakingAlertUMTest.kt | 31 ++++ .../AmountRequirementStateTransformerTest.kt | 152 ++++++++++++++++++ 13 files changed, 409 insertions(+), 116 deletions(-) delete mode 100644 common/ui/src/test/kotlin/com/tangem/common/ui/amountScreen/converters/AmountStateConverterTest.kt create mode 100644 features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelP2PSumLimitTest.kt create mode 100644 features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUMTest.kt create mode 100644 features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformerTest.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt index 50543caa9d..a8d3ad4fe0 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt @@ -38,7 +38,6 @@ class AmountStateConverter( private val iconStateConverter: CryptoCurrencyToIconStateConverter, private val isBalanceHidden: Boolean, private val accountTitleUM: AccountTitleUM, - private val isMaxButtonVisible: Boolean = true, ) : Converter { private val amountFieldConverter by lazy(LazyThreadSafetyMode.NONE) { @@ -73,7 +72,6 @@ class AmountStateConverter( amountTextField = amountFieldConverter.convert(value.value), isPrimaryButtonEnabled = false, appCurrency = appCurrency, - isMaxButtonVisible = isMaxButtonVisible, ) } } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt index c7b3fa5da0..09e6c0d211 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt @@ -24,7 +24,6 @@ sealed class AmountState { * @param isEditingDisabled indicated whether amount is editable * @param reduceAmountBy reduces amount to be sent by specified value * @param isIgnoreReduce ignores reduce amount value - * @param isMaxButtonVisible indicates whether the "Max" button is shown */ data class Data( override val isPrimaryButtonEnabled: Boolean, @@ -38,7 +37,6 @@ sealed class AmountState { val isEditingDisabled: Boolean = false, val reduceAmountBy: BigDecimal = BigDecimal.ZERO, val isIgnoreReduce: Boolean = false, - val isMaxButtonVisible: Boolean = true, ) : AmountState() data object Empty : AmountState() { diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt index 6f8d27de62..12335b79ad 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt @@ -88,7 +88,6 @@ internal fun LazyListScope.amountFieldV2( @Composable private fun AmountInfo(amountUM: AmountState, onMaxAmountClick: () -> Unit, modifier: Modifier = Modifier) { val tokenIconState = (amountUM as? AmountState.Data)?.tokenIconState ?: CurrencyIconState.Loading - val isMaxButtonVisible = (amountUM as? AmountState.Data)?.isMaxButtonVisible != false Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -107,24 +106,22 @@ private fun AmountInfo(amountUM: AmountState, onMaxAmountClick: () -> Unit, modi amountUM = amountUM, modifier = Modifier.weight(1f), ) - if (isMaxButtonVisible) { - Text( - text = stringResourceSafe(R.string.send_max_amount), - style = TangemTheme.typography.caption1, - color = TangemTheme.colors.text.primary1, - modifier = Modifier - .padding(end = 16.dp) - .clip(RoundedCornerShape(16.dp)) - .background(TangemTheme.colors.button.secondary) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = ripple(), - onClick = onMaxAmountClick, - ) - .padding(horizontal = 12.dp, vertical = 4.dp) - .testTag(SendScreenTestTags.MAX_BUTTON), - ) - } + Text( + text = stringResourceSafe(R.string.send_max_amount), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .padding(end = 16.dp) + .clip(RoundedCornerShape(16.dp)) + .background(TangemTheme.colors.button.secondary) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + onClick = onMaxAmountClick, + ) + .padding(horizontal = 12.dp, vertical = 4.dp) + .testTag(SendScreenTestTags.MAX_BUTTON), + ) } } diff --git a/common/ui/src/test/kotlin/com/tangem/common/ui/amountScreen/converters/AmountStateConverterTest.kt b/common/ui/src/test/kotlin/com/tangem/common/ui/amountScreen/converters/AmountStateConverterTest.kt deleted file mode 100644 index b612d7f357..0000000000 --- a/common/ui/src/test/kotlin/com/tangem/common/ui/amountScreen/converters/AmountStateConverterTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.tangem.common.ui.amountScreen.converters - -import com.google.common.truth.Truth.assertThat -import com.tangem.common.ui.account.AccountTitleUM -import com.tangem.common.ui.amountScreen.AmountScreenClickIntents -import com.tangem.common.ui.amountScreen.models.AmountParameters -import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary -import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter -import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.extensions.stringReference -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import io.mockk.every -import io.mockk.mockk -import org.junit.jupiter.api.Test -import java.math.BigDecimal - -internal class AmountStateConverterTest { - - private val currency = mockk { - every { symbol } returns "ETH" - every { decimals } returns 18 - every { name } returns "Ethereum" - } - private val status = CryptoCurrencyStatus(currency = currency, value = mockk(relaxed = true)) - private val iconStateConverter = mockk { - every { convert(any()) } returns CurrencyIconState.Loading - } - private val clickIntents = mockk(relaxed = true) - private val accountTitleUM = mockk() - - private fun convert(isMaxButtonVisible: Boolean = true): AmountState = AmountStateConverter( - clickIntents = clickIntents, - appCurrency = AppCurrency.Default, - cryptoCurrencyStatus = status, - maxEnterAmount = EnterAmountBoundary( - amount = BigDecimal.ONE, - fiatAmount = BigDecimal.TEN, - fiatRate = BigDecimal.ONE, - ), - iconStateConverter = iconStateConverter, - isBalanceHidden = false, - accountTitleUM = accountTitleUM, - isMaxButtonVisible = isMaxButtonVisible, - ).convert(AmountParameters(title = stringReference("Wallet"), value = "")) - - @Test - fun `GIVEN no isMaxButtonVisible param WHEN convert THEN Data isMaxButtonVisible is true`() { - val result = convert() - - assertThat((result as AmountState.Data).isMaxButtonVisible).isTrue() - } - - @Test - fun `GIVEN isMaxButtonVisible false WHEN convert THEN Data isMaxButtonVisible is false`() { - val result = convert(isMaxButtonVisible = false) - - assertThat((result as AmountState.Data).isMaxButtonVisible).isFalse() - } -} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt b/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt index 65407e56ca..30060c605e 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt @@ -181,6 +181,19 @@ sealed class StakingAnalyticsEvent( AnalyticsParam.BLOCKCHAIN to blockchain, ), ) + + data class SumLimitError( + val token: String, + val blockchain: String, + val maxAmount: String, + ) : StakingAnalyticsEvent( + event = "Error - Sum Limit", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to token, + AnalyticsParam.BLOCKCHAIN to blockchain, + AnalyticsParam.ERROR_MESSAGE to "Maximum amount: $maxAmount", + ), + ) } enum class StakeScreenSource { diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index 8afe46564e..da5b4c29e8 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -97,6 +97,7 @@ import com.tangem.features.staking.impl.presentation.state.utils.withStubUnstake import com.tangem.lib.crypto.BlockchainUtils.isTon import com.tangem.utils.Provider import com.tangem.utils.coroutines.* +import com.tangem.utils.extensions.isPositive import com.tangem.utils.extensions.isSingleItem import com.tangem.utils.extensions.orZero import com.tangem.utils.logging.TangemLogger @@ -642,6 +643,31 @@ internal class StakingModel @Inject constructor( integration = integration, ), ) + checkSumLimitExceeded() + } + + private fun checkSumLimitExceeded() { + val maxLimit = (integration as? P2PEthPoolIntegration) + ?.enterArgs?.amountRequirement?.maximum + ?.takeIf { it.isPositive() } + ?: return + + val enteredAmount = (uiState.value.amountState as? AmountState.Data) + ?.amountTextField?.cryptoAmount?.value + ?: return + + if (enteredAmount > maxLimit) { + // Pass the max limit as a stable crypto-formatted value (e.g. "0.15 ETH"); the event + // builds the hardcoded English "Error Message" from it, so the model needs no resources. + val formattedMax = maxLimit.format { crypto(cryptoCurrencyStatus.currency) } + analyticsEventHandler.send( + StakingAnalyticsEvent.SumLimitError( + token = cryptoCurrencyStatus.currency.symbol, + blockchain = cryptoCurrencyStatus.currency.network.name, + maxAmount = formattedMax, + ), + ) + } } override fun onAmountPasteTriggerDismiss() { @@ -658,6 +684,7 @@ internal class StakingModel @Inject constructor( integration = integration, ), ) + checkSumLimitExceeded() } override fun onCurrencyChangeClick(isFiat: Boolean) { @@ -1099,7 +1126,12 @@ internal class StakingModel @Inject constructor( } override fun showPrimaryClickAlert() { - messageSender.send(StakingAlertUM.stakeMoreClickUnavailable(cryptoCurrencyStatus.currency)) + val message = if (integration is P2PEthPoolIntegration) { + StakingAlertUM.stakeMoreClickUnavailableNoTargets() + } else { + StakingAlertUM.stakeMoreClickUnavailable(cryptoCurrencyStatus.currency) + } + messageSender.send(message) } override fun onOpenLearnMoreAboutApproveClick() { diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt index acba4a78d1..a10e3cbaf0 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt @@ -54,6 +54,11 @@ internal object StakingAlertUM { ), ) + fun stakeMoreClickUnavailableNoTargets(): DialogMessage = DialogMessage( + title = null, + message = resourceReference(R.string.staking_no_validators_error_message), + ) + fun rewardsMinimumRequirementsError(cryptoCurrencyName: String, cryptoAmountValue: String): DialogMessage = DialogMessage( title = null, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt index b6105d4f37..58203a7cab 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt @@ -19,7 +19,6 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.staking.StakingBalanceEntry import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.staking.model.P2PEthPoolIntegration import com.tangem.domain.staking.model.Period import com.tangem.domain.staking.model.StakingIntegration import com.tangem.domain.staking.model.StakingTarget @@ -262,7 +261,6 @@ internal class SetInitialDataStateTransformer( walletTitle = stringReference(userWalletProvider().name), prefixText = resourceReference(R.string.common_from), ).convert(account), - isMaxButtonVisible = integration !is P2PEthPoolIntegration, ).convert( AmountParameters( title = stringReference(userWalletProvider().name), diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt index 5421794d27..05a795e7cf 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt @@ -96,12 +96,18 @@ internal class AmountRequirementStateTransformer( return when (actionType) { is StakingActionCommonType.Enter -> { - val enterRequirements = integration.enterArgs?.amountRequirement - enterRequirements?.getError(amountDecimal, R.string.staking_amount_requirement_error) + integration.enterArgs?.amountRequirement?.getError( + amount = amountDecimal, + minErrorRes = R.string.staking_amount_requirement_error, + maxErrorRes = R.string.staking_max_amount_requirement_error, + ) } is StakingActionCommonType.Exit -> { - val exitRequirements = integration.exitArgs?.amountRequirement - exitRequirements?.getError(amountDecimal, R.string.staking_unstake_amount_requirement_error) + integration.exitArgs?.amountRequirement?.getError( + amount = amountDecimal, + minErrorRes = R.string.staking_unstake_amount_requirement_error, + maxErrorRes = R.string.staking_max_amount_requirement_error, + ) } else -> null } @@ -118,31 +124,25 @@ internal class AmountRequirementStateTransformer( return isEnterOrExit && isTron && !isIntegerOnly } - private fun StakingAmountRequirement.getError(amount: BigDecimal, @StringRes errorTextRes: Int): TextReference? { + private fun StakingAmountRequirement.getError( + amount: BigDecimal, + @StringRes minErrorRes: Int, + @StringRes maxErrorRes: Int, + ): TextReference? { + if (!isRequired) return null + val isExceedsMinRequirement = minimum?.compareTo(amount) == 1 - val isExceedsMaxRequirement = if (maximum?.isPositive() == true) { - maximum?.compareTo(amount) == -1 - } else { - maxAmount.amount?.compareTo(amount) == -1 + val effectiveMax = maximum?.takeIf { it.isPositive() } ?: maxAmount.amount + val isExceedsMaxRequirement = effectiveMax?.compareTo(amount) == -1 + + val (errorRes, boundary) = when { + isExceedsMinRequirement -> minErrorRes to minimum + isExceedsMaxRequirement -> maxErrorRes to effectiveMax + else -> return null } - val errorText = when { - isExceedsMinRequirement -> { - minimum.format { - crypto(cryptoCurrencyStatus.currency) - } - } - isExceedsMaxRequirement -> { - maximum.format { - crypto(cryptoCurrencyStatus.currency) - } - } - else -> "" - } - return resourceReference( - errorTextRes, - wrappedList(errorText), - ).takeIf { isRequired && (isExceedsMinRequirement || isExceedsMaxRequirement) } + val formatted = boundary.format { crypto(cryptoCurrencyStatus.currency) } + return resourceReference(errorRes, wrappedList(formatted)) } data class Data( diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelP2PSumLimitTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelP2PSumLimitTest.kt new file mode 100644 index 0000000000..eddadd08d8 --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelP2PSumLimitTest.kt @@ -0,0 +1,131 @@ +package com.tangem.features.staking.impl.presentation.model + +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.domain.staking.analytics.StakingAnalyticsEvent +import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault +import com.tangem.domain.staking.model.ethpool.VaultLimitInfo +import com.tangem.domain.tokens.model.Amount +import com.tangem.features.staking.impl.presentation.state.StakingStep +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +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 +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +/** + * Model-level tests for the P2P ETH pool staking integration: + * verifies that [StakingAnalyticsEvent.SumLimitError] is sent when the entered amount + * exceeds the vault's computed maximum (= limit − totalAssets). + * + * Fixture: + * vault address = "0xabc" totalAssets = 5 + * limit "0xabc" limit = 10 + * → maximum = 10 − 5 = 5.0 (scale=1, RoundingMode.FLOOR) + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal class StakingModelP2PSumLimitTest : StakingModelTestBase() { + + override val testIntegrationId: StakingIntegrationID = StakingIntegrationID.P2PEthPool + + private val vaultAddress = "0xabc" + private val testVault = P2PEthPoolVault( + vaultAddress = vaultAddress, + displayName = "Test Vault", + apy = BigDecimal("4.5"), + baseApy = BigDecimal("4.0"), + capacity = BigDecimal("1000"), + totalAssets = BigDecimal("5"), + feePercent = BigDecimal("0.1"), + isPrivate = false, + isGenesis = false, + isSmoothingPool = false, + isErc20 = false, + tokenName = null, + tokenSymbol = null, + createdAt = 0L, + ) + + // maximum = 10 − 5 = 5.0 (FLOOR scale=1) + private val testLimits = mapOf( + vaultAddress to VaultLimitInfo(limit = BigDecimal("10"), coefficient = null), + ) + + @BeforeEach + fun setUpP2P() { + coEvery { p2pEthPoolRepository.getVaultsSync() } returns listOf(testVault) + coEvery { p2pEthPoolRepository.getVaultLimitsSyncOrNull() } returns testLimits + } + + /** + * Helper: returns a [MutableStateFlow] whose value has [amountState] set to an + * [AmountState.Data] mock with the given [cryptoAmountValue]. + * The flow is also wired to [stateController.uiState]. + */ + private fun stubUiStateWithCryptoAmount(cryptoAmountValue: BigDecimal): MutableStateFlow { + val amountData = mockk(relaxed = true) { + every { amountTextField } returns mockk(relaxed = true) { + every { cryptoAmount } returns Amount( + currencySymbol = "ETH", + value = cryptoAmountValue, + decimals = 18, + ) + } + } + val uiStateFlow = MutableStateFlow( + mockk(relaxed = true) { + every { currentStep } returns StakingStep.InitialInfo + every { amountState } returns amountData + }, + ) + every { stateController.uiState } returns uiStateFlow + return uiStateFlow + } + + // ----- Test A --------------------------------------------------------------- + + @Test + fun `GIVEN P2P vault max=5 WHEN amount 6 entered THEN SumLimitError analytics sent`() = runTest { + stubUiStateWithCryptoAmount(BigDecimal("6")) + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountValueChange("6") + + verify { + analyticsEventHandler.send( + match { it is StakingAnalyticsEvent.SumLimitError } + ) + } + + model.onDestroy() + } + + // ----- Test B --------------------------------------------------------------- + + @Test + fun `GIVEN P2P vault max=5 WHEN amount 4 entered THEN SumLimitError analytics NOT sent`() = runTest { + stubUiStateWithCryptoAmount(BigDecimal("4")) + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountValueChange("4") + + verify(exactly = 0) { + analyticsEventHandler.send( + match { it is StakingAnalyticsEvent.SumLimitError } + ) + } + + model.onDestroy() + } +} \ No newline at end of file diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt index c0d02afd92..92689585c7 100644 --- a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt @@ -57,8 +57,8 @@ internal abstract class StakingModelTestBase { protected val testUserWalletId = UserWalletId("1234567890ABCDEF") protected val testCryptoCurrency: CryptoCurrency = mockk(relaxed = true) - private val testIntegrationId = StakingIntegrationID.StakeKit.Coin.Solana - private val testParams = StakingComponent.Params( + protected open val testIntegrationId: StakingIntegrationID = StakingIntegrationID.StakeKit.Coin.Solana + private val testParams get() = StakingComponent.Params( userWalletId = testUserWalletId, cryptoCurrency = testCryptoCurrency, integrationId = testIntegrationId, diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUMTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUMTest.kt new file mode 100644 index 0000000000..df060f6a6e --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUMTest.kt @@ -0,0 +1,31 @@ +package com.tangem.features.staking.impl.presentation.state.events + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.staking.impl.R +import io.mockk.mockk +import org.junit.jupiter.api.Test + +internal class StakingAlertUMTest { + + @Test + fun `noTargets dialog uses no validators string and has no title`() { + val message = StakingAlertUM.stakeMoreClickUnavailableNoTargets() + + assertThat(message.title).isNull() + assertThat((message.message as TextReference.Res).id) + .isEqualTo(R.string.staking_no_validators_error_message) + } + + @Test + fun `default stake more dialog uses stake more unavailability string`() { + val currency: CryptoCurrency = mockk(relaxed = true) + + val message = StakingAlertUM.stakeMoreClickUnavailable(currency) + + assertThat(message.title).isNull() + assertThat((message.message as TextReference.Res).id) + .isEqualTo(R.string.staking_stake_more_button_unavailability_reason) + } +} \ No newline at end of file diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformerTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformerTest.kt new file mode 100644 index 0000000000..b470fd8de2 --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformerTest.kt @@ -0,0 +1,152 @@ +package com.tangem.features.staking.impl.presentation.state.transformers.amount + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.ui.amountScreen.models.AmountFieldModel +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.staking.model.StakingIntegration +import com.tangem.domain.staking.model.common.StakingActionArgs +import com.tangem.domain.staking.model.common.StakingAmountRequirement +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.domain.tokens.model.Amount +import com.tangem.features.staking.impl.R +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class AmountRequirementStateTransformerTest { + + private val cryptoCurrencyStatus: CryptoCurrencyStatus = mockk(relaxed = true) + + private fun amountState(enteredCrypto: BigDecimal): AmountState.Data = AmountState.Data( + isPrimaryButtonEnabled = true, + accountTitleUM = mockk(relaxed = true), + availableBalanceCrypto = mockk(relaxed = true), + availableBalanceFiat = mockk(relaxed = true), + tokenName = mockk(relaxed = true), + tokenIconState = mockk(relaxed = true), + amountTextField = AmountFieldModel( + value = enteredCrypto.toPlainString(), + onValueChange = {}, + keyboardOptions = mockk(relaxed = true), + keyboardActions = mockk(relaxed = true), + cryptoAmount = Amount(currencySymbol = "ETH", value = enteredCrypto, decimals = 18), + fiatAmount = Amount(currencySymbol = "USD", value = BigDecimal.ZERO, decimals = 2), + isFiatValue = false, + fiatValue = "0", + isFiatUnavailable = false, + isValuePasted = false, + onValuePastedTriggerDismiss = {}, + isError = false, + isWarning = false, + error = stringReference(""), + ), + appCurrency = mockk(relaxed = true), + ) + + private fun enterIntegrationWith(minimum: BigDecimal?, maximum: BigDecimal?): StakingIntegration = mockk { + every { enterArgs } returns StakingActionArgs( + amountRequirement = StakingAmountRequirement( + isRequired = true, + minimum = minimum, + maximum = maximum, + ), + isPartialAmountDisabled = false, + ) + } + + private fun exitIntegrationWith(minimum: BigDecimal?, maximum: BigDecimal?): StakingIntegration = mockk { + every { exitArgs } returns StakingActionArgs( + amountRequirement = StakingAmountRequirement( + isRequired = true, + minimum = minimum, + maximum = maximum, + ), + isPartialAmountDisabled = false, + ) + } + + @Test + fun `WHEN amount exceeds positive maximum THEN max amount error string is used`() { + val transformer = AmountRequirementStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + maxAmount = EnterAmountBoundary(amount = BigDecimal("100"), fiatAmount = null, fiatRate = null), + integration = enterIntegrationWith(minimum = BigDecimal("0.01"), maximum = BigDecimal("0.15")), + actionType = StakingActionCommonType.Enter(skipEnterAmount = false), + ) + + val result = transformer.transform(amountState(BigDecimal("0.2"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isTrue() + assertThat((result.amountTextField.error as TextReference.Res).id) + .isEqualTo(R.string.staking_max_amount_requirement_error) + } + + @Test + fun `WHEN amount below minimum THEN min amount error string is used`() { + val transformer = AmountRequirementStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + maxAmount = EnterAmountBoundary(amount = BigDecimal("100"), fiatAmount = null, fiatRate = null), + integration = enterIntegrationWith(minimum = BigDecimal("0.1"), maximum = null), + actionType = StakingActionCommonType.Enter(skipEnterAmount = false), + ) + + val result = transformer.transform(amountState(BigDecimal("0.05"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isTrue() + assertThat((result.amountTextField.error as TextReference.Res).id) + .isEqualTo(R.string.staking_amount_requirement_error) + } + + @Test + fun `WHEN Exit action and amount below exit minimum THEN unstake min error string is used`() { + val transformer = AmountRequirementStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + maxAmount = EnterAmountBoundary(amount = BigDecimal("100"), fiatAmount = null, fiatRate = null), + integration = exitIntegrationWith(minimum = BigDecimal("0.1"), maximum = null), + actionType = StakingActionCommonType.Exit(partiallyUnstakeDisabled = false), + ) + + val result = transformer.transform(amountState(BigDecimal("0.05"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isTrue() + assertThat((result.amountTextField.error as TextReference.Res).id) + .isEqualTo(R.string.staking_unstake_amount_requirement_error) + } + + @Test + fun `WHEN Enter action and maximum is null and amount exceeds balance cap THEN max error string is used`() { + val transformer = AmountRequirementStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + maxAmount = EnterAmountBoundary(amount = BigDecimal("0.5"), fiatAmount = null, fiatRate = null), + integration = enterIntegrationWith(minimum = BigDecimal("0.01"), maximum = null), + actionType = StakingActionCommonType.Enter(skipEnterAmount = false), + ) + + val result = transformer.transform(amountState(BigDecimal("0.6"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isTrue() + assertThat((result.amountTextField.error as TextReference.Res).id) + .isEqualTo(R.string.staking_max_amount_requirement_error) + } + + @Test + fun `WHEN Exit action and amount exceeds staked balance THEN max amount error string is used`() { + val transformer = AmountRequirementStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + maxAmount = EnterAmountBoundary(amount = BigDecimal("0.5"), fiatAmount = null, fiatRate = null), + integration = exitIntegrationWith(minimum = BigDecimal("0.01"), maximum = null), + actionType = StakingActionCommonType.Exit(partiallyUnstakeDisabled = false), + ) + + val result = transformer.transform(amountState(BigDecimal("0.6"))) as AmountState.Data + + assertThat(result.amountTextField.isError).isTrue() + assertThat((result.amountTextField.error as TextReference.Res).id) + .isEqualTo(R.string.staking_max_amount_requirement_error) + } +} \ No newline at end of file From 0a44bcfc7091797e6fc044974edf68392b1d2e4f Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 11 Jun 2026 17:51:23 +0400 Subject: [PATCH 31/36] Updated on 2026-08-14 --- features/txhistory/impl/build.gradle.kts | 1 + .../txhistory/utils/TxHistoryListManager.kt | 23 ++ .../utils/TxHistoryListManagerTest.kt | 231 ++++++++++++++++++ gradle/tangem_dependencies.toml | 2 +- 4 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManagerTest.kt diff --git a/features/txhistory/impl/build.gradle.kts b/features/txhistory/impl/build.gradle.kts index 6f7a097ec9..8ddff2f3fb 100644 --- a/features/txhistory/impl/build.gradle.kts +++ b/features/txhistory/impl/build.gradle.kts @@ -69,4 +69,5 @@ dependencies { testRuntimeOnly(deps.test.junit5.engine) testImplementation(deps.test.mockk) testImplementation(deps.test.truth) + testImplementation(deps.test.coroutine) } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt index 788a17198d..4095533774 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt @@ -13,6 +13,7 @@ import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateCo import com.tangem.features.txhistory.model.TxHistoryLookupContext import com.tangem.features.txhistory.state.TxHistoryItemsSnapshot import com.tangem.pagination.BatchAction +import com.tangem.pagination.BatchFetchResult import com.tangem.pagination.BatchListState import com.tangem.pagination.PaginationStatus import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -37,6 +38,7 @@ internal class TxHistoryListManager( ) { private val jobHolder = JobHolder() + private val autoLoadMoreJobHolder = JobHolder() private val actionsFlow: MutableSharedFlow = MutableSharedFlow( replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST, @@ -65,6 +67,12 @@ internal class TxHistoryListManager( batchSize = 50, ) + batchFlow.state + .onEach { batchState -> autoLoadMoreUntilScrollable(batchState) } + .flowOn(dispatchers.default) + .launchIn(scope = this) + .saveIn(autoLoadMoreJobHolder) + if (designFeatureToggles.isRedesignEnabled) { var previousLookup: TxHistoryLookupContext? = null combine(batchFlow.state, lookupDataFlow) { batchState, lookup -> batchState to lookup } @@ -146,4 +154,19 @@ internal class TxHistoryListManager( ) } } + + private suspend fun autoLoadMoreUntilScrollable(batchState: BatchListState>) { + val status = batchState.status as? PaginationStatus.Paginating ?: return + val lastResult = status.lastResult as? BatchFetchResult.Success ?: return + val loadedItemsCount = batchState.data.sumOf { batch -> batch.data.items.size } + val shouldLoadMore = loadedItemsCount < AUTO_LOAD_MORE_TARGET_COUNT || lastResult.empty + if (shouldLoadMore) { + loadMore(userWalletId, currency) + } + } + + private companion object { + /** Number of loaded items considered enough to make the list scrollable. */ + const val AUTO_LOAD_MORE_TARGET_COUNT = 20 + } } \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManagerTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManagerTest.kt new file mode 100644 index 0000000000..3f8a86d59a --- /dev/null +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManagerTest.kt @@ -0,0 +1,231 @@ +package com.tangem.features.txhistory.utils + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.DesignFeatureToggles +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.txhistory.model.TxHistoryListBatchFlow +import com.tangem.domain.txhistory.model.TxHistoryListBatchingContext +import com.tangem.domain.txhistory.model.TxHistoryListConfig +import com.tangem.domain.txhistory.models.Page +import com.tangem.domain.txhistory.models.PaginationWrapper +import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2 +import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter +import com.tangem.pagination.BatchFetchResult +import com.tangem.pagination.BatchListSource +import com.tangem.pagination.PaginationStatus +import com.tangem.pagination.fetcher.BatchFetcher +import com.tangem.pagination.toBatchFlow +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** + * Verifies the auto-load behavior for Solana-style histories, where a fetched page is paginated over RAW + * transactions and then filtered down to a single token, so a page can yield few or zero displayable items. + * The manager must keep requesting the next page until the list is long enough to be scrolled or pagination + * ends — instead of stopping on the first page that adds no items. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class TxHistoryListManagerTest { + + private val userWalletId = UserWalletId(stringValue = "01") + private val currency = mockk(relaxed = true) + + @Test + fun `GIVEN pages that are empty for the token WHEN loading THEN auto-loads through them until the end`() = + runTest { + // page 0: 2 items, then two empty-for-token pages, then 3 items on the last page → 5 items total. + val fetcher = ScriptedFetcher { call -> + when (call) { + 0 -> page(itemCount = 2, isLast = false) + 1 -> page(itemCount = 0, isLast = false) + 2 -> page(itemCount = 0, isLast = false) + else -> page(itemCount = 3, isLast = true) + } + } + val repo = fakeRepository(fetcher) + val manager = createManager(repo) + + withLoadedManager(manager) { + // first fetch + 3 auto-loaded next pages = 4 + assertThat(fetcher.fetchCount).isEqualTo(4) + assertThat(repo.loadedItemsCount()).isEqualTo(5) + assertThat(repo.status()).isInstanceOf(PaginationStatus.EndOfPagination::class.java) + } + } + + @Test + fun `GIVEN many small non-final pages WHEN loading THEN stops once the list is long enough to scroll`() = + runTest { + // every page returns 7 items and is never the last page. + val fetcher = ScriptedFetcher { page(itemCount = 7, isLast = false) } + val repo = fakeRepository(fetcher) + val manager = createManager(repo) + + withLoadedManager(manager) { + // 7 -> 14 -> 21: stops after crossing AUTO_LOAD_MORE_TARGET_COUNT (20), does not keep loading. + assertThat(fetcher.fetchCount).isEqualTo(3) + assertThat(repo.loadedItemsCount()).isEqualTo(21) + assertThat(repo.status()).isInstanceOf(PaginationStatus.Paginating::class.java) + } + } + + @Test + fun `GIVEN a full first page WHEN loading THEN does not auto-load more`() = runTest { + val fetcher = ScriptedFetcher { page(itemCount = 25, isLast = false) } + val repo = fakeRepository(fetcher) + val manager = createManager(repo) + + withLoadedManager(manager) { + // first page already exceeds the target → no auto-load, behaves like a normal scroll-driven list. + assertThat(fetcher.fetchCount).isEqualTo(1) + assertThat(repo.loadedItemsCount()).isEqualTo(25) + } + } + + @Test + fun `GIVEN a gap of empty pages mid-history WHEN scrolled to the end THEN auto-loads through the gap`() = + runTest { + // A full first page (no auto-load), then two empty-for-token pages (a gap of other-token + // activity), then one final item. Mirrors a busy account where a token has a long activity gap. + val fetcher = ScriptedFetcher { call -> + when (call) { + 0 -> page(itemCount = 25, isLast = false) + 1 -> page(itemCount = 0, isLast = false) + 2 -> page(itemCount = 0, isLast = false) + else -> page(itemCount = 1, isLast = true) + } + } + val repo = fakeRepository(fetcher) + val manager = createManager(repo) + + withLoadedManager(manager) { + // full first page → no auto-load yet, the list is scrollable. + assertThat(fetcher.fetchCount).isEqualTo(1) + assertThat(repo.loadedItemsCount()).isEqualTo(25) + + // user scrolls to the bottom → one loadMore; the empty gap must be auto-bridged to the end, + // otherwise the list dead-ends and the final transaction is never reached. + manager.loadMore(userWalletId, currency) + advanceUntilIdle() + + assertThat(fetcher.fetchCount).isEqualTo(4) + assertThat(repo.loadedItemsCount()).isEqualTo(26) + assertThat(repo.status()).isInstanceOf(PaginationStatus.EndOfPagination::class.java) + } + } + + private suspend fun TestScope.withLoadedManager( + manager: TxHistoryListManager, + assertions: suspend TestScope.() -> Unit, + ) { + // init() collects forever, so run it in a child coroutine and cancel it once assertions are done. + // Cancellation resets the source state, so assertions must run before it. + val initJob = launch { manager.init() } + advanceUntilIdle() + manager.startLoading() + advanceUntilIdle() + try { + assertions() + } finally { + initJob.cancel() + } + } + + private fun TestScope.fakeRepository( + fetcher: BatchFetcher>, + ): FakeRepository = FakeRepository(testDispatchers(StandardTestDispatcher(testScheduler)), fetcher) + + private fun createManager(repository: FakeRepository): TxHistoryListManager = TxHistoryListManager( + repository = repository, + dispatchers = repository.dispatchers, + userWalletId = userWalletId, + currency = currency, + designFeatureToggles = mockk { every { isRedesignEnabled } returns false }, + txHistoryUiActions = mockk(relaxed = true), + lookupDataFlow = emptyFlow(), + legacyTxHistoryItemConverter = mockk(relaxed = true), + ) + + private fun page(itemCount: Int, isLast: Boolean): Page2Spec = + Page2Spec(itemCount = itemCount, isLast = isLast) + + private fun testDispatchers(dispatcher: CoroutineDispatcher): CoroutineDispatcherProvider = + object : CoroutineDispatcherProvider { + override val main: CoroutineDispatcher = dispatcher + override val mainImmediate: CoroutineDispatcher = dispatcher + override val io: CoroutineDispatcher = dispatcher + override val default: CoroutineDispatcher = dispatcher + override val single: CoroutineDispatcher = dispatcher + } + + /** Page description. The fetcher turns it into a wrapper with a unique cursor, mirroring real pagination. */ + private data class Page2Spec(val itemCount: Int, val isLast: Boolean) + + private class ScriptedFetcher( + private val pageAt: (call: Int) -> Page2Spec, + ) : BatchFetcher> { + + var fetchCount = 0 + private set + + override suspend fun fetchFirst(requestParams: TxHistoryListConfig) = produce() + + override suspend fun fetchNext( + overrideRequestParams: TxHistoryListConfig?, + lastResult: BatchFetchResult>, + ) = produce() + + private fun produce(): BatchFetchResult> { + val spec = pageAt(fetchCount) + // A unique cursor per fetch mirrors real pagination (each page has its own paginationToken) and + // prevents StateFlow from conflating two otherwise-identical empty pages. + val wrapper = PaginationWrapper( + currentPage = if (fetchCount == 0) Page.Initial else Page.Next(value = "cursor-$fetchCount"), + nextPage = if (spec.isLast) Page.LastPage else Page.Next(value = "cursor-${fetchCount + 1}"), + items = List(spec.itemCount) { mockk(relaxed = true) }, + ) + fetchCount++ + return BatchFetchResult.Success( + data = wrapper, + empty = wrapper.items.isEmpty(), + last = spec.isLast, + ) + } + } + + private class FakeRepository( + val dispatchers: CoroutineDispatcherProvider, + private val fetcher: BatchFetcher>, + ) : TxHistoryRepositoryV2 { + + private lateinit var batchFlow: TxHistoryListBatchFlow + + override fun getTxHistoryBatchFlow( + batchSize: Int, + context: TxHistoryListBatchingContext, + ): TxHistoryListBatchFlow = BatchListSource( + fetchDispatcher = dispatchers.io, + context = context, + generateNewKey = { keys -> keys.lastOrNull()?.inc() ?: 0 }, + batchFetcher = fetcher, + ).toBatchFlow().also { batchFlow = it } + + fun loadedItemsCount(): Int = batchFlow.state.value.data.sumOf { batch -> batch.data.items.size } + + fun status(): PaginationStatus<*> = batchFlow.state.value.status + } +} \ No newline at end of file diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 1fbd1c2c9d..14feb56948 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.39-1563" +tangemBlockchainSdk = "releases-5.39-1565" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-5.39-623" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 81ed31befbef544efea941da134d8d22bd52ba84 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 11 Jun 2026 19:28:42 +0500 Subject: [PATCH 32/36] Updated on 2026-08-14 --- .../transaction/usecase/GetFeeUseCase.kt | 64 ++- .../transaction/usecase/GetFeeUseCaseTest.kt | 478 ++++++++++++++++++ .../domain/yield/supply/FeeExtensions.kt | 2 +- 3 files changed, 542 insertions(+), 2 deletions(-) create mode 100644 domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/GetFeeUseCaseTest.kt diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt index e4f0234272..0dcbd3707a 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt @@ -2,11 +2,15 @@ package com.tangem.domain.transaction.usecase import arrow.core.raise.catch import arrow.core.raise.either +import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.Token import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.extensions.Result +import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData import com.tangem.domain.demo.DemoTransactionSender import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.models.currency.CryptoCurrency @@ -15,7 +19,11 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.mapToFeeError import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.isNullOrZero +import com.tangem.utils.logging.TangemLogger import java.math.BigDecimal +import java.math.BigInteger +import java.math.RoundingMode /** * Use case to get transaction fee @@ -47,7 +55,7 @@ class GetFeeUseCase( is Result.Success -> result.data is Result.Failure -> raise(result.mapToFeeError()) } - maybeFee + maybeFee.fixYieldSupplyGasLimit(transactionData = transactionData) }, catch = { raise(GetFeeError.DataError(it)) @@ -115,4 +123,58 @@ class GetFeeUseCase( ) }, ) + + private fun TransactionFee.fixYieldSupplyGasLimit(transactionData: TransactionData): TransactionFee { + val uncompiledTransactionData = transactionData as? TransactionData.Uncompiled ?: return this + val ethereumExtras = uncompiledTransactionData.extras as? EthereumTransactionExtras ?: return this + + return if (ethereumExtras.callData is EthereumYieldSupplySendCallData) { + val patchedFee = when (this) { + is TransactionFee.Choosable -> { + copy( + normal = normal.increaseGasLimitBy(INCREASE_GAS_LIMIT_FOR_SUPPLY), + minimum = minimum.increaseGasLimitBy(INCREASE_GAS_LIMIT_FOR_SUPPLY), + priority = priority.increaseGasLimitBy(INCREASE_GAS_LIMIT_FOR_SUPPLY), + ) + } + is TransactionFee.Single -> copy( + normal = normal.increaseGasLimitBy(INCREASE_GAS_LIMIT_FOR_SUPPLY), + ) + } + TangemLogger.withTag("GAS_FEE_USECASE").i("Fee for Yield Mode adjusted: $patchedFee") + patchedFee + } else { + TangemLogger.withTag("GAS_FEE_USECASE").i("Fee as is: $this") + this + } + } + + /** + * Increase gasLimit for Fee.Ethereum + */ + private fun Fee.increaseGasLimitBy(percent: BigInteger): Fee { + if (this !is Fee.Ethereum) return this + val gasLimit = gasLimit + + if (gasLimit == BigInteger.ZERO || amount.value.isNullOrZero()) return this + + val increasedGasPrice = amount.value?.movePointRight(amount.decimals) + ?.divide(gasLimit.toBigDecimal(), RoundingMode.HALF_UP) + val increasedGasLimit = gasLimit + .multiply(percent) + .divide(HUNDRED_PERCENT) + val increasedAmount = this.amount.copy( + value = increasedGasLimit.toBigDecimal().multiply(increasedGasPrice).movePointLeft(amount.decimals), + ) + return when (this) { + is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]") + is Fee.Ethereum.EIP1559 -> copy(amount = increasedAmount, gasLimit = increasedGasLimit) + is Fee.Ethereum.Legacy -> copy(amount = increasedAmount, gasLimit = increasedGasLimit) + } + } + + private companion object { + private val HUNDRED_PERCENT = 100.toBigInteger() // base 100% + val INCREASE_GAS_LIMIT_FOR_SUPPLY = 140.toBigInteger() // 40% increase [there is also in Yield FeeExtensions.kt] + } } \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/GetFeeUseCaseTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/GetFeeUseCaseTest.kt new file mode 100644 index 0000000000..c3a619ac7e --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/GetFeeUseCaseTest.kt @@ -0,0 +1,478 @@ +package com.tangem.domain.transaction.usecase + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras +import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.smartcontract.SmartContractCallData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchain.extensions.Result +import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData +import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.models.currency.CryptoCurrency +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.transaction.error.GetFeeError +import com.tangem.domain.walletmanager.WalletManagersFacade +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import java.math.BigDecimal +import java.math.BigInteger + +/** + * Unit tests for [GetFeeUseCase]. + * + * Focus is the Yield Mode gas-limit logic introduced on this branch + * (uncompiled Ethereum transactions whose call data is [EthereumYieldSupplySendCallData] + * get their gas limit increased by 40%), plus error mapping, null/exception handling, + * demo card routing, and crypto-currency-to-amount conversion in the second overload. + */ +class GetFeeUseCaseTest { + + private lateinit var walletManagersFacade: WalletManagersFacade + private lateinit var demoConfig: DemoConfig + private lateinit var useCase: GetFeeUseCase + + private lateinit var walletManager: WalletManager + private lateinit var network: Network + private lateinit var userWallet: UserWallet.Hot + private lateinit var userWalletId: UserWalletId + + @Before + fun setup() { + walletManagersFacade = mockk() + demoConfig = mockk() + useCase = GetFeeUseCase(walletManagersFacade, demoConfig) + + walletManager = mockk() + network = mockk() + userWalletId = mockk() + userWallet = mockk() + + every { demoConfig.isDemoCardId(any()) } returns false + every { userWallet.walletId } returns userWalletId + coEvery { + walletManagersFacade.getOrCreateWalletManager(userWalletId, network) + } returns walletManager + } + + // region invoke(userWallet, network, transactionData) — Yield Mode gas-limit logic + + @Test + fun `yield supply uncompiled eth tx increases gas limit by 40 percent for Choosable fee`() = runTest { + // Given + val txData = yieldSupplyTransactionData() + val original = TransactionFee.Choosable( + minimum = eip1559Fee(gasLimit = BigInteger("10000"), value = BigDecimal("0.001")), + normal = eip1559Fee(gasLimit = BigInteger("21000"), value = BigDecimal("0.0021")), + priority = legacyFee(gasLimit = BigInteger("30000"), value = BigDecimal("0.003")), + ) + coEvery { walletManager.getFee(txData) } returns Result.Success(original) + + // When + val result = useCase(userWallet, network, txData) + + // Then + assertThat(result.isRight()).isTrue() + val fee = result.getOrNull().requireAs() + + assertGasLimitIncreased( + patched = fee.normal, + expectedGasLimit = BigInteger("29400"), // 21000 * 140 / 100 + expectedValue = BigDecimal("0.00294"), + ) + assertGasLimitIncreased( + patched = fee.minimum, + expectedGasLimit = BigInteger("14000"), // 10000 * 140 / 100 + expectedValue = BigDecimal("0.0014"), + ) + assertGasLimitIncreased( + patched = fee.priority, + expectedGasLimit = BigInteger("42000"), // 30000 * 140 / 100 + expectedValue = BigDecimal("0.0042"), + ) + } + + @Test + fun `yield supply uncompiled eth tx increases gas limit by 40 percent for Single fee`() = runTest { + // Given + val txData = yieldSupplyTransactionData() + val original = TransactionFee.Single( + normal = eip1559Fee(gasLimit = BigInteger("21000"), value = BigDecimal("0.0021")), + ) + coEvery { walletManager.getFee(txData) } returns Result.Success(original) + + // When + val result = useCase(userWallet, network, txData) + + // Then + assertThat(result.isRight()).isTrue() + val fee = result.getOrNull().requireAs() + assertGasLimitIncreased( + patched = fee.normal, + expectedGasLimit = BigInteger("29400"), + expectedValue = BigDecimal("0.00294"), + ) + } + + @Test + fun `compiled tx is returned unchanged even when fee is ethereum`() = runTest { + // Given + val txData = mockk() + val original = TransactionFee.Single( + normal = eip1559Fee(gasLimit = BigInteger("21000"), value = BigDecimal("0.0021")), + ) + coEvery { walletManager.getFee(txData) } returns Result.Success(original) + + // When + val result = useCase(userWallet, network, txData) + + // Then + assertThat(result.getOrNull()).isEqualTo(original) + } + + @Test + fun `non ethereum extras returns fee unchanged`() = runTest { + // Given + val extras = mockk() + val txData = uncompiledTransactionData(extras) + val original = TransactionFee.Single( + normal = eip1559Fee(gasLimit = BigInteger("21000"), value = BigDecimal("0.0021")), + ) + coEvery { walletManager.getFee(txData) } returns Result.Success(original) + + // When + val result = useCase(userWallet, network, txData) + + // Then + assertThat(result.getOrNull()).isEqualTo(original) + } + + @Test + fun `non yield supply call data returns fee unchanged`() = runTest { + // Given + val extras = EthereumTransactionExtras(callData = mockk()) + val txData = uncompiledTransactionData(extras) + val original = TransactionFee.Single( + normal = eip1559Fee(gasLimit = BigInteger("21000"), value = BigDecimal("0.0021")), + ) + coEvery { walletManager.getFee(txData) } returns Result.Success(original) + + // When + val result = useCase(userWallet, network, txData) + + // Then + assertThat(result.getOrNull()).isEqualTo(original) + } + + @Test + fun `yield supply with non ethereum fee returns fee unchanged`() = runTest { + // Given — call data matches, but the fee is not Fee.Ethereum + val txData = yieldSupplyTransactionData() + val nonEthFee = Fee.Common(amount = stubAmount(BigDecimal("0.5"))) + val original = TransactionFee.Single(normal = nonEthFee) + coEvery { walletManager.getFee(txData) } returns Result.Success(original) + + // When + val result = useCase(userWallet, network, txData) + + // Then — gas-limit logic is a no-op for non-Ethereum fees + val fee = result.getOrNull().requireAs() + assertThat(fee.normal).isEqualTo(nonEthFee) + } + + @Test + fun `yield supply with token currency fee surfaces as DataError`() = runTest { + // Given — increaseGasLimitBy throws for Fee.Ethereum.TokenCurrency (handled in [REDACTED_TASK_KEY]) + val txData = yieldSupplyTransactionData() + val tokenFee = Fee.Ethereum.TokenCurrency( + amount = stubAmount(BigDecimal("0.0021")), + gasLimit = BigInteger("21000"), + coinPriceInToken = BigInteger("1000"), + feeTransferGasLimit = BigInteger("60000"), + baseGas = BigInteger("21000"), + ) + val original = TransactionFee.Single(normal = tokenFee) + coEvery { walletManager.getFee(txData) } returns Result.Success(original) + + // When + val result = useCase(userWallet, network, txData) + + // Then — the thrown error is caught and mapped to DataError + assertThat(result.isLeft()).isTrue() + assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.DataError::class.java) + } + + // endregion + + // region invoke(userWallet, network, transactionData) — error / null / exception handling + + @Test + fun `result failure is mapped to fee error`() = runTest { + // Given + val txData = yieldSupplyTransactionData() + val failure = Result.Failure(BlockchainSdkError.Tron.AccountActivationError(code = 1)) + coEvery { walletManager.getFee(txData) } returns failure + + // When + val result = useCase(userWallet, network, txData) + + // Then + assertThat(result.isLeft()).isTrue() + assertThat(result.leftOrNull()).isEqualTo(GetFeeError.BlockchainErrors.TronActivationError) + } + + @Test + fun `null wallet manager produces DataError`() = runTest { + // Given + val txData = yieldSupplyTransactionData() + coEvery { walletManagersFacade.getOrCreateWalletManager(userWalletId, network) } returns null + + // When + val result = useCase(userWallet, network, txData) + + // Then + assertThat(result.isLeft()).isTrue() + val error = result.leftOrNull().requireAs() + assertThat(error.cause?.message).isEqualTo("Fee is null") + } + + @Test + fun `exception in getFee produces DataError`() = runTest { + // Given + val txData = yieldSupplyTransactionData() + val boom = IllegalStateException("boom") + coEvery { walletManager.getFee(txData) } throws boom + + // When + val result = useCase(userWallet, network, txData) + + // Then + assertThat(result.isLeft()).isTrue() + val error = result.leftOrNull().requireAs() + assertThat(error.cause).isEqualTo(boom) + } + + @Test + fun `demo cold card routes through wallet manager for first overload`() = runTest { + // Given + val coldWallet = mockk() + every { coldWallet.walletId } returns userWalletId + every { coldWallet.scanResponse.card.cardId } returns "DEMO_CARD" + every { demoConfig.isDemoCardId("DEMO_CARD") } returns true + + // DemoTransactionSender may access walletManager.wallet.blockchain when producing stub fees + val demoWallet = mockk(relaxed = true) + every { demoWallet.blockchain } returns com.tangem.blockchain.common.Blockchain.Ethereum + every { walletManager.wallet } returns demoWallet + + val txData = yieldSupplyTransactionData() + + // When + useCase(coldWallet, network, txData) + + // Then — demo sender is built from a wallet manager obtained via the facade + coVerify { walletManagersFacade.getOrCreateWalletManager(userWalletId, network) } + } + + // endregion + + // region invoke(amount, destination, userWallet, cryptoCurrency) + + @Test + fun `second overload converts coin to amount and returns fee`() = runTest { + // Given + val coin = mockk() + every { coin.network } returns network + every { coin.symbol } returns "ETH" + every { coin.decimals } returns 18 + + val expectedFee = TransactionFee.Single( + normal = eip1559Fee(gasLimit = BigInteger("21000"), value = BigDecimal("0.0021")), + ) + val amountSlot = slot() + coEvery { + walletManagersFacade.getFee( + amount = capture(amountSlot), + destination = "dest", + userWalletId = userWalletId, + network = network, + ) + } returns Result.Success(expectedFee) + + // When + val result = useCase.invoke( + amount = BigDecimal("1.5"), + destination = "dest", + userWallet = userWallet, + cryptoCurrency = coin, + ) + + // Then + assertThat(result.getOrNull()).isEqualTo(expectedFee) + val captured = amountSlot.captured + assertThat(captured.type).isEqualTo(AmountType.Coin) + assertThat(captured.currencySymbol).isEqualTo("ETH") + assertThat(captured.decimals).isEqualTo(18) + assertThat(captured.value).isEqualTo(BigDecimal("1.5")) + } + + @Test + fun `second overload converts token to amount with token type`() = runTest { + // Given + val token = mockk() + every { token.network } returns network + every { token.symbol } returns "USDC" + every { token.decimals } returns 6 + every { token.contractAddress } returns "0xUSDC" + + val expectedFee = TransactionFee.Single( + normal = eip1559Fee(gasLimit = BigInteger("21000"), value = BigDecimal("0.0021")), + ) + val amountSlot = slot() + coEvery { + walletManagersFacade.getFee( + amount = capture(amountSlot), + destination = "dest", + userWalletId = userWalletId, + network = network, + ) + } returns Result.Success(expectedFee) + + // When + val result = useCase.invoke( + amount = BigDecimal("100"), + destination = "dest", + userWallet = userWallet, + cryptoCurrency = token, + ) + + // Then + assertThat(result.getOrNull()).isEqualTo(expectedFee) + val captured = amountSlot.captured + val type = captured.type.requireAs() + assertThat(type.token.contractAddress).isEqualTo("0xUSDC") + assertThat(type.token.symbol).isEqualTo("USDC") + assertThat(type.token.decimals).isEqualTo(6) + } + + @Test + fun `second overload maps result failure to fee error`() = runTest { + // Given + val coin = mockk() + every { coin.network } returns network + every { coin.symbol } returns "KAS" + every { coin.decimals } returns 8 + + coEvery { + walletManagersFacade.getFee( + amount = any(), + destination = any(), + userWalletId = userWalletId, + network = network, + ) + } returns Result.Failure(BlockchainSdkError.Kaspa.ZeroUtxoError) + + // When + val result = useCase.invoke( + amount = BigDecimal("1"), + destination = "dest", + userWallet = userWallet, + cryptoCurrency = coin, + ) + + // Then + assertThat(result.leftOrNull()).isEqualTo(GetFeeError.BlockchainErrors.KaspaZeroUtxo) + } + + @Test + fun `second overload null fee produces DataError`() = runTest { + // Given + val coin = mockk() + every { coin.network } returns network + every { coin.symbol } returns "ETH" + every { coin.decimals } returns 18 + + coEvery { + walletManagersFacade.getFee( + amount = any(), + destination = any(), + userWalletId = userWalletId, + network = network, + ) + } returns null + + // When + val result = useCase.invoke( + amount = BigDecimal("1"), + destination = "dest", + userWallet = userWallet, + cryptoCurrency = coin, + ) + + // Then + val error = result.leftOrNull().requireAs() + assertThat(error.cause?.message).isEqualTo("Fee is null") + } + + // endregion + + // region helpers + + private fun yieldSupplyTransactionData(): TransactionData.Uncompiled { + val callData = mockk() + return uncompiledTransactionData(EthereumTransactionExtras(callData = callData)) + } + + private fun uncompiledTransactionData(extras: TransactionExtras): TransactionData.Uncompiled { + return TransactionData.Uncompiled( + amount = stubAmount(BigDecimal.ONE), + fee = null, + sourceAddress = "src", + destinationAddress = "dest", + extras = extras, + ) + } + + private fun eip1559Fee(gasLimit: BigInteger, value: BigDecimal): Fee.Ethereum.EIP1559 { + return Fee.Ethereum.EIP1559( + amount = stubAmount(value), + gasLimit = gasLimit, + maxFeePerGas = BigInteger("50000000000"), + priorityFee = BigInteger("1000000000"), + ) + } + + private fun legacyFee(gasLimit: BigInteger, value: BigDecimal): Fee.Ethereum.Legacy { + return Fee.Ethereum.Legacy( + amount = stubAmount(value), + gasLimit = gasLimit, + gasPrice = BigInteger("50000000000"), + ) + } + + private fun stubAmount(value: BigDecimal): Amount = Amount( + currencySymbol = "ETH", + value = value, + decimals = 18, + type = AmountType.Coin, + ) + + private fun assertGasLimitIncreased(patched: Fee, expectedGasLimit: BigInteger, expectedValue: BigDecimal) { + val eth = patched.requireAs() + assertThat(eth.gasLimit).isEqualTo(expectedGasLimit) + val actualValue = requireNotNull(eth.amount.value) { "Fee amount value must not be null" } + assertThat(actualValue.compareTo(expectedValue)).isEqualTo(0) + } + + private inline fun Any?.requireAs(): T { + val value = this + assertThat(value).isInstanceOf(T::class.java) + return value as T + } + + // endregion +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/FeeExtensions.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/FeeExtensions.kt index 9a798bdc38..0da60c6789 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/FeeExtensions.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/FeeExtensions.kt @@ -6,7 +6,7 @@ import java.math.BigInteger import java.math.RoundingMode private val HUNDRED_PERCENT = 100.toBigInteger() // base 100% -val INCREASE_GAS_LIMIT_FOR_SUPPLY = 140.toBigInteger() // 20% increase +val INCREASE_GAS_LIMIT_FOR_SUPPLY = 140.toBigInteger() // 40% increase [there is also in GetFeeUseCase] fun Fee.fixFee(cryptoCurrency: CryptoCurrency, gasLimit: BigInteger): Fee = when (this) { is Fee.Ethereum.Legacy -> copy( From 314a4b0f376939c105e3aadb9d8750ac297eb60d Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 11 Jun 2026 20:26:18 +0500 Subject: [PATCH 33/36] Updated on 2026-08-14 --- .../sendviaswap/confirm/model/SendWithSwapConfirmModel.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index ad6eac10b6..e34fb30072 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -21,8 +21,8 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.account.status.usecase.GetAccountCurrencyByAddressUseCase import com.tangem.domain.express.models.ExpressOperationType -import com.tangem.domain.express.models.ExpressRateType import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.domain.express.models.ExpressRateType import com.tangem.domain.models.account.derivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -69,6 +69,7 @@ import com.tangem.features.swap.v2.impl.sendviaswap.confirm.model.transformers.S import com.tangem.features.swap.v2.impl.sendviaswap.confirm.model.transformers.SendWithSwapConfirmationNotificationsTransformer import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM import com.tangem.lib.crypto.BlockchainFeeUtils.patchTransactionFeeForSwap +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.orZero import jakarta.inject.Inject @@ -99,6 +100,7 @@ internal class SendWithSwapConfirmModel @Inject constructor( private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, private val swapAlertFactory: SwapAlertFactory, private val analyticsEventHandler: AnalyticsEventHandler, + private val appScope: AppCoroutineScope, swapTransactionSenderFactory: SwapTransactionSender.Factory, paramsContainer: ParamsContainer, ) : Model(), FeeSelectorModelCallback, SendNotificationsComponent.ModelCallback { @@ -368,7 +370,7 @@ internal class SendWithSwapConfirmModel @Inject constructor( txHash = txHash, currency = primaryCurrencyStatus.currency, ).getOrNull().orEmpty() - modelScope.launch(dispatchers.default) { sendSuccessAnalytics() } + appScope.launch(dispatchers.default) { sendSuccessAnalytics() } uiState.transformerUpdate( SendWithSwapConfirmSentStateTransformer( timestamp = timestamp, From 86e5f8c8e62efc879a45ec08cf84f025e854b46f Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 11 Jun 2026 20:35:30 +0500 Subject: [PATCH 34/36] Updated on 2026-08-14 --- .../tap/di/domain/TransactionDomainModule.kt | 3 + domain/transaction/build.gradle.kts | 1 + .../usecase/ReceiveAddressesFactory.kt | 7 ++ .../usecase/ReceiveAddressesFactoryTest.kt | 75 +++++++++++++++++++ 4 files changed, 86 insertions(+) create mode 100644 domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/ReceiveAddressesFactoryTest.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index d88224fe30..1172d04238 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -4,6 +4,7 @@ import com.tangem.data.wallets.hot.TangemHotWalletSigner import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase @@ -263,6 +264,7 @@ internal object TransactionDomainModule { getDynamicReceiveAddressUseCase: GetDynamicReceiveAddressUseCase, dynamicAddressesRepository: DynamicAddressesRepository, dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, + userWalletsListRepository: UserWalletsListRepository, ): ReceiveAddressesFactory { return ReceiveAddressesFactory( getEnsNameUseCase = getEnsNameUseCase, @@ -270,6 +272,7 @@ internal object TransactionDomainModule { getDynamicReceiveAddressUseCase = getDynamicReceiveAddressUseCase, dynamicAddressesRepository = dynamicAddressesRepository, dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles, + userWalletsListRepository = userWalletsListRepository, ) } diff --git a/domain/transaction/build.gradle.kts b/domain/transaction/build.gradle.kts index cc13ce06eb..d9aef0228a 100644 --- a/domain/transaction/build.gradle.kts +++ b/domain/transaction/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { implementation(projects.libs.crypto) implementation(projects.domain.account.status) + implementation(projects.domain.common) implementation(projects.domain.dynamicAddresses) implementation(projects.domain.dynamicAddresses.models) implementation(projects.domain.models) diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ReceiveAddressesFactory.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ReceiveAddressesFactory.kt index 93a25ab440..7f19a5a852 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ReceiveAddressesFactory.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ReceiveAddressesFactory.kt @@ -1,5 +1,7 @@ package com.tangem.domain.transaction.usecase +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.getSyncOrNull import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus @@ -13,6 +15,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase import com.tangem.domain.transaction.R import com.tangem.lib.crypto.BlockchainUtils @@ -25,6 +28,7 @@ class ReceiveAddressesFactory( private val getDynamicReceiveAddressUseCase: GetDynamicReceiveAddressUseCase, private val dynamicAddressesRepository: DynamicAddressesRepository, private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, + private val userWalletsListRepository: UserWalletsListRepository, ) { suspend fun create( @@ -67,6 +71,9 @@ class ReceiveAddressesFactory( if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return null if (cryptoCurrency !is CryptoCurrency.Coin) return null + val userWallet = userWalletsListRepository.getSyncOrNull(userWalletId) + if (userWallet == null || !userWallet.isMultiCurrency) return null + val status = dynamicAddressesRepository.getStatus(userWalletId, cryptoCurrency.network).firstOrNull() if (status != DynamicAddressesStatus.ENABLED) return null diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/ReceiveAddressesFactoryTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/ReceiveAddressesFactoryTest.kt new file mode 100644 index 0000000000..e5440e7e58 --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/ReceiveAddressesFactoryTest.kt @@ -0,0 +1,75 @@ +package com.tangem.domain.transaction.usecase + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +internal class ReceiveAddressesFactoryTest { + + private val getEnsNameUseCase: GetEnsNameUseCase = mockk() + private val getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase = mockk() + private val getDynamicReceiveAddressUseCase: GetDynamicReceiveAddressUseCase = mockk() + private val dynamicAddressesRepository: DynamicAddressesRepository = mockk() + private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles = mockk() + private val userWalletsListRepository: UserWalletsListRepository = mockk() + + private val factory = ReceiveAddressesFactory( + getEnsNameUseCase = getEnsNameUseCase, + getViewedTokenReceiveWarningUseCase = getViewedTokenReceiveWarningUseCase, + getDynamicReceiveAddressUseCase = getDynamicReceiveAddressUseCase, + dynamicAddressesRepository = dynamicAddressesRepository, + dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles, + userWalletsListRepository = userWalletsListRepository, + ) + + @Test + fun `GIVEN single-currency wallet WHEN create THEN standard addresses returned without status check`() = runTest( + timeout = 3.seconds, + ) { + // GIVEN + val userWallet = MockUserWalletFactory.createSingleWalletWithToken() // isMultiCurrency = false + val coin = MockCryptoCurrencyFactory().createCoin(Blockchain.Ethereum) + val status = mockk { + every { currency } returns coin + every { value.networkAddress } returns NetworkAddress.Single( + defaultAddress = NetworkAddress.Address(value = ADDRESS, type = NetworkAddress.Address.Type.Primary), + ) + } + + every { dynamicAddressesFeatureToggles.isDynamicAddressesEnabled } returns true + every { userWalletsListRepository.userWallets } returns MutableStateFlow?>(listOf(userWallet)) + // Single-currency wallets never populate the accounts store, so the status flow never emits ([REDACTED_TASK_KEY]) + every { dynamicAddressesRepository.getStatus(any(), any()) } returns flow { awaitCancellation() } + coEvery { getEnsNameUseCase.invoke(any(), any(), any()) } returns null + coEvery { getViewedTokenReceiveWarningUseCase() } returns emptySet() + + // WHEN + val config = factory.create(status = status, userWalletId = userWallet.walletId) + + // THEN + assertThat(config).isNotNull() + assertThat(config!!.receiveAddress.map { it.value }).containsExactly(ADDRESS) + } + + private companion object { + const val ADDRESS = "0x1234" + } +} \ No newline at end of file From 3f48881045741131b4e833bd83f2e72bcab0bc03 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 15 Jun 2026 17:49:58 +0300 Subject: [PATCH 35/36] Updated on 2026-08-14 --- app/src/main/assets/tangem-app-config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index de8d3eff2b..97ff5929f9 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit de8d3eff2b3a4d6b1d2794ce3c17945c86c449bd +Subproject commit 97ff5929f9ff4da53190eb10e94c45ac3bd05093 From 7bad9be9e84322fe664f1416b608059e0c43d519 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 15 Jun 2026 18:02:46 +0000 Subject: [PATCH 36/36] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 14feb56948..074e02bb80 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.39-1565" +tangemBlockchainSdk = "develop-1567" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.39-623" +tangemCardSdk = "develop-624" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^