Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-16 00:56:37 +04:00
commit 13b189aae7
124 changed files with 3611 additions and 477 deletions

@ -1 +1 @@
Subproject commit 97ff5929f9ff4da53190eb10e94c45ac3bd05093
Subproject commit a7b32c766817076c6346156390c135a3dae1b6ce

View file

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

View file

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

View file

@ -51,6 +51,7 @@ internal object WalletConnectDomainModule {
cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = TwinKey.getOrNull(scanResponse = wallet.scanResponse),
userWalletId = wallet.walletId,
)
}
}

View file

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

View file

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

View file

@ -339,7 +339,6 @@ internal class ChildFactory @Inject constructor(
cryptoAmount = tangemPayInput.cryptoAmount,
fiatAmount = tangemPayInput.fiatAmount,
depositAddress = tangemPayInput.depositAddress,
isWithdrawal = tangemPayInput.isWithdrawal,
)
},
),

View file

@ -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<LastSignedWalletFormStore>(relaxed = true)
private val userWalletsListRepository = mockk<UserWalletsListRepository>()
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<UserWallet>()
@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)
}
}

View file

@ -219,7 +219,6 @@ sealed class AppRoute(val path: String) : Route {
val cryptoAmount: SerializedBigDecimal,
val fiatAmount: SerializedBigDecimal,
val depositAddress: String,
val isWithdrawal: Boolean,
)
@Serializable

View file

@ -38,7 +38,6 @@ class AmountStateConverter(
private val iconStateConverter: CryptoCurrencyToIconStateConverter,
private val isBalanceHidden: Boolean,
private val accountTitleUM: AccountTitleUM,
private val isMaxButtonVisible: Boolean = true,
) : Converter<AmountParameters, AmountState> {
private val amountFieldConverter by lazy(LazyThreadSafetyMode.NONE) {
@ -73,7 +72,6 @@ class AmountStateConverter(
amountTextField = amountFieldConverter.convert(value.value),
isPrimaryButtonEnabled = false,
appCurrency = appCurrency,
isMaxButtonVisible = isMaxButtonVisible,
)
}
}

View file

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

View file

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

View file

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

View file

@ -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<CryptoCurrency.Coin> {
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<CryptoCurrencyToIconStateConverter> {
every { convert(any<CryptoCurrency>()) } returns CurrencyIconState.Loading
}
private val clickIntents = mockk<AmountScreenClickIntents>(relaxed = true)
private val accountTitleUM = mockk<AccountTitleUM>()
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()
}
}

View file

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

View file

@ -8,5 +8,5 @@ interface ABTestsManager {
fun removeUserProperties()
fun getValue(key: String, defaultValue: String): String
suspend fun getValue(key: String, defaultValue: String): String
}

View file

@ -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<Unit>()
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<String, com.amplitude.experiment.Variant>) {
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
}
}

View file

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

View file

@ -6,11 +6,22 @@ package com.tangem.core.analytics.models
open class AnalyticsEvent(
val category: String,
val event: String,
val params: Map<String, String> = mapOf(),
params: Map<String, String> = mapOf(),
) {
var params: Map<String, String> = params
private set
val id: String = "[$category] $event"
fun withParams(newParams: Map<String, String>) = 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<String, String>): AnalyticsEvent {
params = newParams
return this
}
override fun equals(other: Any?): Boolean {
if (this === other) return true

View file

@ -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<AnalyticsHandler> { every { id() } returns "AppsFlyer" }
val otherHandler = mockk<AnalyticsHandler> { every { id() } returns "Amplitude" }
assertThat(filter.canBeConsumedByHandler(appsFlyerHandler, enriched)).isTrue()
assertThat(filter.canBeConsumedByHandler(otherHandler, enriched)).isFalse()
}
}

View file

@ -123,6 +123,7 @@ internal object GeneratedEnvironmentConfigConverter {
etherscanApiKey = GeneratedEnvironmentConfig.etherscanApiKey,
blinkApiKey = GeneratedEnvironmentConfig.blinkApiKey,
tatumApiKey = GeneratedEnvironmentConfig.tatumApiKey,
alchemyApiKey = GeneratedEnvironmentConfig.alchemyApiKey,
)
}

View file

@ -1818,8 +1818,8 @@
<string name="tangempay_card_page_daily_limit_title">Tageslimit</string>
<string name="tangempay_card_page_settings_title">Einstellungen der Karte</string>
<plurals name="tangempay_cards_count">
<item quantity="one">Karte</item>
<item quantity="other">Karten</item>
<item quantity="one">%d Karte</item>
<item quantity="other">%d Karten</item>
</plurals>
<string name="tangempay_change_pin_code">PIN-Code ändern</string>
<string name="tangempay_come_back_if_forget_pin">Kehren Sie zur App zurück, falls Sie ihn vergessen.</string>

View file

@ -1682,6 +1682,10 @@
<string name="tangempay_card_page_daily_limit_success_title">El límite está establecido</string>
<string name="tangempay_card_page_daily_limit_title">Límite diario</string>
<string name="tangempay_card_page_settings_title">Configuración de la tarjeta</string>
<plurals name="tangempay_cards_count">
<item quantity="one">%d tarjeta</item>
<item quantity="other">%d tarjetas</item>
</plurals>
<string name="tangempay_change_pin_code">Cambiar código PIN</string>
<string name="tangempay_come_back_if_forget_pin">Vuelve a la app si lo olvidas.</string>
<string name="tangempay_daily_limit_hint" formatted="false">Establecer un límite de %s a %s</string>

View file

@ -1692,6 +1692,10 @@
<string name="tangempay_card_page_daily_limit_success_title">La limite est définie</string>
<string name="tangempay_card_page_daily_limit_title">Limite quotidienne</string>
<string name="tangempay_card_page_settings_title">Paramètres de la carte</string>
<plurals name="tangempay_cards_count">
<item quantity="one">%d carte</item>
<item quantity="other">%d cartes</item>
</plurals>
<string name="tangempay_change_pin_code">Modifier le code PIN</string>
<string name="tangempay_come_back_if_forget_pin">Revenez dans l\'application si vous l\'oubliez.</string>
<string name="tangempay_daily_limit_hint" formatted="false">Définir une limite de %s à %s</string>

View file

@ -159,6 +159,10 @@
<string name="tangempay_card_page_daily_limit_success_title">Il limite è impostato</string>
<string name="tangempay_card_page_daily_limit_title">Limite giornaliero</string>
<string name="tangempay_card_page_settings_title">Impostazioni della carta</string>
<plurals name="tangempay_cards_count">
<item quantity="one">%d carta</item>
<item quantity="other">%d carte</item>
</plurals>
<string name="tangempay_change_pin_code">Cambia codice PIN</string>
<string name="tangempay_come_back_if_forget_pin">Torna all\'app se lo dimentichi.</string>
<string name="tangempay_daily_limit_hint" formatted="false">Imposta un limite da %s a %s</string>

View file

