diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 97ff5929f9..a7b32c7668 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 97ff5929f9ff4da53190eb10e94c45ac3bd05093 +Subproject commit a7b32c766817076c6346156390c135a3dae1b6ce 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/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/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/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 6400bc4ab8..092b40ff02 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 @@ -339,7 +339,6 @@ internal class ChildFactory @Inject constructor( cryptoAmount = tangemPayInput.cryptoAmount, fiatAmount = tangemPayInput.fiatAmount, depositAddress = tangemPayInput.depositAddress, - isWithdrawal = tangemPayInput.isWithdrawal, ) }, ), 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..b37b2436de --- /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.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 com.tangem.test.core.TestAppCoroutineScope +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/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 5ae1808b7e..d31ec820f0 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/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/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/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/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 c72ac7fe40..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,6 +21,8 @@ internal class AmplitudeABTestsManager( private lateinit var client: ExperimentClient + private val variantsFetched = CompletableDeferred() + private val logger = TangemLogger.withTag(TAG) override fun init() { @@ -43,6 +47,8 @@ internal class AmplitudeABTestsManager( logAllVariants(allVariants) } catch (exception: Exception) { logger.e("Failed to fetch AB test variants", exception) + } finally { + variantsFetched.complete(Unit) } } } @@ -66,10 +72,24 @@ 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 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) + } + } + private fun logAllVariants(allVariants: Map) { val message = buildString { appendLine("AB Tests: Fetched ${allVariants.size} variants") @@ -91,5 +111,6 @@ internal class AmplitudeABTestsManager( private companion object { 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/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 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 63df484831..8adeb78b81 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 @@ -123,6 +123,7 @@ internal object GeneratedEnvironmentConfigConverter { etherscanApiKey = GeneratedEnvironmentConfig.etherscanApiKey, blinkApiKey = GeneratedEnvironmentConfig.blinkApiKey, tatumApiKey = GeneratedEnvironmentConfig.tatumApiKey, + alchemyApiKey = GeneratedEnvironmentConfig.alchemyApiKey, ) } diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 2170ef4b61..f792607c78 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -1818,8 +1818,8 @@ Tageslimit Einstellungen der Karte - Karte - Karten + %d Karte + %d Karten PIN-Code ändern Kehren Sie zur App zurück, falls Sie ihn vergessen. diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index 9916a63c8c..2b4b631cf9 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -1682,6 +1682,10 @@ El límite está establecido Límite diario Configuración de la tarjeta + + %d tarjeta + %d tarjetas + Cambiar código PIN Vuelve a la app si lo olvidas. Establecer un límite de %s a %s diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index eb03feed57..d61d050cc8 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -1692,6 +1692,10 @@ La limite est définie Limite quotidienne Paramètres de la carte + + %d carte + %d cartes + Modifier le code PIN Revenez dans l\'application si vous l\'oubliez. Définir une limite de %s à %s diff --git a/core/res/src/main/res/values-it/strings.xml b/core/res/src/main/res/values-it/strings.xml index 7afaf7fe17..7efa5f1db0 100644 --- a/core/res/src/main/res/values-it/strings.xml +++ b/core/res/src/main/res/values-it/strings.xml @@ -159,6 +159,10 @@ Il limite è impostato Limite giornaliero Impostazioni della carta + + %d carta + %d carte + Cambia codice PIN Torna all\'app se lo dimentichi. Imposta un limite da %s a %s diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 05b3d1cec7..50088975b7 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -90,9 +90,9 @@ Выберите сеть Добавить токен Валюты - Кредитная карта или банковский аккаунт + Банковская карта или банковский счет Пополнить токен - Поделитесь своим адресом или QR-кодом. + Поделитесь своим адресом или QR-кодом Между вашими портфелями Вы получите Добавить адрес @@ -100,20 +100,23 @@ Добавить контакт адрес - адресов + адреса адресов - адреса + адресов + Контакт Имя контакта Копировать адрес Не удалось создать контакт. Пожалуйста, попробуйте позже. + Этот контакт будет удален из всех ваших адресных книг Не удалось удалить контакт. Пожалуйста, попробуйте позже. Управление контактами и адресами Отменить Редактировать адрес - Ввест адрес + Ввести адрес + Неверный адрес Продолжить - Новый котакт + Новый контакт Нет добавленных контактов Здесь отобразятся добавленные вами контакты. Удалить адрес @@ -303,6 +306,7 @@ Подключение Обратиться в поддержку Связаться с поддержкой + Обратиться в службу поддержки Visa Продолжить Конвертировать Копировать @@ -324,8 +328,8 @@ %d день назад - - + %d дня назад + %d дней назад %d дней назад Удалить @@ -410,7 +414,7 @@ NFT Нет Нет адреса - Нет результата + Нет результатов Не добавлено Не доступно Не сейчас @@ -480,7 +484,7 @@ На %s Сегодня Токен - Токен к отправке + Токен для отправки %d токен %d токена @@ -491,7 +495,7 @@ Статус транзакции Транзакции Перевод - Отправлено + Переведено Пожалуйста, попробуйте позже Невозможно загрузить данные… Я понял @@ -502,7 +506,7 @@ Завершить стейкинг Из-за ограничений %1$s в одну транзакцию может поместиться только %2$d UTXO. Это означает, что вы можете отправить только %3$s или меньше. Вам нужно уменьшить сумму. Значение скопировано - Посмотреть все + Показать все Голосование Кошельки Предупреждение @@ -523,7 +527,7 @@ Токен уже существует Десятичное число должно быть действительным целым числом, до %d Своя деривация - Включены динамические адреса. Пользовательские значения change и index недоступны. + Включены динамические адреса. Пользовательские значения \\"change\"\ и \\"index\"\ недоступны. Например m/00\'/0000\'/0\'/0/0 Введите свою деривацию Знаков после запятой @@ -577,25 +581,27 @@ %s сеть Отправляйте средства, используя только Динамические адреса - Включены динамические адреса. Пользовательские значения change и index недоступны. - Вы отключаете динамические адреса. Теперь средства будут поступать на ваш текущий адрес без его изменения. + Включены динамические адреса. Пользовательские значения \"change\" и \"index\" недоступны. + Вы отключаете динамические адреса. Теперь средства будут поступать на ваш фиксированный адрес + Для перевода средств на фиксированный адрес взимается комиссия сети Отключить динамические адреса Отключить динамические адреса Динамические адреса отключены Динамические адреса включены Используйте новый адрес для каждой транзакции, чтобы снизить отслеживаемость и повысить конфиденциальность в блокчейне. Повышенная конфиденциальность - Легко получайте средства в сетях на базе UTXO благодаря автоматической генерации адресов — без необходимости управлять адресами вручную. + Получайте средства как обычно. Tangem управляет UTXO-адресами автоматически, вам не нужно выбирать или отслеживать их вручную. Бесшовное получение Включить динамические адреса - Динамические адреса каждый раз создают новый адрес для дополнительной приватности — ваш общий баланс при этом не меняется. + Ваш кошелек может использовать несколько адресов для получения одного актива. Tangem показывает новый адрес после пополнения предыдущего — все они привязаны к одному кошельку. + Динамические адреса нельзя включить, так как некоторые пользовательские адреса/токены используют измененный путь деривации, который не соответствует требуемым критериям. Динамические адреса недоступны - Не удаётся подключиться к провайдеру. Попробуйте позже. + Не удается подключиться к провайдеру. Пожалуйста, попробуйте позже. Сервис недоступен. Пожалуйста, попробуйте еще раз. На дополнительных адресах найдены средства. Включите динамические адреса, чтобы получить к ним доступ - Обнаружены средства на связанных адресах + Найдены средства на дополнительных адресах Динамический адрес - Управление динамическими адресами будет доступно после завершения ожидающих транзакций в сети %@. + Управление динамическими адресами станет доступно после подтверждения транзакций в сети %@ Лучшие возможности Очистить фильтр Список временно пуст — он обновляется. Пожалуйста, зайдите чуть позже. @@ -665,15 +671,22 @@ Пользуясь сервисом, вы соглашаетесь с %1$s и %2$s. Больше провайдеров на подходе.\nСледите за обновлениями! Провайдер + Лучший курс на DEX Лучший курс + Список предупреждений FCA Фиксированная ставка недоступна Провайдер для обмена Лучший выбор + Провайдер в списке предупреждений FCA Доступно до %s Доступно с %s Недоступно для этой пары Требуется разрешение + Требуется разрешение Рекомендовано + Обменять токен + Обменяйте вручную, а затем отправьте получателю + %s не поддерживается в Swap & Send Куплено %s Покупка %s Покупка %s... @@ -693,7 +706,7 @@ Выбрать токен Рынок и Новости Показать все - Показать меньше + Скрыть В тренде Информация ниже не является обязательной. Вы можете стереть её, если хотите. Расскажите, каких функций вам не хватает, и мы постараемся вам помочь. @@ -724,6 +737,7 @@ Функция подтверждения необходима для предоставления другому адресу разрешения на использование определенного количества ваших токенов. По замыслу смарт-контракты не могут получить доступ к вашим токенам, если вы не одобрите доступ со своей стороны. «Разблокируя» свои токены, вы даете смарт-контракту StakeKit разрешение использовать ваши активы. Майнеры сети получают компенсацию за газ (оплачиваемый вами) за запись этого действия в блокчейне. Как только разрешение будет предоставлено, вы сможете осуществить стейкинг токена. Чтобы продолжить, вам необходимо разрешить смарт контракту Polygon использовать ваш %s Чтобы продолжить, вам нужно разрешить смарт-контракту %1s использовать ваш %2s + Децентрализованные биржи требуют разрешения на взаимодействие с вашим кошельком. %1s Дать разрешение Безлимитно Адреса будут сгенерированы прямо в аппаратном кошельке Tangem — готовы к использованию и полностью защищены @@ -841,7 +855,7 @@ Пополните кошелёк Чтобы начать отслеживать свои криптоактивы и транзакции, добавьте токены Управление токенами - Отсканируйте QR-код, чтобы отправить средства или подключиться к приложению. + Отсканируйте QR-код, чтобы отправить средства или подключиться к приложению Чтобы получить доступ ко всем сетям, вам необходимо отсканировать карту Отсканируйте карту или кольцо Обменивайте свои токены с %1$s комиссии провайдера через Changelly с %2$s по %3$s февраля. @@ -876,12 +890,14 @@ Этот токен недоступен для данного кошелька Добавить APY %s + Рыночная цена Мой портфель Рынок Доход с Tangem Чтобы создать адреса для выбранных сетей, отсканируйте вашу карту или кольцо Tangem кошелька Потяните вверх или коснитесь поисковой строки, чтобы добавить токен - Смахните вверх, чтобы изучить рынок + Потяните вверх, чтобы открыть рынок + Откройте для себя скрытые гемы Данные раздела получены из следующих сетей: %s Невозможно загрузить данные Нет данных @@ -993,7 +1009,7 @@ Объем торгов (24ч) Общая сумма криптовалюты, которая была продана за последние 24 часа, показывающая уровень активности и ликвидности на рынке. Объем торгов (24ч) - %s в сумме + %s всего Объем Потяните вверх или коснитесь поисковой строки, чтобы добавить токены напрямую из рынка Добавить токены @@ -1070,7 +1086,7 @@ %1$d NFT в %2$d коллекции %1$d NFT в %2$d коллекциях - + %1$d NFT в %2$d коллекциях %1$d NFT в %2$d коллекциях Нажмите здесь, чтобы получить первую NFT @@ -1223,13 +1239,14 @@ Моментально Верификация бесплатная и обычно занимает 1-2 минуты Tangem не будет иметь доступа к вашим личным данным, вы передаете их напрямую лицензированному провайдеру - Верификация открывает полный доступ к будущим транзакциям с этим провайдером. + Верификация открывает полный доступ к будущим транзакциям с этим провайдером Выберите другой метод Согласно требованиям законодательства, %@ требует пройти верификацию личности. Провайдер платежей требует подтверждения личности Верифицировать Что важно знать Пользуясь сервисом покупки, вы соглашаетесь с %1$s и %2$s + Услуга предоставляется сторонним провайдером.\nTangem не несет ответственности. Сумма покупки не может быть больше, чем %s Сумма покупки должна составлять минимум %s Общая сумма транзакций свыше %1s может потребовать верификации личности через %2s @@ -1270,19 +1287,31 @@ Доступно от Доступно до Вы получите + Услуга предоставляется сторонним провайдером.\nTangem не несет ответственности. Вы можете закрыть этот экран и проверить статус транзакции на экране информации о токене. До Через через %s Вы заплатите Группы - Сгруппировать по сети + Сгруппировать по сетям Сортировать по балансу По балансу Упорядочить токены Список Поддержка %s + Push-уведомления включены, но не будут работать без вашего разрешения + Разрешить уведомления + Новости продукта, эксклюзивные предложения и напоминания об активности + Предложения и обновления + Получайте уведомления об изменении цен на популярные монеты + Уведомления о ценах + Настройки уведомлений + Уведомления о транзакциях, обменах и важных обновлениях в реальном времени + Уведомления о транзакциях Включить уведомления + Вы не будете получать уведомления о пополнениях, выводах и транзакциях. Включить их можно в любой момент в Настройках Кошелька. + Уведомления отключены Подробнее Вы можете включить нотификации в настройках Включить позже @@ -1302,8 +1331,8 @@ Поддерживаемые токены не найдены Этот QR-код содержит параметры, которые не распознаны: %s. Некоторые данные платежа могут быть утеряны, если вы продолжите. Неизвестные параметры - Кредитная карта или банковский счет - Поделиться адресом или QR кодом + Банковская карта или банковский счет + Поделитесь своим адресом или QR-кодом Продавайте криптовалюту безопасно Отправить на другой кошелек Между вашими портфелями @@ -1428,10 +1457,10 @@ Недопустимый Memo Покрытие сетевой комиссии - Токен %d не совместим с этим адресом - - Токены %d не совместимы с этим адресом - + %d токен не совместим с этим адресом + %d токена не совместимы с этим адресом + %d токенов не совместимо с этим адресом + %d токены не совместимы с этим адресом Nonce Уникальный номер транзакции. Измените, чтобы ускорить отправку или отменить зависшую операцию. @@ -1530,6 +1559,7 @@ Согласно правилам сети, сумма стейкинга будет округлена до %1$sTRX. Сумма для вывода из стейкинга будет округлена до %1$s TRX ввиду особенностей сети. APR %1$s%% + Ваши вознаграждения за стейкинг начнут начисляться через 5 эпох (~25 дней), пока ваша делегация регистрируется и учитывается сетью. Забрать средства Комиссия за стейкинг аккаунт Стейкинг аккаунт — это специальный счет, на котором хранятся застейканные токены SOL. Он создается при делегировании ваших токенов валидатору для участия в подтверждении транзакций и получении наград. За создание стейкинг аккаунта взимается небольшая комиссия, которая возвращается после завершения стейкинга. @@ -1670,6 +1700,7 @@ Поддержка Web 3.0 Для отправки требуется входящая транзакция на сумму не менее %1$s Недостаточно средств + Данные об операции обмена:\nОткуда: %1$s %2$s\nКуда: %3$s %4$s\nЧерез: %5$s - %6$s Открыть чат Открыть почту Подтверждая, вы разрешаете смарт-контракту использовать ваши токены в будущих транзакциях. @@ -1679,6 +1710,7 @@ Обмен в процессе Обменивайте больше токенов по лучшим курсам прямо в вашем кошельке. Новый провайдер обмена! + Ищете что-то другое?\nВоспользуйтесь поиском или выберите другую криптовалюту! Найдите любой токен, даже если его ещё нет в вашем списке Используйте поиск, чтобы найти то, что вам нужно. Простой режим @@ -1717,7 +1749,7 @@ Вы можете получить значительно меньше из-за низкой ликвидности. Попробуйте меньшую сумму или другого провайдера. Высокое влияние на цену Недостаточно средств - Недостаточно средств для завершения этой транзакции. Уменьшите сумму для получения или добавьте больше средств. + Недостаточно средств для завершения транзакции. Уменьшите сумму получения или добавьте больше средств Дать разрешение Оцените ваш опыт взаимодействия с провайдером Напишите ваш отзыв @@ -1730,13 +1762,15 @@ Выберите токен недоступен Недостаточно ликвидности для этой сделки.\nУменьшите сумму или выберите другого провайдера. - Сумма сделки слишком велика - Передача - Передача... + Слишком крупный обмен + Перевод + Перевод... Будем рады вашей обратной связи Tangem Pay в режиме beta + Не удалось переименовать карту Карта заморожена Оплата картой + Единственную карту закрыть нельзя Она сразу пропадёт из приложения Закрыть карту Назад @@ -1757,6 +1791,8 @@ %s・%s MCC %s Другое + ПИН-код + Покупка Переименовать карту Нельзя использовать на устройствах с root-доступом Успешно завершено @@ -1766,6 +1802,8 @@ Тарифы и полные условия Тарифы и лимиты Банк отклонил транзакцию + Категория + MCC Комиссия в соответствии с тарифами обслуживания Транзакция частично или полностью возвращена продавцом Продолжайте пользоваться картой, заморозить всегда успеете @@ -1778,7 +1816,7 @@ Запрещено использовать на root-устройствах Баланс Скрыть KYC с главной - Карта Tangem Pay + Карта Tangem Pay 1 Пополнить Способы пополнения Добавить в Google Wallet @@ -1813,27 +1851,38 @@ Перевыпустить карту Можно вводить только буквы и цифры Недопустимые символы + Название карты Показать Реквизиты Реквизиты Пожалуйста, попробуйте позже Разморозить карту + Загляните в приложение, если забудете его. Ваш ПИН Вывести Вывод сейчас недоступен Вы не можете начать обмен или новый вывод, пока не завершится текущий. Вывод выполняется - Имя карты + Название карты Можно установить от %s Не удалось установить лимит. Пожалуйста, попробуйте снова. + Обычно занимает до 5 минут + Закрытие вашей карты Изменить Текущий лимит Не удалось загрузить суточный лимит. Пожалуйста, попробуйте снова. + Обновите, чтобы попробовать снова Лимит недоступен Вы можете поменять его снова в любой момент Лимит установлен Суточный лимит Настройки карты + + %d карта + %d карты + %d карт + %d карты + Изменить PIN-код Можно посмотреть здесь, если забудете его. Установить лимит от %s до %s @@ -1866,8 +1915,15 @@ Откройте бесплатную виртуальную карту Tangem Visa Получить Tangem Pay Написать в поддержку + Это сгенерирует новые реквизиты карты + Комиссия за выпуск + Пополните платежный счет в USDC для оплаты комиссии за выпуск + Недостаточно средств для оплаты комиссии + Выпустить дополнительную карту? + Выпустить карту Обычно это занимает до 15 минут Готовим вашу Tangem Card + Выпуск новой цифровой карты Выпускаем карту Обычно это занимает не больше 5 минут, в редких случах при необходимости ручной проверки – до 48 часов. Tangem Pay @@ -1884,6 +1940,8 @@ Скрыть KYC с главной Извините, мы не смогли подтвердить ваш профиль. + Вы можете иметь до 3 карт. Удалите одну, чтобы добавить новую. + Выпущено максимальное количество карт Да — для использования регулируемой карты Visa нужна обязательная проверка личности. KYC проводит Sumsub, комплаенс-партнёр. Нужно предоставить документы? Нет. KYC привязывается только к Tangem Pay. Сам Tangem Wallet остаётся полностью отдельной self-custodial средой без KYC. @@ -1931,7 +1989,11 @@ Мы устраняем техническую проблему. Пожалуйста, попробуйте позже. Сервис временно недоступен Не можем показать данные карты, но оплаты продолжают работать. + Установите \n PIN-код + Установить новый ПИН-код + Установить ПИН-код Аккаунт закрыт + Замена вашей карты Используйте карту или кольцо для обновления сессии Используйте карту или кольцо для обновления сессии Обновить сессию @@ -1964,7 +2026,9 @@ Продажа %s в данный момент не поддерживается ни одним провайдером. Мы работаем над добавлением новых возможностей. Следите за нашими новостями. Стейкинг %s в данный момент не поддерживается ни одним провайдером. Мы работаем над добавлением новых возможностей. Следите за нашими новостями. Разрешение было отозвано. Ваши средства остаются в Yield сервисе. Чтобы совершать операции, перейдите в Yield сервис и снова выдайте разрешение. + Доступный баланс Общий баланс + До %s APR До %s APY Сгенерировать XPUB Скрыть @@ -1975,7 +2039,9 @@ Сервис стейкинга %1$s токен в сети %%image%% %2$s Токен в сети %%image%% %1$s - Сеть %s + %s сеть + %1$s в сети %2$s + %1$sв %%image%% %2$s %1$s в %2$s %%image%% Токен %1$s (%2$s) является основной валютой в сети %3$s и не может быть скрыт до тех пор, пока у вас в списке есть другие токены этой сети Невозможно скрыть %s @@ -1992,17 +2058,17 @@ Получение награды контракт: %s Отключение режима доходности - Получено из стейкинга + Заработано на стейкинге У вас еще нет транзакций Не удалось загрузить историю транзакций.\nНажмите на кнопку перезагрузки, чтобы обновить информацию. - С: %%image%% %s + от: %%image%% %s Несколько адресов История транзакций в настоящее время не поддерживается для этого блокчейна. Но не волнуйтесь, мы работаем над этим! А пока вы можете проверить ее в обозревателе. Операция - Ожидание - Награда рестейкнута - Рестейкинг награды - Стейкинг награды + В ожидании + Награды рестейкнуты + Рестейкинг наград + Награда за стейкинг на: %%image%% %s для: %s от: %s @@ -2042,7 +2108,9 @@ Получайте уведомления о входящих транзакциях Узнавайте первым о новых акциях Ранний доступ к новым функциям и эксклюзивным предложениям. + Уведомления об изменении цен, новости продукта и эксклюзивные предложения Новые функции и важные новости + Предложения и обновления Хотите использовать Push-уведомления? Включите push-уведомления, и мы мгновенно сообщим вам, когда поступят средства. Не пропустите транзакцию @@ -2064,6 +2132,7 @@ ПИН не принят. Попробуйте ещё раз или введите другой код. Слабый ПИН: не используйте повторы или последовательности. Код валюты + Оспорить эту транзакцию Разблокировать Отсканируйте свою карту, чтобы разблокировать доступ Выберите тип кошелька @@ -2158,7 +2227,8 @@ Выдача разрешения В этом кошельке возникла проблема с резервным копированием. Обратитесь в службу поддержки для её решения. Пополнение отключено - Требуется действие: сбросьте свой кошелек + Ваши карточки были активированы неправильно. Чтобы обезопасить средства, переведите их на другой кошелек, сбросьте карты до заводских настроек и активируйте заново. + Требуется действие: сбросьте настройки кошелька Похоже, что процесс активации карт или кольца не был завершен корректно. Это могло быть вызвано проблемой взаимодействия с модулем NFC либо некорректным прикладыванием карты или кольца к телефону. Пожалуйста, обратитесь в нашу службу поддержки для уточнения деталей. Ошибка активации С 3 декабря 2024 года сеть BEP-2 была отключена по решению разработчиков сети и более не поддерживается @@ -2400,7 +2470,12 @@ Бонус за первую активацию! Спецпредложение для режима доходности APY x3 + Активировать бонус + Подробности в истории транзакций + Бонус режима доходности выплачен + %1$sдней до разблокировки бонуса Вам доступен буст APY на 30 дней + Действуют Условия и Положения Включите режим доходности впервые и получите до 3x дохода за первые 30 дней Бонус APR за первый месяц Вы получаете рыночный доход + бонус. Бонус выплачивается единоразово в USDT или USDC в течение 14 дней после окончания 30-дневного периода. Акция действует, пока есть промо-бюджет. Действуют правила и условия @@ -2418,6 +2493,7 @@ Комиссия будет списана, и ваши активы снова начнут приносить доход. Чтобы продолжить зарабатывать, нужно выдать разрешение. Подтвердить разрешение + Средний APY %1$s%% Ваши средства в данный момент размещены в протоколе Aave, но вы можете воспользоваться ими в любое время. Ваш %s внесён в Aave Невозможно загрузить график @@ -2491,6 +2567,7 @@ Проценты начисляются автоматически. Режим доходности Включение режима доходности + Режим доходности • %1$s%% APY Режим доходности Установка контракта режима доходности Режим доходности подключен @@ -2507,5 +2584,7 @@ Невозможно покрыть комиссию в %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 2c136df032..fb033c0a84 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -80,10 +80,51 @@ Додайте токени Оберіть токен для отримання Оберіть токен для обміну + Поповнити + Обмін + Переказ + Додати до портфеля Додати токени + Сортування та групування + Упорядкувати токени Оберіть мережу Додати токен Токени + Банківська картка або банківський рахунок + Поповнити токен + Поділіться своєю адресою або QR-кодом + Між вашими портфелями + Ви отримаєте + Додати адресу + Додати адресу та вибрати мережу + Додати контакт + + адреса + адреси + адрес + адрес + + Контакт + Ім\'я контакту + Скопіювати адресу + Не вдалося створити контакт. Будь ласка, спробуйте пізніше. + Цей контакт буде видалено з усіх ваших адресних книг + Не вдалося видалити контакт. Будь ласка, спробуйте пізніше. + Керування контактами та адресами + Скасувати + Редагувати адресу + Ввести адресу + Недійсна адреса + Продовжити + Новий контакт + Немає доданих контактів + Тут відображатимуться додані вами контакти. + Видалити адресу + Цей контакт буде прив\'язано до цього гаманця в адресній книзі. + Вибрати мережу + Адресна книга + Незбережені зміни + Ви впевнені, що хочете скасувати зміни? Надсилайте тільки %1$s (%2$s) в мережі %3$s на цю адресу. Використання іншої мережі може призвести до втрати коштів. За замовчуванням Застарілий @@ -220,8 +261,10 @@ Доступ заборонено Акаунт Акаунти + %s не вдалося Активувати Додати + Поповнити Додати у портфель Додати токен Додайте токени @@ -235,6 +278,8 @@ Застосовувати Затвердження Підтвердити + Схвалено + Схвалення Увага Доступні мережі Резервне копіювання @@ -281,14 +326,22 @@ днів днів + + %d день тому + %d дні тому + %d днів тому + %d дня тому + Видалити Вимкнути Вимкнуто + Вимкнення Від\'єднати Готово Редагувати Увімкнути Увімкнено + Увімкнення Помилка Комісія мережі за поповнення Обміняти @@ -315,6 +368,12 @@ Приховати Утримуйте, щоб %s година + + година + години + годин + години + %d годину тому %d години тому @@ -324,6 +383,7 @@ Імпортувати у В процесі + Недостатній баланс Пізніше Дізнатися більше Залишилося %1$s @@ -338,6 +398,8 @@ %d хвилин тому місяць + Більше + Мережа Комісія мережі Сума відправлення буде зменшена на %1$s (%2$s) для покриття обраного рівня комісії @@ -352,12 +414,14 @@ NFT Ні Немає адреси + Немає результатів Не додано Недоступно Не зараз Зараз ОК Відкрити в браузері + Відкрити налаштування або Основна картка Основне кільце @@ -368,6 +432,8 @@ %1$s — %2$s Детальніше Отримати + Отримано + Отримання Рекомендовано Відхилити Оновити @@ -386,7 +452,10 @@ Надіслати Відправка: Не вдалося надіслати транзакцію + Надсилання + Надіслано Сервер недоступний, спробуйте пізніше + Час сесії вичерпано Поширити Поділитися посиланням Сховати @@ -396,6 +465,7 @@ Пропустити Щось пішло не так Застейкати + Застейкано Стейкінг Почати Продовжити @@ -403,6 +473,8 @@ Підтримка Підтримувані мережі Обмін + Обміняно + Обмін Tangem Tangem Wallet Натисніть і утримуйте @@ -412,6 +484,7 @@ На %s Сьогодні Токен + Токен для надсилання %d токен %d токени @@ -422,18 +495,24 @@ Статус транзакції Транзакції Переказ + Переказано + Будь ласка, спробуйте пізніше Неможливо завантажити дані... Я зрозумів Я розумію, продовжити Виникла помилка. Будь ласка, спробуйте ще раз. + Розблокувати Недоступно Скасувати стейкінг Через обмеження %1$s в одну транзакцію може поміститися тільки %2$d UTXO. Це означає, що ви можете відправити тільки %3$s або менше. Вам потрібно зменшити суму. Скопійовано + Показати всі + Голосування Гаманці Увага тиждень з + Виведення Так Режим дохідності Адреса контракту скопійована! @@ -448,6 +527,7 @@ Токен вже існує Десяткове число повинно бути дійсним цілим числом, до %d Власна деривація + Увімкнено динамічні адреси. Користувацькі значення \\"change\"\ та\\"index\"\ недоступні. Наприклад, m/00\'/0000\'/0\'/0/0 Введіть власну деривацію Знаків після коми @@ -500,6 +580,28 @@ Надсилання активів в інші мережі призведе до безповоротної втрати. %s мережа Надсилайте кошти, використовуючи лише + Динамічні адреси + Увімкнено динамічні адреси. Користувацькі значення \"change\" та \"index\" недоступні. + Ви вимикаєте динамічні адреси. Тепер кошти надходитимуть на вашу фіксовану адресу + Для переказу коштів на фіксовану адресу стягується комісія мережі + Вимкнути динамічні адреси + Вимкнути динамічні адреси + Динамічні адреси вимкнено + Динамічні адреси увімкнено + Використовуйте нову адресу для кожної транзакції, щоб знизити відстежуваність та підвищити конфіденційність у блокчейні. + Підвищена конфіденційність + Отримуйте кошти як зазвичай. Tangem керує UTXO-адресами автоматично, вам не потрібно вибирати чи відстежувати їх вручну. + Безшовне отримання + Увімкнути динамічні адреси + Ваш гаманець може використовувати кілька адрес для отримання одного активу. Tangem показує нову адресу після поповнення попередньої — усі вони прив\'язані до одного гаманця. + Динамічні адреси не можна увімкнути, оскільки деякі користувацькі адреси/токени використовують змінений шлях деривації, що не відповідає потрібним критеріям. + Динамічні адреси недоступні + Не вдається підключитися до провайдера. Будь ласка, спробуйте пізніше. + Сервіс недоступний. Будь ласка, спробуйте ще раз. + На додаткових адресах знайдено кошти. Увімкніть динамічні адреси, щоб отримати до них доступ + Знайдено кошти на додаткових адресах + Динамічна адреса + Керування динамічними адресами стане доступним після підтвердження транзакцій у мережі %@ Найкращі можливості Очистити фільтр Список тимчасово порожній, оскільки триває оновлення. Спробуйте ще раз за мить. @@ -569,16 +671,22 @@ Використовуючи сервіс обміну, ви погоджуєтеся з його %1$s та %2$s. Незабаром з\'являться нові провайдери.\nСлідкуйте за новинами! Провайдер + Найкращий курс на DEX Найкращий курс Список попереджень FCA + Фіксована ставка недоступна Провайдер для обміну Найкращий вибір - Список попереджень FCA + Провайдер у списку попереджень FCA Доступно до %s Доступно від %s Недоступно для цієї пари Потрібен дозвіл + Потрібен дозвіл Рекомендовано + Обміняти токен + Обміняйте вручну, а потім надішліть одержувачу + %s не підтримується у Swap & Send Купив %s Купівля %s Купівля %s... @@ -597,6 +705,8 @@ Оберіть, який токен буде використовуватися для оплати мережевої комісії. %s Обрати токен Ринок та Новини + Показати всі + Сховати В тренді Інформація нижче не є обов\'язковою. Ви можете стерти її, якщо бажаєте. Розкажіть, яких функцій вам не вистачає, і ми спробуємо вам допомогти. @@ -611,6 +721,11 @@ Звернення в підтримку Tangem Не вдається відправити транзакцію Помилка в описі монети + Для забезпечення коректної роботи оновіть застосунок до останньої версії. + Необхідне оновлення + Оновити + Будь ласка, оновіть застосунок до останньої версії, щоб забезпечити його коректну роботу. + Потрібне оновлення Недостатньо коштів Комісія за транзакцію Виникла помилка @@ -622,6 +737,7 @@ Функція підтвердження необхідна для надання дозволу іншій адресі на використання певної кількості ваших токенів. За задумом, смарт-контракти не можуть отримати доступ до ваших токенів без вашого схвалення. \"Розблоковуючи\" свої токени, ви дозволяєте смарт-контракту StakeKit використовувати їх. Майнери мережі отримують плату за газ (сплачену вами), щоб зафіксувати цю дію в блокчейні. Ви зможете застейкати свій токен після того, як дасте дозвіл. Щоб продовжити, вам потрібно дозволити смарт-контракту Polygon використовувати ваш %s Щоб продовжити, вам потрібно дозволити смарт-контракту %1s використовувати ваш %2s + Децентралізовані біржі вимагають дозволу на взаємодію з вашим гаманцем. %1s Надати дозвіл Необмежено Резервна копія створюється на 2–3 картках Tangem. Зберігайте їх окремо для безпеки — seed-фраза не потрібна. @@ -735,8 +851,11 @@ Мережа Koinos вимагає Mana для мережевої комісії. У вас є %1$s/%2$s Mana Рівень Mana Додати та керувати + Купіть криптовалюту або перекажіть її на свій гаманець. + Поповніть гаманець Щоб почати відстежувати свої криптоактиви та транзакції, додайте токени Керування токенами + Відскануйте QR-код, щоб надіслати кошти або підключитися до застосунку Для доступу до всіх мереж необхідно відсканувати картку Відскануйте картку або кільце Насолоджуйтесь %1$s комісією за обслуговування на свопах через Changelly з %2$s по %3$s лютого. @@ -771,20 +890,30 @@ Цей токен недоступний для цього гаманця Додати APY %s + Ринкова ціна Моє портфоліо Маркет Заробляйте з Tangem Щоб згенерувати адреси для обраних мереж, відскануйте вашу картку або кільце Tangem гаманця Щоб додати токени, потягніть вгору або торкніться панелі пошуку + Потягніть вгору, щоб відкрити ринок + Відкрийте для себе приховані геми Дані цього розділу отримані з наступних мереж: %s Не вдалося завантажити дані... Немає даних + **Додайте до свого портфеля**, щоб купувати, обмінювати або отримувати цей актив + У вашому портфелі + Ваш портфель Пульс ринку Швидкі дії + Очистити все Шукати на маркеті + Нещодавні + У вашому портфелі Результат Переглянути токени до 100к USD ринкової капіталізації Показати токени + Криптовалюти, новини та не тільки Жодного результату Виберіть мережу Оберіть гаманець @@ -804,6 +933,7 @@ Режим дохідності Стейкінг — простий спосіб отримувати дохід з вашої криптовалюти. %s Отримуйте до %s APY + в іншій мережі або іншому акаунті Токен додано Про %s @@ -842,6 +972,10 @@ Тримачі Зміна кількості власників токенів протягом певного періоду часу Тримачі + Д + М + Т + Р Інсайти Посилання Ліквідність @@ -857,9 +991,11 @@ Позиція в крипторейтингу між усіма монетами на основі ринкової капіталізації Рейтинг Макс. обсяг + Циркулююча та максимальна пропозиція Максимальна кількість монет або токенів, яка може коли-небудь існувати для певної криптовалюти Максимальна пропозиція Метрики + Без обмежень Офіційні посилання Цінова ефективність Репозиторій @@ -869,9 +1005,11 @@ Загальна проп. Максимальна кількість монет або токенів, яка може коли-небудь існувати для певної криптовалюти Загальна пропозиція + 24г Обсяг торгів (24г) Загальна сума криптовалюти, якою торгували протягом останніх 24 годин, що вказує на рівень активності та ліквідності на ринку Обсяг торгів (24г) + %s загалом Обсяг Потягніть вгору або торкніться панелі пошуку, щоб додати токени безпосередньо з маркету Додати токени @@ -899,6 +1037,7 @@ Пов\'язані токени Схожі новини Залишайтеся в курсі подій + Індекс популярності Функція NFC недоступна на вашому пристрої Про NFT NFT @@ -947,7 +1086,7 @@ %1$d NFT в %2$d колекціi %1$d NFTs в %2$d колекціях - + %1$d NFTs в %2$d колекціях %1$d NFTs в %2$d колекціях Натисніть тут, щоб отримати першу NFT @@ -1025,7 +1164,9 @@ Підготовка На картці, яку ви намагаєтеся додати, вже створено інший гаманець. Якщо у вас є кошти на цьому гаманці, будь ласка, виведіть їх, а потім скиньте цю картку до заводських налаштувань і додайте її як резервну. Збережіть ваш гаманець + Використовувати біометрію Резервна копія + Останній крок Біометрія Дізнатися більше про seed-фразу @@ -1098,6 +1239,7 @@ Миттєво Верифікація безкоштовна і зазвичай займає 1-2 хвилини Tangem не матиме доступу до ваших особистих даних, ви передаєте їх безпосередньо ліцензованому провайдеру + Верифікація відкриває повний доступ до майбутніх транзакцій із цим провайдером Виберіть інший метод Згідно з вимогами законодавства, %@ вимагає пройти верифікацію особи. Провайдер платежів вимагає підтвердження особи @@ -1152,10 +1294,24 @@ через %s Ви заплатите Групами + Згрупувати за мережами + Сортувати за балансом За балансом Сортування токенів Список Підтримка %s + Push-сповіщення ввімкнені, але не працюватимуть без вашого дозволу + Дозволити сповіщення + Новини продукту, ексклюзивні пропозиції та нагадування про активність + Пропозиції та оновлення + Отримуйте сповіщення про зміну цін на популярні монети + Сповіщення про ціни + Налаштування сповіщень + Сповіщення про транзакції, обміни та важливі оновлення в реальному часі + Сповіщення про транзакції + Увімкнути сповіщення + Ви не отримуватимете сповіщення про поповнення, виведення та транзакції. Увімкнути їх можна будь-коли в Налаштуваннях Гаманця. + Сповіщення вимкнено Більше Ви можете ввімкнути сповіщення у налаштуваннях Увімкнути пізніше @@ -1175,6 +1331,12 @@ Підтримуваних токенів не знайдено Цей QR-код містить нерозпізнані параметри: %s. Деякі деталі платежу можуть бути втрачені, якщо ви продовжите. Невідомі параметри + Банківська картка або банківський рахунок + Поділіться своєю адресою або QR-кодом + Безпечно продавайте криптовалюту + Надіслати на інший гаманець + Між вашими портфелями + Інші Швидке поповнення Memo не вимагається %1$s (%2$s) у мережі %3$s @@ -1294,6 +1456,12 @@ Мемо: %s Недопустимий Memo Покриття мережевої комісії + + %d токен не сумісний із цією адресою + %d токени не сумісні із цією адресою + %d токенів не сумісно із цією адресою + %d токени не сумісні із цією адресою + Nonce Унікальний номер транзакції. Змініть його, щоб пришвидшити відправку або скасувати завислу операцію. Введіть nonce… @@ -1321,6 +1489,11 @@ Ліміт транзакції Опціонально Будь ласка, сумістіть свій QR-код з квадратом, щоб відсканувати його. Переконайтеся, що ви скануєте адресу в мережі %s. + При фіксованому курсі сума, яку ви отримаєте, фіксується в момент обміну. Це захищає вас від зміни ціни під час транзакції. + Фіксований курс + Плаваючий курс означає, що підсумкова сума, яку ви отримаєте, може дещо змінитися залежно від ринкових умов у період між початком і завершенням обміну. + Плаваючий курс + Курс зафіксовано Останні Одержувач Недійсна адреса @@ -1364,6 +1537,7 @@ До отримання %s Ви впевнені, що хочете скасувати конвертацію? Ваші попередні дані будуть видалені. Прибрати конвертацію + Щось пішло не так. Спробуйте ще раз Надіслати з обміном Трансакцію надіслано Підготуйтеся до сканування кільця або картки, яку ви хочете налаштувати. @@ -1416,6 +1590,7 @@ Період, який ви повинні чекати після запиту на виведення коштів зі стейкінгу, перш ніж токени стануть доступними. Період блокування Відведений час для активації участі в стейкінгу. + Стейкінг увімкнено Наразі немає вільних валідаторів. Повторіть спробу пізніше. Стейкінг недоступний Комісія за схвалення токену, щоб верифікувати, що саме ви дозволяєте використання токена для стейкінгу. @@ -1525,6 +1700,9 @@ Web 3.0 сумісність Для відправки потрібна вхідна транзакція на суму не менше %1$s Недостатньо коштів + Дані про операцію обміну:\nЗвідки: %1$s %2$s\nКуди: %3$s %4$s\nЧерез: %5$s - %6$s + Відкрити чат + Відкрити пошту Підтверджуючи, ви дозволяєте смартконтракту використовувати ваші токени в майбутніх транзакціях. Детальний режим Фіксований курс @@ -1564,11 +1742,14 @@ Всі децентралізовані біржі вимагають схвалення, щоб запобігти доступу смарт-контрактів до вашого гаманця без вашого дозволу. За задумом смарт-контракти не можуть отримати доступ до ваших токенів без вашого схвалення. \"Розблоковуючи\" свої токени, ви дозволяєте смарт-контракту 1inch витрачати ваші активи. Майнери мережі отримують плату за газ (сплачену вами), щоб зафіксувати цю дію в блокчейні. Ви можете обміняти свій токен після того, як дасте дозвіл. Підтвердити Помилка при розрахунку комісії. Будь ласка, надішліть відгук до служби підтримки. + Ви надсилаєте з Ви обмінюєте Ви надсилаєте Обмін цієї кількості обраних токенів призведе до значного впливу на ціну і зменшить вашу кінцеву суму. + Ви можете отримати значно менше через низьку ліквідність. Спробуйте меншу суму або іншого провайдера. Високий вплив на ціну Недостатньо коштів + Недостатньо коштів для завершення транзакції. Зменште суму отримання або додайте більше коштів Надати дозвіл Оцініть ваш досвід взаємодії з провайдером Напишіть ваш відгук @@ -1580,10 +1761,16 @@ Ви отримаєте Оберіть токен недоступно + Недостатньо ліквідності для цього обміну.\nЗменште суму або виберіть іншого провайдера. + Занадто великий обмін + Переказ + Переказ... Будемо раді вашому зворотньому звʼязку Tangem Pay у режимі beta + Не вдалося перейменувати картку Картку заморожено Оплата карткою + Єдину картку закрити не можна Воно зникне з застосунку Закрити картку Назад @@ -1598,11 +1785,15 @@ Не вдалося заморозити картку. Спробуйте пізніше. Заморозити Вашу картку заморожено. + Розморозити Звернутися до підтримки Причина: %s %s.%s MCC %s Інше + ПІН-код + Покупка + Перейменувати картку Неможливо використовувати на пристроях з root-правами. Завершено Відхилено @@ -1611,6 +1802,8 @@ Умови, комісії та ліміти Умови та обмеження Банк відхилив цей запит на транзакцію. + Категорія + MCC Комісія відповідно до тарифів обслуговування Транзакцію було частково або повністю скасовано продавцем Продовжуйте користуватися карткою. Заморозити можна в будь-який момент. @@ -1623,8 +1816,10 @@ Заборонено використовувати на root-пристроях Баланс Приховати KYC з головного екрана + Картка Tangem Pay 1 Поповнити рахунок Варіанти поповнення + Додати до Google Wallet Номер картки ПІН-код Картка готова до оплат. @@ -1656,33 +1851,44 @@ Перевипустити картку Дозволені лише літери та цифри Неприпустимі символи + Назва картки Показати Реквізити Реквізити Будь ласка, спробуйте пізніше Розморозити картку - Поверніться до додатка, якщо ви забудете його. + Поверніться до застосунку, якщо забудете його Ваш ПІН Вивести Вивід наразі недоступний Ви не можете розпочати обмін або новий вивід, поки не завершено поточний. Вивід виконується + Назва картки Встановити ліміт від %s Не вдалося встановити ліміт. Будь ласка, спробуйте ще раз. + Зазвичай займає до 5 хвилин + Закриття вашої картки Змінити Поточний ліміт Не вдалося завантажити денний ліміт. Будь ласка, спробуйте ще раз. + Оновіть, щоб спробувати знову Ліміт недоступний Ви можете змінити це будь-коли Ліміт встановлено Денний ліміт Налаштування картки + + %d картка + %d картки + %d карток + %d картки + Змінити PIN-код Можна подивитися тут, якщо забудете його. Встановити ліміт від %s до %s Встановити недостатньо коштів - se superó el límite de gasto de la tarjeta + перевищено ліміт витрат за карткою помилка перевірки CVV2 неправильний термін дії картки неправильний PIN-код @@ -1709,8 +1915,15 @@ Отримайте безкоштовну віртуальну картку Tangem Visa Отримати Tangem Pay Написати у підтримку + Це згенерує нові реквізити картки + Комісія за випуск + Поповніть платіжний рахунок у USDC для оплати комісії за випуск + Недостатньо коштів для оплати комісії + Випустити додаткову картку? + Випустити картку Зазвичай це займає до 15 хвилин Готуємо вашу картку Tangem + Випуск нової цифрової картки Випускаємо картку Карту зазвичай випускають автоматично протягом 5 хвилин. У рідкісних випадках, коли потрібна ручна перевірка, процес може тривати до 48 годин. Tangem Pay @@ -1727,6 +1940,8 @@ Приховати блок KYC Вибачте, ми не змогли підтвердити вашу особу. + Ви можете мати до 3 карток. Видаліть одну, щоб додати нову. + Випущено максимальну кількість карток Так — для користування регульованою карткою Visa обов’язкова верифікація особи. KYC проводить Sumsub, compliance-партнер. Чи потрібно надавати документи? Ні. KYC стосується лише акаунта Tangem Pay. Сам Tangem Wallet залишається окремим self-custodial середовищем без KYC. @@ -1775,7 +1990,10 @@ Сервіс тимчасово недоступний Не можемо показати дані картки, але оплати продовжують працювати. Встановіть \nPIN-код + Встановити новий ПІН-код + Встановити ПІН-код Рахунок закрито + Заміна вашої картки Використайте картку або кільце для поновлення сесії Використайте картку або кільце для поновлення сесії Відновити доступ @@ -1808,6 +2026,10 @@ Наразі продаж %s недоступний. Слідкуйте за нашими оновленнями. Стейкінг %s зараз недоступний. Будь ласка, слідкуйте за нашими оновленнями. Дозвіл скасовано. Ваші кошти залишаються в режимі прибутковості. Щоб виконати дії, перейдіть у режим прибутковості та надайте дозвіл знову. + Доступний баланс + Загальний баланс + До %s APR + До %s APY Згенерувати XPUB Приховати Ви збираєтеся приховати цей токен з головного екрану. Ви можете додати його назад будь-коли на сторінці керування токенами. @@ -1817,8 +2039,13 @@ Сервіс стейкінгу %1$s токен в мережі %%image%% %2$s Токен у мережі %%image%% %1$s + %s мережа + %1$s у мережі %2$s + %1$s у %%image%% %2$s + %1$s у %2$s %%image%% Токен %1$s (%2$s) є основною валютою в мережі %3$s і не може бути прихований до тих пір, поки у вас в списку є інші токени цієї мережі Неможливо приховати %s + N/A Показати QR-код Обмінюйте цей токен на інші з %1$s комісією за обслуговування з %2$s по %3$s лютого. Обмін із Changelly, %s комісії @@ -1828,18 +2055,28 @@ Недоступно для продажу Недоступно для обміну з %s Недоступно для обміну + Отримання нагороди контракт: %s + Вимкнення режиму дохідності + Зароблено на стейкінгу У вас ще немає транзакцій Не вдалося завантажити історію транзакцій.\nНатисніть кнопку нижче, щоб оновити інформацію + від: %%image%% %s Кілька адрес Історія транзакцій наразі не підтримується для цього блокчейну. Але не хвилюйтеся, ми працюємо над цим! А поки можете перевірити це в оглядачі. Операція + В очікуванні + Нагороди рестейкнуто + Рестейкінг нагород + Нагорода за стейкінг + на: %%image%% %s для: %s від: %s до: %s валідатор: %s Сповіщення ввімкнені, але не працюватимуть, доки ви не дозволите їх у налаштуваннях пристрою. Сповіщення про транзакції + Переказ в процесі Мінімум %s Мінімальна сума транзакції становить %1$s. Комісії мережі Tron для популярних токенів можуть бути вищими. Стейкінг TRX може допомогти знизити транзакційні витрати. @@ -1861,6 +2098,9 @@ Виникла помилка. Код помилки: %s. Спробуйте, будь ласка, знову. Якщо проблема знову виникає — зверніться до нашої служби підтримки. Використовуйте %s або відскануйте картку/кільце, щоб отримати доступ до свого гаманця Не вдалося встановити з\'єднання: Цей dApp використовує Wallet Connect версії 1.0, яка не підтримується. Будь ласка, переконайтеся, що dApp підтримує Wallet Connect версії 2.0 для успішного підключення. + Попередній дозвіл буде відкликано, а замість нього видано новий. За кожну з цих дій мережа стягне комісію за підтвердження токена. В історії транзакцій з\'явиться операція з нульовою сумою як підтвердження відкликання дозволу. + Сума транзакції перевищує раніше наданий дозвіл.\nОновіть дозвіл, щоб продовжити. + Оновіть дозвіл Оновлення до апаратного гаманця Будьте в курсі останніх функцій та новин Миттєві сповіщення про транзакції, обміни та важливі оновлення. @@ -1868,7 +2108,9 @@ Отримуйте сповіщення про вхідні транзакції Дізнавайтеся першими про нові акції Ранній доступ до нових функцій та ексклюзивних пропозицій. + Сповіщення про зміну цін, новини продукту та ексклюзивні пропозиції Нові функції та важливі новини + Пропозиції та оновлення Бажаєте використовувати Push-повідомлення? Увімкніть push-сповіщення, щоб отримувати повідомлення, коли кошти надходять на ваш гаманець. Не пропустіть транзакцію @@ -1998,6 +2240,10 @@ Використовуйте %s або відскануйте картку/кільце, щоб розблокувати доступ до вашого гаманця Процес підтвердження наразі триває і буде завершено найближчим часом Підтвердження в процесі + У цьому гаманці виникла проблема з резервним копіюванням. Зверніться до служби підтримки для її вирішення. + Поповнення вимкнено + Ваші картки були активовані неправильно. Щоб убезпечити кошти, переведіть їх на інший гаманець, скиньте картки до заводських налаштувань і активуйте заново. + Потрібна дія: скиньте налаштування гаманця Схоже, що активація карток або кільця була виконана неправильно. Це може бути пов\'язано з проблемою з модулем NFC вашого пристрою або неправильним прикладанням картки або кільця до телефону. Будь ласка, зверніться за допомогою до нашої служби підтримки для уточнення деталей. Помилка активації 3 грудня 2024 року мережу BEP-2 було відключено за рішенням розробників мережі, і вона більше не підтримується @@ -2084,6 +2330,8 @@ Резервна копія відсутня Ця картка вже використовувалася для здійснення транзакцій. Якщо вона отримана з ненадійного джерела, подумайте про те, щоб зняти всі кошти. Якщо це ваша картка, ніяких додаткових дій не потрібно. Картка вже підписувала транзакції + Буде оновлено якнайшвидше. + Відсутня частина балансу токенів. Ваш відгук мотивує нас робити гаманець Tangem Wallet ще кращим Подобається Tangem? Вам необхідно провести асоціацію токена, щоб мати можливість приймати його @@ -2237,7 +2485,12 @@ Бонус за першу активацію! Спецпропозиція для режиму дохідності APY x3 + Активувати бонус + Деталі в історії транзакцій + Бонус режиму дохідності виплачено + %1$sднів до розблокування бонусу Вам доступний буст APY на 30 днів + Діють Умови та Положення Увімкніть режим дохідності вперше та отримайте до 3x доходу за перші 30 днів Бонус APR за перший місяць Ви отримуєте ринковий дохід + Бонус. Бонус виплачується одноразово в USDT або USDC протягом 14 днів після закінчення 30-денного періоду. Акція діє, доки є промо-бюджет. Діють правила та умови @@ -2255,6 +2508,7 @@ Комісія буде знята, а ваші активи знову почнуть приносити дохід. Щоб продовжити заробляти, потрібне схвалення. Підтвердити дозвіл + Середній APY %1$s%% Наразі ваші кошти розміщені у протоколі Aave, але ви можете керувати ними в будь-який час. Ваш %s внесений у Aave Неможливо завантажити графік @@ -2328,6 +2582,7 @@ Відсотки нараховуються автоматично Режим дохідності Відправка ваших коштів + Режим дохідності • %1$s%% APY Режим прибутковості Встановлення контракту режиму дохідності Режим прибутковості увімкнено @@ -2344,5 +2599,7 @@ Неможливо покрити комісію в %s Сервіс доходності у даний час недоступний. Будь ласка, спробуйте пізніше. Режим дохідності недоступний + Умови виплати бонусу перевірено + до розблокування бонусу Не вдалося завантажити графік... 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 b7a0a6f363..6ba6530f44 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -390,6 +390,9 @@ 提款进行中 從 %s 設定限額 我们无法设置限额,请稍后再试。 + + %d 張卡 + 修改PIN码 如果忘记了,请返回应用查看。 餘額不足 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 048d7cbf3b..274fa58650 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -275,6 +275,7 @@ Attention Available networks Backup + Backup Error Balance: %s Balance biometric authentication @@ -566,10 +567,10 @@ Dynamic addresses enabled Use a new address for each transaction to reduce traceability and improve on-chain privacy. Enhanced Privacy - Easily receive funds in UTXO-based networks with automatic address generation — no manual address management required. + Receive funds as usual. Tangem manages UTXO addresses automatically, so you don’t need to choose or track them manually. Seamless receiving Enable Dynamic Addresses - Dynamic addresses create a new one each time for extra privacy — your total balance stays the same. + Your wallet can use multiple receiving addresses for one asset. Tangem shows a new address after the previous one receives funds — all addresses still belong to the same wallet. Dynamic Addresses cannot be enabled because some custom addresses/tokens use a modified derivation path, which doesn’t meet the required criteria. Dynamic Addresses Unavailable We can’t connect to the provider right now. Please try again later. @@ -647,6 +648,7 @@ By using swap functionality, you agree with provider’s %1$s and %2$s. More providers will be available soon Provider + Best DEX Rate Best rate FCA Warning List Fixed rate is unavailable @@ -1516,9 +1518,9 @@ APR APY Rewards automatically accumulate in your staking balance daily. - Rewards are compounded to your staking balance. Funds earned: %s - Rewards are compounded to your staking balance. Funds earned: %s + Rewards are compounded to your staking balance. + Rewards are compounded to your staking balance. Funds earned: %s Available Average Reward Rate How Staking Works? diff --git a/core/ui/ds-tokens b/core/ui/ds-tokens index 0be9b5e9f7..76d6a50dc3 160000 --- a/core/ui/ds-tokens +++ b/core/ui/ds-tokens @@ -1 +1 @@ -Subproject commit 0be9b5e9f7fe13c483f9d037617e36c8848f2842 +Subproject commit 76d6a50dc3161cfc6cc7055afc8ce4ba619ac5c6 diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash b/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash index 05023a00ba..0800a2ba15 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash @@ -1 +1 @@ -058e1ecd83440d5fda33512248f67176db593029c9b398e4a2692816703e451c +7a974320353cf7ea1e0a25ca074f8e200ce44506044cc5b2bdcb00aee2c6dc85 diff --git a/core/ui/token-gen/README.md b/core/ui/token-gen/README.md index c34e73747e..e170af92ef 100644 --- a/core/ui/token-gen/README.md +++ b/core/ui/token-gen/README.md @@ -1,6 +1,6 @@ # token-gen -Generates Kotlin (Jetpack Compose) source files from design tokens defined in the `ds-tokens` git submodule. +Generates Kotlin (Jetpack Compose) source files from design tokens and icons defined in the `ds-tokens` git submodule. ## Updating tokens @@ -23,10 +23,18 @@ Generates Kotlin (Jetpack Compose) source files from design tokens defined in th ```bash cd core/ui/token-gen && npm run build ``` + `npm run build` generates both tokens and icons. To regenerate only one of them: + ```bash + npm run build:tokens # node build-tokens.mjs + npm run build:icons # node build-icons.mjs + ``` 3. Commit the generated files (and the submodule pointer too, only if you ran step 1). ## How it works -The script uses [Style Dictionary v5](https://styledictionary.com/) with [@tokens-studio/sd-transforms](https://github.com/tokens-studio/sd-transforms) to read JSON token files from `core/ui/ds-tokens/tokens/` and generate Kotlin files into `core/ui/src/main/java/com/tangem/core/ui/res/generated/`. +The build runs two scripts: + +- **`build-tokens.mjs`** uses [Style Dictionary v5](https://styledictionary.com/) with [@tokens-studio/sd-transforms](https://github.com/tokens-studio/sd-transforms) to read JSON token files from `core/ui/ds-tokens/tokens/` and generate Kotlin files into `core/ui/src/main/java/com/tangem/core/ui/res/generated/`. +- **`build-icons.mjs`** generates Kotlin icon source files into `core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/`. All generated files are written to `com.tangem.core.ui.res.generated` and should not be edited manually. diff --git a/core/ui/token-gen/package.json b/core/ui/token-gen/package.json index cfb40a9fc8..f5bb756831 100644 --- a/core/ui/token-gen/package.json +++ b/core/ui/token-gen/package.json @@ -4,7 +4,9 @@ "private": true, "type": "module", "scripts": { - "build": "node build-tokens.mjs" + "build": "node build-tokens.mjs && node build-icons.mjs", + "build:tokens": "node build-tokens.mjs", + "build:icons": "node build-icons.mjs" }, "devDependencies": { "style-dictionary": "^5.4.0", 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/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/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/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/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/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/domain/transaction/build.gradle.kts b/domain/transaction/build.gradle.kts index 2791afdd38..7d7aadb53f 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/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/GetFeeUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt index 27e8209635..1b9254b8bd 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 @@ -3,11 +3,15 @@ package com.tangem.domain.transaction.usecase import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager +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 @@ -16,7 +20,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 @@ -63,7 +71,7 @@ class GetFeeUseCase( is Result.Success -> result.data is Result.Failure -> raise(result.mapToFeeError()) } - maybeFee + maybeFee.fixYieldSupplyGasLimit(transactionData = transactionData) }, catch = { raise(GetFeeError.DataError(it)) @@ -131,4 +139,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/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/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/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 fc42d06ff1..5fe647247b 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 @@ -234,6 +234,7 @@ class CreateAndSendGaslessTransactionUseCase( cardSdkConfigRepository.getCommonSigner( cardId = card.cardId.takeIf { isCardNotBackedUp }, twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), + userWalletId = userWallet.walletId, ) } is UserWallet.Hot -> getHotWalletSigner(userWallet) 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 index 3d61651862..0c59db1832 100644 --- 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 @@ -2,35 +2,42 @@ package com.tangem.domain.transaction.usecase import arrow.core.right import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager -import com.tangem.blockchain.common.TransactionData -import com.tangem.blockchain.common.WalletManager +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.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk +import io.mockk.* import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Test +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.math.BigInteger /** - * Tests for [GetFeeUseCase] — the compiled-transaction overload that decides between the new - * simulated `estimateFee` path and the legacy `getFee` path. + * Unit tests for [GetFeeUseCase]. * - * The simulated estimation is selected only when ALL of these hold: - * - [GetFeeUseCase.invoke] is called with `isSimulateEstimation = true` - * - `spenderAddress != null` - * - the resolved transaction sender is an [EthereumWalletManager] + * Covers two orthogonal pieces of the compiled-transaction overload: + * - The fee-source selection that chooses between the simulated `estimateFeeWithOverride` path and the legacy + * `getFee` path. The simulated estimation is selected only when ALL of these hold: + * - [GetFeeUseCase.invoke] is called with `isSimulateEstimation = true` + * - `spenderAddress != null` + * - the resolved transaction sender is an [EthereumWalletManager] + * - The Yield Mode gas-limit logic: uncompiled Ethereum transactions whose call data is + * [EthereumYieldSupplySendCallData] get their gas limit increased by 40%. * - * Any other combination falls back to the legacy `getFee(transactionData)`. + * Plus error mapping, null/exception handling, demo card routing, and crypto-currency-to-amount conversion in the + * second overload. */ internal class GetFeeUseCaseTest { @@ -44,20 +51,27 @@ internal class GetFeeUseCaseTest { private val network: Network = mockk(relaxed = true) private val userWalletId = UserWalletId(stringValue = "deadbeef") - private val userWallet: UserWallet = mockk(relaxed = true) { + private val userWallet: UserWallet.Hot = mockk(relaxed = true) { every { walletId } returns userWalletId } - private val transactionData: TransactionData = mockk(relaxed = true) - private val expectedFee: TransactionFee = mockk(relaxed = true) + private val walletManager: WalletManager = mockk() private val ethereumWalletManager: EthereumWalletManager = mockk() private val plainWalletManager: WalletManager = mockk() - @Before - fun setUp() { + private val transactionData: TransactionData = mockk(relaxed = true) + private val expectedFee: TransactionFee = mockk(relaxed = true) + + @BeforeEach + fun setup() { every { demoConfig.isDemoCardId(any()) } returns false + coEvery { + walletManagersFacade.getOrCreateWalletManager(userWalletId, network) + } returns walletManager } + // region invoke(userWallet, network, transactionData, spenderAddress, isSimulateEstimation) — fee-source selection + @Test fun `GIVEN simulate + spender + ethereum manager THEN estimateFee is used`() = runTest { coEvery { @@ -166,7 +180,7 @@ internal class GetFeeUseCaseTest { @Test fun `GIVEN getFee returns failure THEN error is mapped to GetFeeError`() = runTest { - val failure = Result.Failure(com.tangem.blockchain.common.BlockchainSdkError.CustomError("boom")) + val failure = Result.Failure(BlockchainSdkError.CustomError("boom")) coEvery { walletManagersFacade.getOrCreateWalletManager(userWalletId, network) } returns plainWalletManager @@ -182,24 +196,425 @@ internal class GetFeeUseCaseTest { assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.DataError::class.java) } + // endregion + + // region invoke(userWallet, network, transactionData) — Yield Mode gas-limit logic + @Test - fun `GIVEN wallet manager is null THEN DataError is raised`() = runTest { - coEvery { - walletManagersFacade.getOrCreateWalletManager(userWalletId, network) - } returns null - - val result = useCase( - userWallet = userWallet, - network = network, - transactionData = transactionData, - spenderAddress = null, - isSimulateEstimation = false, + 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 + private companion object { const val SPENDER = "0xSpender" } 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 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/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( 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 46b5a407bf..9612bf90ef 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 @@ -83,7 +83,7 @@ internal class AddFundsModel @Inject constructor( ) { actionsState, isAccountMode -> CryptoCurrencyData( userWallet = request.userWallet, - status = request.status, + status = actionsState.cryptoCurrencyStatus, actions = actionsState.states, isAccountMode = isAccountMode, account = request.account, diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCase.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCase.kt index de1bdc445f..9d8bdc2024 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCase.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCase.kt @@ -9,7 +9,7 @@ class GetPushNotificationsDoubleAskVariantUseCase @Inject constructor( private val abTestsManager: ABTestsManager, ) { - operator fun invoke(): DoubleAskVariant { + suspend operator fun invoke(): DoubleAskVariant { if (!pushNotificationsFeatureToggles.isOnboardingPushDoubleAskAbEnabled) { return DoubleAskVariant.Off } diff --git a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt index 6acc5eca76..aee804ce16 100644 --- a/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt +++ b/features/push-notifications/impl/src/main/java/com/tangem/features/pushnotifications/impl/model/PushNotificationsModel.kt @@ -79,13 +79,15 @@ internal class PushNotificationsModel @Inject constructor( modelScope.launch { proceedAfterLater() } return } - val variant = getPushNotificationsDoubleAskVariantUseCase() - resolvedVariant = variant.key - if (variant == DoubleAskVariant.On) { - analyticHandler.send(PushNotificationAnalyticEvents.WarningScreenShown(source, resolvedVariant)) - _isDoubleAskSheetShown.value = true - } else { - modelScope.launch { proceedAfterLater() } + modelScope.launch { + val variant = getPushNotificationsDoubleAskVariantUseCase() + resolvedVariant = variant.key + if (variant == DoubleAskVariant.On) { + analyticHandler.send(PushNotificationAnalyticEvents.WarningScreenShown(source, resolvedVariant)) + _isDoubleAskSheetShown.value = true + } else { + proceedAfterLater() + } } } diff --git a/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCaseTest.kt b/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCaseTest.kt index 3f446feca3..a42e32f434 100644 --- a/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCaseTest.kt +++ b/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/domain/GetPushNotificationsDoubleAskVariantUseCaseTest.kt @@ -3,9 +3,11 @@ package com.tangem.features.pushnotifications.impl.domain import com.google.common.truth.Truth.assertThat import com.tangem.core.abtests.manager.ABTestsManager import com.tangem.features.pushnotifications.PushNotificationsFeatureToggles +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 internal class GetPushNotificationsDoubleAskVariantUseCaseTest { @@ -19,38 +21,38 @@ internal class GetPushNotificationsDoubleAskVariantUseCaseTest { ) @Test - fun `GIVEN toggle disabled WHEN invoke THEN returns Off and AB not queried`() { + fun `GIVEN toggle disabled WHEN invoke THEN returns Off and AB not queried`() = runTest { every { featureToggles.isOnboardingPushDoubleAskAbEnabled } returns false val result = useCase() assertThat(result).isEqualTo(DoubleAskVariant.Off) - verify(exactly = 0) { abTestsManager.getValue(any(), any()) } + coVerify(exactly = 0) { abTestsManager.getValue(any(), any()) } } @Test - fun `GIVEN toggle enabled AND AB returns treatment WHEN invoke THEN returns On`() { + fun `GIVEN toggle enabled AND AB returns treatment WHEN invoke THEN returns On`() = runTest { every { featureToggles.isOnboardingPushDoubleAskAbEnabled } returns true - every { abTestsManager.getValue(KEY, "control") } returns "treatment" + coEvery { abTestsManager.getValue(KEY, "control") } returns "treatment" val result = useCase() assertThat(result).isEqualTo(DoubleAskVariant.On) - verify(exactly = 1) { abTestsManager.getValue(KEY, "control") } + coVerify(exactly = 1) { abTestsManager.getValue(KEY, "control") } } @Test - fun `GIVEN toggle enabled AND AB returns control WHEN invoke THEN returns Off`() { + fun `GIVEN toggle enabled AND AB returns control WHEN invoke THEN returns Off`() = runTest { every { featureToggles.isOnboardingPushDoubleAskAbEnabled } returns true - every { abTestsManager.getValue(KEY, "control") } returns "control" + coEvery { abTestsManager.getValue(KEY, "control") } returns "control" assertThat(useCase()).isEqualTo(DoubleAskVariant.Off) } @Test - fun `GIVEN toggle enabled AND AB returns unknown WHEN invoke THEN returns Off`() { + fun `GIVEN toggle enabled AND AB returns unknown WHEN invoke THEN returns Off`() = runTest { every { featureToggles.isOnboardingPushDoubleAskAbEnabled } returns true - every { abTestsManager.getValue(KEY, "control") } returns "unexpected_value" + coEvery { abTestsManager.getValue(KEY, "control") } returns "unexpected_value" assertThat(useCase()).isEqualTo(DoubleAskVariant.Off) } diff --git a/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/model/PushNotificationsModelTest.kt b/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/model/PushNotificationsModelTest.kt index afea4868f1..d35162df1f 100644 --- a/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/model/PushNotificationsModelTest.kt +++ b/features/push-notifications/impl/src/test/kotlin/com/tangem/features/pushnotifications/impl/model/PushNotificationsModelTest.kt @@ -19,8 +19,8 @@ import com.tangem.features.pushnotifications.impl.domain.GetPushNotificationsDou import com.tangem.features.pushnotifications.impl.domain.DoubleAskVariant import com.tangem.features.pushnotificationsettings.PushNotificationSettingsFeatureToggles import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -49,12 +49,12 @@ internal class PushNotificationsModelTest { @BeforeEach fun setUp() { - every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.Off + coEvery { getDoubleAskVariantUseCase() } returns DoubleAskVariant.Off } @Test fun `GIVEN onboarding treatment WHEN onLaterClick THEN double ask shown and not proceeded`() = runTest { - every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On + coEvery { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On val model = createModel(testScope = this) advanceUntilIdle() @@ -75,7 +75,7 @@ internal class PushNotificationsModelTest { @Test fun `GIVEN onboarding control WHEN onLaterClick THEN proceeds without double ask`() = runTest { - every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.Off + coEvery { getDoubleAskVariantUseCase() } returns DoubleAskVariant.Off val model = createModel(testScope = this) advanceUntilIdle() @@ -104,13 +104,13 @@ internal class PushNotificationsModelTest { advanceUntilIdle() assertThat(model.isDoubleAskSheetShown.value).isFalse() - verify(exactly = 0) { getDoubleAskVariantUseCase() } + coVerify(exactly = 0) { getDoubleAskVariantUseCase() } verify { modelCallbacks.onDenySystemPermission() } } @Test fun `GIVEN double ask shown WHEN onDoubleAskEnableClick THEN enable tapped sent and not proceeded`() = runTest { - every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On + coEvery { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On val model = createModel(testScope = this) advanceUntilIdle() model.onLaterClick() @@ -128,7 +128,7 @@ internal class PushNotificationsModelTest { @Test fun `GIVEN double ask shown WHEN onDoubleAskSkipClick THEN event sent and proceeded`() = runTest { - every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On + coEvery { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On val model = createModel(testScope = this) advanceUntilIdle() model.onLaterClick() @@ -146,7 +146,7 @@ internal class PushNotificationsModelTest { @Test fun `GIVEN double ask shown WHEN onDoubleAskDismiss THEN sheet hidden and not proceeded`() = runTest { - every { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On + coEvery { getDoubleAskVariantUseCase() } returns DoubleAskVariant.On val model = createModel(testScope = this) advanceUntilIdle() model.onLaterClick() 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/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/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 5f75691356..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 @@ -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 @@ -20,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 @@ -37,6 +35,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 +177,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) } @@ -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), @@ -301,8 +299,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 { 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 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 cd90496629..d4f2c02323 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, 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/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/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 12309f2f38..830560dcc8 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 @@ -35,6 +35,12 @@ interface SwapInteractor { pairs: List, ): List + fun extractFromSwapCurrencyFromPair( + pair: SwapPairLeast, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + ): SwapCurrencyStatus? + @Throws(IllegalStateException::class) suspend fun findBestQuote( fromSwapCurrencyStatus: SwapCurrencyStatus, @@ -109,6 +115,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)` — @@ -133,6 +143,7 @@ interface SwapInteractor { swapData: SwapDataModel?, selectedFeeToken: CryptoCurrencyStatus?, isGasless: Boolean, + txType: ExpressTxType? = null, ): Either fun integratedApprovalFallback(fromSwapCurrencyStatus: SwapCurrencyStatus, spenderAddress: String) 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 f22074bb35..0b10ee5654 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 @@ -181,6 +181,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, @@ -1164,20 +1183,19 @@ 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 (quotesLoadedState.swapProvider.type) { - ExchangeProviderType.DEX, - ExchangeProviderType.DEX_BRIDGE, - -> loadDexSwapFee( + return when (resolveQuoteFlow(quotesLoadedState.swapProvider, txType)) { + ResolvedFlow.DexLike -> loadDexSwapFee( fromStatus = fromStatus, swapData = swapData, selectedFeeToken = selectedFeeToken, permissionState = quotesLoadedState.permissionState, ) - ExchangeProviderType.CEX -> loadCexSwapFee( + ResolvedFlow.CexLike -> loadCexSwapFee( fromStatus = fromStatus, amount = amount, selectedFeeToken = selectedFeeToken, @@ -1619,6 +1637,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/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/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 f8692f938a..72c809308b 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 @@ -12,6 +12,7 @@ import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning 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 @@ -39,7 +40,7 @@ sealed interface SwapState { val currencyCheck: CryptoCurrencyCheck? = null, val validationResult: Throwable? = null, val minAdaValue: BigDecimal?, - + val txType: ExpressTxType? = null, ) : SwapState data class Transfer( 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/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/SwapInteractorImplLoadSwapFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadSwapFeeTest.kt index ce0f27bb3f..98c4993e97 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 @@ -16,6 +16,7 @@ import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.feature.swap.domain.fee.CexFeeResult import com.tangem.feature.swap.domain.fee.DexFeeResult import com.tangem.feature.swap.domain.fee.TransactionFeeResult +import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.ui.* @@ -253,6 +254,92 @@ 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( + quotesLoadedState = buildQuotesLoadedState(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_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( + quotesLoadedState = buildQuotesLoadedState(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) + val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) + val swapData = SwapDataModel( + toTokenAmount = SwapAmount(BigDecimal("0.5"), 18), + transaction = buildDexTransaction(), + ) + coEvery { + dexSwapFeeCalculator.calculate(any(), any(), any()) + } returns GetFeeError.DataError(cause = ExpressDataError.UnknownError()).left() + + val result = sut.loadSwapFee( + quotesLoadedState = buildQuotesLoadedState(ExchangeProviderType.DEX_BRIDGE), + fromStatus = fromStatus, + toStatus = toStatus, + amount = SwapAmount(BigDecimal.ONE, 18), + swapData = swapData, + selectedFeeToken = null, + isGasless = false, + ) + + assertThat(result.isLeft()).isTrue() + result.onLeft { error -> + assertThat(error).isInstanceOf(GetFeeError.DataError::class.java) + assertThat((error as? GetFeeError.DataError)?.cause).isInstanceOf(ExpressDataError.UnknownError::class.java) + } + } + // ------------------------------------------------------------------------- // CEX branch // ------------------------------------------------------------------------- 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 adf2316e02..38121250b6 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 @@ -103,7 +103,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() @@ -141,7 +142,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() @@ -176,7 +178,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() @@ -223,7 +226,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() @@ -297,7 +301,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() @@ -342,7 +347,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 521975fd77..8ae771fb83 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 @@ -148,6 +148,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( val isInTransferMode by remember { derivedStateOf { dataState.currentTransferState != null } } 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 460b766115..ad918f5f78 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 @@ -112,6 +112,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 @@ -578,6 +579,7 @@ internal class SwapModel @Inject constructor( } } + @Suppress("LongMethod") private fun onChangeCardsClicked() { modelScope.launch { singleTaskScheduler.cancelTask() @@ -595,7 +597,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( @@ -671,11 +681,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( @@ -686,7 +692,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, @@ -1266,7 +1276,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) @@ -2220,8 +2230,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 { @@ -2405,6 +2438,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) @@ -2447,17 +2502,15 @@ internal class SwapModel @Inject constructor( return Either.Left(GetFeeError.UnknownError) } - val swapAmount = SwapAmount(amount, 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 amountDecimal = lastAmount.value.replace(",", ".").toBigDecimalOrNull() + ?: return Either.Left(GetFeeError.UnknownError) + val swapAmount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) + val swapDataForCall = resolveDexSwapDataForFee(quoteState) + .getOrElse { return Either.Left(it) } + val integratedSettings = (quoteState.permissionState as? PermissionDataState.PermissionSettings) ?.takeIf { swapFeatureToggles.isSwapIntegratedApproveEnabled } - // Get swap tx fee return swapInteractor.loadSwapFee( quotesLoadedState = quoteState, fromStatus = fromSwapCurrencyStatus, @@ -2466,6 +2519,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 @@ -2518,11 +2572,16 @@ internal class SwapModel @Inject constructor( val swapAmount = SwapAmount(amount, 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 } @@ -2535,6 +2594,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 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 6facac10e1..ff3fd003ee 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 currencyIconState: CurrencyIconState, val tokenSymbol: TextReference, val amountEquivalent: TextReference?, - val balance: String, + val balance: TextReference, val isBalanceHidden: Boolean, val appCurrency: AppCurrency, val amountField: AmountFieldModel? = null, 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, ) }, 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 64e53e0692..3b95b37203 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 @@ -307,7 +307,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, amountField = if (isFromCard) { @@ -320,7 +320,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, amountField = if (isFromCard) { @@ -410,7 +410,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(), amountField = if (isFromCard) { emptyAmountField(swapCurrencyStatus) @@ -451,7 +451,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(), appCurrency = appCurrencyProvider(), ), @@ -463,7 +463,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(), appCurrency = appCurrencyProvider(), ), @@ -579,7 +579,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(), amountField = uiStateHolder.sendCardData.amountField, appCurrency = appCurrencyProvider(), @@ -618,7 +618,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(), appCurrency = appCurrencyProvider(), ), @@ -804,7 +804,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(), appCurrency = appCurrencyProvider(), ) @@ -1326,10 +1326,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 a69892d96b..62993727b1 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 @@ -111,10 +111,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, ) @@ -283,7 +281,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() @@ -305,13 +308,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 085404f678..314841491e 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 @@ -32,10 +32,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 @@ -93,10 +90,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, ) @@ -260,7 +255,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() @@ -282,10 +282,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 d57d2b84eb..c53ed18a6f 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 @@ -37,7 +37,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, appCurrency = AppCurrency.Default, amountField = AmountFieldModel( @@ -74,7 +74,7 @@ internal object SwapTransactionCardPreview { amountEquivalent = stringReference("1 000 000"), currencyIconState = CurrencyIconState.Loading, tokenSymbol = stringReference("DAI"), - balance = "33333", + balance = stringReference("Balance: 33333 DAI"), isBalanceHidden = false, appCurrency = AppCurrency.Default, amountField = AmountFieldModel( 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 28ab6ffc5e..6dff203ba8 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 @@ -37,7 +37,6 @@ import com.tangem.feature.swap.models.states.SwapNotificationUM import com.tangem.feature.swap.presentation.R import com.tangem.features.send.api.utils.formatFooterFiatFee import com.tangem.features.send.api.utils.getTronTokenFeeSendingText -import com.tangem.utils.StringsSigns.DASH_SIGN import com.tangem.utils.extensions.orZero import kotlinx.collections.immutable.ImmutableList import java.math.BigDecimal @@ -286,9 +285,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 e19b4ec0ec..0468dcbcba 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 @@ -782,7 +782,7 @@ internal class SwapTransferStateBuilderTest { tokenSymbol = stringReference(""), amountEquivalent = TextReference.EMPTY, amountField = initialAmountField, - balance = "", + balance = TextReference.EMPTY, isBalanceHidden = false, ), receiveCardData = SwapCardState.Loading( 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 20cd8763fb..58d867a663 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 @@ -482,7 +482,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 0607216242..5d5650e27a 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 @@ -225,7 +225,6 @@ internal class TangemPayDetailsModel @Inject constructor( cryptoAmount = balance.availableForWithdrawal, fiatAmount = balance.availableForWithdrawal, depositAddress = balance.cryptoBalance.depositAddress, - isWithdrawal = true, ), ), ) @@ -317,7 +316,6 @@ internal class TangemPayDetailsModel @Inject constructor( cryptoAmount = data.cryptoBalance, fiatAmount = data.fiatBalance, depositAddress = data.depositAddress, - isWithdrawal = false, ), ), ) 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 46ced8cd7f..50c9102fa5 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 @@ -114,13 +114,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/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/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/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 691cb5f5dc..925d0d9ec3 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 @@ -69,6 +69,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 @@ -732,11 +733,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() @@ -1204,7 +1203,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 dedf153f73..12dc90644f 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 @@ -47,6 +47,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) } } @@ -103,6 +104,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/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 a051b8688e..4a554824e8 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 @@ -46,6 +46,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta 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 @@ -63,6 +64,7 @@ private val MarketBlockHorizontalPadding: Dp = 14.dp private const val TOP_FADE_MID_STOP = 0.8f private const val TOP_FADE_MID_ALPHA = 0.8f +@Suppress("LongParameterList") @Composable internal fun TokenDetailsScreen( tokenDetailsUM: TokenDetailsUM, @@ -70,6 +72,7 @@ internal fun TokenDetailsScreen( yieldSupplyComponent: YieldSupplyComponent, txHistoryComponent: TxHistoryComponent, expressTransactionsComponent: ExpressTransactionsComponent, + ratingComponent: RatingComponent?, modifier: Modifier = Modifier, ) { val expressState by expressTransactionsComponent.state.collectAsStateWithLifecycle() @@ -112,7 +115,10 @@ internal fun TokenDetailsScreen( onHeightChange = { marketBlockHeight = it }, ) } - expressState.bottomSheetSlot?.content(null) + + expressState.bottomSheetSlot?.content( + ratingComponent?.let { comp -> { comp.Content(modifier = Modifier.fillMaxWidth()) } }, + ) } } } @@ -310,6 +316,7 @@ private fun TokenDetailsScreen_Preview() { override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryItemsUM) = Unit }, expressTransactionsComponent = PreviewExpressTransactionsComponent, + ratingComponent = null, ) } } 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 }, 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 054e33a23d..09d0c4833b 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 @@ -92,6 +92,33 @@ 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) + } + @Test fun `GIVEN active staked balance AND balance hidden WHEN transform THEN trailing balance hidden`() { // Arrange @@ -104,7 +131,7 @@ class UpdateStakingNotificationTransformerTest { val transformer = createTransformer( availability = availableOption(BigDecimal("4.2")), entryInfo = StakingEntryInfo(tokenSymbol = "ETH"), - cryptoCurrencyStatus = status, + status = status, isBalanceHidden = true, ) @@ -129,7 +156,7 @@ class UpdateStakingNotificationTransformerTest { val transformer = createTransformer( availability = availableOption(BigDecimal("4.2")), entryInfo = StakingEntryInfo(tokenSymbol = "ETH"), - cryptoCurrencyStatus = status, + status = status, isBalanceHidden = true, ) @@ -154,7 +181,7 @@ class UpdateStakingNotificationTransformerTest { val transformer = createTransformer( availability = availableOption(BigDecimal("4.2")), entryInfo = StakingEntryInfo(tokenSymbol = "ETH"), - cryptoCurrencyStatus = status, + status = status, isBalanceHidden = false, ) @@ -173,10 +200,10 @@ class UpdateStakingNotificationTransformerTest { private fun createTransformer( availability: StakingAvailability, entryInfo: StakingEntryInfo?, - cryptoCurrencyStatus: CryptoCurrencyStatus = buildStatus(), + status: CryptoCurrencyStatus = buildStatus(), isBalanceHidden: Boolean = false, ) = UpdateStakingNotificationTransformer( - cryptoCurrencyStatus = cryptoCurrencyStatus, + cryptoCurrencyStatus = status, stakingAvailability = availability, stakingEntryInfo = entryInfo, appCurrency = AppCurrency.Default, @@ -244,6 +271,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(isBalanceHidden: Boolean = false): TokenDetailsUM = TokenDetailsUM( topAppBarUM = TokenDetailsTopAppBarUM( titleState = TitleState.Simple(tokenName = "Solana"), diff --git a/features/txhistory/impl/build.gradle.kts b/features/txhistory/impl/build.gradle.kts index 43a54f613d..04d8147aa1 100644 --- a/features/txhistory/impl/build.gradle.kts +++ b/features/txhistory/impl/build.gradle.kts @@ -64,4 +64,5 @@ dependencies { testImplementation(deps.test.junit5) 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/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/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt index 6a43ac5437..d3b0628dbf 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 } @@ -157,4 +165,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/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 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/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( 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, ) } 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 b03a710fd8..89a265f09e 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 @@ -85,6 +87,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 { @@ -187,12 +190,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, + ) + } } /** @@ -421,6 +430,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 } 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/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 b0bb93adb9..325ecc9c1e 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", 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 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), 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