@ -90,9 +90,9 @@
<string name="add_custom_token_choose_network">Выберите сеть</string>
<string name="add_custom_token_title">Добавить токен</string>
<string name="add_tokens_title">Валюты</string>
<string name="addfunds_buy_row_description">Кредитная карта или банковский аккаунт</string>
<string name="addfunds_buy_row_description">Банковская карта или банковский счет</string>
<string name="addfunds_fund_token">Пополнить токен</string>
<string name="addfunds_receive_row_description">Поделитесь своим адресом или QR-кодом.</string>
<string name="addfunds_receive_row_description">Поделитесь своим адресом или QR-кодом</string>
<string name="addfunds_swap_row_description">Между вашими портфелями</string>
<string name="addfunds_you_receive_title">Вы получите</string>
<string name="address_book_add_address">Добавить адрес</string>
@ -100,20 +100,23 @@
<string name="address_book_add_contact">Добавить контакт</string>
<plurals name="address_book_addresses">
<item quantity="one">адрес</item>
<item quantity="few">адресов</item>
<item quantity="few">адреса</item>
<item quantity="many">адресов</item>
<item quantity="other">адреса</item>
<item quantity="other">адресов</item>
</plurals>
<string name="address_book_contact">Контакт</string>
<string name="address_book_contact_name">Имя контакта</string>
<string name="address_book_copy_address">Копировать адрес</string>
<string name="address_book_creating_error">Не удалось создать контакт. Пожалуйста, попробуйте позже.</string>
<string name="address_book_delete_contact_description">Этот контакт будет удален из всех ваших адресных книг</string>
<string name="address_book_deleting_error">Не удалось удалить контакт. Пожалуйста, попробуйте позже.</string>
<string name="address_book_description">Управление контактами и адресами</string>
<string name="address_book_discard">Отменить</string>
<string name="address_book_edit_address">Редактировать адрес</string>
<string name="address_book_enter_address">Ввест адрес</string>
<string name="address_book_enter_address">Ввести адрес</string>
<string name="address_book_invalid_address_error">Неверный адрес</string>
<string name="address_book_keep_editing">Продолжить</string>
<string name="address_book_new_contact">Новый котакт</string>
<string name="address_book_new_contact">Новый контакт</string>
<string name="address_book_no_contacts">Нет добавленных контактов</string>
<string name="address_book_no_contacts_description">Здесь отобразятся добавленные вами контакты.</string>
<string name="address_book_remove_address">Удалить адрес</string>
@ -303,6 +306,7 @@
<string name="common_connecting">Подключение</string>
<string name="common_contact_support">Обратиться в поддержку</string>
<string name="common_contact_tangem_support">Связаться с поддержкой</string>
<string name="common_contact_visa_support">Обратиться в службу поддержки Visa</string>
<string name="common_continue">Продолжить</string>
<string name="common_convert">Конвертировать</string>
<string name="common_copy">Копировать</string>
@ -324,8 +328,8 @@
</plurals>
<plurals name="common_days_time_ago">
<item quantity="one">%d день назад</item>
<item quantity="few"></item>
<item quantity="many"></item>
<item quantity="few">%d дня назад</item>
<item quantity="many">%d дней назад</item>
<item quantity="other">%d дней назад</item>
</plurals>
<string name="common_delete">Удалить</string>
@ -410,7 +414,7 @@
<string name="common_nft">NFT</string>
<string name="common_no">Нет</string>
<string name="common_no_address">Нет адреса</string>
<string name="common_no_results">Нет результата</string>
<string name="common_no_results">Нет результатов</string>
<string name="common_not_added">Не добавлено</string>
<string name="common_not_available">Не доступно</string>
<string name="common_not_now">Не сейчас</string>
@ -480,7 +484,7 @@
<string name="common_to_wallet_name">На %s</string>
<string name="common_today">Сегодня</string>
<string name="common_token">Токен</string>
<string name="common_token_send">Токен к отправке</string>
<string name="common_token_send">Токен для отправки</string>
<plurals name="common_tokens_count">
<item quantity="one">%d токен</item>
<item quantity="few">%d токена</item>
@ -491,7 +495,7 @@
<string name="common_transaction_status">Статус транзакции</string>
<string name="common_transactions">Транзакции</string>
<string name="common_transfer">Перевод</string>
<string name="common_transferred">Отправлено</string>
<string name="common_transferred">Переведено</string>
<string name="common_try_again_later">Пожалуйста, попробуйте позже</string>
<string name="common_unable_to_load">Невозможно загрузить данные…</string>
<string name="common_understand">Я понял</string>
@ -502,7 +506,7 @@
<string name="common_unstake">Завершить стейкинг</string>
<string name="common_utxo_validate_withdrawal_message_warning">Из-за ограничений %1$s в одну транзакцию может поместиться только %2$d UTXO. Это означает, что вы можете отправить только %3$s или меньше. Вам нужно уменьшить сумму.</string>
<string name="common_value_copied">Значение скопировано</string>
<string name="common_view_all">Посмотреть все</string>
<string name="common_view_all">Показать все</string>
<string name="common_voting">Голосование</string>
<string name="common_wallets">Кошельки</string>
<string name="common_warning">Предупреждение</string>
@ -523,7 +527,7 @@
<string name="custom_token_creation_error_token_already_exist_title">Токен уже существует</string>
<string name="custom_token_creation_error_wrong_decimals">Десятичное число должно быть действительным целым числом, до %d</string>
<string name="custom_token_custom_derivation">Своя деривация</string>
<string name="custom_token_custom_derivation_dynamic_addresses_enabled_error">Включены динамические адреса. Пользовательские значения change и index недоступны.</string>
<string name="custom_token_custom_derivation_dynamic_addresses_enabled_error">Включены динамические адреса. Пользовательские значения \\"change\"\ и \\"index\"\ недоступны.</string>
<string name="custom_token_custom_derivation_placeholder">Например m/00\'/0000\'/0\'/0/0</string>
<string name="custom_token_custom_derivation_title">Введите свою деривацию</string>
<string name="custom_token_decimals_input_title">Знаков после запятой</string>
@ -577,25 +581,27 @@
<string name="domain_receive_assets_onboarding_network_name">%s сеть</string>
<string name="domain_receive_assets_onboarding_title">Отправляйте средства, используя только</string>
<string name="dynamic_addresses">Динамические адреса</string>
<string name="dynamic_addresses_custom_token_error_on_addition">Включены динамические адреса. Пользовательские значения change и index недоступны.</string>
<string name="dynamic_addresses_disable_description">Вы отключаете динамические адреса. Теперь средства будут поступать на ваш текущий адрес без его изменения.</string>
<string name="dynamic_addresses_custom_token_error_on_addition">Включены динамические адреса. Пользовательские значения \"change\" и \"index\" недоступны.</string>
<string name="dynamic_addresses_disable_description">Вы отключаете динамические адреса. Теперь средства будут поступать на ваш фиксированный адрес</string>
<string name="dynamic_addresses_disable_fee_description">Для перевода средств на фиксированный адрес взимается комиссия сети</string>
<string name="dynamic_addresses_disable_main_button_title">Отключить динамические адреса</string>
<string name="dynamic_addresses_disable_title">Отключить динамические адреса</string>
<string name="dynamic_addresses_disabled_popup_title">Динамические адреса отключены</string>
<string name="dynamic_addresses_enabled_toast_title">Динамические адреса включены</string>
<string name="dynamic_addresses_enter_features_privacy_description">Используйте новый адрес для каждой транзакции, чтобы снизить отслеживаемость и повысить конфиденциальность в блокчейне.</string>
<string name="dynamic_addresses_enter_features_privacy_title">Повышенная конфиденциальность</string>
<string name="dynamic_addresses_enter_features_receving_description">Легко получайте средства в сетях на базе UTXO благодаря автоматической генерации адресов — без необходимости управлять адресами вручную.</string>
<string name="dynamic_addresses_enter_features_receving_description">Получайте средства как обычно. Tangem управляет UTXO-адресами автоматически, вам не нужно выбирать или отслеживать их вручную.</string>
<string name="dynamic_addresses_enter_features_receving_title">Бесшовное получение</string>
<string name="dynamic_addresses_enter_main_button_title">Включить динамические адреса</string>
<string name="dynamic_addresses_enter_subtitle">Динамические адреса каждый раз создают новый адрес для дополнительной приватности — ваш общий баланс при этом не меняется.</string>
<string name="dynamic_addresses_enter_subtitle">Ваш кошелек может использовать несколько адресов для получения одного актива. Tangem показывает новый адрес после пополнения предыдущего — все они привязаны к одному кошельку.</string>
<string name="dynamic_addresses_error_has_custom_token_description">Динамические адреса нельзя включить, так как некоторые пользовательские адреса/токены используют измененный путь деривации, который не соответствует требуемым критериям.</string>
<string name="dynamic_addresses_error_has_custom_token_title">Динамические адреса недоступны</string>
<string name="dynamic_addresses_error_service_unavailable_description">Не удаётся подключиться к провайдеру. Попробуйте позже.</string>
<string name="dynamic_addresses_error_service_unavailable_description">Не удается подключиться к провайдеру. Пожалуйста, попробуйте позже.</string>
<string name="dynamic_addresses_error_service_unavailable_title">Сервис недоступен. Пожалуйста, попробуйте еще раз.</string>
<string name="dynamic_addresses_notification_funds_found_description">На дополнительных адресах найдены средства. Включите динамические адреса, чтобы получить к ним доступ</string>
<string name="dynamic_addresses_notification_funds_found_title">Обнаружены средства на связанных адресах</string>
<string name="dynamic_addresses_notification_funds_found_title">Найдены средства на дополнительных адресах</string>
<string name="dynamic_addresses_receive_badge">Динамический адрес</string>
<string name="dynamic_addresses_unavailability_reason_pending_transaction_send">Управление динамическими адресами будет доступно после завершения ожидающих транзакций в сети %@.</string>
<string name="dynamic_addresses_unavailability_reason_pending_transaction_send">Управление динамическими адресами станет доступно после подтверждения транзакций в сети %@</string>
<string name="earn_best_opportunities">Лучшие возможности</string>
<string name="earn_clear_filter">Очистить фильтр</string>
<string name="earn_empty">Список временно пуст — он обновляется. Пожалуйста, зайдите чуть позже.</string>
@ -665,15 +671,22 @@
<string name="express_legal_two_placeholders">Пользуясь сервисом, вы соглашаетесь с %1$s и %2$s.</string>
<string name="express_more_providers_soon">Больше провайдеров на подходе.\nСледите за обновлениями!</string>
<string name="express_provider">Провайдер</string>
<string name="express_provider_best_dex_rate">Лучший курс на DEX</string>
<string name="express_provider_best_rate">Лучший курс</string>
<string name="express_provider_fca_warning_list">Список предупреждений FCA</string>
<string name="express_provider_fixed_rate_is_unavailable">Фиксированная ставка недоступна</string>
<string name="express_provider_for_swap">Провайдер для обмена</string>
<string name="express_provider_great_rate">Лучший выбор</string>
<string name="express_provider_in_fca_warning_list">Провайдер в списке предупреждений FCA</string>
<string name="express_provider_max_amount">Доступно до %s</string>
<string name="express_provider_min_amount">Доступно с %s</string>
<string name="express_provider_not_available">Недоступно для этой пары</string>
<string name="express_provider_permission_needed">Требуется разрешение</string>
<string name="express_provider_permission_needed_v2">Требуется разрешение</string>
<string name="express_provider_recommended">Рекомендовано</string>
<string name="express_send_with_swap_not_supported_button">Обменять токен</string>
<string name="express_send_with_swap_not_supported_text">Обменяйте вручную, а затем отправьте получателю</string>
<string name="express_send_with_swap_not_supported_title">%s не поддерживается в Swap &amp; Send</string>
<string name="express_status_bought">Куплено %s</string>
<string name="express_status_buying">Покупка %s</string>
<string name="express_status_buying_active">Покупка %s...</string>
@ -693,7 +706,7 @@
<string name="fee_selector_choose_token_title">Выбрать токен</string>
<string name="feed_market_and_news">Рынок и Новости</string>
<string name="feed_search_show_all_user_assets">Показать все</string>
<string name="feed_search_show_less_user_assets">Показать меньше</string>
<string name="feed_search_show_less_user_assets">Скрыть</string>
<string name="feed_trending_now">В тренде</string>
<string name="feedback_data_collection_message">Информация ниже не является обязательной. Вы можете стереть её, если хотите.</string>
<string name="feedback_preface_rate_negative">Расскажите, каких функций вам не хватает, и мы постараемся вам помочь.</string>
@ -724,6 +737,7 @@
<string name="give_permission_staking_footer">Функция подтверждения необходима для предоставления другому адресу разрешения на использование определенного количества ваших токенов. По замыслу смарт-контракты не могут получить доступ к вашим токенам, если вы не одобрите доступ со своей стороны. «Разблокируя» свои токены, вы даете смарт-контракту StakeKit разрешение использовать ваши активы. Майнеры сети получают компенсацию за газ (оплачиваемый вами) за запись этого действия в блокчейне. Как только разрешение будет предоставлено, вы сможете осуществить стейкинг токена.</string>
<string name="give_permission_staking_subtitle">Чтобы продолжить, вам необходимо разрешить смарт контракту Polygon использовать ваш %s</string>
<string name="give_permission_swap_subtitle" formatted="false">Чтобы продолжить, вам нужно разрешить смарт-контракту %1s использовать ваш %2s</string>
<string name="give_permission_swap_subtitle_v2">Децентрализованные биржи требуют разрешения на взаимодействие с вашим кошельком. %1s</string>
<string name="give_permission_title">Дать разрешение</string>
<string name="give_permission_unlimited">Безлимитно</string>
<string name="hardware_wallet_backup_feature_description">Адреса будут сгенерированы прямо в аппаратном кошельке Tangem — готовы к использованию и полностью защищены</string>
@ -841,7 +855,7 @@
<string name="main_add_funds_promo_title">Пополните кошелёк</string>
<string name="main_empty_tokens_list_message">Чтобы начать отслеживать свои криптоактивы и транзакции, добавьте токены</string>
<string name="main_manage_tokens">Управление токенами</string>
<string name="main_qr_scan_hint">Отсканируйте QR-код, чтобы отправить средства или подключиться к приложению.</string>
<string name="main_qr_scan_hint">Отсканируйте QR-код, чтобы отправить средства или подключиться к приложению</string>
<string name="main_scan_card_warning_view_subtitle">Чтобы получить доступ ко всем сетям, вам необходимо отсканировать карту</string>
<string name="main_scan_card_warning_view_title">Отсканируйте карту или кольцо</string>
<string name="main_swap_changelly_promotion_message">Обменивайте свои токены с %1$s комиссии провайдера через Changelly с %2$s по %3$s февраля.</string>
@ -876,12 +890,14 @@
<string name="markets_add_to_my_portfolio_unavailable_for_wallet_description">Этот токен недоступен для данного кошелька</string>
<string name="markets_add_token">Добавить</string>
<string name="markets_apy_placeholder">APY %s</string>
<string name="markets_common_market_price">Рыночная цена</string>
<string name="markets_common_my_portfolio">Мой портфель</string>
<string name="markets_common_title">Рынок</string>
<string name="markets_earn_common_title">Доход с Tangem</string>
<string name="markets_generate_addresses_notification">Чтобы создать адреса для выбранных сетей, отсканируйте вашу карту или кольцо Tangem кошелька</string>
<string name="markets_hint">Потяните вверх или коснитесь поисковой строки, чтобы добавить токен</string>
<string name="markets_hint_part_one">Смахните вверх, чтобы изучить рынок</string>
<string name="markets_hint_part_one">Потяните вверх, чтобы открыть рынок</string>
<string name="markets_hint_part_two">Откройте для себя скрытые гемы</string>
<string name="markets_insights_info_description_message">Данные раздела получены из следующих сетей: %s</string>
<string name="markets_loading_error_title">Невозможно загрузить данные</string>
<string name="markets_loading_no_data_title">Нет данных</string>
@ -993,7 +1009,7 @@
<string name="markets_token_details_trading_volume">Объем торгов (24ч)</string>
<string name="markets_token_details_trading_volume_24h_description">Общая сумма криптовалюты, которая была продана за последние 24 часа, показывающая уровень активности и ликвидности на рынке.</string>
<string name="markets_token_details_trading_volume_full">Объем торгов (24ч)</string>
<string name="markets_token_details_valuation_value_in_total">%s в сумме</string>
<string name="markets_token_details_valuation_value_in_total">%s всего</string>
<string name="markets_token_details_volume">Объем</string>
<string name="markets_tooltip_message">Потяните вверх или коснитесь поисковой строки, чтобы добавить токены напрямую из рынка</string>
<string name="markets_tooltip_title">Добавить токены</string>
@ -1070,7 +1086,7 @@
<plurals name="nft_wallet_count_android">
<item quantity="one">%1$d NFT в %2$d коллекции</item>
<item quantity="few">%1$d NFT в %2$d коллекциях</item>
<item quantity="many"></item>
<item quantity="many">%1$d NFT в %2$d коллекциях</item>
<item quantity="other">%1$d NFT в %2$d коллекциях</item>
</plurals>
<string name="nft_wallet_receive_nft">Нажмите здесь, чтобы получить первую NFT</string>
@ -1223,13 +1239,14 @@
<string name="onramp_instant_status">Моментально</string>
<string name="onramp_kyc_verification_bullet_free">Верификация бесплатная и обычно занимает 1-2 минуты</string>
<string name="onramp_kyc_verification_bullet_privacy">Tangem не будет иметь доступа к вашим личным данным, вы передаете их напрямую лицензированному провайдеру</string>
<string name="onramp_kyc_verification_bullet_unlocks">Верификация открывает полный доступ к будущим транзакциям с этим провайдером.</string>
<string name="onramp_kyc_verification_bullet_unlocks">Верификация открывает полный доступ к будущим транзакциям с этим провайдером</string>
<string name="onramp_kyc_verification_choose_another">Выберите другой метод</string>
<string name="onramp_kyc_verification_subtitle">Согласно требованиям законодательства, %@ требует пройти верификацию личности.</string>
<string name="onramp_kyc_verification_title">Провайдер платежей требует подтверждения личности</string>
<string name="onramp_kyc_verification_verify_button">Верифицировать</string>
<string name="onramp_kyc_verification_whats_important">Что важно знать</string>
<string name="onramp_legal">Пользуясь сервисом покупки, вы соглашаетесь с %1$s и %2$s</string>
<string name="onramp_legal_text">Услуга предоставляется сторонним провайдером.\nTangem не несет ответственности.</string>
<string name="onramp_max_amount_restriction">Сумма покупки не может быть больше, чем %s</string>
<string name="onramp_min_amount_restriction">Сумма покупки должна составлять минимум %s</string>
<string name="onramp_native_payment_cumulative_limit" formatted="false">Общая сумма транзакций свыше %1s может потребовать верификации личности через %2s</string>
@ -1270,19 +1287,31 @@
<string name="onramp_title_available_from">Доступно от</string>
<string name="onramp_title_available_up_to">Доступно до</string>
<string name="onramp_title_you_get">Вы получите</string>
<string name="onramp_tos_external_providers">Услуга предоставляется сторонним провайдером.\nTangem не несет ответственности.</string>
<string name="onramp_transaction_status_footer_text">Вы можете закрыть этот экран и проверить статус транзакции на экране информации о токене.</string>
<string name="onramp_up_to_rate">До</string>
<string name="onramp_via">Через</string>
<string name="onramp_via_provider">через %s</string>
<string name="onramp_you_will_pay_title">Вы заплатите</string>
<string name="organize_tokens_group">Группы</string>
<string name="organize_tokens_menu_group_by_networks">Сгруппировать по сети</string>
<string name="organize_tokens_menu_group_by_networks">Сгруппировать по сетям</string>
<string name="organize_tokens_menu_sort_by_balance">Сортировать по балансу</string>
<string name="organize_tokens_sort_by_balance">По балансу</string>
<string name="organize_tokens_title">Упорядочить токены</string>
<string name="organize_tokens_ungroup">Список</string>
<string name="provider_name_support">Поддержка %s</string>
<string name="push_notification_settings_banner_description">Push-уведомления включены, но не будут работать без вашего разрешения</string>
<string name="push_notification_settings_banner_title">Разрешить уведомления</string>
<string name="push_notification_settings_offers_updates_subtitle">Новости продукта, эксклюзивные предложения и напоминания об активности</string>
<string name="push_notification_settings_offers_updates_title">Предложения и обновления</string>
<string name="push_notification_settings_price_alerts_subtitle">Получайте уведомления об изменении цен на популярные монеты</string>
<string name="push_notification_settings_price_alerts_title">Уведомления о ценах</string>
<string name="push_notification_settings_title">Настройки уведомлений</string>
<string name="push_notification_settings_transaction_alerts_subtitle">Уведомления о транзакциях, обменах и важных обновлениях в реальном времени</string>
<string name="push_notification_settings_transaction_alerts_title">Уведомления о транзакциях</string>
<string name="push_notification_warning_sheet_button_enable">Включить уведомления</string>
<string name="push_notification_warning_sheet_description">Вы не будете получать уведомления о пополнениях, выводах и транзакциях. Включить их можно в любой момент в Настройках Кошелька.</string>
<string name="push_notification_warning_sheet_title">Уведомления отключены</string>
<string name="push_notifications_more_info">Подробнее</string>
<string name="push_notifications_permission_alert_description">Вы можете включить нотификации в настройках</string>
<string name="push_notifications_permission_alert_negative_button">Включить позже</string>
@ -1302,8 +1331,8 @@
<string name="qr_scanner_error_unsupported_network_title">Поддерживаемые токены не найдены</string>
<string name="qr_scanner_warning_unknown_parameters_message">Этот QR-код содержит параметры, которые не распознаны: %s. Некоторые данные платежа могут быть утеряны, если вы продолжите.</string>
<string name="qr_scanner_warning_unknown_parameters_title">Неизвестные параметры</string>
<string name="quick_action_buy_description">Кредитная карта или банковский счет</string>
<string name="quick_action_receive_description">Поделиться адресом или QR кодом</string>
<string name="quick_action_buy_description">Банковская карта или банковский счет</string>
<string name="quick_action_receive_description">Поделитесь своим адресом или QR-кодом</string>
<string name="quick_action_sell_description">Продавайте криптовалюту безопасно</string>
<string name="quick_action_send_description">Отправить на другой кошелек</string>
<string name="quick_action_swap_description">Между вашими портфелями</string>
@ -1428,10 +1457,10 @@
<string name="send_memo_destination_tag_error">Недопустимый Memo</string>
<string name="send_network_fee_warning_title">Покрытие сетевой комиссии</string>
<plurals name="send_network_selection_hidden_tokens">
<item quantity="one">Токен %d не совместим с этим адресом</item>
<item quantity="few"></item>
<item quantity="many">Токены %d не совместимы с этим адресом</item>
<item quantity="other"></item>
<item quantity="one">%d токен не совместим с этим адресом</item>
<item quantity="few">%d токена не совместимы с этим адресом</item>
<item quantity="many">%d токенов не совместимо с этим адресом</item>
<item quantity="other">%d токены не совместимы с этим адресом</item>
</plurals>
<string name="send_nonce">Nonce</string>
<string name="send_nonce_footer">Уникальный номер транзакции. Измените, чтобы ускорить отправку или отменить зависшую операцию.</string>
@ -1530,6 +1559,7 @@
<string name="staking_amount_tron_integer_error">Согласно правилам сети, сумма стейкинга будет округлена до %1$sTRX.</string>
<string name="staking_amount_tron_integer_error_unstaking">Сумма для вывода из стейкинга будет округлена до %1$s TRX ввиду особенностей сети.</string>
<string name="staking_apr_earn_badge">APR %1$s%%</string>
<string name="staking_cardano_details_rewards_info_text">Ваши вознаграждения за стейкинг начнут начисляться через 5 эпох (~25 дней), пока ваша делегация регистрируется и учитывается сетью.</string>
<string name="staking_claim_unstaked">Забрать средства</string>
<string name="staking_details_account_fee">Комиссия за стейкинг аккаунт</string>
<string name="staking_details_account_fee_info">Стейкинг аккаунт — это специальный счет, на котором хранятся застейканные токены SOL. Он создается при делегировании ваших токенов валидатору для участия в подтверждении транзакций и получении наград. За создание стейкинг аккаунта взимается небольшая комиссия, которая возвращается после завершения стейкинга.</string>
@ -1670,6 +1700,7 @@
<string name="story_web3_title">Поддержка Web 3.0</string>
<string name="sui_not_enough_coin_for_fee_description">Для отправки требуется входящая транзакция на сумму не менее %1$s</string>
<string name="sui_not_enough_coin_for_fee_title">Недостаточно средств</string>
<string name="support_chat_swap_prefilled_message">Данные об операции обмена:\nОткуда: %1$s %2$s\nКуда: %3$s %4$s\nЧерез: %5$s - %6$s</string>
<string name="support_selector_view_chat_button">Открыть чат</string>
<string name="support_selector_view_email_button">Открыть почту</string>
<string name="swap_approve_description">Подтверждая, вы разрешаете смарт-контракту использовать ваши токены в будущих транзакциях.</string>
@ -1679,6 +1710,7 @@
<string name="swap_in_progress">Обмен в процессе</string>
<string name="swap_promo_text">Обменивайте больше токенов по лучшим курсам прямо в вашем кошельке.</string>
<string name="swap_promo_title">Новый провайдер обмена!</string>
<string name="swap_search_suggestion_hint">Ищете что-то другое?\nВоспользуйтесь поиском или выберите другую криптовалюту!</string>
<string name="swap_search_tooltip_description">Найдите любой токен, даже если его ещё нет в вашем списке</string>
<string name="swap_search_tooltip_title">Используйте поиск, чтобы найти то, что вам нужно.</string>
<string name="swap_simple_mode">Простой режим</string>
@ -1717,7 +1749,7 @@
<string name="swapping_high_price_impact_text">Вы можете получить значительно меньше из-за низкой ликвидности. Попробуйте меньшую сумму или другого провайдера.</string>
<string name="swapping_high_price_impact_title">Высокое влияние на цену</string>
<string name="swapping_insufficient_funds">Недостаточно средств</string>
<string name="swapping_insufficient_funds_description">Недостаточно средств для завершения этой транзакции. Уменьшите сумму для получения или добавьте больше средств.</string>
<string name="swapping_insufficient_funds_description">Недостаточно средств для завершения транзакции. Уменьшите сумму получения или добавьте больше средств</string>
<string name="swapping_permission_header">Дать разрешение</string>
<string name="swapping_rate_experience_title">Оцените ваш опыт взаимодействия с провайдером</string>
<string name="swapping_rate_feedback_placeholder">Напишите ваш отзыв</string>
@ -1730,13 +1762,15 @@
<string name="swapping_token_list_title">Выберите токен</string>
<string name="swapping_token_not_available">недоступен</string>
<string name="swapping_trade_too_large_text">Недостаточно ликвидности для этой сделки.\nУменьшите сумму или выберите другого провайдера.</string>
<string name="swapping_trade_too_large_title">Сумма сделки слишком велика</string>
<string name="swapping_transfer_action">Передача</string>
<string name="swapping_transfer_action_in_progress">Передача...</string>
<string name="swapping_trade_too_large_title">Слишком крупный обмен</string>
<string name="swapping_transfer_action">Перевод</string>
<string name="swapping_transfer_action_in_progress">Перевод...</string>
<string name="tangem_pay_beta_notification_subtitle">Будем рады вашей обратной связи</string>
<string name="tangem_pay_beta_notification_title">Tangem Pay в режиме beta</string>
<string name="tangem_pay_card_details_unable_to_rename_card_title">Не удалось переименовать карту</string>
<string name="tangem_pay_card_frozen">Карта заморожена</string>
<string name="tangem_pay_card_payment">Оплата картой</string>
<string name="tangem_pay_close_card_disabled_last_card">Единственную карту закрыть нельзя</string>
<string name="tangem_pay_close_card_popup_description">Она сразу пропадёт из приложения</string>
<string name="tangem_pay_close_card_popup_primary_button_title">Закрыть карту</string>
<string name="tangem_pay_close_card_popup_secondary_button_title">Назад</string>
@ -1757,6 +1791,8 @@
<string name="tangem_pay_history_item_spend_mc_title_format" formatted="false">%s・%s</string>
<string name="tangem_pay_history_item_spend_mcc">MCC %s</string>
<string name="tangem_pay_other">Другое</string>
<string name="tangem_pay_pin_code_title">ПИН-код</string>
<string name="tangem_pay_purchase">Покупка</string>
<string name="tangem_pay_rename_card_title">Переименовать карту</string>
<string name="tangem_pay_rooted_device_subtitle">Нельзя использовать на устройствах с root-доступом</string>
<string name="tangem_pay_status_completed">Успешно завершено</string>
@ -1766,6 +1802,8 @@
<string name="tangem_pay_terms_fees_limits">Тарифы и полные условия</string>
<string name="tangem_pay_terms_limits">Тарифы и лимиты</string>
<string name="tangem_pay_transaction_declined_notification_text">Банк отклонил транзакцию</string>
<string name="tangem_pay_transaction_details_category">Категория</string>
<string name="tangem_pay_transaction_details_mcc">MCC</string>
<string name="tangem_pay_transaction_fee_notification_text">Комиссия в соответствии с тарифами обслуживания</string>
<string name="tangem_pay_transaction_reversed_notification_text">Транзакция частично или полностью возвращена продавцом</string>
<string name="tangem_pay_unfreeze_card_alert_body">Продолжайте пользоваться картой, заморозить всегда успеете</string>
@ -1778,7 +1816,7 @@
<string name="tangempay_account_unable_to_use_rooted">Запрещено использовать на root-устройствах</string>
<string name="tangempay_available_balance">Баланс</string>
<string name="tangempay_cancel_kyc">Скрыть KYC с главной</string>
<string name="tangempay_card_default_name">Карта Tangem Pay</string>
<string name="tangempay_card_default_name">Карта Tangem Pay 1</string>
<string name="tangempay_card_details_add_funds">Пополнить</string>
<string name="tangempay_card_details_add_funds_subtitle">Способы пополнения</string>
<string name="tangempay_card_details_add_to_wallet_button_text">Добавить в Google Wallet</string>
@ -1813,27 +1851,38 @@
<string name="tangempay_card_details_reissue_card">Перевыпустить карту</string>
<string name="tangempay_card_details_rename_card_invalid_description">Можно вводить только буквы и цифры</string>
<string name="tangempay_card_details_rename_card_invalid_title">Недопустимые символы</string>
<string name="tangempay_card_details_rename_card_placeholder">Название карты</string>
<string name="tangempay_card_details_reveal_text">Показать</string>
<string name="tangempay_card_details_show_details">Реквизиты</string>
<string name="tangempay_card_details_title">Реквизиты</string>
<string name="tangempay_card_details_unable_to_rename_card_description">Пожалуйста, попробуйте позже</string>
<string name="tangempay_card_details_unfreeze_card">Разморозить карту</string>
<string name="tangempay_card_details_view_pin_code_description">Загляните в приложение, если забудете его.</string>
<string name="tangempay_card_details_view_pin_code_title">Ваш ПИН</string>
<string name="tangempay_card_details_withdraw">Вывести</string>
<string name="tangempay_card_details_withdraw_error_title">Вывод сейчас недоступен</string>
<string name="tangempay_card_details_withdraw_in_progress_description">Вы не можете начать обмен или новый вывод, пока не завершится текущий.</string>
<string name="tangempay_card_details_withdraw_in_progress_title">Вывод выполняется</string>
<string name="tangempay_card_edit_name_placeholder">Имя карты</string>
<string name="tangempay_card_edit_name_placeholder">Название карты</string>
<string name="tangempay_card_limit_setup_amount_subtitle">Можно установить от %s</string>
<string name="tangempay_card_limit_setup_error_message">Не удалось установить лимит. Пожалуйста, попробуйте снова.</string>
<string name="tangempay_card_page_closing_banner_description">Обычно занимает до 5 минут</string>
<string name="tangempay_card_page_closing_banner_title">Закрытие вашей карты</string>
<string name="tangempay_card_page_daily_limit_change">Изменить</string>
<string name="tangempay_card_page_daily_limit_current_limit">Текущий лимит</string>
<string name="tangempay_card_page_daily_limit_error_description">Не удалось загрузить суточный лимит. Пожалуйста, попробуйте снова.</string>
<string name="tangempay_card_page_daily_limit_error_subtitle">Обновите, чтобы попробовать снова</string>
<string name="tangempay_card_page_daily_limit_error_title">Лимит недоступен</string>
<string name="tangempay_card_page_daily_limit_success_description">Вы можете поменять его снова в любой момент</string>
<string name="tangempay_card_page_daily_limit_success_title">Лимит установлен</string>
<string name="tangempay_card_page_daily_limit_title">Суточный лимит</string>
<string name="tangempay_card_page_settings_title">Настройки карты</string>
<plurals name="tangempay_cards_count">
<item quantity="one">%d карта</item>
<item quantity="few">%d карты</item>
<item quantity="many">%d карт</item>
<item quantity="other">%d карты</item>
</plurals>
<string name="tangempay_change_pin_code">Изменить PIN-код</string>
<string name="tangempay_come_back_if_forget_pin">Можно посмотреть здесь, если забудете его.</string>
<string name="tangempay_daily_limit_hint" formatted="false">Установить лимит от %s до %s</string>
@ -1866,8 +1915,15 @@
<string name="tangempay_get_banner_description">Откройте бесплатную виртуальную карту Tangem Visa</string>
<string name="tangempay_get_tangem_pay">Получить Tangem Pay</string>
<string name="tangempay_go_to_support">Написать в поддержку</string>
<string name="tangempay_issue_additional_card_description">Это сгенерирует новые реквизиты карты</string>
<string name="tangempay_issue_additional_card_fee_label">Комиссия за выпуск</string>
<string name="tangempay_issue_additional_card_insufficient_funds_subtitle">Пополните платежный счет в USDC для оплаты комиссии за выпуск</string>
<string name="tangempay_issue_additional_card_insufficient_funds_title">Недостаточно средств для оплаты комиссии</string>
<string name="tangempay_issue_additional_card_title">Выпустить дополнительную карту?</string>
<string name="tangempay_issue_card">Выпустить карту</string>
<string name="tangempay_issue_card_notification_description">Обычно это занимает до 15 минут</string>
<string name="tangempay_issue_card_notification_title">Готовим вашу Tangem Card</string>
<string name="tangempay_issuing_new_digital_card_title">Выпуск новой цифровой карты</string>
<string name="tangempay_issuing_your_card">Выпускаем карту</string>
<string name="tangempay_issuing_your_card_description">Обычно это занимает не больше 5 минут, в редких случах при необходимости ручной проверки до 48 часов.</string>
<string name="tangempay_kyc_card_ready_notification_title">Tangem Pay</string>
@ -1884,6 +1940,8 @@
<string name="tangempay_kyc_rejected_button_text">Скрыть KYC с главной</string>
<string name="tangempay_kyc_rejected_description">Извините, мы не смогли подтвердить</string>
<string name="tangempay_kyc_rejected_description_span">ваш профиль.</string>
<string name="tangempay_maximum_cards_issued_description">Вы можете иметь до 3 карт. Удалите одну, чтобы добавить новую.</string>
<string name="tangempay_maximum_cards_issued_title">Выпущено максимальное количество карт</string>
<string name="tangempay_newonboard_Q1_body">Да — для использования регулируемой карты Visa нужна обязательная проверка личности. KYC проводит Sumsub, комплаенс-партнёр.</string>
<string name="tangempay_newonboard_Q1_title">Нужно предоставить документы?</string>
<string name="tangempay_newonboard_Q2_body">Нет. KYC привязывается только к Tangem Pay. Сам Tangem Wallet остаётся полностью отдельной self-custodial средой без KYC.</string>
@ -1931,7 +1989,11 @@
<string name="tangempay_service_unavailable_description">Мы устраняем техническую проблему. Пожалуйста, попробуйте позже.</string>
<string name="tangempay_service_unavailable_title">Сервис временно недоступен</string>
<string name="tangempay_service_unreachable_try_later">Не можем показать данные карты, но оплаты продолжают работать.</string>
<string name="tangempay_set_pin_code">Установите \n PIN-код</string>
<string name="tangempay_set_pin_header">Установить новый ПИН-код</string>
<string name="tangempay_set_pin_title">Установить ПИН-код</string>
<string name="tangempay_status_deactivated">Аккаунт закрыт</string>
<string name="tangempay_status_replacing">Замена вашей карты</string>
<string name="tangempay_sync_needed">Используйте карту или кольцо для обновления сессии</string>
<string name="tangempay_sync_needed_body">Используйте карту или кольцо для обновления сессии</string>
<string name="tangempay_sync_needed_button">Обновить сессию</string>
@ -1964,7 +2026,9 @@
<string name="token_button_unavailability_reason_sell_unavailable">Продажа %s в данный момент не поддерживается ни одним провайдером. Мы работаем над добавлением новых возможностей. Следите за нашими новостями.</string>
<string name="token_button_unavailability_reason_staking_unavailable">Стейкинг %s в данный момент не поддерживается ни одним провайдером. Мы работаем над добавлением новых возможностей. Следите за нашими новостями.</string>
<string name="token_button_unavailability_reason_yield_supply_approval">Разрешение было отозвано. Ваши средства остаются в Yield сервисе. Чтобы совершать операции, перейдите в Yield сервис и снова выдайте разрешение.</string>
<string name="token_details_balance_available">Доступный баланс</string>
<string name="token_details_balance_total">Общий баланс</string>
<string name="token_details_earn_staking_subtitle">До %s APR</string>
<string name="token_details_earn_staking_subtitle_apy">До %s APY</string>
<string name="token_details_generate_xpub">Сгенерировать XPUB</string>
<string name="token_details_hide_alert_hide">Скрыть</string>
@ -1975,7 +2039,9 @@
<string name="token_details_staking_block_title">Сервис стейкинга</string>
<string name="token_details_token_type_subtitle">%1$s токен в сети %%image%% %2$s</string>
<string name="token_details_token_type_subtitle_no_standard">Токен в сети %%image%% %1$s</string>
<string name="token_details_toolbar_subtitle_network">Сеть %s</string>
<string name="token_details_toolbar_subtitle_network">%s сеть</string>
<string name="token_details_toolbar_subtitle_standard">%1$s в сети %2$s</string>
<string name="token_details_toolbar_title_token_in_account">%1$sв %%image%% %2$s</string>
<string name="token_details_toolbar_title_token_in_wallet">%1$s в %2$s %%image%%</string>
<string name="token_details_unable_hide_alert_message">Токен %1$s (%2$s) является основной валютой в сети %3$s и не может быть скрыт до тех пор, пока у вас в списке есть другие токены этой сети</string>
<string name="token_details_unable_hide_alert_title">Невозможно скрыть %s</string>
@ -1992,17 +2058,17 @@
<string name="transaction_history_claiming_reward">Получение награды</string>
<string name="transaction_history_contract_address">контракт: %s</string>
<string name="transaction_history_disabling_yield_mode">Отключение режима доходности</string>
<string name="transaction_history_earned_from_stake">Получено из стейкинга</string>
<string name="transaction_history_earned_from_stake">Заработано на стейкинге</string>
<string name="transaction_history_empty_transactions">У вас еще нет транзакций</string>
<string name="transaction_history_error_failed_to_load">Не удалось загрузить историю транзакций.\nНажмите на кнопку перезагрузки, чтобы обновить информацию.</string>
<string name="transaction_history_from_inline_address">С: %%image%% %s</string>
<string name="transaction_history_from_inline_address">от: %%image%% %s</string>
<string name="transaction_history_multiple_addresses">Несколько адресов</string>
<string name="transaction_history_not_supported_description">История транзакций в настоящее время не поддерживается для этого блокчейна. Но не волнуйтесь, мы работаем над этим! А пока вы можете проверить ее в обозревателе.</string>
<string name="transaction_history_operation">Операция</string>
<string name="transaction_history_pending">Ожидание</string>
<string name="transaction_history_rewards_restaked">Награда рестейкнута</string>
<string name="transaction_history_rewards_restaking">Рестейкинг награды</string>
<string name="transaction_history_staking_reward">Стейкинг награды</string>
<string name="transaction_history_pending">В ожидании</string>
<string name="transaction_history_rewards_restaked">Награды рестейкнуты</string>
<string name="transaction_history_rewards_restaking">Рестейкинг наград</string>
<string name="transaction_history_staking_reward">Награда за стейкинг</string>
<string name="transaction_history_to_inline_address">на: %%image%% %s</string>
<string name="transaction_history_transaction_for_address">для: %s</string>
<string name="transaction_history_transaction_from_address">от: %s</string>
@ -2042,7 +2108,9 @@
<string name="user_push_notification_agreement_argument_three">Получайте уведомления о входящих транзакциях</string>
<string name="user_push_notification_agreement_argument_two">Узнавайте первым о новых акциях</string>
<string name="user_push_notification_agreement_argument_two_subtitle">Ранний доступ к новым функциям и эксклюзивным предложениям.</string>
<string name="user_push_notification_agreement_argument_two_subtitle_v2">Уведомления об изменении цен, новости продукта и эксклюзивные предложения</string>
<string name="user_push_notification_agreement_argument_two_title">Новые функции и важные новости</string>
<string name="user_push_notification_agreement_argument_two_title_v2">Предложения и обновления</string>
<string name="user_push_notification_agreement_header">Хотите использовать Push-уведомления?</string>
<string name="user_push_notification_banner_subtitle">Включите push-уведомления, и мы мгновенно сообщим вам, когда поступят средства.</string>
<string name="user_push_notification_banner_title">Не пропустите транзакцию</string>
@ -2064,6 +2132,7 @@
<string name="visa_onboarding_pin_not_accepted">ПИН не принят. Попробуйте ещё раз или введите другой код.</string>
<string name="visa_onboarding_pin_validation_error_message">Слабый ПИН: не используйте повторы или последовательности.</string>
<string name="visa_transaction_details_currency_code">Код валюты</string>
<string name="visa_tx_dispute_button">Оспорить эту транзакцию</string>
<string name="visa_unlock_notification_button">Разблокировать</string>
<string name="visa_unlock_notification_subtitle">Отсканируйте свою карту, чтобы разблокировать доступ</string>
<string name="wallet_add_common_title">Выберите тип кошелька</string>
@ -2158,7 +2227,8 @@
<string name="warning_approval_in_progress_title">Выдача разрешения</string>
<string name="warning_backup_error_add_funds_message">В этом кошельке возникла проблема с резервным копированием. Обратитесь в службу поддержки для её решения.</string>
<string name="warning_backup_error_add_funds_title">Пополнение отключено</string>
<string name="warning_backup_error_attention_title">Требуется действие: сбросьте свой кошелек</string>
<string name="warning_backup_error_attention_message">Ваши карточки были активированы неправильно. Чтобы обезопасить средства, переведите их на другой кошелек, сбросьте карты до заводских настроек и активируйте заново.</string>
<string name="warning_backup_error_attention_title">Требуется действие: сбросьте настройки кошелька</string>
<string name="warning_backup_errors_message">Похоже, что процесс активации карт или кольца не был завершен корректно. Это могло быть вызвано проблемой взаимодействия с модулем NFC либо некорректным прикладыванием карты или кольца к телефону. Пожалуйста, обратитесь в нашу службу поддержки для уточнения деталей.</string>
<string name="warning_backup_errors_title">Ошибка активации</string>
<string name="warning_beacon_chain_retirement_content">С 3 декабря 2024 года сеть BEP-2 была отключена по решению разработчиков сети и более не поддерживается</string>
@ -2400,7 +2470,12 @@
<string name="yield_apy_boost_banner_subtitle">Бонус за первую активацию!</string>
<string name="yield_apy_boost_banner_title">Спецпредложение для режима доходности</string>
<string name="yield_apy_boost_banner_title_apy_multiplied">APY x3</string>
<string name="yield_apy_boost_promo_activate_bonus">Активировать бонус</string>
<string name="yield_apy_boost_promo_bonus_paid_out_subtitle">Подробности в истории транзакций</string>
<string name="yield_apy_boost_promo_bonus_paid_out_title">Бонус режима доходности выплачен</string>
<string name="yield_apy_boost_promo_days_left_to_unlock">%1$sдней до разблокировки бонуса</string>
<string name="yield_apy_boost_promo_eligibility_text">Вам доступен буст APY на 30 дней</string>
<string name="yield_apy_boost_promo_terms_and_conditions">Действуют Условия и Положения</string>
<string name="yield_apy_boost_story_first_subtitle">Включите режим доходности впервые и получите до 3x дохода за первые 30 дней</string>
<string name="yield_apy_boost_story_first_title">Бонус APR за первый месяц</string>
<string name="yield_apy_boost_story_fourth_subtitle">Вы получаете рыночный доход + бонус. Бонус выплачивается единоразово в USDT или USDC в течение 14 дней после окончания 30-дневного периода. Акция действует, пока есть промо-бюджет. Действуют правила и условия</string>
@ -2418,6 +2493,7 @@
<string name="yield_module_approve_sheet_fee_note">Комиссия будет списана, и ваши активы снова начнут приносить доход.</string>
<string name="yield_module_approve_sheet_subtitle">Чтобы продолжить зарабатывать, нужно выдать разрешение.</string>
<string name="yield_module_approve_sheet_title">Подтвердить разрешение</string>
<string name="yield_module_average_apy">Средний APY %1$s%%</string>
<string name="yield_module_balance_info_sheet_subtitle">Ваши средства в данный момент размещены в протоколе Aave, но вы можете воспользоваться ими в любое время.</string>
<string name="yield_module_balance_info_sheet_title">Ваш %s внесён в Aave</string>
<string name="yield_module_chart_loading_error">Невозможно загрузить график</string>
@ -2491,6 +2567,7 @@
<string name="yield_module_token_details_earn_notification_earning_on_your_balance_subtitle">Проценты начисляются автоматически.</string>
<string name="yield_module_token_details_earn_notification_earning_on_your_balance_title">Режим доходности</string>
<string name="yield_module_token_details_earn_notification_processing">Включение режима доходности</string>
<string name="yield_module_token_details_earn_notification_subtitle">Режим доходности • %1$s%% APY</string>
<string name="yield_module_token_details_earn_notification_title">Режим доходности</string>
<string name="yield_module_transaction_deploy_contract">Установка контракта режима доходности</string>
<string name="yield_module_transaction_enter">Режим доходности подключен</string>
@ -2507,5 +2584,7 @@
<string name="yield_module_unable_to_cover_fee_title">Невозможно покрыть комиссию в %s</string>
<string name="yield_module_unavailable_subtitle">Режим доходности в данный момент недоступен. Пожалуйста, попробуйте позже.</string>
<string name="yield_module_unavailable_title">Режим доходности недоступен</string>
<string name="yield_promo_completed">Условия выплаты бонуса проверены</string>
<string name="yield_promo_left_title">до разблокировки бонуса</string>
<string name="yield_supply_chart_loading_error">Невозможно загрузить график</string>
</resources>

View file

@ -80,10 +80,51 @@
<string name="action_buttons_swap_not_enough_tokens_alert_title">Додайте токени</string>
<string name="action_buttons_you_want_to_receive">Оберіть токен для отримання</string>
<string name="action_buttons_you_want_to_swap">Оберіть токен для обміну</string>
<string name="actionbutton_addfunds_title">Поповнити</string>
<string name="actionbutton_swap_title">Обмін</string>
<string name="actionbutton_transfer_title">Переказ</string>
<string name="add_and_manage_sheet_manage_subtitle">Додати до портфеля</string>
<string name="add_and_manage_sheet_manage_title">Додати токени</string>
<string name="add_and_manage_sheet_organize_subtitle">Сортування та групування</string>
<string name="add_and_manage_sheet_organize_title">Упорядкувати токени</string>
<string name="add_custom_token_choose_network">Оберіть мережу</string>
<string name="add_custom_token_title">Додати токен</string>
<string name="add_tokens_title">Токени</string>
<string name="addfunds_buy_row_description">Банківська картка або банківський рахунок</string>
<string name="addfunds_fund_token">Поповнити токен</string>
<string name="addfunds_receive_row_description">Поділіться своєю адресою або QR-кодом</string>
<string name="addfunds_swap_row_description">Між вашими портфелями</string>
<string name="addfunds_you_receive_title">Ви отримаєте</string>
<string name="address_book_add_address">Додати адресу</string>
<string name="address_book_add_address_description">Додати адресу та вибрати мережу</string>
<string name="address_book_add_contact">Додати контакт</string>
<plurals name="address_book_addresses">
<item quantity="one">адреса</item>
<item quantity="few">адреси</item>
<item quantity="many">адрес</item>
<item quantity="other">адрес</item>
</plurals>
<string name="address_book_contact">Контакт</string>
<string name="address_book_contact_name">Ім\'я контакту</string>
<string name="address_book_copy_address">Скопіювати адресу</string>
<string name="address_book_creating_error">Не вдалося створити контакт. Будь ласка, спробуйте пізніше.</string>
<string name="address_book_delete_contact_description">Цей контакт буде видалено з усіх ваших адресних книг</string>
<string name="address_book_deleting_error">Не вдалося видалити контакт. Будь ласка, спробуйте пізніше.</string>
<string name="address_book_description">Керування контактами та адресами</string>
<string name="address_book_discard">Скасувати</string>
<string name="address_book_edit_address">Редагувати адресу</string>
<string name="address_book_enter_address">Ввести адресу</string>
<string name="address_book_invalid_address_error">Недійсна адреса</string>
<string name="address_book_keep_editing">Продовжити</string>
<string name="address_book_new_contact">Новий контакт</string>
<string name="address_book_no_contacts">Немає доданих контактів</string>
<string name="address_book_no_contacts_description">Тут відображатимуться додані вами контакти.</string>
<string name="address_book_remove_address">Видалити адресу</string>
<string name="address_book_save_wallet_to_description">Цей контакт буде прив\'язано до цього гаманця в адресній книзі.</string>
<string name="address_book_select_network">Вибрати мережу</string>
<string name="address_book_title">Адресна книга</string>
<string name="address_book_unsaved_changes">Незбережені зміни</string>
<string name="address_book_unsaved_changes_description">Ви впевнені, що хочете скасувати зміни?</string>
<string name="address_qr_code_message_format">Надсилайте тільки %1$s (%2$s) в мережі %3$s на цю адресу. Використання іншої мережі може призвести до втрати коштів.</string>
<string name="address_type_default">За замовчуванням</string>
<string name="address_type_legacy">Застарілий</string>
@ -220,8 +261,10 @@
<string name="common_access_denied">Доступ заборонено</string>
<string name="common_account">Акаунт</string>
<string name="common_accounts">Акаунти</string>
<string name="common_action_failed">%s не вдалося</string>
<string name="common_activate">Активувати</string>
<string name="common_add">Додати</string>
<string name="common_add_funds">Поповнити</string>
<string name="common_add_to_portfolio">Додати у портфель</string>
<string name="common_add_token">Додати токен</string>
<string name="common_add_tokens">Додайте токени</string>
@ -235,6 +278,8 @@
<string name="common_apply">Застосовувати</string>
<string name="common_approval">Затвердження</string>
<string name="common_approve">Підтвердити</string>
<string name="common_approved">Схвалено</string>
<string name="common_approving">Схвалення</string>
<string name="common_attention">Увага</string>
<string name="common_available_networks">Доступні мережі</string>
<string name="common_backup">Резервне копіювання</string>
@ -281,14 +326,22 @@
<item quantity="many">днів</item>
<item quantity="other">днів</item>
</plurals>
<plurals name="common_days_time_ago">
<item quantity="one">%d день тому</item>
<item quantity="few">%d дні тому</item>
<item quantity="many">%d днів тому</item>
<item quantity="other">%d дня тому</item>
</plurals>
<string name="common_delete">Видалити</string>
<string name="common_disable">Вимкнути</string>
<string name="common_disabled">Вимкнуто</string>
<string name="common_disabling">Вимкнення</string>
<string name="common_disconnect">Від\'єднати</string>
<string name="common_done">Готово</string>
<string name="common_edit">Редагувати</string>
<string name="common_enable">Увімкнути</string>
<string name="common_enabled">Увімкнено</string>
<string name="common_enabling">Увімкнення</string>
<string name="common_error">Помилка</string>
<string name="common_estimated_fee">Комісія мережі за поповнення</string>
<string name="common_exchange">Обміняти</string>
@ -315,6 +368,12 @@
<string name="common_hide">Приховати</string>
<string name="common_hold_to">Утримуйте, щоб %s</string>
<string name="common_hour">година</string>
<plurals name="common_hours">
<item quantity="one">година</item>
<item quantity="few">години</item>
<item quantity="many">годин</item>
<item quantity="other">години</item>
</plurals>
<plurals name="common_hours_time_ago">
<item quantity="one">%d годину тому</item>
<item quantity="few">%d години тому</item>
@ -324,6 +383,7 @@
<string name="common_import">Імпортувати</string>
<string name="common_in">у</string>
<string name="common_in_progress">В процесі</string>
<string name="common_insufficient_balance">Недостатній баланс</string>
<string name="common_later">Пізніше</string>
<string name="common_learn_more">Дізнатися більше</string>
<string name="common_left">Залишилося %1$s</string>
@ -338,6 +398,8 @@
<item quantity="other">%d хвилин тому</item>
</plurals>
<string name="common_month">місяць</string>
<string name="common_more">Більше</string>
<string name="common_network">Мережа</string>
<string name="common_network_fee_title">Комісія мережі</string>
<string name="common_network_fee_warning_content">Сума відправлення буде зменшена на %1$s (%2$s) для покриття обраного рівня комісії</string>
<plurals name="common_networks_count">
@ -352,12 +414,14 @@
<string name="common_nft">NFT</string>
<string name="common_no">Ні</string>
<string name="common_no_address">Немає адреси</string>
<string name="common_no_results">Немає результатів</string>
<string name="common_not_added">Не додано</string>
<string name="common_not_available">Недоступно</string>
<string name="common_not_now">Не зараз</string>
<string name="common_now">Зараз</string>
<string name="common_ok">ОК</string>
<string name="common_open_in_browser">Відкрити в браузері</string>
<string name="common_open_settings_button_title">Відкрити налаштування</string>
<string name="common_or">або</string>
<string name="common_origin_card">Основна картка</string>
<string name="common_origin_ring">Основне кільце</string>
@ -368,6 +432,8 @@
<string name="common_range_with_space">%1$s — %2$s</string>
<string name="common_read_more">Детальніше</string>
<string name="common_receive">Отримати</string>
<string name="common_received">Отримано</string>
<string name="common_receiving">Отримання</string>
<string name="common_recommended">Рекомендовано</string>
<string name="common_reject">Відхилити</string>
<string name="common_reload">Оновити</string>
@ -386,7 +452,10 @@
<string name="common_send">Надіслати</string>
<string name="common_send_colon">Відправка:</string>
<string name="common_send_tx_error">Не вдалося надіслати транзакцію</string>
<string name="common_sending">Надсилання</string>
<string name="common_sent">Надіслано</string>
<string name="common_server_unavailable">Сервер недоступний, спробуйте пізніше</string>
<string name="common_session_expired">Час сесії вичерпано</string>
<string name="common_share">Поширити</string>
<string name="common_share_link">Поділитися посиланням</string>
<string name="common_show_less">Сховати</string>
@ -396,6 +465,7 @@
<string name="common_skip">Пропустити</string>
<string name="common_something_went_wrong">Щось пішло не так</string>
<string name="common_stake">Застейкати</string>
<string name="common_staked">Застейкано</string>
<string name="common_staking">Стейкінг</string>
<string name="common_start">Почати</string>
<string name="common_submit">Продовжити</string>
@ -403,6 +473,8 @@
<string name="common_support">Підтримка</string>
<string name="common_supported_networks">Підтримувані мережі</string>
<string name="common_swap">Обмін</string>
<string name="common_swapped">Обміняно</string>
<string name="common_swapping">Обмін</string>
<string name="common_tangem">Tangem</string>
<string name="common_tangem_wallet">Tangem Wallet</string>
<string name="common_tap_and_hold_hint">Натисніть і утримуйте</string>
@ -412,6 +484,7 @@
<string name="common_to_wallet_name">На %s</string>
<string name="common_today">Сьогодні</string>
<string name="common_token">Токен</string>
<string name="common_token_send">Токен для надсилання</string>
<plurals name="common_tokens_count">
<item quantity="one">%d токен</item>
<item quantity="few">%d токени</item>
@ -422,18 +495,24 @@
<string name="common_transaction_status">Статус транзакції</string>
<string name="common_transactions">Транзакції</string>
<string name="common_transfer">Переказ</string>
<string name="common_transferred">Переказано</string>
<string name="common_try_again_later">Будь ласка, спробуйте пізніше</string>
<string name="common_unable_to_load">Неможливо завантажити дані...</string>
<string name="common_understand">Я зрозумів</string>
<string name="common_understand_continue">Я розумію, продовжити</string>
<string name="common_unknown_error">Виникла помилка. Будь ласка, спробуйте ще раз.</string>
<string name="common_unlock">Розблокувати</string>
<string name="common_unreachable">Недоступно</string>
<string name="common_unstake">Скасувати стейкінг</string>
<string name="common_utxo_validate_withdrawal_message_warning">Через обмеження %1$s в одну транзакцію може поміститися тільки %2$d UTXO. Це означає, що ви можете відправити тільки %3$s або менше. Вам потрібно зменшити суму.</string>
<string name="common_value_copied">Скопійовано</string>
<string name="common_view_all">Показати всі</string>
<string name="common_voting">Голосування</string>
<string name="common_wallets">Гаманці</string>
<string name="common_warning">Увага</string>
<string name="common_week">тиждень</string>
<string name="common_with">з</string>
<string name="common_withdrawing">Виведення</string>
<string name="common_yes">Так</string>
<string name="common_yield_mode">Режим дохідності</string>
<string name="contract_address_copied_message">Адреса контракту скопійована!</string>
@ -448,6 +527,7 @@
<string name="custom_token_creation_error_token_already_exist_title">Токен вже існує</string>
<string name="custom_token_creation_error_wrong_decimals">Десяткове число повинно бути дійсним цілим числом, до %d</string>
<string name="custom_token_custom_derivation">Власна деривація</string>
<string name="custom_token_custom_derivation_dynamic_addresses_enabled_error">Увімкнено динамічні адреси. Користувацькі значення \\"change\"\ та\\"index\"\ недоступні.</string>
<string name="custom_token_custom_derivation_placeholder">Наприклад, m/00\'/0000\'/0\'/0/0</string>
<string name="custom_token_custom_derivation_title">Введіть власну деривацію</string>
<string name="custom_token_decimals_input_title">Знаків після коми</string>
@ -500,6 +580,28 @@
<string name="domain_receive_assets_onboarding_description">Надсилання активів в інші мережі призведе до безповоротної втрати.</string>
<string name="domain_receive_assets_onboarding_network_name">%s мережа</string>
<string name="domain_receive_assets_onboarding_title">Надсилайте кошти, використовуючи лише</string>
<string name="dynamic_addresses">Динамічні адреси</string>
<string name="dynamic_addresses_custom_token_error_on_addition">Увімкнено динамічні адреси. Користувацькі значення \"change\" та \"index\" недоступні.</string>
<string name="dynamic_addresses_disable_description">Ви вимикаєте динамічні адреси. Тепер кошти надходитимуть на вашу фіксовану адресу</string>
<string name="dynamic_addresses_disable_fee_description">Для переказу коштів на фіксовану адресу стягується комісія мережі</string>
<string name="dynamic_addresses_disable_main_button_title">Вимкнути динамічні адреси</string>
<string name="dynamic_addresses_disable_title">Вимкнути динамічні адреси</string>
<string name="dynamic_addresses_disabled_popup_title">Динамічні адреси вимкнено</string>
<string name="dynamic_addresses_enabled_toast_title">Динамічні адреси увімкнено</string>
<string name="dynamic_addresses_enter_features_privacy_description">Використовуйте нову адресу для кожної транзакції, щоб знизити відстежуваність та підвищити конфіденційність у блокчейні.</string>
<string name="dynamic_addresses_enter_features_privacy_title">Підвищена конфіденційність</string>
<string name="dynamic_addresses_enter_features_receving_description">Отримуйте кошти як зазвичай. Tangem керує UTXO-адресами автоматично, вам не потрібно вибирати чи відстежувати їх вручну.</string>
<string name="dynamic_addresses_enter_features_receving_title">Безшовне отримання</string>
<string name="dynamic_addresses_enter_main_button_title">Увімкнути динамічні адреси</string>
<string name="dynamic_addresses_enter_subtitle">Ваш гаманець може використовувати кілька адрес для отримання одного активу. Tangem показує нову адресу після поповнення попередньої — усі вони прив\'язані до одного гаманця.</string>
<string name="dynamic_addresses_error_has_custom_token_description">Динамічні адреси не можна увімкнути, оскільки деякі користувацькі адреси/токени використовують змінений шлях деривації, що не відповідає потрібним критеріям.</string>
<string name="dynamic_addresses_error_has_custom_token_title">Динамічні адреси недоступні</string>
<string name="dynamic_addresses_error_service_unavailable_description">Не вдається підключитися до провайдера. Будь ласка, спробуйте пізніше.</string>
<string name="dynamic_addresses_error_service_unavailable_title">Сервіс недоступний. Будь ласка, спробуйте ще раз.</string>
<string name="dynamic_addresses_notification_funds_found_description">На додаткових адресах знайдено кошти. Увімкніть динамічні адреси, щоб отримати до них доступ</string>
<string name="dynamic_addresses_notification_funds_found_title">Знайдено кошти на додаткових адресах</string>
<string name="dynamic_addresses_receive_badge">Динамічна адреса</string>
<string name="dynamic_addresses_unavailability_reason_pending_transaction_send">Керування динамічними адресами стане доступним після підтвердження транзакцій у мережі %@</string>
<string name="earn_best_opportunities">Найкращі можливості</string>
<string name="earn_clear_filter">Очистити фільтр</string>
<string name="earn_empty">Список тимчасово порожній, оскільки триває оновлення. Спробуйте ще раз за мить.</string>
@ -569,16 +671,22 @@
<string name="express_legal_two_placeholders">Використовуючи сервіс обміну, ви погоджуєтеся з його %1$s та %2$s.</string>
<string name="express_more_providers_soon">Незабаром з\'являться нові провайдери.\nСлідкуйте за новинами!</string>
<string name="express_provider">Провайдер</string>
<string name="express_provider_best_dex_rate">Найкращий курс на DEX</string>
<string name="express_provider_best_rate">Найкращий курс</string>
<string name="express_provider_fca_warning_list">Список попереджень FCA</string>
<string name="express_provider_fixed_rate_is_unavailable">Фіксована ставка недоступна</string>
<string name="express_provider_for_swap">Провайдер для обміну</string>
<string name="express_provider_great_rate">Найкращий вибір</string>
<string name="express_provider_in_fca_warning_list">Список попереджень FCA</string>
<string name="express_provider_in_fca_warning_list">Провайдер у списку попереджень FCA</string>
<string name="express_provider_max_amount">Доступно до %s</string>
<string name="express_provider_min_amount">Доступно від %s</string>
<string name="express_provider_not_available">Недоступно для цієї пари</string>
<string name="express_provider_permission_needed">Потрібен дозвіл</string>
<string name="express_provider_permission_needed_v2">Потрібен дозвіл</string>
<string name="express_provider_recommended">Рекомендовано</string>
<string name="express_send_with_swap_not_supported_button">Обміняти токен</string>
<string name="express_send_with_swap_not_supported_text">Обміняйте вручну, а потім надішліть одержувачу</string>
<string name="express_send_with_swap_not_supported_title">%s не підтримується у Swap &amp; Send</string>
<string name="express_status_bought">Купив %s</string>
<string name="express_status_buying">Купівля %s</string>
<string name="express_status_buying_active">Купівля %s...</string>
@ -597,6 +705,8 @@
<string name="fee_selector_choose_token_description">Оберіть, який токен буде використовуватися для оплати мережевої комісії. %s</string>
<string name="fee_selector_choose_token_title">Обрати токен</string>
<string name="feed_market_and_news">Ринок та Новини</string>
<string name="feed_search_show_all_user_assets">Показати всі</string>
<string name="feed_search_show_less_user_assets">Сховати</string>
<string name="feed_trending_now">В тренді</string>
<string name="feedback_data_collection_message">Інформація нижче не є обов\'язковою. Ви можете стерти її, якщо бажаєте.</string>
<string name="feedback_preface_rate_negative">Розкажіть, яких функцій вам не вистачає, і ми спробуємо вам допомогти.</string>
@ -611,6 +721,11 @@
<string name="feedback_subject_support_tangem">Звернення в підтримку Tangem</string>
<string name="feedback_subject_tx_failed">Не вдається відправити транзакцію</string>
<string name="feedback_token_description_error">Помилка в описі монети</string>
<string name="force_update_banner_message">Для забезпечення коректної роботи оновіть застосунок до останньої версії.</string>
<string name="force_update_banner_title">Необхідне оновлення</string>
<string name="force_update_button">Оновити</string>
<string name="force_update_warning_message">Будь ласка, оновіть застосунок до останньої версії, щоб забезпечити його коректну роботу.</string>
<string name="force_update_warning_title">Потрібне оновлення</string>
<string name="gasless_not_enough_funds_to_cover_token_fee">Недостатньо коштів</string>
<string name="gasless_transaction_fee">Комісія за транзакцію</string>
<string name="generic_error">Виникла помилка</string>
@ -622,6 +737,7 @@
<string name="give_permission_staking_footer">Функція підтвердження необхідна для надання дозволу іншій адресі на використання певної кількості ваших токенів. За задумом, смарт-контракти не можуть отримати доступ до ваших токенів без вашого схвалення. \"Розблоковуючи\" свої токени, ви дозволяєте смарт-контракту StakeKit використовувати їх. Майнери мережі отримують плату за газ (сплачену вами), щоб зафіксувати цю дію в блокчейні. Ви зможете застейкати свій токен після того, як дасте дозвіл.</string>
<string name="give_permission_staking_subtitle">Щоб продовжити, вам потрібно дозволити смарт-контракту Polygon використовувати ваш %s</string>
<string name="give_permission_swap_subtitle" formatted="false">Щоб продовжити, вам потрібно дозволити смарт-контракту %1s використовувати ваш %2s</string>
<string name="give_permission_swap_subtitle_v2">Децентралізовані біржі вимагають дозволу на взаємодію з вашим гаманцем. %1s</string>
<string name="give_permission_title">Надати дозвіл</string>
<string name="give_permission_unlimited">Необмежено</string>
<string name="hardware_wallet_backup_feature_description">Резервна копія створюється на 23 картках Tangem. Зберігайте їх окремо для безпеки — seed-фраза не потрібна.</string>
@ -735,8 +851,11 @@
<string name="koinos_mana_level_description">Мережа Koinos вимагає Mana для мережевої комісії. У вас є %1$s/%2$s Mana</string>
<string name="koinos_mana_level_title">Рівень Mana</string>
<string name="main_add_and_manage_tokens">Додати та керувати</string>
<string name="main_add_funds_promo_description">Купіть криптовалюту або перекажіть її на свій гаманець.</string>
<string name="main_add_funds_promo_title">Поповніть гаманець</string>
<string name="main_empty_tokens_list_message">Щоб почати відстежувати свої криптоактиви та транзакції, додайте токени</string>
<string name="main_manage_tokens">Керування токенами</string>
<string name="main_qr_scan_hint">Відскануйте QR-код, щоб надіслати кошти або підключитися до застосунку</string>
<string name="main_scan_card_warning_view_subtitle">Для доступу до всіх мереж необхідно відсканувати картку</string>
<string name="main_scan_card_warning_view_title">Відскануйте картку або кільце</string>
<string name="main_swap_changelly_promotion_message">Насолоджуйтесь %1$s комісією за обслуговування на свопах через Changelly з %2$s по %3$s лютого.</string>
@ -771,20 +890,30 @@
<string name="markets_add_to_my_portfolio_unavailable_for_wallet_description">Цей токен недоступний для цього гаманця</string>
<string name="markets_add_token">Додати</string>
<string name="markets_apy_placeholder">APY %s</string>
<string name="markets_common_market_price">Ринкова ціна</string>
<string name="markets_common_my_portfolio">Моє портфоліо</string>
<string name="markets_common_title">Маркет</string>
<string name="markets_earn_common_title">Заробляйте з Tangem</string>
<string name="markets_generate_addresses_notification">Щоб згенерувати адреси для обраних мереж, відскануйте вашу картку або кільце Tangem гаманця</string>
<string name="markets_hint">Щоб додати токени, потягніть вгору або торкніться панелі пошуку</string>
<string name="markets_hint_part_one">Потягніть вгору, щоб відкрити ринок</string>
<string name="markets_hint_part_two">Відкрийте для себе приховані геми</string>
<string name="markets_insights_info_description_message">Дані цього розділу отримані з наступних мереж: %s</string>
<string name="markets_loading_error_title">Не вдалося завантажити дані...</string>
<string name="markets_loading_no_data_title">Немає даних</string>
<string name="markets_portfolio_block_add_token_title">**Додайте до свого портфеля**, щоб купувати, обмінювати або отримувати цей актив</string>
<string name="markets_portfolio_block_subtitle">У вашому портфелі</string>
<string name="markets_portfolio_block_title">Ваш портфель</string>
<string name="markets_pulse_common_title">Пульс ринку</string>
<string name="markets_quick_actions">Швидкі дії</string>
<string name="markets_search_clear_all_hints">Очистити все</string>
<string name="markets_search_header_title">Шукати на маркеті</string>
<string name="markets_search_hint_header">Нещодавні</string>
<string name="markets_search_portfolio_header">У вашому портфелі</string>
<string name="markets_search_result_title">Результат</string>
<string name="markets_search_see_tokens_under_100k">Переглянути токени до 100к USD ринкової капіталізації</string>
<string name="markets_search_show_tokens">Показати токени</string>
<string name="markets_search_title_placeholder">Криптовалюти, новини та не тільки</string>
<string name="markets_search_token_no_result_title">Жодного результату</string>
<string name="markets_select_network">Виберіть мережу</string>
<string name="markets_select_wallet">Оберіть гаманець</string>
@ -804,6 +933,7 @@
<string name="markets_sort_by_yield_mode_title">Режим дохідності</string>
<string name="markets_staking_banner_description_placeholder">Стейкінг — простий спосіб отримувати дохід з вашої криптовалюти. %s</string>
<string name="markets_staking_banner_title">Отримуйте до %s APY</string>
<string name="markets_token_add_subtitle">в іншій мережі або іншому акаунті</string>
<string name="markets_token_added">Токен додано</string>
<string name="markets_token_details_about_token_title">Про %s</string>
<plurals name="markets_token_details_amount_exchanges">
@ -842,6 +972,10 @@
<string name="markets_token_details_holders">Тримачі</string>
<string name="markets_token_details_holders_description">Зміна кількості власників токенів протягом певного періоду часу</string>
<string name="markets_token_details_holders_full">Тримачі</string>
<string name="markets_token_details_insight_day_timeline">Д</string>
<string name="markets_token_details_insight_month_timeline">М</string>
<string name="markets_token_details_insight_week_timeline">Т</string>
<string name="markets_token_details_insight_year_timeline">Р</string>
<string name="markets_token_details_insights">Інсайти</string>
<string name="markets_token_details_links">Посилання</string>
<string name="markets_token_details_liquidity">Ліквідність</string>
@ -857,9 +991,11 @@
<string name="markets_token_details_market_rating_description">Позиція в крипторейтингу між усіма монетами на основі ринкової капіталізації</string>
<string name="markets_token_details_market_rating_full">Рейтинг</string>
<string name="markets_token_details_max_supply">Макс. обсяг</string>
<string name="markets_token_details_max_supply_and_circulation_full">Циркулююча та максимальна пропозиція</string>
<string name="markets_token_details_max_supply_description">Максимальна кількість монет або токенів, яка може коли-небудь існувати для певної криптовалюти</string>
<string name="markets_token_details_max_supply_full">Максимальна пропозиція</string>
<string name="markets_token_details_metrics">Метрики</string>
<string name="markets_token_details_metrics_no_limited">Без обмежень</string>
<string name="markets_token_details_official_links">Офіційні посилання</string>
<string name="markets_token_details_price_performance">Цінова ефективність</string>
<string name="markets_token_details_repository">Репозиторій</string>
@ -869,9 +1005,11 @@
<string name="markets_token_details_total_supply">Загальна проп.</string>
<string name="markets_token_details_total_supply_description">Максимальна кількість монет або токенів, яка може коли-небудь існувати для певної криптовалюти</string>
<string name="markets_token_details_total_supply_full">Загальна пропозиція</string>
<string name="markets_token_details_trading_interval">24г</string>
<string name="markets_token_details_trading_volume">Обсяг торгів (24г)</string>
<string name="markets_token_details_trading_volume_24h_description">Загальна сума криптовалюти, якою торгували протягом останніх 24 годин, що вказує на рівень активності та ліквідності на ринку</string>
<string name="markets_token_details_trading_volume_full">Обсяг торгів (24г)</string>
<string name="markets_token_details_valuation_value_in_total">%s загалом</string>
<string name="markets_token_details_volume">Обсяг</string>
<string name="markets_tooltip_message">Потягніть вгору або торкніться панелі пошуку, щоб додати токени безпосередньо з маркету</string>
<string name="markets_tooltip_title">Додати токени</string>
@ -899,6 +1037,7 @@
<string name="news_related_tokens">Пов\'язані токени</string>
<string name="news_sources">Схожі новини</string>
<string name="news_stay_in_the_loop">Залишайтеся в курсі подій</string>
<string name="news_trending_score">Індекс популярності</string>
<string name="nfc_error_unavailable">Функція NFC недоступна на вашому пристрої</string>
<string name="nft_about_title">Про NFT</string>
<string name="nft_asset">NFT</string>
@ -947,7 +1086,7 @@
<plurals name="nft_wallet_count_android">
<item quantity="one">%1$d NFT в %2$d колекціi</item>
<item quantity="few">%1$d NFTs в %2$d колекціях</item>
<item quantity="many"></item>
<item quantity="many">%1$d NFTs в %2$d колекціях</item>
<item quantity="other">%1$d NFTs в %2$d колекціях</item>
</plurals>
<string name="nft_wallet_receive_nft">Натисніть тут, щоб отримати першу NFT</string>
@ -1025,7 +1164,9 @@
<string name="onboarding_getting_started">Підготовка</string>
<string name="onboarding_linking_error_card_with_wallets">На картці, яку ви намагаєтеся додати, вже створено інший гаманець. Якщо у вас є кошти на цьому гаманці, будь ласка, виведіть їх, а потім скиньте цю картку до заводських налаштувань і додайте її як резервну.</string>
<string name="onboarding_navbar_save_wallet">Збережіть ваш гаманець</string>
<string name="onboarding_navbar_title_biometrics">Використовувати біометрію</string>
<string name="onboarding_navbar_title_creating_backup">Резервна копія</string>
<string name="onboarding_navbar_title_last_step">Останній крок</string>
<string name="onboarding_navbar_upgrade_wallet_biometrics">Біометрія</string>
<string name="onboarding_seed_button_read_more">Дізнатися більше про seed-фразу</string>
<plurals name="onboarding_seed_generate_message_words_count">
@ -1098,6 +1239,7 @@
<string name="onramp_instant_status">Миттєво</string>
<string name="onramp_kyc_verification_bullet_free">Верифікація безкоштовна і зазвичай займає 1-2 хвилини</string>
<string name="onramp_kyc_verification_bullet_privacy">Tangem не матиме доступу до ваших особистих даних, ви передаєте їх безпосередньо ліцензованому провайдеру</string>
<string name="onramp_kyc_verification_bullet_unlocks">Верифікація відкриває повний доступ до майбутніх транзакцій із цим провайдером</string>
<string name="onramp_kyc_verification_choose_another">Виберіть інший метод</string>
<string name="onramp_kyc_verification_subtitle">Згідно з вимогами законодавства, %@ вимагає пройти верифікацію особи.</string>
<string name="onramp_kyc_verification_title">Провайдер платежів вимагає підтвердження особи</string>
@ -1152,10 +1294,24 @@
<string name="onramp_via_provider">через %s</string>
<string name="onramp_you_will_pay_title">Ви заплатите</string>
<string name="organize_tokens_group">Групами</string>
<string name="organize_tokens_menu_group_by_networks">Згрупувати за мережами</string>
<string name="organize_tokens_menu_sort_by_balance">Сортувати за балансом</string>
<string name="organize_tokens_sort_by_balance">За балансом</string>
<string name="organize_tokens_title">Сортування токенів</string>
<string name="organize_tokens_ungroup">Список</string>
<string name="provider_name_support">Підтримка %s</string>
<string name="push_notification_settings_banner_description">Push-сповіщення ввімкнені, але не працюватимуть без вашого дозволу</string>
<string name="push_notification_settings_banner_title">Дозволити сповіщення</string>
<string name="push_notification_settings_offers_updates_subtitle">Новини продукту, ексклюзивні пропозиції та нагадування про активність</string>
<string name="push_notification_settings_offers_updates_title">Пропозиції та оновлення</string>
<string name="push_notification_settings_price_alerts_subtitle">Отримуйте сповіщення про зміну цін на популярні монети</string>
<string name="push_notification_settings_price_alerts_title">Сповіщення про ціни</string>
<string name="push_notification_settings_title">Налаштування сповіщень</string>
<string name="push_notification_settings_transaction_alerts_subtitle">Сповіщення про транзакції, обміни та важливі оновлення в реальному часі</string>
<string name="push_notification_settings_transaction_alerts_title">Сповіщення про транзакції</string>
<string name="push_notification_warning_sheet_button_enable">Увімкнути сповіщення</string>
<string name="push_notification_warning_sheet_description">Ви не отримуватимете сповіщення про поповнення, виведення та транзакції. Увімкнути їх можна будь-коли в Налаштуваннях Гаманця.</string>
<string name="push_notification_warning_sheet_title">Сповіщення вимкнено</string>
<string name="push_notifications_more_info">Більше</string>
<string name="push_notifications_permission_alert_description">Ви можете ввімкнути сповіщення у налаштуваннях</string>
<string name="push_notifications_permission_alert_negative_button">Увімкнути пізніше</string>
@ -1175,6 +1331,12 @@
<string name="qr_scanner_error_unsupported_network_title">Підтримуваних токенів не знайдено</string>
<string name="qr_scanner_warning_unknown_parameters_message">Цей QR-код містить нерозпізнані параметри: %s. Деякі деталі платежу можуть бути втрачені, якщо ви продовжите.</string>
<string name="qr_scanner_warning_unknown_parameters_title">Невідомі параметри</string>
<string name="quick_action_buy_description">Банківська картка або банківський рахунок</string>
<string name="quick_action_receive_description">Поділіться своєю адресою або QR-кодом</string>
<string name="quick_action_sell_description">Безпечно продавайте криптовалюту</string>
<string name="quick_action_send_description">Надіслати на інший гаманець</string>
<string name="quick_action_swap_description">Між вашими портфелями</string>
<string name="quick_top_up_chip_other">Інші</string>
<string name="quick_top_up_title">Швидке поповнення</string>
<string name="receive_bottom_sheet_no_memo_required_message">Memo не вимагається</string>
<string name="receive_bottom_sheet_warning_message">%1$s (%2$s) у мережі %3$s</string>
@ -1294,6 +1456,12 @@
<string name="send_memo">Мемо: %s</string>
<string name="send_memo_destination_tag_error">Недопустимий Memo</string>
<string name="send_network_fee_warning_title">Покриття мережевої комісії</string>
<plurals name="send_network_selection_hidden_tokens">
<item quantity="one">%d токен не сумісний із цією адресою</item>
<item quantity="few">%d токени не сумісні із цією адресою</item>
<item quantity="many">%d токенів не сумісно із цією адресою</item>
<item quantity="other">%d токени не сумісні із цією адресою</item>
</plurals>
<string name="send_nonce">Nonce</string>
<string name="send_nonce_footer">Унікальний номер транзакції. Змініть його, щоб пришвидшити відправку або скасувати завислу операцію.</string>
<string name="send_nonce_hint">Введіть nonce…</string>
@ -1321,6 +1489,11 @@
<string name="send_notification_transaction_limit_title">Ліміт транзакції</string>
<string name="send_optional_field">Опціонально</string>
<string name="send_qrcode_scan_info">Будь ласка, сумістіть свій QR-код з квадратом, щоб відсканувати його. Переконайтеся, що ви скануєте адресу в мережі %s.</string>
<string name="send_rate_fixed_info_description">При фіксованому курсі сума, яку ви отримаєте, фіксується в момент обміну. Це захищає вас від зміни ціни під час транзакції.</string>
<string name="send_rate_fixed_info_title">Фіксований курс</string>
<string name="send_rate_floating_info_description">Плаваючий курс означає, що підсумкова сума, яку ви отримаєте, може дещо змінитися залежно від ринкових умов у період між початком і завершенням обміну.</string>
<string name="send_rate_floating_info_title">Плаваючий курс</string>
<string name="send_rate_is_fixed">Курс зафіксовано</string>
<string name="send_recent_transactions">Останні</string>
<string name="send_recipient">Одержувач</string>
<string name="send_recipient_address_error">Недійсна адреса</string>
@ -1364,6 +1537,7 @@
<string name="send_with_swap_recipient_get_amount">До отримання %s</string>
<string name="send_with_swap_remove_convert_alert_message">Ви впевнені, що хочете скасувати конвертацію? Ваші попередні дані будуть видалені.</string>
<string name="send_with_swap_remove_convert_alert_title">Прибрати конвертацію</string>
<string name="send_with_swap_something_went_wrong">Щось пішло не так. Спробуйте ще раз</string>
<string name="send_with_swap_title">Надіслати з обміном</string>
<string name="sent_transaction_sent_title">Трансакцію надіслано</string>
<string name="settings_card_settings_footer">Підготуйтеся до сканування кільця або картки, яку ви хочете налаштувати.</string>
@ -1416,6 +1590,7 @@
<string name="staking_details_unbonding_period_info">Період, який ви повинні чекати після запиту на виведення коштів зі стейкінгу, перш ніж токени стануть доступними.</string>
<string name="staking_details_warmup_period">Період блокування</string>
<string name="staking_details_warmup_period_info">Відведений час для активації участі в стейкінгу.</string>
<string name="staking_enabled">Стейкінг увімкнено</string>
<string name="staking_error_no_validators_message">Наразі немає вільних валідаторів. Повторіть спробу пізніше.</string>
<string name="staking_error_no_validators_title">Стейкінг недоступний</string>
<string name="staking_give_permission_fee_footer">Комісія за схвалення токену, щоб верифікувати, що саме ви дозволяєте використання токена для стейкінгу.</string>
@ -1525,6 +1700,9 @@
<string name="story_web3_title">Web 3.0 сумісність</string>
<string name="sui_not_enough_coin_for_fee_description">Для відправки потрібна вхідна транзакція на суму не менше %1$s</string>
<string name="sui_not_enough_coin_for_fee_title">Недостатньо коштів</string>
<string name="support_chat_swap_prefilled_message">Дані про операцію обміну:\nЗвідки: %1$s %2$s\nКуди: %3$s %4$s\nЧерез: %5$s - %6$s</string>
<string name="support_selector_view_chat_button">Відкрити чат</string>
<string name="support_selector_view_email_button">Відкрити пошту</string>
<string name="swap_approve_description">Підтверджуючи, ви дозволяєте смартконтракту використовувати ваші токени в майбутніх транзакціях.</string>
<string name="swap_detailed_mode">Детальний режим</string>
<string name="swap_fixed_rate">Фіксований курс</string>
@ -1564,11 +1742,14 @@
<string name="swapping_approve_information_text">Всі децентралізовані біржі вимагають схвалення, щоб запобігти доступу смарт-контрактів до вашого гаманця без вашого дозволу. За задумом смарт-контракти не можуть отримати доступ до ваших токенів без вашого схвалення. \"Розблоковуючи\" свої токени, ви дозволяєте смарт-контракту 1inch витрачати ваші активи. Майнери мережі отримують плату за газ (сплачену вами), щоб зафіксувати цю дію в блокчейні. Ви можете обміняти свій токен після того, як дасте дозвіл.</string>
<string name="swapping_approve_information_title">Підтвердити</string>
<string name="swapping_fee_estimation_error_text">Помилка при розрахунку комісії. Будь ласка, надішліть відгук до служби підтримки.</string>
<string name="swapping_from_account_title">Ви надсилаєте з</string>
<string name="swapping_from_title">Ви обмінюєте</string>
<string name="swapping_from_title_v2">Ви надсилаєте</string>
<string name="swapping_high_price_impact_description">Обмін цієї кількості обраних токенів призведе до значного впливу на ціну і зменшить вашу кінцеву суму.</string>
<string name="swapping_high_price_impact_text">Ви можете отримати значно менше через низьку ліквідність. Спробуйте меншу суму або іншого провайдера.</string>
<string name="swapping_high_price_impact_title">Високий вплив на ціну</string>
<string name="swapping_insufficient_funds">Недостатньо коштів</string>
<string name="swapping_insufficient_funds_description">Недостатньо коштів для завершення транзакції. Зменште суму отримання або додайте більше коштів</string>
<string name="swapping_permission_header">Надати дозвіл</string>
<string name="swapping_rate_experience_title">Оцініть ваш досвід взаємодії з провайдером</string>
<string name="swapping_rate_feedback_placeholder">Напишіть ваш відгук</string>
@ -1580,10 +1761,16 @@
<string name="swapping_to_title">Ви отримаєте</string>
<string name="swapping_token_list_title">Оберіть токен</string>
<string name="swapping_token_not_available">недоступно</string>
<string name="swapping_trade_too_large_text">Недостатньо ліквідності для цього обміну.\nЗменште суму або виберіть іншого провайдера.</string>
<string name="swapping_trade_too_large_title">Занадто великий обмін</string>
<string name="swapping_transfer_action">Переказ</string>
<string name="swapping_transfer_action_in_progress">Переказ...</string>
<string name="tangem_pay_beta_notification_subtitle">Будемо раді вашому зворотньому звʼязку</string>
<string name="tangem_pay_beta_notification_title">Tangem Pay у режимі beta</string>
<string name="tangem_pay_card_details_unable_to_rename_card_title">Не вдалося перейменувати картку</string>
<string name="tangem_pay_card_frozen">Картку заморожено</string>
<string name="tangem_pay_card_payment">Оплата карткою</string>
<string name="tangem_pay_close_card_disabled_last_card">Єдину картку закрити не можна</string>
<string name="tangem_pay_close_card_popup_description">Воно зникне з застосунку</string>
<string name="tangem_pay_close_card_popup_primary_button_title">Закрити картку</string>
<string name="tangem_pay_close_card_popup_secondary_button_title">Назад</string>
@ -1598,11 +1785,15 @@
<string name="tangem_pay_freeze_card_failed">Не вдалося заморозити картку. Спробуйте пізніше.</string>
<string name="tangem_pay_freeze_card_freeze">Заморозити</string>
<string name="tangem_pay_freeze_card_success">Вашу картку заморожено.</string>
<string name="tangem_pay_freeze_card_unfreeze">Розморозити</string>
<string name="tangem_pay_get_help">Звернутися до підтримки</string>
<string name="tangem_pay_history_item_spend_mc_declined_reason">Причина: %s</string>
<string name="tangem_pay_history_item_spend_mc_title_format" formatted="false">%s.%s</string>
<string name="tangem_pay_history_item_spend_mcc">MCC %s</string>
<string name="tangem_pay_other">Інше</string>
<string name="tangem_pay_pin_code_title">ПІН-код</string>
<string name="tangem_pay_purchase">Покупка</string>
<string name="tangem_pay_rename_card_title">Перейменувати картку</string>
<string name="tangem_pay_rooted_device_subtitle">Неможливо використовувати на пристроях з root-правами.</string>
<string name="tangem_pay_status_completed">Завершено</string>
<string name="tangem_pay_status_declined">Відхилено</string>
@ -1611,6 +1802,8 @@
<string name="tangem_pay_terms_fees_limits">Умови, комісії та ліміти</string>
<string name="tangem_pay_terms_limits">Умови та обмеження</string>
<string name="tangem_pay_transaction_declined_notification_text">Банк відхилив цей запит на транзакцію.</string>
<string name="tangem_pay_transaction_details_category">Категорія</string>
<string name="tangem_pay_transaction_details_mcc">MCC</string>
<string name="tangem_pay_transaction_fee_notification_text">Комісія відповідно до тарифів обслуговування</string>
<string name="tangem_pay_transaction_reversed_notification_text">Транзакцію було частково або повністю скасовано продавцем</string>
<string name="tangem_pay_unfreeze_card_alert_body">Продовжуйте користуватися карткою. Заморозити можна в будь-який момент.</string>
@ -1623,8 +1816,10 @@
<string name="tangempay_account_unable_to_use_rooted">Заборонено використовувати на root-пристроях</string>
<string name="tangempay_available_balance">Баланс</string>
<string name="tangempay_cancel_kyc">Приховати KYC з головного екрана</string>
<string name="tangempay_card_default_name">Картка Tangem Pay 1</string>
<string name="tangempay_card_details_add_funds">Поповнити рахунок</string>
<string name="tangempay_card_details_add_funds_subtitle">Варіанти поповнення</string>
<string name="tangempay_card_details_add_to_wallet_button_text">Додати до Google Wallet</string>
<string name="tangempay_card_details_card_number">Номер картки</string>
<string name="tangempay_card_details_change_pin">ПІН-код</string>
<string name="tangempay_card_details_change_pin_success_description">Картка готова до оплат.</string>
@ -1656,33 +1851,44 @@
<string name="tangempay_card_details_reissue_card">Перевипустити картку</string>
<string name="tangempay_card_details_rename_card_invalid_description">Дозволені лише літери та цифри</string>
<string name="tangempay_card_details_rename_card_invalid_title">Неприпустимі символи</string>
<string name="tangempay_card_details_rename_card_placeholder">Назва картки</string>
<string name="tangempay_card_details_reveal_text">Показати</string>
<string name="tangempay_card_details_show_details">Реквізити</string>
<string name="tangempay_card_details_title">Реквізити</string>
<string name="tangempay_card_details_unable_to_rename_card_description">Будь ласка, спробуйте пізніше</string>
<string name="tangempay_card_details_unfreeze_card">Розморозити картку</string>
<string name="tangempay_card_details_view_pin_code_description">Поверніться до додатка, якщо ви забудете його.</string>
<string name="tangempay_card_details_view_pin_code_description">Поверніться до застосунку, якщо забудете його</string>
<string name="tangempay_card_details_view_pin_code_title">Ваш ПІН</string>
<string name="tangempay_card_details_withdraw">Вивести</string>
<string name="tangempay_card_details_withdraw_error_title">Вивід наразі недоступний</string>
<string name="tangempay_card_details_withdraw_in_progress_description">Ви не можете розпочати обмін або новий вивід, поки не завершено поточний.</string>
<string name="tangempay_card_details_withdraw_in_progress_title">Вивід виконується</string>
<string name="tangempay_card_edit_name_placeholder">Назва картки</string>
<string name="tangempay_card_limit_setup_amount_subtitle">Встановити ліміт від %s</string>
<string name="tangempay_card_limit_setup_error_message">Не вдалося встановити ліміт. Будь ласка, спробуйте ще раз.</string>
<string name="tangempay_card_page_closing_banner_description">Зазвичай займає до 5 хвилин</string>
<string name="tangempay_card_page_closing_banner_title">Закриття вашої картки</string>
<string name="tangempay_card_page_daily_limit_change">Змінити</string>
<string name="tangempay_card_page_daily_limit_current_limit">Поточний ліміт</string>
<string name="tangempay_card_page_daily_limit_error_description">Не вдалося завантажити денний ліміт. Будь ласка, спробуйте ще раз.</string>
<string name="tangempay_card_page_daily_limit_error_subtitle">Оновіть, щоб спробувати знову</string>
<string name="tangempay_card_page_daily_limit_error_title">Ліміт недоступний</string>
<string name="tangempay_card_page_daily_limit_success_description">Ви можете змінити це будь-коли</string>
<string name="tangempay_card_page_daily_limit_success_title">Ліміт встановлено</string>
<string name="tangempay_card_page_daily_limit_title">Денний ліміт</string>
<string name="tangempay_card_page_settings_title">Налаштування картки</string>
<plurals name="tangempay_cards_count">
<item quantity="one">%d картка</item>
<item quantity="few">%d картки</item>
<item quantity="many">%d карток</item>
<item quantity="other">%d картки</item>
</plurals>
<string name="tangempay_change_pin_code">Змінити PIN-код</string>
<string name="tangempay_come_back_if_forget_pin">Можна подивитися тут, якщо забудете його.</string>
<string name="tangempay_daily_limit_hint" formatted="false">Встановити ліміт від %s до %s</string>
<string name="tangempay_daily_limit_set_button">Встановити</string>
<string name="tangempay_declined_reason_1">недостатньо коштів</string>
<string name="tangempay_declined_reason_10">se superó el límite de gasto de la tarjeta</string>
<string name="tangempay_declined_reason_10">перевищено ліміт витрат за карткою</string>
<string name="tangempay_declined_reason_11">помилка перевірки CVV2</string>
<string name="tangempay_declined_reason_12">неправильний термін дії картки</string>
<string name="tangempay_declined_reason_13">неправильний PIN-код</string>
@ -1709,8 +1915,15 @@
<string name="tangempay_get_banner_description">Отримайте безкоштовну віртуальну картку Tangem Visa</string>
<string name="tangempay_get_tangem_pay">Отримати Tangem Pay</string>
<string name="tangempay_go_to_support">Написати у підтримку</string>
<string name="tangempay_issue_additional_card_description">Це згенерує нові реквізити картки</string>
<string name="tangempay_issue_additional_card_fee_label">Комісія за випуск</string>
<string name="tangempay_issue_additional_card_insufficient_funds_subtitle">Поповніть платіжний рахунок у USDC для оплати комісії за випуск</string>
<string name="tangempay_issue_additional_card_insufficient_funds_title">Недостатньо коштів для оплати комісії</string>
<string name="tangempay_issue_additional_card_title">Випустити додаткову картку?</string>
<string name="tangempay_issue_card">Випустити картку</string>
<string name="tangempay_issue_card_notification_description">Зазвичай це займає до 15 хвилин</string>
<string name="tangempay_issue_card_notification_title">Готуємо вашу картку Tangem</string>
<string name="tangempay_issuing_new_digital_card_title">Випуск нової цифрової картки</string>
<string name="tangempay_issuing_your_card">Випускаємо картку</string>
<string name="tangempay_issuing_your_card_description">Карту зазвичай випускають автоматично протягом 5 хвилин. У рідкісних випадках, коли потрібна ручна перевірка, процес може тривати до 48 годин.</string>
<string name="tangempay_kyc_card_ready_notification_title">Tangem Pay</string>
@ -1727,6 +1940,8 @@
<string name="tangempay_kyc_rejected_button_text">Приховати блок KYC</string>
<string name="tangempay_kyc_rejected_description">Вибачте, ми не змогли підтвердити</string>
<string name="tangempay_kyc_rejected_description_span">вашу особу.</string>
<string name="tangempay_maximum_cards_issued_description">Ви можете мати до 3 карток. Видаліть одну, щоб додати нову.</string>
<string name="tangempay_maximum_cards_issued_title">Випущено максимальну кількість карток</string>
<string name="tangempay_newonboard_Q1_body">Так — для користування регульованою карткою Visa обов’язкова верифікація особи. KYC проводить Sumsub, compliance-партнер.</string>
<string name="tangempay_newonboard_Q1_title">Чи потрібно надавати документи?</string>
<string name="tangempay_newonboard_Q2_body">Ні. KYC стосується лише акаунта Tangem Pay. Сам Tangem Wallet залишається окремим self-custodial середовищем без KYC.</string>
@ -1775,7 +1990,10 @@
<string name="tangempay_service_unavailable_title">Сервіс тимчасово недоступний</string>
<string name="tangempay_service_unreachable_try_later">Не можемо показати дані картки, але оплати продовжують працювати.</string>
<string name="tangempay_set_pin_code">Встановіть \nPIN-код</string>
<string name="tangempay_set_pin_header">Встановити новий ПІН-код</string>
<string name="tangempay_set_pin_title">Встановити ПІН-код</string>
<string name="tangempay_status_deactivated">Рахунок закрито</string>
<string name="tangempay_status_replacing">Заміна вашої картки</string>
<string name="tangempay_sync_needed">Використайте картку або кільце для поновлення сесії</string>
<string name="tangempay_sync_needed_body">Використайте картку або кільце для поновлення сесії</string>
<string name="tangempay_sync_needed_button">Відновити доступ</string>
@ -1808,6 +2026,10 @@
<string name="token_button_unavailability_reason_sell_unavailable">Наразі продаж %s недоступний. Слідкуйте за нашими оновленнями.</string>
<string name="token_button_unavailability_reason_staking_unavailable">Стейкінг %s зараз недоступний. Будь ласка, слідкуйте за нашими оновленнями.</string>
<string name="token_button_unavailability_reason_yield_supply_approval">Дозвіл скасовано. Ваші кошти залишаються в режимі прибутковості. Щоб виконати дії, перейдіть у режим прибутковості та надайте дозвіл знову.</string>
<string name="token_details_balance_available">Доступний баланс</string>
<string name="token_details_balance_total">Загальний баланс</string>
<string name="token_details_earn_staking_subtitle">До %s APR</string>
<string name="token_details_earn_staking_subtitle_apy">До %s APY</string>
<string name="token_details_generate_xpub">Згенерувати XPUB</string>
<string name="token_details_hide_alert_hide">Приховати</string>
<string name="token_details_hide_alert_message">Ви збираєтеся приховати цей токен з головного екрану. Ви можете додати його назад будь-коли на сторінці керування токенами.</string>
@ -1817,8 +2039,13 @@
<string name="token_details_staking_block_title">Сервіс стейкінгу</string>
<string name="token_details_token_type_subtitle">%1$s токен в мережі %%image%% %2$s</string>
<string name="token_details_token_type_subtitle_no_standard">Токен у мережі %%image%% %1$s</string>
<string name="token_details_toolbar_subtitle_network">%s мережа</string>
<string name="token_details_toolbar_subtitle_standard">%1$s у мережі %2$s</string>
<string name="token_details_toolbar_title_token_in_account">%1$s у %%image%% %2$s</string>
<string name="token_details_toolbar_title_token_in_wallet">%1$s у %2$s %%image%%</string>
<string name="token_details_unable_hide_alert_message">Токен %1$s (%2$s) є основною валютою в мережі %3$s і не може бути прихований до тих пір, поки у вас в списку є інші токени цієї мережі</string>
<string name="token_details_unable_hide_alert_title">Неможливо приховати %s</string>
<string name="token_market_metrics_no_data">N/A</string>
<string name="token_receive_show_qr_code_title">Показати QR-код</string>
<string name="token_swap_changelly_promotion_message">Обмінюйте цей токен на інші з %1$s комісією за обслуговування з %2$s по %3$s лютого.</string>
<string name="token_swap_changelly_promotion_title">Обмін із Changelly, %s комісії</string>
@ -1828,18 +2055,28 @@
<string name="tokens_list_unavailable_to_sell_header">Недоступно для продажу</string>
<string name="tokens_list_unavailable_to_swap_header">Недоступно для обміну з %s</string>
<string name="tokens_list_unavailable_to_swap_source_header">Недоступно для обміну</string>
<string name="transaction_history_claiming_reward">Отримання нагороди</string>
<string name="transaction_history_contract_address">контракт: %s</string>
<string name="transaction_history_disabling_yield_mode">Вимкнення режиму дохідності</string>
<string name="transaction_history_earned_from_stake">Зароблено на стейкінгу</string>
<string name="transaction_history_empty_transactions">У вас ще немає транзакцій</string>
<string name="transaction_history_error_failed_to_load">Не вдалося завантажити історію транзакцій.\nНатисніть кнопку нижче, щоб оновити інформацію</string>
<string name="transaction_history_from_inline_address">від: %%image%% %s</string>
<string name="transaction_history_multiple_addresses">Кілька адрес</string>
<string name="transaction_history_not_supported_description">Історія транзакцій наразі не підтримується для цього блокчейну. Але не хвилюйтеся, ми працюємо над цим! А поки можете перевірити це в оглядачі.</string>
<string name="transaction_history_operation">Операція</string>
<string name="transaction_history_pending">В очікуванні</string>
<string name="transaction_history_rewards_restaked">Нагороди рестейкнуто</string>
<string name="transaction_history_rewards_restaking">Рестейкінг нагород</string>
<string name="transaction_history_staking_reward">Нагорода за стейкінг</string>
<string name="transaction_history_to_inline_address">на: %%image%% %s</string>
<string name="transaction_history_transaction_for_address">для: %s</string>
<string name="transaction_history_transaction_from_address">від: %s</string>
<string name="transaction_history_transaction_to_address">до: %s</string>
<string name="transaction_history_transaction_validator">валідатор: %s</string>
<string name="transaction_notifications_warning_description">Сповіщення ввімкнені, але не працюватимуть, доки ви не дозволите їх у налаштуваннях пристрою.</string>
<string name="transaction_notifications_warning_title">Сповіщення про транзакції</string>
<string name="transfer_in_progress_title">Переказ в процесі</string>
<string name="transfer_min_amount_error">Мінімум %s</string>
<string name="transfer_notification_invalid_minimum_transaction_amount_text">Мінімальна сума транзакції становить %1$s.</string>
<string name="tron_will_be_send_token_fee_description">Комісії мережі Tron для популярних токенів можуть бути вищими. Стейкінг TRX може допомогти знизити транзакційні витрати.</string>
@ -1861,6 +2098,9 @@
<string name="universal_error">Виникла помилка. Код помилки: %s. Спробуйте, будь ласка, знову. Якщо проблема знову виникає — зверніться до нашої служби підтримки.</string>
<string name="unlock_wallet_description_full">Використовуйте %s або відскануйте картку/кільце, щоб отримати доступ до свого гаманця</string>
<string name="unsupported_wc_version">Не вдалося встановити з\'єднання: Цей dApp використовує Wallet Connect версії 1.0, яка не підтримується. Будь ласка, переконайтеся, що dApp підтримує Wallet Connect версії 2.0 для успішного підключення.</string>
<string name="update_approval_permission_fee_note">Попередній дозвіл буде відкликано, а замість нього видано новий. За кожну з цих дій мережа стягне комісію за підтвердження токена. В історії транзакцій з\'явиться операція з нульовою сумою як підтвердження відкликання дозволу.</string>
<string name="update_approval_permission_subtitle">Сума транзакції перевищує раніше наданий дозвіл.\nОновіть дозвіл, щоб продовжити.</string>
<string name="update_approval_permission_title">Оновіть дозвіл</string>
<string name="upgrade_to_hardware_wallet_button_title">Оновлення до апаратного гаманця</string>
<string name="user_push_notification_agreement_argument_one">Будьте в курсі останніх функцій та новин</string>
<string name="user_push_notification_agreement_argument_one_subtitle">Миттєві сповіщення про транзакції, обміни та важливі оновлення.</string>
@ -1868,7 +2108,9 @@
<string name="user_push_notification_agreement_argument_three">Отримуйте сповіщення про вхідні транзакції</string>
<string name="user_push_notification_agreement_argument_two">Дізнавайтеся першими про нові акції</string>
<string name="user_push_notification_agreement_argument_two_subtitle">Ранній доступ до нових функцій та ексклюзивних пропозицій.</string>
<string name="user_push_notification_agreement_argument_two_subtitle_v2">Сповіщення про зміну цін, новини продукту та ексклюзивні пропозиції</string>
<string name="user_push_notification_agreement_argument_two_title">Нові функції та важливі новини</string>
<string name="user_push_notification_agreement_argument_two_title_v2">Пропозиції та оновлення</string>
<string name="user_push_notification_agreement_header">Бажаєте використовувати Push-повідомлення?</string>
<string name="user_push_notification_banner_subtitle">Увімкніть push-сповіщення, щоб отримувати повідомлення, коли кошти надходять на ваш гаманець.</string>
<string name="user_push_notification_banner_title">Не пропустіть транзакцію</string>
@ -1998,6 +2240,10 @@
<string name="warning_access_denied_message">Використовуйте %s або відскануйте картку/кільце, щоб розблокувати доступ до вашого гаманця</string>
<string name="warning_approval_in_progress_message">Процес підтвердження наразі триває і буде завершено найближчим часом</string>
<string name="warning_approval_in_progress_title">Підтвердження в процесі</string>
<string name="warning_backup_error_add_funds_message">У цьому гаманці виникла проблема з резервним копіюванням. Зверніться до служби підтримки для її вирішення.</string>
<string name="warning_backup_error_add_funds_title">Поповнення вимкнено</string>
<string name="warning_backup_error_attention_message">Ваші картки були активовані неправильно. Щоб убезпечити кошти, переведіть їх на інший гаманець, скиньте картки до заводських налаштувань і активуйте заново.</string>
<string name="warning_backup_error_attention_title">Потрібна дія: скиньте налаштування гаманця</string>
<string name="warning_backup_errors_message">Схоже, що активація карток або кільця була виконана неправильно. Це може бути пов\'язано з проблемою з модулем NFC вашого пристрою або неправильним прикладанням картки або кільця до телефону. Будь ласка, зверніться за допомогою до нашої служби підтримки для уточнення деталей.</string>
<string name="warning_backup_errors_title">Помилка активації</string>
<string name="warning_beacon_chain_retirement_content">3 грудня 2024 року мережу BEP-2 було відключено за рішенням розробників мережі, і вона більше не підтримується</string>
@ -2084,6 +2330,8 @@
<string name="warning_no_backup_title">Резервна копія відсутня</string>
<string name="warning_number_of_signed_hashes_incorrect_message">Ця картка вже використовувалася для здійснення транзакцій. Якщо вона отримана з ненадійного джерела, подумайте про те, щоб зняти всі кошти. Якщо це ваша картка, ніяких додаткових дій не потрібно.</string>
<string name="warning_number_of_signed_hashes_incorrect_title">Картка вже підписувала транзакції</string>
<string name="warning_outdated_data_message">Буде оновлено якнайшвидше.</string>
<string name="warning_outdated_data_title">Відсутня частина балансу токенів.</string>
<string name="warning_rate_app_message">Ваш відгук мотивує нас робити гаманець Tangem Wallet ще кращим</string>
<string name="warning_rate_app_title">Подобається Tangem?</string>
<string name="warning_receive_blocked_hedera_token_association_required_message">Вам необхідно провести асоціацію токена, щоб мати можливість приймати його</string>
@ -2237,7 +2485,12 @@
<string name="yield_apy_boost_banner_subtitle">Бонус за першу активацію!</string>
<string name="yield_apy_boost_banner_title">Спецпропозиція для режиму дохідності</string>
<string name="yield_apy_boost_banner_title_apy_multiplied">APY x3</string>
<string name="yield_apy_boost_promo_activate_bonus">Активувати бонус</string>
<string name="yield_apy_boost_promo_bonus_paid_out_subtitle">Деталі в історії транзакцій</string>
<string name="yield_apy_boost_promo_bonus_paid_out_title">Бонус режиму дохідності виплачено</string>
<string name="yield_apy_boost_promo_days_left_to_unlock">%1$sднів до розблокування бонусу</string>
<string name="yield_apy_boost_promo_eligibility_text">Вам доступний буст APY на 30 днів</string>
<string name="yield_apy_boost_promo_terms_and_conditions">Діють Умови та Положення</string>
<string name="yield_apy_boost_story_first_subtitle">Увімкніть режим дохідності вперше та отримайте до 3x доходу за перші 30 днів</string>
<string name="yield_apy_boost_story_first_title">Бонус APR за перший місяць</string>
<string name="yield_apy_boost_story_fourth_subtitle">Ви отримуєте ринковий дохід + Бонус. Бонус виплачується одноразово в USDT або USDC протягом 14 днів після закінчення 30-денного періоду. Акція діє, доки є промо-бюджет. Діють правила та умови</string>
@ -2255,6 +2508,7 @@
<string name="yield_module_approve_sheet_fee_note">Комісія буде знята, а ваші активи знову почнуть приносити дохід.</string>
<string name="yield_module_approve_sheet_subtitle">Щоб продовжити заробляти, потрібне схвалення.</string>
<string name="yield_module_approve_sheet_title">Підтвердити дозвіл</string>
<string name="yield_module_average_apy">Середній APY %1$s%%</string>
<string name="yield_module_balance_info_sheet_subtitle">Наразі ваші кошти розміщені у протоколі Aave, але ви можете керувати ними в будь-який час.</string>
<string name="yield_module_balance_info_sheet_title">Ваш %s внесений у Aave</string>
<string name="yield_module_chart_loading_error">Неможливо завантажити графік</string>
@ -2328,6 +2582,7 @@
<string name="yield_module_token_details_earn_notification_earning_on_your_balance_subtitle">Відсотки нараховуються автоматично</string>
<string name="yield_module_token_details_earn_notification_earning_on_your_balance_title">Режим дохідності</string>
<string name="yield_module_token_details_earn_notification_processing">Відправка ваших коштів</string>
<string name="yield_module_token_details_earn_notification_subtitle">Режим дохідності • %1$s%% APY</string>
<string name="yield_module_token_details_earn_notification_title">Режим прибутковості</string>
<string name="yield_module_transaction_deploy_contract">Встановлення контракту режиму дохідності</string>
<string name="yield_module_transaction_enter">Режим прибутковості увімкнено</string>
@ -2344,5 +2599,7 @@
<string name="yield_module_unable_to_cover_fee_title">Неможливо покрити комісію в %s</string>
<string name="yield_module_unavailable_subtitle">Сервіс доходності у даний час недоступний. Будь ласка, спробуйте пізніше.</string>
<string name="yield_module_unavailable_title">Режим дохідності недоступний</string>
<string name="yield_promo_completed">Умови виплати бонусу перевірено</string>
<string name="yield_promo_left_title">до розблокування бонусу</string>
<string name="yield_supply_chart_loading_error">Не вдалося завантажити графік...</string>
</resources>

View file

@ -390,6 +390,9 @@
<string name="tangempay_card_details_withdraw_in_progress_title">提款进行中</string>
<string name="tangempay_card_limit_setup_amount_subtitle">從 %s 設定限額</string>
<string name="tangempay_card_limit_setup_error_message">我们无法设置限额,请稍后再试。</string>
<plurals name="tangempay_cards_count">
<item quantity="other">%d 張卡</item>
</plurals>
<string name="tangempay_change_pin_code">修改PIN码</string>
<string name="tangempay_come_back_if_forget_pin">如果忘记了,请返回应用查看。</string>
<string name="tangempay_declined_reason_1">餘額不足</string>

View file

@ -275,6 +275,7 @@
<string name="common_attention">Attention</string>
<string name="common_available_networks">Available networks</string>
<string name="common_backup">Backup</string>
<string name="common_backup_error">Backup Error</string>
<string name="common_balance">Balance: %s</string>
<string name="common_balance_title">Balance</string>
<string name="common_biometric_authentication">biometric authentication</string>
@ -566,10 +567,10 @@
<string name="dynamic_addresses_enabled_toast_title">Dynamic addresses enabled</string>
<string name="dynamic_addresses_enter_features_privacy_description">Use a new address for each transaction to reduce traceability and improve on-chain privacy.</string>
<string name="dynamic_addresses_enter_features_privacy_title">Enhanced Privacy</string>
<string name="dynamic_addresses_enter_features_receving_description">Easily receive funds in UTXO-based networks with automatic address generation — no manual address management required.</string>
<string name="dynamic_addresses_enter_features_receving_description">Receive funds as usual. Tangem manages UTXO addresses automatically, so you dont need to choose or track them manually.</string>
<string name="dynamic_addresses_enter_features_receving_title">Seamless receiving</string>
<string name="dynamic_addresses_enter_main_button_title">Enable Dynamic Addresses</string>
<string name="dynamic_addresses_enter_subtitle">Dynamic addresses create a new one each time for extra privacy — your total balance stays the same. </string>
<string name="dynamic_addresses_enter_subtitle">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.</string>
<string name="dynamic_addresses_error_has_custom_token_description">Dynamic Addresses cannot be enabled because some custom addresses/tokens use a modified derivation path, which doesnt meet the required criteria.</string>
<string name="dynamic_addresses_error_has_custom_token_title">Dynamic Addresses Unavailable</string>
<string name="dynamic_addresses_error_service_unavailable_description">We cant connect to the provider right now. Please try again later.</string>
@ -647,6 +648,7 @@
<string name="express_legal_two_placeholders">By using swap functionality, you agree with providers %1$s and %2$s.</string>
<string name="express_more_providers_soon">More providers will be available soon</string>
<string name="express_provider">Provider</string>
<string name="express_provider_best_dex_rate">Best DEX Rate</string>
<string name="express_provider_best_rate">Best rate</string>
<string name="express_provider_fca_warning_list">FCA Warning List</string>
<string name="express_provider_fixed_rate_is_unavailable">Fixed rate is unavailable</string>
@ -1516,9 +1518,9 @@
<string name="staking_details_apr">APR</string>
<string name="staking_details_apy">APY</string>
<string name="staking_details_auto_claiming_rewards_daily_text">Rewards automatically accumulate in your staking balance daily.</string>
<string name="staking_details_autocompound_rewards_earned">Rewards are compounded to your staking balance. Funds earned: %s</string>
<string name="staking_details_autocompound_rewards_compounded">Rewards are compounded to your staking balance.</string>
<string name="staking_details_autocompound_funds_earned">Funds earned: %s</string>
<string name="staking_details_autocompound_rewards_compounded">Rewards are compounded to your staking balance.</string>
<string name="staking_details_autocompound_rewards_earned">Rewards are compounded to your staking balance. Funds earned: %s</string>
<string name="staking_details_available">Available</string>
<string name="staking_details_average_reward_rate">Average Reward Rate</string>
<string name="staking_details_banner_text">How Staking Works?</string>

@ -1 +1 @@
Subproject commit 0be9b5e9f7fe13c483f9d037617e36c8848f2842
Subproject commit 76d6a50dc3161cfc6cc7055afc8ce4ba619ac5c6

View file

@ -1 +1 @@
058e1ecd83440d5fda33512248f67176db593029c9b398e4a2692816703e451c
7a974320353cf7ea1e0a25ca074f8e200ce44506044cc5b2bdcb00aee2c6dc85

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<P2PEthPoolApi>(relaxed = true)
private val vaultsStore = mockk<P2PEthPoolVaultsStore>(relaxed = true)
private val limitsStore = mockk<P2PVaultLimitsStore>(relaxed = true)
private val tangemTechApi = mockk<TangemTechApi>(relaxed = true)
private val featureToggles = mockk<StakingFeatureToggles>(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)
}
}

View file

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

View file

@ -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<String, AccountStatus.Payment>
@ -65,7 +61,7 @@ internal class PaymentAccountStatusesStore(
}
}
fun get(userWalletId: UserWalletId): Flow<AccountStatus.Payment> {
fun get(userWalletId: UserWalletId): Flow<AccountStatus.Payment?> {
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? {

View file

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

View file

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

View file

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

View file

@ -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()
}
}
/** 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
}

View file

@ -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<String, VaultLimitInfo>()
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)

View file

@ -184,6 +184,7 @@ internal open class BaseActionsFactory(
unavailabilityReason = ScenarioUnavailabilityReason.StakingUnavailable(currency.name),
option = null,
)
is StakingAvailability.Full -> null
StakingAvailability.Unavailable -> null
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -43,6 +43,7 @@ class SignCloreMessageUseCase(
cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = null,
userWalletId = userWallet.walletId,
)
}
is UserWallet.Hot -> getHotWalletSigner(userWallet)

View file

@ -44,6 +44,7 @@ class SignUseCase(
return cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
userWalletId = userWallet.walletId,
)
}
}

View file

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

View file

@ -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<UserWallet.Hot>(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<TransactionFee.Choosable>()
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<TransactionFee.Single>()
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<TransactionData.Compiled>()
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<TransactionExtras>()
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<SmartContractCallData>())
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<TransactionFee.Single>()
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<GetFeeError.DataError>()
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<GetFeeError.DataError>()
assertThat(error.cause).isEqualTo(boom)
}
@Test
fun `demo cold card routes through wallet manager for first overload`() = runTest {
// Given
val coldWallet = mockk<UserWallet.Cold>()
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<com.tangem.blockchain.common.Wallet>(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<CryptoCurrency.Coin>()
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<Amount>()
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<CryptoCurrency.Token>()
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<Amount>()
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<AmountType.Token>()
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<CryptoCurrency.Coin>()
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<CryptoCurrency.Coin>()
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<GetFeeError.DataError>()
assertThat(error.cause?.message).isEqualTo("Fee is null")
}
// endregion
// region helpers
private fun yieldSupplyTransactionData(): TransactionData.Uncompiled {
val callData = mockk<EthereumYieldSupplySendCallData>()
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<Fee.Ethereum>()
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 <reified T> Any?.requireAs(): T {
val value = this
assertThat(value).isInstanceOf(T::class.java)
return value as T
}
// endregion
private companion object {
const val SPENDER = "0xSpender"
}

View file

@ -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<CryptoCurrencyStatus> {
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<List<UserWallet>?>(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"
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<StakingUiState> {
val amountData = mockk<AmountState.Data>(relaxed = true) {
every { amountTextField } returns mockk(relaxed = true) {
every { cryptoAmount } returns Amount(
currencySymbol = "ETH",
value = cryptoAmountValue,
decimals = 18,
)
}
}
val uiStateFlow = MutableStateFlow(
mockk<StakingUiState>(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()
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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. */

View file

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

View file

@ -35,6 +35,12 @@ interface SwapInteractor {
pairs: List<SwapPairLeast>,
): List<SwapProvider>
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<GetFeeError, SwapFee>
fun integratedApprovalFallback(fromSwapCurrencyStatus: SwapCurrencyStatus, spenderAddress: String)

View file

@ -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<GetFeeError, SwapFee> = 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)) {

View file

@ -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<SwapProvider>,
)
data class CryptoCurrencySwapInfo(
val currencyStatus: CryptoCurrencyStatus,
val providers: List<SwapProvider>,
)
/**
* Provider that could swap given cryptocurrencies
*

View file

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

View file

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

View file

@ -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<SwapPairLeast>`).
*
* 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<SwapPairLeast>.applyTangemPayFilter(
fromStatus: SwapCurrencyStatus,
toStatus: SwapCurrencyStatus,
): List<SwapPairLeast> = 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<SwapPairLeast>().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()
}
}
}

View file

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

View file

@ -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<TransactionFeeExtended>(relaxed = true) {
io.mockk.every { transactionFee } returns TransactionFee.Single(normal = mockk<Fee.Common>(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<TransactionFeeExtended>(relaxed = true) {
io.mockk.every { transactionFee } returns TransactionFee.Single(normal = mockk<Fee.Common>(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
// -------------------------------------------------------------------------

View file

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

View file

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

View file

@ -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<SwapPairLeast>.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<SwapProvider, SwapState>.getLastLoadedSuccessStates(): SuccessLoadedSwapData {
@ -2405,6 +2438,28 @@ internal class SwapModel @Inject constructor(
override val forceUpdateState = MutableSharedFlow<FeeSelectorUM>()
/**
* 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<GetFeeError, SwapDataModel?> {
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<GetFeeError, TransactionFee> {
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -782,7 +782,7 @@ internal class SwapTransferStateBuilderTest {
tokenSymbol = stringReference(""),
amountEquivalent = TextReference.EMPTY,
amountField = initialAmountField,
balance = "",
balance = TextReference.EMPTY,
isBalanceHidden = false,
),
receiveCardData = SwapCardState.Loading(

View file

@ -482,7 +482,6 @@ internal class TangemPayCardPageModel @Inject constructor(
cryptoAmount = data.cryptoBalance,
fiatAmount = data.fiatBalance,
depositAddress = data.depositAddress,
isWithdrawal = false,
),
),
)

Some files were not shown because too many files have changed in this diff Show more