Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-02 11:18:27 +02:00
commit 788d7064b5
217 changed files with 6061 additions and 1892 deletions

View file

@ -2,7 +2,7 @@
name: analyze-logs
description: Analyze Tangem app user logs — extract device info, navigation path, errors, and key events timeline. Use when user provides a log file for bug investigation.
allowed-tools: Read, Grep
argument-hint: /path/to/logfile.txt [/path/to/logs.rtf]
argument-hint: /path/to/logfile.txt [/path/to/logs.rtf] [--no-secrets-audit]
---
Analyze the Tangem app user log file at path: `$ARGUMENTS`
@ -108,12 +108,37 @@ Launch ALL Grep calls below in parallel. Steps 2+3 search the **full file** (dev
- `MainActivity.*onNewIntent` — deep link or push notification
- `CardSDK_Session.*start card session` — NFC session starts
**Secrets & PII Audit (full file, head_limit: 20 each, -n: true):**
Skip this entire group if `--no-secrets-audit` is in arguments.
- API key leak in URL: `[?&](api[_-]?key|apiKey|access_token|token|secret)=(?!\*+)[^&\s]{8,}`
- Bearer token: `Bearer\s+[A-Za-z0-9._\-]{20,}`
- JWT: `eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+`
- Authorization header: `(?i)authorization:\s*\S+`
- Critical PII in JSON: `"(privateKey|mnemonic|seedPhrase|private_key|seed_phrase)"\s*:\s*"[^"]+"`
- card_public_key in JSON: `"card_public_key"\s*:\s*"[^"]{40,}"`
- FCM push token: `:APA91[A-Za-z0-9_\-]{100,}`
- xprv/tprv extended private key: `\b[xytzuv]prv[A-Za-z0-9]{100,}`
- Suspicious long hex in URL path: `https?://[^?\s]+/[A-Fa-f0-9]{32,}\b`
- Masking health check: count of `\*{6,}` — if 0 in a build that should mask, flag pipeline broken
**Error filtering:** When processing error results, skip these noisy matches:
- `java.io.IOException: Canceled` — normal request cancellation
- `HttpException(code=304` — HTTP "Not Modified"
- Bare stacktrace lines starting with `\tat`
- `<-- HTTP FAILED: java.io.IOException: Canceled`
### Step 6.5: Masking Consistency Check
Skip if `--no-secrets-audit` in arguments. Run sequentially after the parallel batch (needs results from the masked-endpoint grep).
1. Grep `https?://[^/\s]+/[^\s*]*\*{6,}` (full file, head_limit: 50) — collect all URLs where a path segment is masked
2. For each unique `host + path-prefix-before-mask`, derive the prefix string
3. For each prefix, Grep the prefix followed by a non-`*` character (`<prefix>[^*\s]`, head_limit: 20)
- If hits found → masking inconsistency: same endpoint has both masked and unmasked variants
- Record the prefix, count of masked hits, count of unmasked hits, first unmasked line number
### Step 7: Deep Dive
For each significant error found above:
@ -207,6 +232,37 @@ Structure your report EXACTLY as follows:
|------|-------|---------|
(chronological: app starts, card sessions, navigation, errors, notable API calls)
## Secrets & PII Audit
Omit this section entirely if `--no-secrets-audit` was passed.
### Health Check
- Total masked tokens (`******`) in log: **N**
- If N = 0 in a build expected to mask, flag: "masking pipeline may be broken"
### Confirmed Leaks (CRITICAL / HIGH)
| Line | Severity | Type | Matched (first 16 chars + `…`) | Context |
|------|----------|------|--------------------------------|---------|
### Masking Inconsistencies
| Endpoint Prefix | Masked Hits | Unmasked Hits | First Unmasked Line |
|-----------------|-------------|---------------|---------------------|
### Suspected Leaks (MEDIUM / LOW)
| Line | Severity | Type | Pattern Matched | Why Suspect |
|------|----------|------|-----------------|-------------|
**Severity legend:**
- **CRITICAL** — private key / mnemonic / xprv in clear text
- **HIGH** — API key / bearer / JWT / card_public_key visible
- **MEDIUM** — push token, card_id, persistent identifiers
- **LOW** — heuristic patterns that may be false positives (tx hash, content hash)
**Output rules:**
- Never include the full matched value — always truncate to 16 chars + `…`
- For LOW severity, add a "Why Suspect" column explaining typical false positives
- Skip matches from these known-public Tangem endpoints: `/v1/coins/settings`, `/v1/geo`, `/v1/currencies`, `/v1/hot_crypto`
## Analysis Summary
(2-3 paragraphs: what the user was doing, what broke, probable cause, recommendations.
If FOCUS_AREA was specified, emphasize errors, navigation, and API calls related to that area.)

View file

@ -36,6 +36,9 @@ fun BaseTestCase.checkMultiCurrencyMainScreen(
step("Assert 'Buy' button is displayed") {
onMainScreen { buyButton.assertIsDisplayed() }
}
step("Assert 'Add funds' button is displayed") {
onMainScreen { addFundsButton.assertIsDisplayed() }
}
step("Assert 'Swap' button is displayed") {
onMainScreen { swapButton.assertIsDisplayed() }
}
@ -55,8 +58,8 @@ fun BaseTestCase.checkMultiCurrencyMainScreen(
fun BaseTestCase.assertActionButtonsForMultiCurrencyWallet(isEnabled: Boolean = true) {
if (isEnabled) {
step("Assert 'Buy' button is enabled") {
onMainScreen { buyButton.assertIsEnabled() }
step("Assert 'Add funds' button is enabled") {
onMainScreen { addFundsButton.assertIsEnabled() }
}
step("Assert 'Swap' button is enabled") {
onMainScreen { swapButton.assertIsEnabled() }
@ -65,8 +68,8 @@ fun BaseTestCase.assertActionButtonsForMultiCurrencyWallet(isEnabled: Boolean =
onMainScreen { sellButton.assertIsEnabled() }
}
} else {
step("Assert 'Buy' button is not enabled") {
onMainScreen { buyButton.assertIsNotEnabled() }
step("Assert 'Add funds' button is not enabled") {
onMainScreen { addFundsButton.assertIsNotEnabled() }
}
step("Assert 'Swap' button is not enabled") {
onMainScreen { swapButton.assertIsNotEnabled() }

View file

@ -0,0 +1,39 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.BaseSearchBarTestTags
import com.tangem.core.ui.test.BuyTokenScreenTestTags
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText
/**
* "You receive" token chooser opened from the main-screen "Add funds" button.
*/
class ChooseTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<ChooseTokenPageObject>(semanticsProvider = semanticsProvider) {
val topAppBarTitle: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
useUnmergedTree = true
}
val searchBar: KNode = child {
hasTestTag(BaseSearchBarTestTags.SEARCH_BAR)
}
fun tokenWithTitle(tokenTitle: String): KNode = child {
hasTestTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM)
hasAnyDescendant(withTestTag(TokenElementsTestTags.TOKEN_TITLE))
hasAnyDescendant(withText(tokenTitle))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onChooseTokenScreen(function: ChooseTokenPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -0,0 +1,45 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.R
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
/**
* "Get token" bottom sheet shown after picking a token in the Add funds flow.
* Contains quick actions (Buy / Receive / ) and the "Go to token" button.
*/
class GetTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<GetTokenBottomSheetPageObject>(semanticsProvider = semanticsProvider) {
val title: KNode = child {
hasTestTag(BaseBottomSheetTestTags.TITLE)
}
val closeButton: KNode = child {
hasTestTag(BaseBottomSheetTestTags.CLOSE_BUTTON)
}
// The "Get token" sheet action rows use combinedClickable; the row's testTag lands on a
// separate zero-bounds semantics node that fails assertIsDisplayed. Matching the merged node
// by its title text yields the displayed, clickable row (performClick injects a touch at its
// center, which the row's clickable handles).
val buyButton: KNode = child {
hasText(getResourceString(R.string.common_buy))
}
val receiveButton: KNode = child {
hasText(getResourceString(R.string.common_receive))
}
val goToTokenButton: KNode = child {
hasText(getResourceString(R.string.common_go_to_token))
}
}
internal fun BaseTestCase.onGetTokenBottomSheet(function: GetTokenBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -55,6 +55,11 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
useUnmergedTree = true
}
val addFundsButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_add_funds))
}
val sendButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasAnyDescendant(withText(getResourceString(R.string.common_send)))

View file

@ -12,7 +12,7 @@ class TangemPayAddFundsSheetPageObject(semanticsProvider: SemanticsNodeInteracti
ComposeScreen<TangemPayAddFundsSheetPageObject>(semanticsProvider = semanticsProvider) {
val swapOption: KNode = child {
hasText(getResourceString(CoreResR.string.common_exchange))
hasText(getResourceString(CoreResR.string.tangempay_topup_swap_title))
useUnmergedTree = true
}

View file

@ -40,15 +40,18 @@ class BuyTokenTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on 'Buy' button") {
onMainScreen { buyButton.clickWithAssertion() }
step("Click on 'Add funds' button") {
onMainScreen { addFundsButton.clickWithAssertion() }
}
step("Click on token with name: '$tokenTitle'") {
onBuyTokenScreen {
onChooseTokenScreen {
topAppBarTitle.assertIsDisplayed()
tokenWithTitleAndFiatAmount(tokenTitle).clickWithAssertion()
tokenWithTitle(tokenTitle).clickWithAssertion()
}
}
step("Click on 'Buy' in 'Get token' bottom sheet") {
onGetTokenBottomSheet { buyButton.clickWithAssertion() }
}
step("Assert error notification title is displayed") {
onBuyTokenDetailsScreen { errorNotificationTitle.assertIsDisplayed() }
}
@ -84,15 +87,18 @@ class BuyTokenTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on 'Buy' button") {
onMainScreen { buyButton.clickWithAssertion() }
step("Click on 'Add funds' button") {
onMainScreen { addFundsButton.clickWithAssertion() }
}
step("Click on token with name: '$tokenTitle'") {
onBuyTokenScreen {
onChooseTokenScreen {
topAppBarTitle.assertIsDisplayed()
tokenWithTitleAndFiatAmount(tokenTitle).clickWithAssertion()
tokenWithTitle(tokenTitle).clickWithAssertion()
}
}
step("Click on 'Buy' in 'Get token' bottom sheet") {
onGetTokenBottomSheet { buyButton.clickWithAssertion() }
}
step("Click on 'Confirm' button in 'Dialog'") {
onDialog { confirmButton.clickWithAssertion() }
}
@ -155,15 +161,18 @@ class BuyTokenTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on 'Buy' button") {
onMainScreen { buyButton.clickWithAssertion() }
step("Click on 'Add funds' button") {
onMainScreen { addFundsButton.clickWithAssertion() }
}
step("Click on token with name: '$tokenTitle'") {
onBuyTokenScreen {
onChooseTokenScreen {
topAppBarTitle.assertIsDisplayed()
tokenWithTitleAndFiatAmount(tokenTitle).clickWithAssertion()
tokenWithTitle(tokenTitle).clickWithAssertion()
}
}
step("Click on 'Buy' in 'Get token' bottom sheet") {
onGetTokenBottomSheet { buyButton.clickWithAssertion() }
}
step("Click on 'Confirm' button in 'Dialog'") {
onDialog { confirmButton.clickWithAssertion() }
}
@ -238,15 +247,18 @@ class BuyTokenTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on 'Buy' button") {
onMainScreen { buyButton.clickWithAssertion() }
step("Click on 'Add funds' button") {
onMainScreen { addFundsButton.clickWithAssertion() }
}
step("Click on token with name: '$tokenTitle'") {
onBuyTokenScreen {
onChooseTokenScreen {
topAppBarTitle.assertIsDisplayed()
tokenWithTitleAndFiatAmount(tokenTitle).clickWithAssertion()
tokenWithTitle(tokenTitle).clickWithAssertion()
}
}
step("Click on 'Buy' in 'Get token' bottom sheet") {
onGetTokenBottomSheet { buyButton.clickWithAssertion() }
}
step("Click on 'Confirm' button in 'Dialog'") {
onDialog { confirmButton.clickWithAssertion() }
}
@ -320,15 +332,18 @@ class BuyTokenTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on 'Buy' button") {
onMainScreen { buyButton.clickWithAssertion() }
step("Click on 'Add funds' button") {
onMainScreen { addFundsButton.clickWithAssertion() }
}
step("Click on token with name: '$tokenTitle'") {
onBuyTokenScreen {
onChooseTokenScreen {
topAppBarTitle.assertIsDisplayed()
tokenWithTitleAndFiatAmount(tokenTitle).clickWithAssertion()
tokenWithTitle(tokenTitle).clickWithAssertion()
}
}
step("Click on 'Buy' in 'Get token' bottom sheet") {
onGetTokenBottomSheet { buyButton.clickWithAssertion() }
}
step("Click on 'Confirm' button in 'Dialog'") {
onDialog { confirmButton.clickWithAssertion() }
}
@ -406,15 +421,18 @@ class BuyTokenTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on 'Buy' button") {
onMainScreen { buyButton.clickWithAssertion() }
step("Click on 'Add funds' button") {
onMainScreen { addFundsButton.clickWithAssertion() }
}
step("Click on token with name: '$tokenTitle'") {
onBuyTokenScreen {
onChooseTokenScreen {
topAppBarTitle.assertIsDisplayed()
tokenWithTitleAndFiatAmount(tokenTitle).clickWithAssertion()
tokenWithTitle(tokenTitle).clickWithAssertion()
}
}
step("Click on 'Buy' in 'Get token' bottom sheet") {
onGetTokenBottomSheet { buyButton.clickWithAssertion() }
}
step("Click on 'Confirm' button in 'Dialog'") {
onDialog { confirmButton.clickWithAssertion() }
}

View file

@ -422,17 +422,17 @@ class MainScreenActionButtonsTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Assert 'Buy' button is displayed") {
onMainScreen { buyButton.assertIsDisplayed() }
step("Assert 'Add funds' button is displayed") {
onMainScreen { addFundsButton.assertIsDisplayed() }
}
step("Click on 'Buy' button") {
onMainScreen { buyButton.performClick() }
step("Click on 'Add funds' button") {
onMainScreen { addFundsButton.performClick() }
}
step("Assert 'Buy' screen title is displayed") {
onBuyTokenScreen { topAppBarTitle.assertIsDisplayed() }
step("Assert 'Choose token' screen title is displayed") {
onChooseTokenScreen { topAppBarTitle.assertIsDisplayed() }
}
step("Assert token with title: '$tokenTitle' is displayed") {
onBuyTokenScreen { tokenWithTitleAndFiatAmount(tokenTitle).assertIsDisplayed() }
onChooseTokenScreen { tokenWithTitle(tokenTitle).assertIsDisplayed() }
}
step("Press 'Back' button") {
device.uiDevice.pressBack()
@ -481,17 +481,18 @@ class MainScreenActionButtonsTest : BaseTestCase() {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Assert 'Buy' button is displayed") {
onMainScreen { buyButton.assertIsDisplayed() }
step("Assert 'Add funds' button is displayed") {
onMainScreen { addFundsButton.assertIsDisplayed() }
}
step("Click on 'Buy' button") {
onMainScreen { buyButton.performClick() }
step("Click on 'Add funds' button") {
onMainScreen { addFundsButton.performClick() }
}
step("Check 'Action is unavailable' dialog") {
checkActionIsUnavailableDialog()
step("Assert 'Choose token' screen opens (Add funds is always available)") {
onChooseTokenScreen { topAppBarTitle.assertIsDisplayed() }
}
step("Click on 'Ok' button") {
onDialog { okButton.performClick() }
step("Press 'Back' to return to main screen") {
device.uiDevice.pressBack()
waitForIdle()
}
step("Assert 'Swap' button is displayed") {
onMainScreen { swapButton.assertIsDisplayed() }
@ -538,17 +539,18 @@ class MainScreenActionButtonsTest : BaseTestCase() {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Assert 'Buy' button is displayed") {
onMainScreen { buyButton.assertIsDisplayed() }
step("Assert 'Add funds' button is displayed") {
onMainScreen { addFundsButton.assertIsDisplayed() }
}
step("Click on 'Buy' button") {
onMainScreen { buyButton.performClick() }
step("Click on 'Add funds' button") {
onMainScreen { addFundsButton.performClick() }
}
step("Check 'Action is unavailable' dialog") {
checkActionIsUnavailableDialog()
step("Assert 'Choose token' screen opens (Add funds is always available)") {
onChooseTokenScreen { topAppBarTitle.assertIsDisplayed() }
}
step("Click on 'Ok' button") {
onDialog { okButton.performClick() }
step("Press 'Back' to return to main screen") {
device.uiDevice.pressBack()
waitForIdle()
}
step("Assert 'Swap' button is displayed") {
onMainScreen { swapButton.assertIsDisplayed() }

View file

@ -51,9 +51,12 @@ class HideTokenTest : BaseTestCase() {
dialogContainer.assertIsDisplayed()
okButton.clickWithAssertion()
}
waitForIdle()
}
step("Assert token: '$tokenTitle' is not displayed") {
onMainScreen { assertTokenDoesNotExist(tokenTitle) }
flakySafely {
onMainScreen { assertTokenDoesNotExist(tokenTitle) }
}
}
}
}

View file

@ -4,10 +4,12 @@ import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.swipeVertical
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onAddAndManageBottomSheet
import com.tangem.screens.onMainScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
@ -86,6 +88,15 @@ class MainScreenTest : BaseTestCase() {
step("Assert 'Add & Manage' button is displayed") {
onMainScreen { addAndManageButtonNode.assertIsDisplayed() }
}
step("Click 'Add & Manage' button") {
onMainScreen { addAndManageButtonNode.clickWithAssertion() }
}
step("Assert 'Organize tokens' option is not displayed (nothing to organize)") {
onAddAndManageBottomSheet { organizeTokensButton.assertIsNotDisplayed() }
}
step("Assert 'Add tokens' option is displayed") {
onAddAndManageBottomSheet { addTokensButton.assertIsDisplayed() }
}
}
}

View file

@ -170,7 +170,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
private val onActivityResultCallbacks = mutableListOf<OnActivityResultCallback>()
override fun onCreate(savedInstanceState: Bundle?) {
TangemLogger.i("onCreate")
TangemLogger.i("onCreate: data=${intent?.data}, extras=${intent?.extras?.keySet()}")
// We need to call it before onCreate to prevent unnecessary activity recreation
installAppTheme()

View file

@ -14,12 +14,14 @@ class AppsFlyerDeepLinkListener @Inject constructor(
override fun onDeepLinking(p0: DeepLinkResult) {
when (p0.status) {
DeepLinkResult.Status.FOUND -> {
referralParamsHandler.handle(deepLink = p0.deepLink)
referralParamsHandler.handleDeeplink(deepLink = p0.deepLink)
}
DeepLinkResult.Status.NOT_FOUND -> {
referralParamsHandler.handleNoDeeplink()
TangemLogger.i("No deep link found")
}
DeepLinkResult.Status.ERROR -> {
referralParamsHandler.handleNoDeeplink()
TangemLogger.e("Deep link error: ${p0.error}")
}
}

View file

@ -7,6 +7,7 @@ import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.feature.referral.domain.SetShouldShowMobileWalletPromoUseCase
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -23,14 +24,7 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
) {
private val mutex = Mutex()
fun handle(deepLink: DeepLink) {
handle(
deepLinkValue = deepLink.deepLinkValue,
deepLinkSub1 = deepLink.getStringValue(DEEP_LINK_SUB_1),
deepLinkSub2 = deepLink.getStringValue(DEEP_LINK_SUB_2),
)
}
private val deepLinkDeferred = CompletableDeferred<String?>()
fun handle(params: Map<String?, Any?>) {
handle(
@ -40,6 +34,31 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
)
}
fun handleDeeplink(deepLink: DeepLink) {
handle(
deepLinkValue = deepLink.deepLinkValue,
deepLinkSub1 = deepLink.getStringValue(DEEP_LINK_SUB_1),
deepLinkSub2 = deepLink.getStringValue(DEEP_LINK_SUB_2),
)
deepLinkDeferred.complete(deepLink.deepLinkValue)
}
fun handleNoDeeplink() {
deepLinkDeferred.complete(null)
}
suspend fun waitForDeeplink(deeplinkSource: AppsFlyerDeeplinkSource): String? {
val deeplinkFromCache = appsFlyerStore.getDeeplink(deeplinkSource)
return if (deeplinkFromCache == null) {
val value = when (deeplinkSource) {
AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding -> TANGEM_PAY_HOT_WALLET_ONBOARDING_DEEP_LINK_VALUE
}
deepLinkDeferred.await().takeIf { it == value }
} else {
deeplinkFromCache
}
}
private fun handle(deepLinkValue: String?, deepLinkSub1: String?, deepLinkSub2: String?) {
TangemLogger.i("AppsFlyer deeplink received: value=$deepLinkValue")
when (deepLinkValue) {

View file

@ -4,15 +4,20 @@ import android.app.Application
import com.chuckerteam.chucker.api.ChuckerInterceptor
import com.tangem.Log
import com.tangem.TangemSdkLogger
import com.tangem.blockchain.common.BlockchainSdkConfig
import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder
import com.tangem.datasource.api.common.createNetworkLoggingInterceptor
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.logs.SensitiveUrlMasker
import com.tangem.datasource.utils.NetworkLogsSaveInterceptor
import com.tangem.datasource.utils.WireMockRedirectInterceptor
import com.tangem.domain.common.LogConfig
import com.tangem.operations.attestation.api.TangemApiServiceSettings
import com.tangem.utils.JsonStringValuesExtractor
import com.tangem.utils.logging.TangemLogger
import com.tangem.wallet.BuildConfig
import kotlinx.serialization.json.Json
/**
* Owns all app-startup wiring of the logging subsystem in a single place:
@ -23,12 +28,15 @@ import com.tangem.wallet.BuildConfig
* @property appLogsStore app logs store used by file-based writer and the network logs save
* interceptor
* @property tangemSdkLogger Card SDK logger registered with [Log.addLogger]
* @property environmentConfig source of [BlockchainSdkConfig] used to build the blockchain
* URL masker
*
[REDACTED_AUTHOR]
*/
class TangemLoggingInitializer(
private val appLogsStore: AppLogsStore,
private val tangemSdkLogger: TangemSdkLogger,
private val environmentConfig: EnvironmentConfig,
) {
fun initAppLogging() {
@ -64,6 +72,13 @@ class TangemLoggingInitializer(
}
add(createNetworkLoggingInterceptor())
add(ChuckerInterceptor(application))
add(
NetworkLogsSaveInterceptor(
appLogsStore = appLogsStore,
sensitiveUrlMasker = createBlockchainSensitiveUrlMasker(),
shouldCheckResponseBodySize = true,
),
)
}
TangemApiServiceSettings.addInterceptors(
@ -77,4 +92,16 @@ class TangemLoggingInitializer(
}.toTypedArray(),
)
}
private fun createBlockchainSensitiveUrlMasker(): SensitiveUrlMasker {
val json = Json.encodeToJsonElement(
BlockchainSdkConfig.serializer(),
environmentConfig.blockchainSdkConfig,
)
// Drop URL-shaped values (e.g. public endpoint URLs from BlockchainSdkConfig like
// kaspaSecondaryApiUrl); they are not secrets and would obscure unrelated requests in logs.
val values = JsonStringValuesExtractor.extract(json)
.filter { it.isNotBlank() && !it.startsWith("http", ignoreCase = true) }
return SensitiveUrlMasker(values)
}
}

View file

@ -5,12 +5,10 @@ import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.domain.card.BuildConfig
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager
import com.tangem.tap.domain.sdk.impl.MockTangemSdkManager
import com.tangem.tap.domain.tasks.product.BlockchainToDeriveFinder
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.visa.VisaCardScanHandler
@ -34,8 +32,6 @@ internal class TangemSdkManagerModule {
visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
blockchainToDeriveFinder: BlockchainToDeriveFinder,
analyticsErrorHandler: AnalyticsErrorHandler,
cardRepository: CardRepository,
): TangemSdkManager {
@ -49,8 +45,6 @@ internal class TangemSdkManagerModule {
visaCardActivationTaskFactory = visaCardActivationTaskFactory,
tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory,
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles,
blockchainToDeriveFinder = blockchainToDeriveFinder,
analyticsErrorHandler = analyticsErrorHandler,
cardRepository = cardRepository,
)

View file

@ -1,6 +1,7 @@
package com.tangem.tap.di.data
import com.tangem.blockchain.common.logging.BlockchainSDKLogger
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.tap.common.log.TangemBlockchainSDKLogger
import com.tangem.tap.common.log.TangemCardSDKLogger
@ -17,10 +18,14 @@ internal object TangemLoggingModule {
@Provides
@Singleton
fun provideLoggingInitializer(appLogsStore: AppLogsStore): TangemLoggingInitializer {
fun provideLoggingInitializer(
appLogsStore: AppLogsStore,
environmentConfig: EnvironmentConfig,
): TangemLoggingInitializer {
return TangemLoggingInitializer(
appLogsStore = appLogsStore,
tangemSdkLogger = TangemCardSDKLogger(appLogsStore),
environmentConfig = environmentConfig,
)
}

View file

@ -27,7 +27,6 @@ import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWalletId
@ -58,6 +57,7 @@ import com.tangem.tap.domain.twins.CreateFirstTwinWalletTask
import com.tangem.tap.domain.twins.CreateSecondTwinWalletTask
import com.tangem.tap.domain.twins.FinalizeTwinTask
import com.tangem.tap.domain.visa.VisaCardScanHandler
import com.tangem.utils.logging.TangemLogger
import com.tangem.wallet.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
@ -73,8 +73,6 @@ internal class DefaultTangemSdkManager(
private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
private val blockchainToDeriveFinder: BlockchainToDeriveFinder,
private val analyticsErrorHandler: AnalyticsErrorHandler,
private val cardRepository: CardRepository,
) : TangemSdkManager {
@ -145,12 +143,10 @@ internal class DefaultTangemSdkManager(
runTaskAsyncReturnOnMain(
runnable = ScanProductTask(
card = null,
blockchainToDeriveFinder = blockchainToDeriveFinder,
allowsRequestAccessCodeFromRepository = allowsRequestAccessCodeFromRepository,
visaCardScanHandler = visaCardScanHandler,
visaCoroutineScope = this,
shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated,
isDynamicAddressesEnabled = dynamicAddressesFeatureToggles.isDynamicAddressesEnabled,
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
cardRepository = cardRepository,
),
@ -242,6 +238,7 @@ internal class DefaultTangemSdkManager(
Analytics.send(event = analyticsEvent.withParams(params.toMap()))
}
.doOnFailure { tangemError ->
TangemLogger.e("scanProduct failed: code=${tangemError.code}, message=${tangemError.customMessage}")
(tangemError as? TangemSdkError)?.let { error ->
Analytics.sendErrorEvent(TangemSdkErrorEvent(error))
}
@ -470,7 +467,6 @@ internal class DefaultTangemSdkManager(
runnable = FinalizeTwinTask(
twinPublicKey = secondCardPublicKey,
issuerKeys = issuerKeyPair,
isDynamicAddressesEnabled = dynamicAddressesFeatureToggles.isDynamicAddressesEnabled,
cardRepository = cardRepository,
),
cardId = cardId,

View file

@ -1,74 +0,0 @@
package com.tangem.tap.domain.tasks.product
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.derivation.DerivationStyle
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.wallets.derivations.BlockchainToDerive
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.tap.features.demo.DemoHelper
import javax.inject.Inject
/**
* Finder of blockchains to derive.
* Returns only saved, default or demo blockchains without any additional logic
* (no cardano/ethereum additions or unnecessary blockchain removals).
*/
class BlockchainToDeriveFinder @Inject constructor(
private val walletAccountsFetcher: WalletAccountsFetcher,
) {
suspend fun find(card: CardDTO): Set<BlockchainToDerive> {
if (!card.settings.isHDWalletAllowed || card.wallets.isEmpty()) return emptySet()
val userWalletId = UserWalletIdBuilder.card(card).build() ?: return emptySet()
val derivationStyle = card.derivationStyleProvider.getDerivationStyle()
val blockchains = getBlockchains(userWalletId).ifEmpty {
if (DemoHelper.isDemoCardId(card.cardId)) {
getDemoBlockchains(derivationStyle, card.cardId)
} else {
getDefaultBlockchains(derivationStyle)
}
}
return blockchains
}
private suspend fun getBlockchains(userWalletId: UserWalletId): Set<BlockchainToDerive> {
return walletAccountsFetcher.getSaved(userWalletId)?.accounts.orEmpty()
.flatMap { accountDTO ->
accountDTO.tokens.orEmpty()
.filter { it.contractAddress == null }
}
.mapNotNull { coin ->
val blockchain = Blockchain.fromNetworkId(coin.networkId) ?: return@mapNotNull null
val derivationPath = coin.derivationPath?.let(::DerivationPath) ?: return@mapNotNull null
BlockchainToDerive(blockchain, derivationPath)
}
.toSet()
}
private fun getDemoBlockchains(derivationStyle: DerivationStyle?, cardId: String): Set<BlockchainToDerive> {
return DemoHelper.config.getDemoBlockchains(cardId).mapToBlockchainsWithDerivations(derivationStyle)
}
private fun getDefaultBlockchains(derivationStyle: DerivationStyle?): Set<BlockchainToDerive> {
val defaultBlockchains = setOf(Blockchain.Bitcoin, Blockchain.Ethereum)
return defaultBlockchains.mapToBlockchainsWithDerivations(derivationStyle)
}
private fun Set<Blockchain>.mapToBlockchainsWithDerivations(
derivationStyle: DerivationStyle?,
): Set<BlockchainToDerive> {
return mapNotNullTo(hashSetOf()) { blockchain ->
val derivationPath = blockchain.derivationPath(derivationStyle) ?: return@mapNotNullTo null
BlockchainToDerive(blockchain, derivationPath)
}
}
}

View file

@ -12,8 +12,6 @@ import com.tangem.common.extensions.*
import com.tangem.common.tlv.Tlv
import com.tangem.common.tlv.TlvDecoder
import com.tangem.crypto.CryptoUtils
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.wallets.derivations.MissedDerivationsFinder
import com.tangem.domain.card.common.TapWorkarounds.isExcluded
import com.tangem.domain.card.common.TapWorkarounds.isNotSupportedInThatRelease
import com.tangem.domain.card.common.TapWorkarounds.isStart2Coin
@ -32,25 +30,21 @@ import com.tangem.operations.PreflightReadMode
import com.tangem.operations.ScanTask
import com.tangem.operations.backup.PrimaryCard
import com.tangem.operations.backup.StartPrimaryCardLinkingTask
import com.tangem.operations.derivation.DeriveMultipleWalletPublicKeysTask
import com.tangem.operations.files.ReadFilesTask
import com.tangem.operations.issuerAndUserData.ReadIssuerDataCommand
import com.tangem.tap.domain.TapSdkError
import com.tangem.tap.domain.visa.VisaCardScanHandler
import com.tangem.tap.mainScope
import com.tangem.tap.scope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Suppress("LongParameterList")
internal class ScanProductTask(
private val card: Card?,
private val blockchainToDeriveFinder: BlockchainToDeriveFinder?,
private val visaCardScanHandler: VisaCardScanHandler?,
private val visaCoroutineScope: CoroutineScope?,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles?,
private val shouldCheckIsAlreadyActivated: Boolean,
private val isDynamicAddressesEnabled: Boolean,
private val cardRepository: CardRepository,
override val allowsRequestAccessCodeFromRepository: Boolean = false,
) : CardSessionRunnable<ScanResponse> {
@ -80,8 +74,6 @@ internal class ScanProductTask(
session = session,
cardDto = cardDto,
scanWalletProcessor = ScanWalletProcessor(
blockchainToDeriveFinder = blockchainToDeriveFinder,
isDynamicAddressesEnabled = isDynamicAddressesEnabled,
cardRepository = cardRepository,
),
callback = callback,
@ -92,8 +84,6 @@ internal class ScanProductTask(
val commandProcessor = when {
cardDto.isTangemTwins -> ScanTwinProcessor()
else -> ScanWalletProcessor(
blockchainToDeriveFinder = blockchainToDeriveFinder,
isDynamicAddressesEnabled = isDynamicAddressesEnabled,
cardRepository = cardRepository,
)
}
@ -102,8 +92,8 @@ internal class ScanProductTask(
is CompletionResult.Success -> ScanTask().run(session) { scanTaskResult ->
when (scanTaskResult) {
is CompletionResult.Success -> {
// it needed because processorResult.data.card doesn't contains attestation result
// and CardWallet.derivedKeys
// It's needed because processorResult.data.card doesn't contain the attestation
// result or the existing CardWallet.derivedKeys read from the card.
val processorScanResponseWithNewCard = processorResult.data.copy(
card = CardDTO(scanTaskResult.data),
)
@ -176,8 +166,6 @@ internal class ScanProductTask(
}
private class ScanWalletProcessor(
private val blockchainToDeriveFinder: BlockchainToDeriveFinder?,
private val isDynamicAddressesEnabled: Boolean,
private val cardRepository: CardRepository,
) : ProductCommandProcessor<ScanResponse> {
@ -281,48 +269,34 @@ private class ScanWalletProcessor(
when (linkingResult) {
is CompletionResult.Success -> {
primaryCard = linkingResult.data
deriveKeysIfNeeded(card, session, callback)
completeScan(card, session, callback)
}
is CompletionResult.Failure -> {
deriveKeysIfNeeded(card, session, callback)
completeScan(card, session, callback)
}
}
}
} else {
deriveKeysIfNeeded(card, session, callback)
completeScan(card, session, callback)
}
}
}
private fun deriveKeysIfNeeded(
// Keys are no longer derived during scan: default derivations are created up front in
// CreateProductWalletTask, and derivations for additional tokens are handled by
// DefaultColdMapDerivationsRepository when the user explicitly adds a token.
private fun completeScan(
card: CardDTO,
session: CardSession,
callback: (result: CompletionResult<ScanResponse>) -> Unit,
) {
val productType = getWalletProductType(card)
scope.launch {
val scanResponse = ScanResponse(
card = card,
productType = productType,
walletData = session.environment.walletData,
primaryCard = primaryCard,
)
val derivations = collectDerivations(card, scanResponse)
if (derivations.isEmpty() || !card.settings.isHDWalletAllowed) {
callback(CompletionResult.Success(scanResponse))
return@launch
}
DeriveMultipleWalletPublicKeysTask(derivations).run(session) { result ->
when (result) {
is CompletionResult.Success -> {
val response = scanResponse.copy(derivedKeys = result.data.entries)
callback(CompletionResult.Success(response))
}
is CompletionResult.Failure -> callback(CompletionResult.Failure(result.error))
}
}
}
val scanResponse = ScanResponse(
card = card,
productType = getWalletProductType(card),
walletData = session.environment.walletData,
primaryCard = primaryCard,
)
callback(CompletionResult.Success(scanResponse))
}
private fun getWalletProductType(card: CardDTO): ProductType {
@ -334,17 +308,6 @@ private class ScanWalletProcessor(
else -> ProductType.Wallet
}
}
private suspend fun collectDerivations(
card: CardDTO,
scanResponse: ScanResponse,
): Map<ByteArrayKey, List<DerivationPath>> {
val blockchains = blockchainToDeriveFinder
?.find(card)
?: return emptyMap()
return MissedDerivationsFinder(scanResponse, isDynamicAddressesEnabled).findByBlockchainsToDerive(blockchains)
}
}
@Suppress("MagicNumber")

View file

@ -13,7 +13,6 @@ import com.tangem.tap.domain.tasks.product.ScanProductTask
class FinalizeTwinTask(
private val twinPublicKey: ByteArray,
private val issuerKeys: KeyPair,
private val isDynamicAddressesEnabled: Boolean,
private val cardRepository: CardRepository,
) : CardSessionRunnable<ScanResponse> {
@ -31,11 +30,9 @@ class FinalizeTwinTask(
is CompletionResult.Success ->
ScanProductTask(
card = readResult.data,
blockchainToDeriveFinder = null,
visaCardScanHandler = null,
visaCoroutineScope = null,
shouldCheckIsAlreadyActivated = false,
isDynamicAddressesEnabled = isDynamicAddressesEnabled,
onboardingV2FeatureToggles = null,
cardRepository = cardRepository,
).run(session, callback)

View file

@ -325,11 +325,7 @@ internal class DefaultUserWalletsListRepository(
sensitiveInformationRepository.getAll(listOf(encryptionKey))
.doOnSuccess { sensitiveInfo ->
updateWallets { wallets ->
// It is necessary to update derivations because when scanning we obtain the missing keys
wallets?.updateWith(
walletIdToSensitiveInformation = sensitiveInfo,
walletIdToDerivedKeys = mapOf(userWallet.walletId to scanResponse.derivedKeys),
)
wallets?.updateWith(walletIdToSensitiveInformation = sensitiveInfo)
}
trackSignInEvent(userWallet, AnalyticsParam.SignInType.Card)
}

View file

@ -1,10 +1,8 @@
package com.tangem.tap.domain.userWalletList.utils
import com.tangem.domain.models.scan.KeyWalletPublicKey
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import com.tangem.tap.domain.userWalletList.model.UserWalletPublicInformation
import com.tangem.tap.domain.userWalletList.model.UserWalletSensitiveInformation
@ -74,10 +72,7 @@ internal fun List<UserWalletPublicInformation>.toUserWallets(): List<UserWallet>
return this.map { it.toUserWallet() }
}
internal fun UserWallet.updateWith(
sensitiveInformation: UserWalletSensitiveInformation,
derivedKeys: Map<KeyWalletPublicKey, ExtendedPublicKeysMap>?,
): UserWallet {
internal fun UserWallet.updateWith(sensitiveInformation: UserWalletSensitiveInformation): UserWallet {
return when (this) {
is UserWallet.Cold -> {
copy(
@ -85,7 +80,6 @@ internal fun UserWallet.updateWith(
card = scanResponse.card.copy(
wallets = requireNotNull(sensitiveInformation.wallets),
),
derivedKeys = derivedKeys ?: scanResponse.derivedKeys,
// visaCardActivationStatus = sensitiveInformation.visaCardActivationStatus,
),
)
@ -98,17 +92,14 @@ internal fun UserWallet.updateWith(
internal fun List<UserWallet>.updateWith(
walletIdToSensitiveInformation: Map<UserWalletId, UserWalletSensitiveInformation>,
walletIdToDerivedKeys: Map<UserWalletId, Map<KeyWalletPublicKey, ExtendedPublicKeysMap>>? = null,
): List<UserWallet> {
return if (walletIdToSensitiveInformation.isEmpty()) {
this
} else {
this.map { wallet ->
val sensitiveInformation = walletIdToSensitiveInformation[wallet.walletId]
val derivedKeys = walletIdToDerivedKeys?.get(wallet.walletId)
if (sensitiveInformation != null) {
wallet.updateWith(sensitiveInformation, derivedKeys)
wallet.updateWith(sensitiveInformation)
} else {
wallet
}

View file

@ -32,7 +32,6 @@ import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.scan.ScanResponse
@ -54,6 +53,7 @@ import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.android.create
import com.tangem.sdk.api.BackupServiceHolder
import com.tangem.tap.common.SnackbarHandler
import com.tangem.tap.common.analytics.appsflyer.AppsFlyerReferralParamsHandler
import com.tangem.tap.features.hot.TangemHotSDKProxy
import com.tangem.tap.features.root.RootDetectedWarningComponent
import com.tangem.tap.features.scanfails.ScanFailsComponent
@ -70,6 +70,8 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration.Companion.seconds
@Suppress("LongParameterList", "LargeClass")
internal class DefaultRoutingComponent @AssistedInject constructor(
@ -88,7 +90,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
private val userWalletsListRepository: UserWalletsListRepository,
private val cardRepository: CardRepository,
private val onboardingRepository: OnboardingRepository,
private val appsFlyerStore: AppsFlyerStore,
private val appsFlyerReferralParamsHandler: AppsFlyerReferralParamsHandler,
private val trackingContextProxy: TrackingContextProxy,
private val scanFailsComponentFactory: ScanFailsComponent.Factory,
private val scanFailsRequesterProxy: ScanFailsRequesterProxy,
@ -212,11 +214,10 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING,
)
TangemLogger.i("[TangemPay][HWO] Feature toggle enabled=$isHotWalletOnboardingEnabled")
if (isHotWalletOnboardingEnabled) {
val tangemPayHotWalletOnboardingDeepLink = appsFlyerStore.getDeeplink(
AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding,
)
val tangemPayHotWalletOnboardingDeepLink = withTimeoutOrNull(2.seconds) {
appsFlyerReferralParamsHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
}
TangemLogger.i("[TangemPay][HWO] Deep link present=${tangemPayHotWalletOnboardingDeepLink != null}")
if (tangemPayHotWalletOnboardingDeepLink != null) {
val hotWalletRoute = AppRoute.TangemPayHotWalletOnboarding

View file

@ -9,7 +9,6 @@ import com.tangem.feature.stories.api.StoriesComponent
import com.tangem.feature.usedesk.api.UsedeskComponent
import com.tangem.feature.walletsettings.component.WalletSettingsComponent
import com.tangem.features.account.AccountCreateEditComponent
import com.tangem.features.commonfeatures.api.addfunds.AddFundsComponent
import com.tangem.features.account.AccountDetailsComponent
import com.tangem.features.account.ArchivedAccountListComponent
import com.tangem.features.createwalletselection.CreateWalletSelectionComponent
@ -116,7 +115,6 @@ internal class ChildFactory @Inject constructor(
private val surveyComponentFactory: SurveyComponent.Factory,
private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory,
private val feedEntryComponentFactory: FeedEntryComponent.Factory,
private val addFundsComponentFactory: AddFundsComponent.Factory,
) {
@Suppress("LongMethod", "CyclomaticComplexMethod")
@ -237,13 +235,6 @@ internal class ChildFactory @Inject constructor(
componentFactory = buyCryptoComponentFactory,
)
}
is AppRoute.AddFunds -> {
createComponentChild(
context = context,
params = AddFundsComponent.Params(userWalletId = route.userWalletId),
componentFactory = addFundsComponentFactory,
)
}
is AppRoute.SellCrypto -> {
createComponentChild(
context = context,

View file

@ -29,15 +29,19 @@ class AppsFlyerDeepLinkListenerTest {
@ProvideTestModels
fun onDeepLinking(model: OnDeepLinkingModel) = runTest {
if (model.shouldHandle) {
every { referralParamsHandler.handle(deepLink = model.deepLinkResult.deepLink) } just Runs
every { referralParamsHandler.handleDeeplink(deepLink = model.deepLinkResult.deepLink) } just Runs
} else {
every { referralParamsHandler.handleNoDeeplink() } just Runs
}
listener.onDeepLinking(p0 = model.deepLinkResult)
if (model.shouldHandle) {
coVerify { referralParamsHandler.handle(deepLink = model.deepLinkResult.deepLink) }
coVerify { referralParamsHandler.handleDeeplink(deepLink = model.deepLinkResult.deepLink) }
verify(inverse = true) { referralParamsHandler.handleNoDeeplink() }
} else {
coVerify(inverse = true) { referralParamsHandler.handle(deepLink = any()) }
coVerify(inverse = true) { referralParamsHandler.handleDeeplink(deepLink = any()) }
verify { referralParamsHandler.handleNoDeeplink() }
}
}

View file

@ -1,6 +1,8 @@
package com.tangem.tap.common.analytics.appsflyer
import com.appsflyer.deeplink.DeepLink
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.feature.referral.domain.SetShouldShowMobileWalletPromoUseCase
@ -15,6 +17,7 @@ import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@ -46,7 +49,7 @@ class AppsFlyerReferralParamsHandlerTest {
@ParameterizedTest
@ProvideTestModels
fun handle(model: HandleDeepLinkModel) = runTest {
handler.handle(deepLink = model.deepLink)
handler.handleDeeplink(deepLink = model.deepLink)
if (model.shouldStore) {
val value = AppsFlyerConversionData(refcode = SUCCESS_REFCODE, campaign = SUCCESS_CAMPAIGN)
@ -165,6 +168,78 @@ class AppsFlyerReferralParamsHandlerTest {
data class HandleParamsModel(val params: Map<String?, Any?>, val shouldStore: Boolean)
@Nested
inner class WaitForDeeplink {
private val localStore: AppsFlyerStore = mockk(relaxUnitFun = true)
private val localHandler = AppsFlyerReferralParamsHandler(
appsFlyerStore = localStore,
coroutineScope = TestAppCoroutineScope(),
setShouldShowMobileWalletPromoUseCase = mockk { coEvery { this@mockk.invoke(true) } returns Unit.right() },
)
@Test
fun `GIVEN cached deeplink WHEN waitForDeeplink THEN returns cached value`() = runTest {
// GIVEN
coEvery {
localStore.getDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
} returns "tpay_mobileonboard"
// WHEN
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
// THEN
assertThat(result).isEqualTo("tpay_mobileonboard")
}
@Test
fun `GIVEN no cache and matching deeplink WHEN handleDeeplink then waitForDeeplink THEN returns deeplink value`() = runTest {
// GIVEN
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) } returns null
val deepLink = mockk<DeepLink> {
every { deepLinkValue } returns "tpay_mobileonboard"
every { getStringValue(any()) } returns null
}
// WHEN
localHandler.handleDeeplink(deepLink)
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
// THEN
assertThat(result).isEqualTo("tpay_mobileonboard")
}
@Test
fun `GIVEN no cache and non-matching deeplink WHEN handleDeeplink then waitForDeeplink THEN returns null`() = runTest {
// GIVEN
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) } returns null
val deepLink = mockk<DeepLink> {
every { deepLinkValue } returns "referral"
every { getStringValue(any()) } returns null
}
// WHEN
localHandler.handleDeeplink(deepLink)
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN no cache WHEN handleNoDeeplink then waitForDeeplink THEN returns null`() = runTest {
// GIVEN
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding) } returns null
// WHEN
localHandler.handleNoDeeplink()
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
// THEN
assertThat(result).isNull()
}
}
private companion object Companion {
const val SUCCESS_REFCODE = "valid_refcode"
const val SUCCESS_CAMPAIGN = "valid_campaign"

View file

@ -1,252 +0,0 @@
package com.tangem.tap.domain.tasks.product
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.derivation.DerivationStyle
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.wallets.derivations.BlockchainToDerive
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class BlockchainToDeriveFinderTest {
private val walletAccountsFetcher = mockk<WalletAccountsFetcher>()
private val finder = BlockchainToDeriveFinder(
walletAccountsFetcher = walletAccountsFetcher,
)
@AfterEach
fun tearDown() {
clearMocks(walletAccountsFetcher)
}
@Test
fun `GIVEN card is not HD wallet THEN return empty set`() = runTest {
// Arrange
val card = mockk<CardDTO> {
every { this@mockk.settings.isHDWalletAllowed } returns false
}
// Act
val actual = finder.find(card)
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `GIVEN card has empty wallets THEN return empty set`() = runTest {
// Arrange
val card = mockk<CardDTO> {
every { this@mockk.settings.isHDWalletAllowed } returns true
every { this@mockk.wallets } returns emptyList()
}
// Act
val actual = finder.find(card)
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `GIVEN saved bitcoin THEN return only bitcoin`() = runTest {
// Arrange
val card = createCardDTO()
val response = createResponse(Blockchain.Bitcoin)
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns response
// Act
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Bitcoin),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN empty store and common demo card THEN return demo blockchains`() = runTest {
// Arrange
val demoCardId = "AC01000000045754"
val card = createCardDTO(cardId = demoCardId)
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns null
// Act
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Bitcoin),
createExpected(Blockchain.Ethereum),
createExpected(Blockchain.Dogecoin),
createExpected(Blockchain.Solana),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN empty store and DE00 demo card THEN return demo blockchains`() = runTest {
// Arrange
val demoCardId = "DE00"
val card = createCardDTO(cardId = demoCardId)
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns null
// Act
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Bitcoin),
createExpected(Blockchain.Ethereum),
createExpected(Blockchain.Dogecoin),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN empty store THEN return default blockchains`() = runTest {
// Arrange
val card = createCardDTO()
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns null
// Act
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Bitcoin),
createExpected(Blockchain.Ethereum),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN saved cardano THEN return only cardano`() = runTest {
// Arrange
val card = createCardDTO()
val response = createResponse(Blockchain.Cardano)
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns response
// Act
val actual = finder.find(card)
// Assert
val expected = setOf(
createExpected(Blockchain.Cardano),
)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
@Test
fun `GIVEN saved eth-like blockchains THEN return all saved blockchains without filtering`() = runTest {
// Arrange
val card = createCardDTO()
val blockchains = listOf(Blockchain.Ethereum, Blockchain.BSC, Blockchain.Polygon)
val response = createResponse(*blockchains.toTypedArray())
coEvery { walletAccountsFetcher.getSaved(userWalletId) } returns response
// Act
val actual = finder.find(card)
// Assert
val expected = blockchains.mapTo(hashSetOf(), ::createExpected)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerify(exactly = 1) { walletAccountsFetcher.getSaved(userWalletId) }
}
private fun createCardDTO(cardId: String = "0001", batchId: String = "AC10"): CardDTO {
val wallet = mockk<CardDTO.Wallet> {
every { this@mockk.publicKey } returns byteArrayOf(0)
}
return mockk<CardDTO> {
every { this@mockk.cardId } returns cardId
every { this@mockk.batchId } returns batchId
every { this@mockk.settings.isHDWalletAllowed } returns true
every { this@mockk.settings.isKeysImportAllowed } returns true
every { this@mockk.firmwareVersion } returns CardDTO.FirmwareVersion(
major = 6,
minor = 33,
patch = 0,
type = com.tangem.common.card.FirmwareVersion.FirmwareType.Release,
)
every { this@mockk.wallets } returns listOf(wallet)
}
}
private fun createResponse(vararg blockchains: Blockchain): GetWalletAccountsResponse {
val tokens = blockchains.map { blockchain ->
mockk<UserTokensResponse.Token> {
every { this@mockk.networkId } returns blockchain.toNetworkId()
every { this@mockk.derivationPath } returns blockchain.getDerivationPath().rawPath
every { this@mockk.contractAddress } returns null
}
}
val account = mockk<WalletAccountDTO> {
every { this@mockk.tokens } returns tokens
}
return mockk {
every { this@mockk.accounts } returns listOf(account)
}
}
private fun createExpected(
blockchain: Blockchain,
derivationPath: DerivationPath = blockchain.getDerivationPath(),
): BlockchainToDerive {
return BlockchainToDerive(blockchain = blockchain, derivationPath = derivationPath)
}
private fun Blockchain.getDerivationPath(): DerivationPath {
return derivationPath(DerivationStyle.V3)!!
}
private companion object {
// for byteArrayOf(0)
val userWalletId = UserWalletId("41448576B8DA24C7D8F5F0F79863D20D7D8312A7F9E50D3248304136DDB7AAD7")
}
}

View file

@ -59,11 +59,6 @@ sealed class AppRoute(val path: String) : Route {
@Serializable
data object Wallet : AppRoute(path = "/wallet")
@Serializable
data class AddFunds(
val userWalletId: UserWalletId,
) : AppRoute(path = "/add_funds/${userWalletId.stringValue}")
@Serializable
data class CurrencyDetails(
val userWalletId: UserWalletId,

View file

@ -34,7 +34,7 @@ class TokenActionsHandler @AssistedInject constructor(
private val urlOpener: UrlOpener,
private val analyticsEventHandler: AnalyticsEventHandler,
@Assisted private val currentAppCurrency: Provider<AppCurrency>,
@Assisted private val onHandleQuickAction: (HandledQuickAction) -> Unit,
@Assisted private val onHandleQuickAction: (action: HandledQuickAction, shouldDismiss: Boolean) -> Unit,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val messageSender: UiMessageSender,
) {
@ -49,6 +49,18 @@ class TokenActionsHandler @AssistedInject constructor(
action = action,
cryptoCurrencyData = cryptoCurrencyData,
),
when (action) {
TokenActionsBSContentUM.Action.Receive,
TokenActionsBSContentUM.Action.CopyAddress,
TokenActionsBSContentUM.Action.Sell,
-> false
TokenActionsBSContentUM.Action.Send,
TokenActionsBSContentUM.Action.Stake,
TokenActionsBSContentUM.Action.YieldMode,
TokenActionsBSContentUM.Action.Buy,
TokenActionsBSContentUM.Action.Exchange,
-> true
},
)
val userWallet = cryptoCurrencyData.userWallet
if (userWallet is UserWallet.Cold && handleDemoMode(action, userWallet)) return
@ -164,7 +176,7 @@ class TokenActionsHandler @AssistedInject constructor(
interface Factory {
fun create(
currentAppCurrency: Provider<AppCurrency>,
onHandleQuickAction: (HandledQuickAction) -> Unit,
onHandleQuickAction: (HandledQuickAction, shouldDismiss: Boolean) -> Unit,
): TokenActionsHandler
}

View file

@ -122,5 +122,9 @@
{
"name": "AND_15258_QUICK_TOP_UP_ENABLED",
"version": "undefined"
},
{
"name": "AND_15368_VISA_PAY_REDESIGN",
"version": "undefined"
}
]

View file

@ -95,6 +95,7 @@ dependencies {
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.coroutines.rx2)
implementation(deps.kotlin.datetime)
implementation(deps.kotlin.serialization)
/** Logging */

View file

@ -1,26 +0,0 @@
package com.tangem.datasource.api.auth.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* RFC 9457 / RFC 7807 Problem Details response. Returned by Tangem Auth Service with
* `Content-Type: application/problem+json` on every 4xx / 5xx response.
*/
@JsonClass(generateAdapter = true)
data class ProblemDetailResponse(
/** URI identifying the problem type. */
@Json(name = "type") val type: String,
/** Short human-readable summary (e.g. `"Too Many Requests"`). */
@Json(name = "title") val title: String,
/** HTTP status code. */
@Json(name = "status") val status: Int,
/** Human-readable explanation. */
@Json(name = "detail") val detail: String?,
/** URI reference to this occurrence (e.g. `"/api/v1/auth/refresh"`). */
@Json(name = "instance") val instance: String?,
/** Application-specific error code. */
@Json(name = "code") val code: String?,
/** Retry delay for rate limiting (`429`). */
@Json(name = "retryAfterSeconds") val retryAfterSeconds: Int?,
)

View file

@ -97,6 +97,12 @@ interface TangemPayApi {
@Body body: ReissueCardRequest,
): ApiResponse<ReissueCardResponse>
@POST("v1/customer/card/close")
suspend fun closeCard(
@Header("Authorization") authHeader: String,
@Body body: CloseCardRequest,
): ApiResponse<CloseCardResponse>
@POST("v1/customer/card/withdraw/data")
suspend fun getWithdrawData(
@Header("Authorization") authHeader: String,

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.pay.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CloseCardRequest(
@Json(name = "card_id") val cardId: String,
)

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CloseCardResponse(
@Json(name = "result") val result: Result,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "order_id") val orderId: String,
@Json(name = "status") val status: OrderResponse.Result.Status,
)
}

View file

@ -11,7 +11,6 @@ data class YieldBoostStatusResponse(
@Json(name = "userAddress") val userAddress: String?,
@Json(name = "contractAddress") val contractAddress: String?,
@Json(name = "promoEnrollmentStatus") val promoEnrollmentStatus: String,
@Json(name = "activationDate") val activationDate: String?,
@Json(name = "qualificationEndDate") val qualificationEndDate: String?,
@Json(name = "disqualificationReason") val disqualificationReason: String?,
)

View file

@ -3,8 +3,10 @@ package com.tangem.datasource.di
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.visa.DefaultTangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.DefaultTangemPayCloseCardStore
import com.tangem.datasource.local.visa.DefaultTangemPayReissueCardStore
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayCloseCardStore
import com.tangem.datasource.local.visa.TangemPayReissueCardStore
import dagger.Module
import dagger.Provides
@ -32,4 +34,12 @@ internal object TangemPayStoresModule {
prefs = prefs,
)
}
@Provides
@Singleton
fun provideTangemPayCloseCardStore(prefs: AppPreferencesStore): TangemPayCloseCardStore {
return DefaultTangemPayCloseCardStore(
prefs = prefs,
)
}
}

View file

@ -16,11 +16,15 @@ import com.tangem.datasource.api.utils.ConnectTimeout
import com.tangem.datasource.api.utils.ReadTimeout
import com.tangem.datasource.api.utils.WriteTimeout
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.logs.SensitiveUrlMasker
import com.tangem.datasource.utils.NetworkLogsSaveInterceptor
import com.tangem.datasource.utils.WireMockRedirectInterceptor
import com.tangem.datasource.utils.addHeaders
import com.tangem.utils.JsonStringValuesExtractor
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import retrofit2.Invocation
@ -41,6 +45,7 @@ import javax.inject.Singleton
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
@Singleton
internal class RetrofitApiBuilder @Inject constructor(
private val apiConfigs: ApiConfigs,
@ -49,10 +54,20 @@ internal class RetrofitApiBuilder @Inject constructor(
private val analyticsErrorHandler: AnalyticsErrorHandler,
@ApplicationContext private val context: Context,
private val appLogsStore: AppLogsStore,
private val environmentConfig: EnvironmentConfig,
) {
private val configsBaseUrls: Map<ApiConfig.ID, Set<String>> = getConfigsBaseUrls()
private val sensitiveUrlMasker: SensitiveUrlMasker by lazy {
val json = Json.encodeToJsonElement(EnvironmentConfig.serializer(), environmentConfig)
// Drop URL-shaped values (e.g. public endpoint URLs from config); they are not secrets
// and would obscure unrelated requests in logs.
val values = JsonStringValuesExtractor.extract(json)
.filter { it.isNotBlank() && !it.startsWith("http", ignoreCase = true) }
SensitiveUrlMasker(values)
}
/**
* Builds a Retrofit API instance for the specified API configuration ID
*
@ -179,7 +194,7 @@ internal class RetrofitApiBuilder @Inject constructor(
private fun OkHttpClient.Builder.applyLogsSaving(): OkHttpClient.Builder {
return addInterceptor(
interceptor = NetworkLogsSaveInterceptor(appLogsStore),
interceptor = NetworkLogsSaveInterceptor(appLogsStore, sensitiveUrlMasker),
)
}

View file

@ -4,7 +4,10 @@ import com.tangem.blockchain.common.BlockchainSdkConfig
import com.tangem.datasource.local.config.environment.models.ExpressModel
import com.tangem.datasource.local.config.environment.models.P2PKeys
import com.tangem.datasource.local.config.environment.models.SurveySparrowSwapRatingConfig
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@Serializable
data class EnvironmentConfig(
val moonPayApiKey: String = "",
val moonPayApiSecretKey: String = "",
@ -32,6 +35,7 @@ data class EnvironmentConfig(
val gaslessTxApiKey: String? = null,
val customerIoCdpApiKey: String? = null,
val surveySparrowToken: String? = null,
@Transient
val surveySparrowSwapRating: SurveySparrowSwapRatingConfig? = null,
val authServiceKey: String? = null,
)

View file

@ -1,7 +1,11 @@
package com.tangem.datasource.local.config.environment.models
import kotlinx.serialization.Serializable
@Serializable
data class ExpressModel(val apiKey: String, val signVerifierPublicKey: String)
@Serializable
data class P2PKeys(val mainnet: String, val hoodi: String)
data class SurveySparrowSwapRatingConfig(

View file

@ -0,0 +1,24 @@
package com.tangem.datasource.local.logs
class SensitiveUrlMasker(sensitiveValues: Collection<String>) {
// Sorted by descending length so a value that is a prefix of another (e.g. "my-node" vs
// "my-node-prod") cannot mask the shorter one first and leave the suffix in the log.
private val sensitiveValues: List<String> = sensitiveValues
.distinct()
.sortedByDescending(String::length)
fun mask(url: String): String {
var result = url
for (value in sensitiveValues) {
if (result.contains(value, ignoreCase = true)) {
result = result.replace(value, MASKED_VALUE, ignoreCase = true)
}
}
return result
}
companion object {
const val MASKED_VALUE = "******"
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.datasource.local.visa
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.store
internal class DefaultTangemPayCloseCardStore(
private val prefs: AppPreferencesStore,
) : TangemPayCloseCardStore {
override suspend fun setCloseOrderId(cardId: String, orderId: String?) {
if (orderId == null) {
prefs.edit { it.remove(getCloseKey(cardId)) }
} else {
prefs.store(
key = getCloseKey(cardId),
value = orderId,
)
}
}
override suspend fun getOrderId(cardId: String): String? {
return prefs.getSyncOrNull(key = getCloseKey(cardId))
}
private fun getCloseKey(cardId: String) = stringPreferencesKey("tangem_pay_close_card_$cardId")
}

View file

@ -0,0 +1,8 @@
package com.tangem.datasource.local.visa
interface TangemPayCloseCardStore {
suspend fun setCloseOrderId(cardId: String, orderId: String?)
suspend fun getOrderId(cardId: String): String?
}

View file

@ -45,6 +45,7 @@ sealed interface PaymentAccountStatusValueDM {
@Json(name = "deposit_address") val depositAddress: String?,
@Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM,
@Json(name = "crypto_balance") val cryptoBalance: CryptoBalanceDM,
@Json(name = "fiat_rate") val fiatRate: BigDecimal?,
@Json(name = "available_for_withdrawal") val availableForWithdrawal: BigDecimal,
@Json(name = "cards") val cards: List<TangemPayCard>,
) : PaymentAccountStatusValueDM
@ -58,6 +59,7 @@ sealed interface PaymentAccountStatusValueDM {
@NameLabel("deactivated_account")
data class DeactivatedAccount(
@Json(name = "deactivated_account") val marker: Boolean = true,
@Json(name = "fiat_rate") val fiatRate: BigDecimal?,
@Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM,
@Json(name = "crypto_balance") val cryptoBalance: CryptoBalanceDM,
) : PaymentAccountStatusValueDM
@ -86,6 +88,6 @@ sealed interface PaymentAccountStatusValueDM {
@Json(name = "admin_daily_limit") val adminDailyLimit: SerializedBigDecimal?,
@Json(name = "frozen_state") val frozenState: String,
@Json(name = "last_digits") val lastDigits: String,
@Json(name = "is_reissuing") val isReissuing: Boolean,
@Json(name = "state") val state: String,
)
}

View file

@ -1,7 +1,9 @@
package com.tangem.datasource.utils
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.logs.SensitiveUrlMasker
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
@ -22,11 +24,15 @@ private const val JSON_INDENT_SPACES = 4
* Interceptor for save network requests and responses logs
*
* @property appLogsStore app logs store
* @property sensitiveUrlMasker masker for sensitive data in URLs
* @property shouldCheckResponseBodySize whether to skip logging large response bodies
*
[REDACTED_AUTHOR]
*/
class NetworkLogsSaveInterceptor(
private val appLogsStore: AppLogsStore,
private val sensitiveUrlMasker: SensitiveUrlMasker? = null,
private val shouldCheckResponseBodySize: Boolean = false,
) : Interceptor {
@Throws(IOException::class)
@ -65,7 +71,7 @@ class NetworkLogsSaveInterceptor(
val connection = chain.connection()
val connectionProtocol = if (connection != null) " ${connection.protocol()}" else ""
saveLogMessage("--> ${request.method} ${request.url}$connectionProtocol\n")
saveLogMessage("--> ${request.method} ${request.url.maskSensitiveInfo()}$connectionProtocol\n")
}
private fun logRequestMessage(chain: Interceptor.Chain, request: Request) {
@ -73,7 +79,7 @@ class NetworkLogsSaveInterceptor(
val connectionProtocol = if (connection != null) " ${connection.protocol()}" else ""
saveLogMessage(
"--> ${request.method} ${request.url}$connectionProtocol\n",
"--> ${request.method} ${request.url.maskSensitiveInfo()}$connectionProtocol\n",
createRequestEndMessage(request),
)
}
@ -110,7 +116,7 @@ class NetworkLogsSaveInterceptor(
val tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs)
saveLogMessage(
"<-- ${response.code}",
" ${response.request.url} (${tookMs}ms)\n",
" ${response.request.url.maskSensitiveInfo()} (${tookMs}ms)\n",
)
}
@ -123,39 +129,45 @@ class NetworkLogsSaveInterceptor(
"<-- END HTTP"
} else if (bodyHasUnknownEncoding(response.headers)) {
"<-- END HTTP (encoded body omitted)"
} else if (shouldCheckResponseBodySize && contentLength > WRITE_LOG_THRESHOLD_BYTES_SIZE) {
"Response size too large: $contentLength bytes \n<-- END HTTP"
} else {
val source = responseBody.source()
source.request(Long.MAX_VALUE)
var buffer = source.buffer
var gzippedLength: Long? = null
if ("gzip".equals(responseHeaders["Content-Encoding"], ignoreCase = true)) {
gzippedLength = buffer.size
GzipSource(buffer.clone()).use { gzippedResponseBody ->
buffer = Buffer()
buffer.writeAll(gzippedResponseBody)
}
}
val contentType = responseBody.contentType()
val charset: Charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
if (!buffer.isProbablyUtf8()) {
"<-- END HTTP (binary ${buffer.size}-byte body omitted)"
if (shouldCheckResponseBodySize && buffer.size > WRITE_LOG_THRESHOLD_BYTES_SIZE) {
"Response size too large: ${buffer.size} bytes \n<-- END HTTP"
} else {
val json = if (contentLength != 0L) {
buffer.clone().readString(charset).beautifyJson()
} else {
""
var gzippedLength: Long? = null
if ("gzip".equals(responseHeaders["Content-Encoding"], ignoreCase = true)) {
gzippedLength = buffer.size
GzipSource(buffer.clone()).use { gzippedResponseBody ->
buffer = Buffer()
buffer.writeAll(gzippedResponseBody)
}
}
val end = if (gzippedLength != null) {
"<-- END HTTP (${buffer.size}-byte, $gzippedLength-gzipped-byte body)"
} else {
"<-- END HTTP (${buffer.size}-byte body)"
}
val contentType = responseBody.contentType()
val charset: Charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
"$json\n$end"
if (!buffer.isProbablyUtf8()) {
"<-- END HTTP (binary ${buffer.size}-byte body omitted)"
} else {
val json = if (contentLength != 0L) {
buffer.clone().readString(charset).beautifyJson()
} else {
""
}
val end = if (gzippedLength != null) {
"<-- END HTTP (${buffer.size}-byte, $gzippedLength-gzipped-byte body)"
} else {
"<-- END HTTP (${buffer.size}-byte body)"
}
"$json\n$end"
}
}
}
@ -166,12 +178,16 @@ class NetworkLogsSaveInterceptor(
saveLogMessage(
"<-- ${response.code}",
spaceBeforeResponseMessage,
response.message,
" ${response.request.url} (${tookMs}ms)\n",
" ${response.request.url.maskSensitiveInfo()} (${tookMs}ms)\n",
message,
)
}
private fun HttpUrl.maskSensitiveInfo(): String {
val url = toString()
return sensitiveUrlMasker?.mask(url) ?: url
}
private fun bodyHasUnknownEncoding(headers: Headers): Boolean {
val contentEncoding = headers["Content-Encoding"] ?: return false
return !contentEncoding.equals("identity", ignoreCase = true) &&
@ -231,6 +247,9 @@ class NetworkLogsSaveInterceptor(
}
private companion object {
const val WRITE_LOG_THRESHOLD_BYTES_SIZE = 2_048_000L
/**
* List of URLs (host + path) for which logging is restricted
*/

View file

@ -0,0 +1,107 @@
package com.tangem.datasource.local.logs
import com.google.common.truth.Truth
import com.tangem.datasource.local.logs.SensitiveUrlMasker.Companion.MASKED_VALUE
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SensitiveUrlMaskerTest {
@ParameterizedTest
@ProvideTestModels
fun mask(model: TestModel) {
// Arrange
val masker = SensitiveUrlMasker(model.sensitiveValues)
// Act
val actual = masker.mask(model.input)
// Assert
Truth.assertThat(actual).isEqualTo(model.expected)
}
@Test
fun `mask returns url unchanged when no sensitive values provided`() {
// Arrange
val masker = SensitiveUrlMasker(emptyList())
val url = "https://api.tangem.com/v1/cards/abc123"
// Act
val actual = masker.mask(url)
// Assert
Truth.assertThat(actual).isEqualTo(url)
}
@Test
fun `constructor deduplicates input values`() {
// Arrange — same secret repeated; if no dedup, replace would be invoked twice
// (idempotent on already-masked string, but we assert behavior is identical
// to a single-value masker as a smoke-check)
val withDuplicates = SensitiveUrlMasker(listOf("secret123", "secret123", "secret123"))
val withSingle = SensitiveUrlMasker(listOf("secret123"))
val url = "https://api.tangem.com/?key=secret123"
// Act
val withDup = withDuplicates.mask(url)
val withSingleResult = withSingle.mask(url)
// Assert
Truth.assertThat(withDup).isEqualTo(withSingleResult)
Truth.assertThat(withDup).isEqualTo("https://api.tangem.com/?key=$MASKED_VALUE")
}
private fun provideTestModels() = listOf(
TestModel(
input = "https://api.tangem.com/?key=secret123",
sensitiveValues = listOf("secret123"),
expected = "https://api.tangem.com/?key=$MASKED_VALUE",
),
TestModel(
input = "https://api.tangem.com/?a=alpha&b=beta",
sensitiveValues = listOf("alpha", "beta"),
expected = "https://api.tangem.com/?a=$MASKED_VALUE&b=$MASKED_VALUE",
),
TestModel(
input = "https://api.tangem.com/?key=SECRET123",
sensitiveValues = listOf("secret123"),
expected = "https://api.tangem.com/?key=$MASKED_VALUE",
),
TestModel(
input = "https://api.tangem.com/v1/balance",
sensitiveValues = listOf("notInUrl"),
expected = "https://api.tangem.com/v1/balance",
),
TestModel(
input = "https://api.tangem.com/?key=secret123&other=secret123",
sensitiveValues = listOf("secret123"),
expected = "https://api.tangem.com/?key=$MASKED_VALUE&other=$MASKED_VALUE",
),
TestModel(
input = "https://api.tangem.com/v1/cards",
sensitiveValues = emptyList(),
expected = "https://api.tangem.com/v1/cards",
),
// Regression: when one value is a prefix of another, the longer one must be masked first
// regardless of input order, otherwise the suffix leaks (e.g. "my-node-prod" -> "******-prod").
TestModel(
input = "https://my-node-prod.example.com/v1",
sensitiveValues = listOf("my-node", "my-node-prod"),
expected = "https://$MASKED_VALUE.example.com/v1",
),
TestModel(
input = "https://my-node-prod.example.com/v1",
sensitiveValues = listOf("my-node-prod", "my-node"),
expected = "https://$MASKED_VALUE.example.com/v1",
),
)
data class TestModel(
val input: String,
val sensitiveValues: List<String>,
val expected: String,
)
}

View file

@ -91,6 +91,7 @@
<string name="add_custom_token_title">Token anlegen</string>
<string name="add_tokens_title">Token verwalten</string>
<string name="addfunds_buy_row_description">Kreditkarte oder Bankkonto</string>
<string name="addfunds_fund_token">Token erhalten</string>
<string name="addfunds_receive_row_description">Teile deine Adresse oder dein QR-Code</string>
<string name="addfunds_swap_row_description">Zwische deinen Portfolios</string>
<string name="addfunds_you_receive_title">Empfangen</string>
@ -662,6 +663,11 @@
<string name="feedback_subject_support_tangem">Feedback zu Tangem</string>
<string name="feedback_subject_tx_failed">Eine Transaktion kann nicht gesendet werden</string>
<string name="feedback_token_description_error">Fehler in der Coinbeschreibung</string>
<string name="force_update_banner_message">Aktualisiere die Anwendung auf die neueste Version, um die ordnungsgemäße Funktionalität zu gewährleisten</string>
<string name="force_update_banner_title">Aktualisierung erforderlich</string>
<string name="force_update_button">Update</string>
<string name="force_update_warning_message">Bitte aktualisiere die Anwendung auf die neueste Version, um eine einwandfreie Funktion zu gewährleisten.</string>
<string name="force_update_warning_title">Aktualisierung erforderlich</string>
<string name="gasless_not_enough_funds_to_cover_token_fee">Nicht genügend Mittel</string>
<string name="gasless_transaction_fee">Transaktionsgebühr</string>
<string name="generic_error">Es ist ein Fehler aufgetreten</string>
@ -787,6 +793,8 @@
<string name="koinos_mana_level_description">Das Koinos-Netzwerk benötigt Mana als Netzwerkgebühr. Du hast %1$s/%2$s Mana</string>
<string name="koinos_mana_level_title">Mana-Level</string>
<string name="main_add_and_manage_tokens">Hinzufügen und Verwalten</string>
<string name="main_add_funds_promo_description">Krypto einzahlen oder mit Karte kaufen, um loszulegen</string>
<string name="main_add_funds_promo_title">Hol dir deine erste Kryptowährung</string>
<string name="main_empty_tokens_list_message">Um mit der Verfolgung deiner Krypto-Assets und -Transaktionen zu beginnen, füge einen Token hinzu</string>
<string name="main_manage_tokens">Token verwalten</string>
<string name="main_qr_scan_hint">QR-Code scannen, um Geld zu senden oder eine Verbindung zu einer App herzustellen</string>
@ -1073,7 +1081,7 @@
<string name="onboarding_create_wallet_options_button_options">Andere Optionen</string>
<string name="onboarding_create_wallet_options_message">Deine Schlüssel(private-keys) werden sicher im Inneren der Karte oder Ring generiert. Es gibt keine Seed-Phrase, d. h. niemand kann sie exportieren oder stehlen.</string>
<string name="onboarding_create_wallet_options_title">Schlüssel anonym generieren</string>
<string name="onboarding_create_wallet_term_of_conditions_text">Indem Sie fortfahren, stimmen Sie den folgenden Bedingungen zu:%s</string>
<string name="onboarding_create_wallet_term_of_conditions_text">Indem Sie fortfahren, stimmen Sie den folgenden Bedingungen zu:\n%s</string>
<string name="onboarding_done_body">Deine Karte oder Ring ist aktiviert und einsatzbereit</string>
<string name="onboarding_done_header">Erfolgreich!</string>
<string name="onboarding_done_wallet">Deine Wallet ist eingerichtet und einsatzbereit!</string>
@ -1210,9 +1218,7 @@
<string name="organize_tokens_title">Token organisieren</string>
<string name="organize_tokens_ungroup">Gruppe löschen</string>
<string name="provider_name_support">%s Unterstützung</string>
<string name="push_notification_settings_banner_button_grant_permission">Genehmigung erteilen</string>
<string name="push_notification_settings_banner_description">Push-Benachrichtigungen sind aktiviert, funktionieren aber erst, nachdem du Benachrichtigungen in den Geräteeinstellungen zugelassen hast.</string>
<string name="push_notification_settings_banner_description_grant_permission">Push-Benachrichtigungen sind aktiviert, funktionieren aber erst nach Ihrer Zustimmung.</string>
<string name="push_notification_settings_banner_title">Benachrichtigungen zulassen</string>
<string name="push_notification_settings_offers_updates_subtitle">Produktneuheiten, exklusive Angebote und Erinnerungen an Aktivitäten.</string>
<string name="push_notification_settings_offers_updates_title">Angebote &amp; Updates</string>
@ -1684,11 +1690,13 @@
<string name="tangem_pay_freeze_card_failed">Karte konnte nicht eingefroren werden. Versuchen Sie es später erneut.</string>
<string name="tangem_pay_freeze_card_freeze">Einfrieren</string>
<string name="tangem_pay_freeze_card_success">Ihre Karte ist eingefroren.</string>
<string name="tangem_pay_freeze_card_unfreeze">Aufheben</string>
<string name="tangem_pay_get_help">Hilfe erhalten</string>
<string name="tangem_pay_history_item_spend_mc_declined_reason">Grund: %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">Andere</string>
<string name="tangem_pay_pin_code_title">PIN-Code</string>
<string name="tangem_pay_rooted_device_subtitle">Nicht nutzbar auf gerooteten Geräten</string>
<string name="tangem_pay_status_completed">Abgeschlossen</string>
<string name="tangem_pay_status_declined">Abgelehnt</string>
@ -1697,15 +1705,15 @@
<string name="tangem_pay_terms_fees_limits">Bedingungen, Gebühren &amp; Limits</string>
<string name="tangem_pay_terms_limits">Bedingungen und Einschränkungen</string>
<string name="tangem_pay_transaction_declined_notification_text">Die Bank hat diese Transaktionsanfrage abgelehnt.</string>
<string name="tangem_pay_transaction_fee_notification_text">Diese Gebühr dient zur Deckung der Kosten für die Abwicklung Deiner Überweisung.</string>
<string name="tangem_pay_transaction_fee_notification_text">Eine Gebühr wird gemäß den Servicetarifen erhoben</string>
<string name="tangem_pay_transaction_reversed_notification_text">Die Transaktion wurde vom Händler teilweise oder vollständig storniert</string>
<string name="tangem_pay_unfreeze_card_alert_body">Nutze Dein Geld weiterhin. Du kannst es jederzeit einfrieren.</string>
<string name="tangem_pay_unfreeze_card_alert_title">Karte entsperren?</string>
<string name="tangem_pay_unfreeze_card_failed">Entsperren der Karte fehlgeschlagen. Versuchen Sie es später erneut.</string>
<string name="tangem_pay_unfreeze_card_success">Ihre Karte ist entsperrt.</string>
<string name="tangem_pay_withdrawal">Abhebung</string>
<string name="tangempay_account_deactivated_message_subtitle">Dies wurde aufgrund regulatorischer Anforderungen durchgeführt. Auszahlungen sind jedoch weiterhin verfügbar.</string>
<string name="tangempay_account_deactivated_message_title">Ihre Karte wurde deaktiviert</string>
<string name="tangempay_account_deactivated_message_subtitle">Bei Fragen zu Ihrem Konto, Ihren Daten oder Ihrem Transaktionsverlauf wenden Sie sich bitte an den Support</string>
<string name="tangempay_account_deactivated_message_title">Ihr Konto wurde geschlossen</string>
<string name="tangempay_account_unable_to_use_rooted">Auf gerooteten Geräten nicht nutzbar.</string>
<string name="tangempay_available_balance">Verfügbares Guthaben</string>
<string name="tangempay_cancel_kyc">KYC vom Hauptbildschirm ausblenden</string>
@ -1739,7 +1747,6 @@
<string name="tangempay_card_details_open_wallet_title">Karte zu Google Pay hinzufügen</string>
<string name="tangempay_card_details_open_wallet_title_apple">Karte zu Apple Pay hinzufügen</string>
<string name="tangempay_card_details_pin_code">Pin Code</string>
<string name="tangempay_card_details_receive_description">Teile Deine Adresse mit oder zeig den QR-Code.</string>
<string name="tangempay_card_details_receive_error_description">Es wurden technische Probleme festgestellt. Bitte versuche es später erneut oder kontaktiere den Support.</string>
<string name="tangempay_card_details_receive_error_title">Empfangen ist jetzt nicht verfügbar</string>
<string name="tangempay_card_details_reissue_card">Karte neu ausstellen</string>
@ -1748,7 +1755,6 @@
<string name="tangempay_card_details_rename_card_placeholder">Kartenname</string>
<string name="tangempay_card_details_reveal_text">Aufdecken</string>
<string name="tangempay_card_details_show_details">Details anzeigen</string>
<string name="tangempay_card_details_swap_description">Tausche beliebige Vermögenswerte in Deinem Portfolio für deine Karte.</string>
<string name="tangempay_card_details_title">Kartendetails</string>
<string name="tangempay_card_details_unable_to_rename_card_description">Bitte versuche es später noch einmal.</string>
<string name="tangempay_card_details_unfreeze_card">Karte entsperren</string>
@ -1766,6 +1772,7 @@
<string name="tangempay_card_page_daily_limit_change">Ändern</string>
<string name="tangempay_card_page_daily_limit_current_limit">Aktuelles Limit</string>
<string name="tangempay_card_page_daily_limit_error_description">Ihr Tageslimit konnte nicht geladen werden. Bitte versuchen Sie es erneut.</string>
<string name="tangempay_card_page_daily_limit_error_subtitle">Neu laden und es erneut versuchen</string>
<string name="tangempay_card_page_daily_limit_error_title">Tageslimit nicht verfügbar</string>
<string name="tangempay_card_page_daily_limit_success_description">Sie können es jederzeit wieder ändern</string>
<string name="tangempay_card_page_daily_limit_success_title">Tageslimit ist festgelegt</string>
@ -1827,7 +1834,7 @@
<string name="tangempay_onboarding_security_title">Unerreichte Privatsphäre</string>
<string name="tangempay_onboarding_setup_wallet_description">Verknüpfen Sie eine Zahlungskarte</string>
<string name="tangempay_onboarding_setup_wallet_title">Wir richten eine Wallet ein.</string>
<string name="tangempay_onboarding_title">Erhalten Sie Ihre kostenlose Tangem Pay Card in wenigen Minuten</string>
<string name="tangempay_onboarding_title">Holen Sie sich Ihre Tangem Pay Karte</string>
<string name="tangempay_pay_support">Bezahlen mit</string>
<string name="tangempay_payment_account">Zahlungskonto</string>
<string name="tangempay_payment_account_sync_needed">Tangem Pay sitzung abgelaufen</string>
@ -1851,7 +1858,6 @@
<string name="tangempay_sync_needed">Karte oder Ring verwenden, um die Sitzung zu verlängern</string>
<string name="tangempay_sync_needed_body">Karte oder Ring verwenden, um die Sitzung zu verlängern</string>
<string name="tangempay_sync_needed_button">Zugang wiederherstellen</string>
<string name="tangempay_sync_needed_restore_access">Zugang wiederherstellen</string>
<string name="tangempay_sync_needed_title">Tangem Pay sitzung abgelaufen</string>
<string name="tangempay_tangem_visa_card">Nutzen Sie USDC für alltägliche Zahlungen</string>
<string name="tangempay_temporarily_unavailable">Tangem Pay ist vorübergehend nicht erreichbar.</string>
@ -1861,7 +1867,6 @@
<string name="tangempay_topup_swap_body">Tauschen Sie beliebige Assets in USDC Polygon um</string>
<string name="tangempay_topup_swap_title">Aus Ihrer Tangem Wallet</string>
<string name="tangempay_usdc_on_polygon_network">USDC im Polygon</string>
<string name="tangempay_use_tangem_device_to_restore_payment_account">Klicken Sie auf die Schaltfläche unten, um den Zugriff wiederherzustellen</string>
<string name="tangempay_withdrawal_note_description">Gelder aus erstatteten Käufen werden nicht auf Ihr On-Chain-Guthaben zurückerstattet und stehen nicht für Abhebungen zur Verfügung, bleiben aber auf Ihrem Kartenguthaben für Einkäufe verfügbar</string>
<string name="tangempay_withdrawal_note_title">Bitte beachten Sie</string>
<string name="tangempay_your_pin_code">Ihr PIN-Code</string>

View file

@ -370,6 +370,7 @@
<string name="common_select_action">Seleccione una acción</string>
<string name="common_sell">Vender</string>
<string name="common_send">Enviar</string>
<string name="common_send_colon">Enviar:</string>
<string name="common_send_tx_error">Error al enviar la transacción</string>
<string name="common_server_unavailable">El servidor no está disponible, por favor inténtelo de nuevo más tarde</string>
<string name="common_share">Compartir</string>
@ -1609,15 +1610,15 @@
<string name="tangem_pay_terms_fees_limits">Términos, tarifas y límites</string>
<string name="tangem_pay_terms_limits">Términos y límites</string>
<string name="tangem_pay_transaction_declined_notification_text">El banco rechazó esta solicitud de transacción.</string>
<string name="tangem_pay_transaction_fee_notification_text">Esta tarifa cubre el costo de procesar tu transferencia.</string>
<string name="tangem_pay_transaction_fee_notification_text">Se cobra una comisión de acuerdo con las tarifas de servicio</string>
<string name="tangem_pay_transaction_reversed_notification_text">La transacción fue revertida parcial o totalmente por el comerciante</string>
<string name="tangem_pay_unfreeze_card_alert_body">Sigue usando tu dinero. Puedes congelarlo en cualquier momento.</string>
<string name="tangem_pay_unfreeze_card_alert_title">¿Descongelar tu tarjeta?</string>
<string name="tangem_pay_unfreeze_card_failed">No se pudo descongelar la tarjeta. Inténtalo de nuevo más tarde.</string>
<string name="tangem_pay_unfreeze_card_success">Tu tarjeta está descongelada.</string>
<string name="tangem_pay_withdrawal">Retirada</string>
<string name="tangempay_account_deactivated_message_subtitle">Esto se hizo debido a requisitos regulatorios. Sin embargo, los retiros siguen estando disponibles.</string>
<string name="tangempay_account_deactivated_message_title">Su tarjeta ha sido desactivada</string>
<string name="tangempay_account_deactivated_message_subtitle">Para consultas sobre su cuenta, datos o historial de transacciones, contacte con el soporte</string>
<string name="tangempay_account_deactivated_message_title">Su cuenta ha sido cerrada</string>
<string name="tangempay_account_unable_to_use_rooted">No se puede usar en un dispositivo rooteado</string>
<string name="tangempay_available_balance">Saldo</string>
<string name="tangempay_cancel_kyc">Ocultar verificación de la pantalla</string>
@ -1650,7 +1651,6 @@
<string name="tangempay_card_details_open_wallet_title">Añadir tarjeta a Google Pay</string>
<string name="tangempay_card_details_open_wallet_title_apple">Añade tu tarjeta a Apple Pay</string>
<string name="tangempay_card_details_pin_code">Código PIN</string>
<string name="tangempay_card_details_receive_description">Comparte tu dirección o muestra el código QR</string>
<string name="tangempay_card_details_receive_error_description">Se detectaron problemas técnicos. Inténtelo de nuevo más tarde o póngase en contacto con el servicio de asistencia.</string>
<string name="tangempay_card_details_receive_error_title">Recepción no disponible ahora</string>
<string name="tangempay_card_details_reissue_card">Reemitir tarjeta</string>
@ -1658,7 +1658,6 @@
<string name="tangempay_card_details_rename_card_invalid_title">Caracteres no válidos</string>
<string name="tangempay_card_details_reveal_text">Mostrar</string>
<string name="tangempay_card_details_show_details">Mostrar detalles</string>
<string name="tangempay_card_details_swap_description">Intercambia cualquier activo de tu portafolio por una tarjeta</string>
<string name="tangempay_card_details_title">Detalles de la tarjeta</string>
<string name="tangempay_card_details_unable_to_rename_card_description">Por favor, inténtalo de nuevo más tarde</string>
<string name="tangempay_card_details_unfreeze_card">Descongelar tarjeta</string>
@ -1719,7 +1718,7 @@
<string name="tangempay_onboarding_purchases_title">Paga exactamente lo que ves</string>
<string name="tangempay_onboarding_security_description">Se creará una cuenta de pago separada sin divulgar tus direcciones y activos</string>
<string name="tangempay_onboarding_security_title">Privacidad inigualable</string>
<string name="tangempay_onboarding_title">Obtén tu tarjeta Tangem Pay gratuita en minutos</string>
<string name="tangempay_onboarding_title">Obtén tu tarjeta Tangem Pay en minutos</string>
<string name="tangempay_payment_account">Cuenta de pago</string>
<string name="tangempay_payment_account_sync_needed">Tangem Pay sesión expirada</string>
<string name="tangempay_pin_validation_error_message">PIN no válido: evitar secuencias o repeticiones</string>
@ -1741,7 +1740,6 @@
<string name="tangempay_sync_needed">Usa la tarjeta o el anillo para renovar la sesión</string>
<string name="tangempay_sync_needed_body">Usa la tarjeta o el anillo para renovar la sesión</string>
<string name="tangempay_sync_needed_button">Restablecer acceso</string>
<string name="tangempay_sync_needed_restore_access">Restablecer acceso</string>
<string name="tangempay_sync_needed_title">Tangem Pay sesión expirada</string>
<string name="tangempay_tangem_visa_card">Usa USDC para pagos cotidianos</string>
<string name="tangempay_temporarily_unavailable">Tangem Pay no está disponible temporalmente.</string>
@ -1751,7 +1749,6 @@
<string name="tangempay_topup_swap_body">Intercambia cualquier activo por USDC Polygon</string>
<string name="tangempay_topup_swap_title">Desde tu Tangem Wallet</string>
<string name="tangempay_usdc_on_polygon_network">USDC en Polygon</string>
<string name="tangempay_use_tangem_device_to_restore_payment_account">Haga clic en el botón de abajo para restaurar el acceso</string>
<string name="tangempay_withdrawal_note_description">Los fondos de compras reembolsadas no se devolverán a tu saldo on-chain Polygon ni estarán disponibles para retiro, pero permanecerán en tu saldo de tarjeta para compras</string>
<string name="tangempay_withdrawal_note_title">Tenga en cuenta</string>
<string name="tangempay_your_pin_code">Tu código PIN</string>

View file

@ -80,6 +80,7 @@
<string name="action_buttons_swap_not_enough_tokens_alert_title">Ajouter des jetons</string>
<string name="action_buttons_you_want_to_receive">Sélectionnez le jeton que vous souhaitez recevoir</string>
<string name="action_buttons_you_want_to_swap">Sélectionnez le jeton que vous souhaitez échanger</string>
<string name="add_and_manage_sheet_manage_title">Ajouter des jetons</string>
<string name="add_custom_token_choose_network">Choisissez le réseau</string>
<string name="add_custom_token_title">Ajouter un jeton personnalisé</string>
<string name="add_tokens_title">Gérer les jetons</string>
@ -367,6 +368,7 @@
<string name="common_select_action">Sélectionnez une action</string>
<string name="common_sell">Vendre</string>
<string name="common_send">Envoyer</string>
<string name="common_send_colon">Vous envoyez :</string>
<string name="common_send_tx_error">Échec d\'envoi de la transaction</string>
<string name="common_server_unavailable">Le serveur n\'est pas disponible, veuillez réessayer plus tard</string>
<string name="common_share">Partager</string>
@ -549,6 +551,7 @@
<string name="express_provider">Fournisseur</string>
<string name="express_provider_best_rate">Meilleur taux</string>
<string name="express_provider_fca_warning_list">Liste davertissement de la FCA</string>
<string name="express_provider_for_swap">Prestataire pour l\'échange</string>
<string name="express_provider_great_rate">Meilleur choix</string>
<string name="express_provider_in_fca_warning_list">Fournisseur figurant sur la liste d\'avertissement de la FCA</string>
<string name="express_provider_max_amount">Disponible jusqu\'à %s</string>
@ -711,6 +714,7 @@
<string name="koinos_mana_exceeds_koin_balance_title">Limite de Mana</string>
<string name="koinos_mana_level_description">Le réseau Koinos nécessite du Mana pour les frais de réseau. Vous avez %1$s/%2$s Mana</string>
<string name="koinos_mana_level_title">Quantité de Mana</string>
<string name="main_add_and_manage_tokens">Ajouter &amp; gérer</string>
<string name="main_empty_tokens_list_message">Pour commencer à suivre vos actifs et transactions crypto, ajoutez des jetons</string>
<string name="main_manage_tokens">Gérer les jetons</string>
<string name="main_scan_card_warning_view_subtitle">Pour accéder à tous les réseaux, vous devez scanner la carte</string>
@ -1047,16 +1051,29 @@
<string name="onramp_error_transaction_already_processed">Cette transaction a déjà été traitée. Aucune autre action n\'est requise.</string>
<string name="onramp_fetching_best_rates">Recherche des meilleurs tarifs...</string>
<string name="onramp_instant_status">Instantané</string>
<string name="onramp_kyc_verification_bullet_free">La vérification est gratuite et prend généralement entre 1 et 2 minutes</string>
<string name="onramp_kyc_verification_bullet_privacy">Tangem n\'a pas accès à vos données personnelles, vous les partagez directement au prestataire agréé</string>
<string name="onramp_kyc_verification_bullet_unlocks">La vérification vous donne un accès complet aux futures transactions avec ce prestataire</string>
<string name="onramp_kyc_verification_choose_another">Sélectionner une autre méthode</string>
<string name="onramp_kyc_verification_subtitle">Conformément aux exigences réglementaires locales, %@ exige une vérification d\'identité.</string>
<string name="onramp_kyc_verification_title">Vérification d\'identité requise par le prestataire de paiement</string>
<string name="onramp_kyc_verification_verify_button">Passer la vérification</string>
<string name="onramp_kyc_verification_whats_important">Ce qui est important</string>
<string name="onramp_legal">En utilisant la fonctionnalité onramp, vous acceptez %1$s et %2$s du fournisseur</string>
<string name="onramp_legal_text">Le service est fourni par un prestataire externe. Tangem n\'est pas responsable.</string>
<string name="onramp_max_amount_restriction">Le montant de l\'achat ne doit pas dépasser %s</string>
<string name="onramp_min_amount_restriction">Le montant à acheter doit être au moins %s</string>
<string name="onramp_native_payment_cumulative_limit" formatted="false">Si le montant cumulé des transactions dépasse %1s, une vérification d\'identité via %2s pourrait être requise</string>
<string name="onramp_native_payment_cumulative_limit_equivalent" formatted="false">Si le montant cumulé des transactions dépasse l\'équivalent de %1s, une vérification d\'identité via %2s pourrait être requise</string>
<string name="onramp_native_payment_legal_notice" formatted="false">En appuyant sur Acheter, vous acceptez %1s %2s et %3s.</string>
<string name="onramp_no_available_providers">Aucun fournisseur disponible pour cette devise</string>
<string name="onramp_offer_type_fastet">Le plus rapide</string>
<string name="onramp_pay_with">Payer avec</string>
<string name="onramp_payment_method_subtitle">Mode de paiement</string>
<string name="onramp_provider_max_amount">Disponible jusqu\'à %s</string>
<string name="onramp_provider_min_amount">Disponible à partir de %s</string>
<string name="onramp_provider_requirements_body">Les cartes émises aux États-Unis et au Royaume-Uni ne peuvent pas être traitées par ce moyen. Le prestataire pourrait exiger une vérification d\'identité supplémentaire</string>
<string name="onramp_provider_requirements_title">Exigences du prestataire</string>
<plurals name="onramp_providers_count">
<item quantity="one">%d fournisseur</item>
<item quantity="other">%d fournisseurs</item>
@ -1456,6 +1473,7 @@
<string name="sui_not_enough_coin_for_fee_description">Une transaction entrante d\'au moins de %1$s est requise pour continuer</string>
<string name="sui_not_enough_coin_for_fee_title">Fonds insuffisants</string>
<string name="swap_approve_description">En approuvant, vous autorisez le contrat intelligent à utiliser vos jetons dans de futures transactions.</string>
<string name="swap_detailed_mode">Mode détaillé</string>
<string name="swap_fixed_rate">Taux fixe</string>
<string name="swap_give_permission_fee_footer">Le réseau facturera des frais d\'approbation de jeton pour vérifier que vous autorisez l\'utilisation de votre jeton pour l\'échange.</string>
<string name="swap_in_progress">Échange en cours</string>
@ -1463,6 +1481,7 @@
<string name="swap_promo_title">Nouveau fournisseur d\'échange disponible !</string>
<string name="swap_search_tooltip_description">Recherchez nimporte quel token, même sil ne figure pas encore dans votre liste.</string>
<string name="swap_search_tooltip_title">Utilisez la recherche pour trouver ce dont vous avez besoin.</string>
<string name="swap_simple_mode">Mode simplifié</string>
<string name="swap_story_fifth_subtitle">Ayez confiance en notre assistance 24 heures sur 24 pour vous aider à résoudre tous vos problèmes</string>
<string name="swap_story_fifth_title">Assistance 24 heures sur 24</string>
<string name="swap_story_first_subtitle">Plusieurs fournisseurs de confiance en un seul endroit : échangez n\'importe quel actif facilement</string>
@ -1534,15 +1553,15 @@
<string name="tangem_pay_terms_fees_limits">Conditions, frais et limites</string>
<string name="tangem_pay_terms_limits">Conditions et limites</string>
<string name="tangem_pay_transaction_declined_notification_text">La banque a rejeté cette demande de transaction.</string>
<string name="tangem_pay_transaction_fee_notification_text">Ces frais couvrent le coût du traitement de votre virement.</string>
<string name="tangem_pay_transaction_fee_notification_text">Des frais sont prélevés conformément aux tarifs de service</string>
<string name="tangem_pay_transaction_reversed_notification_text">La transaction a été partiellement ou totalement annulée par le commerçant</string>
<string name="tangem_pay_unfreeze_card_alert_body">Continuez à utiliser votre argent. Vous pouvez le geler à tout moment.</string>
<string name="tangem_pay_unfreeze_card_alert_title">Dégeler votre carte ?</string>
<string name="tangem_pay_unfreeze_card_failed">Échec du dégel de la carte. Réessayez plus tard.</string>
<string name="tangem_pay_unfreeze_card_success">Votre carte est dégelée.</string>
<string name="tangem_pay_withdrawal">Retrait</string>
<string name="tangempay_account_deactivated_message_subtitle">Cela a été fait conformément aux exigences réglementaires. Toutefois, les retraits restent disponibles.</string>
<string name="tangempay_account_deactivated_message_title">Votre carte a été désactivée</string>
<string name="tangempay_account_deactivated_message_subtitle">Pour toute question concernant votre compte, vos données ou votre historique de transactions, veuillez contacter le support</string>
<string name="tangempay_account_deactivated_message_title">Votre compte a été fermé</string>
<string name="tangempay_account_unable_to_use_rooted">Impossible à utiliser sur un appareil rooté</string>
<string name="tangempay_available_balance">Solde</string>
<string name="tangempay_cancel_kyc">Masquer la vérification de l\'écran</string>
@ -1574,7 +1593,6 @@
<string name="tangempay_card_details_open_wallet_title">Ajouter une carte à Google Pay</string>
<string name="tangempay_card_details_open_wallet_title_apple">Ajouter la carte à Apple Pay</string>
<string name="tangempay_card_details_pin_code">code PIN</string>
<string name="tangempay_card_details_receive_description">Partagez votre adresse ou montrez le QR code</string>
<string name="tangempay_card_details_receive_error_description">Problèmes techniques détectés. Veuillez réessayer plus tard ou contacter le service d\'assistance.</string>
<string name="tangempay_card_details_receive_error_title">Réception indisponible pour le moment</string>
<string name="tangempay_card_details_reissue_card">Réémettre la carte</string>
@ -1582,7 +1600,6 @@
<string name="tangempay_card_details_rename_card_invalid_title">Caractères non valides</string>
<string name="tangempay_card_details_reveal_text">Révéler</string>
<string name="tangempay_card_details_show_details">Afficher les détails</string>
<string name="tangempay_card_details_swap_description">Échangez n\'importe quel actif de votre portefeuille contre une carte</string>
<string name="tangempay_card_details_title">Détails de la carte</string>
<string name="tangempay_card_details_unable_to_rename_card_description">Veuillez réessayer plus tard</string>
<string name="tangempay_card_details_unfreeze_card">Dégeler la carte</string>
@ -1643,7 +1660,7 @@
<string name="tangempay_onboarding_purchases_title">Payez exactement ce que vous voyez</string>
<string name="tangempay_onboarding_security_description">Un compte de paiement séparé sera créé sans divulguer vos adresses et actifs</string>
<string name="tangempay_onboarding_security_title">Confidentialité inégalée</string>
<string name="tangempay_onboarding_title">Obtenez votre carte Tangem Pay gratuite en quelques minutes</string>
<string name="tangempay_onboarding_title">Obtenez votre carte Tangem Pay en minutes</string>
<string name="tangempay_payment_account">Compte de paiement</string>
<string name="tangempay_payment_account_sync_needed">Tangem Pay session expirée</string>
<string name="tangempay_pin_validation_error_message">Code PIN invalide : évitez les séquences ou les répétitions</string>
@ -1665,7 +1682,6 @@
<string name="tangempay_sync_needed">Utilisez carte ou bague pour renouveler la session</string>
<string name="tangempay_sync_needed_body">Utilisez carte ou bague pour renouveler la session</string>
<string name="tangempay_sync_needed_button">Restaurer l\'accès</string>
<string name="tangempay_sync_needed_restore_access">Restaurer l\'accès</string>
<string name="tangempay_sync_needed_title">Tangem Pay session expirée</string>
<string name="tangempay_tangem_visa_card">Utilisez USDC pour les paiements quotidiens</string>
<string name="tangempay_temporarily_unavailable">Tangem Pay est temporairement indisponible</string>
@ -1675,7 +1691,6 @@
<string name="tangempay_topup_swap_body">Échangez n\'importe quel actif contre USDC Polygon</string>
<string name="tangempay_topup_swap_title">Depuis votre Tangem Wallet</string>
<string name="tangempay_usdc_on_polygon_network">USDC sur Polygon</string>
<string name="tangempay_use_tangem_device_to_restore_payment_account">Cliquez sur le bouton ci-dessous pour restaurer l\'accès</string>
<string name="tangempay_withdrawal_note_description">Les fonds des achats remboursés ne seront pas retournés à votre solde sur Polygon ni disponibles pour un retrait, mais resteront sur votre solde de carte pour vos achats</string>
<string name="tangempay_withdrawal_note_title">Veuillez noter</string>
<string name="tangempay_your_pin_code">Votre code PIN</string>

View file

@ -94,15 +94,15 @@
<string name="tangem_pay_terms_fees_limits">Termini, commissioni e limiti</string>
<string name="tangem_pay_terms_limits">Termini e limiti</string>
<string name="tangem_pay_transaction_declined_notification_text">La banca ha rifiutato questa richiesta di transazione.</string>
<string name="tangem_pay_transaction_fee_notification_text">Questa commissione copre il costo della gestione del tuo trasferimento.</string>
<string name="tangem_pay_transaction_fee_notification_text">Viene addebitata una commissione in base alle tariffe del servizio</string>
<string name="tangem_pay_transaction_reversed_notification_text">La transazione è stata parzialmente o totalmente stornata dal commerciante</string>
<string name="tangem_pay_unfreeze_card_alert_body">Continua a usare i tuoi soldi. Puoi congelarli in qualsiasi momento.</string>
<string name="tangem_pay_unfreeze_card_alert_title">Sbloccare la tua carta?</string>
<string name="tangem_pay_unfreeze_card_failed">Impossibile sbloccare la carta. Riprova più tardi.</string>
<string name="tangem_pay_unfreeze_card_success">La tua carta è sbloccata.</string>
<string name="tangem_pay_withdrawal">Prelievo</string>
<string name="tangempay_account_deactivated_message_subtitle">Questo è stato fatto a causa dei requisiti normativi. Tuttavia, i prelievi sono ancora disponibili.</string>
<string name="tangempay_account_deactivated_message_title">La tua carta è stata disattivata</string>
<string name="tangempay_account_deactivated_message_subtitle">Per domande su account, dati o cronologia delle transazioni, contatta il supporto</string>
<string name="tangempay_account_deactivated_message_title">Il tuo account è stato chiuso</string>
<string name="tangempay_available_balance">Saldo</string>
<string name="tangempay_cancel_kyc">Nascondi verifica dalla schermata</string>
<string name="tangempay_card_details_add_funds">Aggiungi fondi</string>
@ -132,14 +132,12 @@
<string name="tangempay_card_details_open_wallet_step_5">Tutto pronto! La tua carta è pronta per l\'uso.</string>
<string name="tangempay_card_details_open_wallet_title">Aggiungi carta a Google Pay</string>
<string name="tangempay_card_details_open_wallet_title_apple">Aggiungi carta ad Apple Pay</string>
<string name="tangempay_card_details_receive_description">Condividi il tuo indirizzo o mostra il QR code</string>
<string name="tangempay_card_details_receive_error_title">Ricezione non disponibile al momento</string>
<string name="tangempay_card_details_reissue_card">Riemettere la carta</string>
<string name="tangempay_card_details_rename_card_invalid_description">Sono consentite solo lettere e numeri</string>
<string name="tangempay_card_details_rename_card_invalid_title">Caratteri non validi</string>
<string name="tangempay_card_details_reveal_text">Rivela</string>
<string name="tangempay_card_details_show_details">Mostra dettagli</string>
<string name="tangempay_card_details_swap_description">Scambia qualsiasi asset nel tuo portafoglio con una carta</string>
<string name="tangempay_card_details_title">Dettagli carta</string>
<string name="tangempay_card_details_unable_to_rename_card_description">Per favore riprova più tardi</string>
<string name="tangempay_card_details_unfreeze_card">Sblocca carta</string>
@ -194,7 +192,7 @@
<string name="tangempay_onboarding_purchases_title">Paga esattamente quello che vedi</string>
<string name="tangempay_onboarding_security_description">Verrà creato un conto di pagamento separato senza divulgare i tuoi indirizzi e asset</string>
<string name="tangempay_onboarding_security_title">Privacy senza rivali</string>
<string name="tangempay_onboarding_title">Ottieni la tua carta Tangem Pay gratuita in pochi minuti</string>
<string name="tangempay_onboarding_title">Ottieni la tua carta Tangem Pay in pochi minuti</string>
<string name="tangempay_payment_account">Conto di pagamento</string>
<string name="tangempay_payment_account_sync_needed">Tangem Pay sessione scaduta</string>
<string name="tangempay_pin_validation_error_message">PIN non valido: evitare sequenze o ripetizioni</string>
@ -223,7 +221,6 @@
<string name="tangempay_topup_swap_body">Converti qualsiasi asset in USDC Polygon</string>
<string name="tangempay_topup_swap_title">Dal tuo Tangem Wallet</string>
<string name="tangempay_usdc_on_polygon_network">USDC sulla Polygon</string>
<string name="tangempay_use_tangem_device_to_restore_payment_account">Fare clic sul pulsante in basso per ripristinare l\'accesso</string>
<string name="tangempay_withdrawal_note_description">I fondi degli acquisti rimborsati non verranno restituiti al tuo saldo on-chain Polygon né saranno disponibili per il prelievo, ma rimarranno sul saldo della tua carta per gli acquisti</string>
<string name="tangempay_withdrawal_note_title">Attenzione</string>
<string name="tangempay_your_pin_code">Il tuo codice PIN</string>

View file

@ -393,6 +393,7 @@
<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>
@ -776,6 +777,8 @@
<string name="koinos_mana_level_description">Koinosネットワークでは、ネットワーク手数料としてManaが必要です。あなたは%1$s / %2$sManaを持っています。</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>
@ -1187,9 +1190,7 @@
<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_button_grant_permission">許可する</string>
<string name="push_notification_settings_banner_description">プッシュ通知は有効になっていますが、端末の設定で通知を許可するまで動作しません。</string>
<string name="push_notification_settings_banner_description_grant_permission">プッシュ通知は有効になっていますが、許可するまで機能しません</string>
<string name="push_notification_settings_banner_description">プッシュ通知は有効ですが、許可するまで動作しません</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>
@ -1628,7 +1629,7 @@
<string name="swapping_rate_experience_title">プロバイダーの利用体験を評価してください</string>
<string name="swapping_rate_feedback_placeholder">フィードバックを入力してください</string>
<string name="swapping_rate_feedback_submit">フィードバックを送信</string>
<string name="swapping_rate_feedback_title">ご利用体験に影響した点は\n何ですか</string>
<string name="swapping_rate_feedback_title">ご利用中に気になった点を\n教えてください</string>
<string name="swapping_swap_action">スワップ</string>
<string name="swapping_swap_action_in_progress">スワップ中…</string>
<string name="swapping_to_account_title">受け取り先</string>
@ -1671,15 +1672,15 @@
<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_fee_notification_text">この手数料は、送金処理にかかるコストをカバーするためのものです。</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>
<string name="tangem_pay_unfreeze_card_alert_title">カードの一時停止を解除しますか?</string>
<string name="tangem_pay_unfreeze_card_failed">カードの凍結解除に失敗しました。しばらくしてからもう一度お試しください。</string>
<string name="tangem_pay_unfreeze_card_success">カードの凍結が解除されました</string>
<string name="tangem_pay_withdrawal">出金</string>
<string name="tangempay_account_deactivated_message_subtitle">規制上の要件により無効化されましたが、出金は引き続き可能です。</string>
<string name="tangempay_account_deactivated_message_title">カードが無効化されました</string>
<string name="tangempay_account_deactivated_message_subtitle">アカウント、データ、または取引履歴に関するご質問は、サポートまでご連絡ください</string>
<string name="tangempay_account_deactivated_message_title">あなたのアカウントは閉鎖されました</string>
<string name="tangempay_account_unable_to_use_rooted">Root化された端末では使用できません</string>
<string name="tangempay_available_balance">利用可能残高</string>
<string name="tangempay_cancel_kyc">メイン画面からKYCを非表示にする</string>
@ -1713,7 +1714,6 @@
<string name="tangempay_card_details_open_wallet_title">Google Payにカードを追加する</string>
<string name="tangempay_card_details_open_wallet_title_apple">Apple Payにカードを追加する</string>
<string name="tangempay_card_details_pin_code">PINコード</string>
<string name="tangempay_card_details_receive_description">アドレスを共有するか、QRコードを表示してください。</string>
<string name="tangempay_card_details_receive_error_description">技術的な問題が検出されました。しばらくしてからもう一度お試しいただくか、サポートにお問い合わせください。</string>
<string name="tangempay_card_details_receive_error_title">現在、受け取りは利用できません</string>
<string name="tangempay_card_details_reissue_card">カードを交換する</string>
@ -1722,7 +1722,6 @@
<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_swap_description">ポートフォリオ内のあらゆる資産をカードと交換</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>
@ -1800,7 +1799,7 @@
<string name="tangempay_onboarding_security_title">他に類を見ないプライバシー</string>
<string name="tangempay_onboarding_setup_wallet_description">そして支払いカードを連携します</string>
<string name="tangempay_onboarding_setup_wallet_title">ウォレットを設定します</string>
<string name="tangempay_onboarding_title">無料のTangem Payカードを数分でゲットしましょ</string>
<string name="tangempay_onboarding_title">Tangem Pay カードをすぐに手に入れよ</string>
<string name="tangempay_pay_support">Payサポート</string>
<string name="tangempay_payment_account">支払いアカウント</string>
<string name="tangempay_payment_account_sync_needed">Tangem Pay セッションの有効期限が切れました</string>
@ -1824,17 +1823,15 @@
<string name="tangempay_sync_needed">カードまたはリングでセッションを更新してください</string>
<string name="tangempay_sync_needed_body">カードまたはリングでセッションを更新してください</string>
<string name="tangempay_sync_needed_button">セッションを更新</string>
<string name="tangempay_sync_needed_restore_access">セッションを更新</string>
<string name="tangempay_sync_needed_title">Tangem Pay セッションの有効期限が切れました</string>
<string name="tangempay_tangem_visa_card">日常の支払いにUSDCを利用</string>
<string name="tangempay_temporarily_unavailable">Tangem Payは現在一時的に利用できません。</string>
<string name="tangempay_title">Tangem Pay</string>
<string name="tangempay_topup_receive_body">USDC Polygon をアカウントのアドレスに送信</string>
<string name="tangempay_topup_receive_title">別のウォレットまたは取引所から</string>
<string name="tangempay_topup_swap_body">任意の資産を USDC Polygon にスワップ</string>
<string name="tangempay_topup_swap_title">Tangem ウォレットから</string>
<string name="tangempay_topup_swap_body">ウォレットの暗号資産を使って、決済アカウントにチャージできます</string>
<string name="tangempay_topup_swap_title">Tangemウォレットからスワップ</string>
<string name="tangempay_usdc_on_polygon_network">Polygonネットワーク上のUSDC</string>
<string name="tangempay_use_tangem_device_to_restore_payment_account">下のボタンをクリックしてアクセスを復元してください</string>
<string name="tangempay_withdrawal_note_description">返金分はオンチェーンのPolygon残高には戻らず、出金にも利用できません。ただし、カード残高として残り、支払いに利用できます。</string>
<string name="tangempay_withdrawal_note_title">ご注意ください</string>
<string name="tangempay_your_pin_code">PINコード</string>
@ -2346,6 +2343,10 @@
<string name="yield_apy_boost_banner_title">利息モード限定オファー</string>
<string name="yield_apy_boost_banner_title_apy_multiplied">APY 3倍</string>
<string name="yield_apy_boost_block_activate">APYブーストを有効にする</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">30日間APYブーストの対象です。利用規約が適用されます。詳細はこちら。</string>
<string name="yield_apy_boost_story_first_subtitle">初めて利息モードを有効にすると、最初の30日間は最大3倍の利回りを獲得できます。</string>
<string name="yield_apy_boost_story_first_title">初月APRボーナス</string>

View file

@ -91,6 +91,7 @@
<string name="add_custom_token_title">Adicionar token personalizado</string>
<string name="add_tokens_title">Gerenciar tokens</string>
<string name="addfunds_buy_row_description">Cartão de crédito ou conta bancária</string>
<string name="addfunds_fund_token">Adicionar token</string>
<string name="addfunds_receive_row_description">Compartilhe seu endereço ou código QR.</string>
<string name="addfunds_swap_row_description">Entre seus portfólios</string>
<string name="addfunds_you_receive_title">Você recebe</string>
@ -227,7 +228,7 @@
<string name="common_action_failed">%s fracassado</string>
<string name="common_activate">Ativar</string>
<string name="common_add">Adicionar</string>
<string name="common_add_funds">Adicionar fundos</string>
<string name="common_add_funds">Depositar</string>
<string name="common_add_to_portfolio">Adicionar ao portfólio</string>
<string name="common_add_token">Adicionar token</string>
<string name="common_add_tokens">Adicionar tokens</string>
@ -662,6 +663,11 @@
<string name="feedback_subject_support_tangem">Feedback Tangem</string>
<string name="feedback_subject_tx_failed">Não foi possível enviar uma transação.</string>
<string name="feedback_token_description_error">Erro na descrição da moeda</string>
<string name="force_update_banner_message">Atualize o aplicativo para a versão mais recente para garantir o funcionamento correto.</string>
<string name="force_update_banner_title">Atualização necessária</string>
<string name="force_update_button">Atualizar</string>
<string name="force_update_warning_message">Por favor, atualize o aplicativo para a versão mais recente para garantir o funcionamento correto.</string>
<string name="force_update_warning_title">Atualização necessária</string>
<string name="gasless_not_enough_funds_to_cover_token_fee">Fundos insuficientes</string>
<string name="gasless_transaction_fee">Taxa de transação</string>
<string name="generic_error">Ocorreu um erro.</string>
@ -787,6 +793,8 @@
<string name="koinos_mana_level_description">A rede Koinos exige Mana para o pagamento das taxas de rede. Você tem %1$s/%2$s Mana</string>
<string name="koinos_mana_level_title">Nível de mana</string>
<string name="main_add_and_manage_tokens">Adicionar e gerenciar</string>
<string name="main_add_funds_promo_description">Compre ou receba criptomoedas para começar a usar sua carteira.</string>
<string name="main_add_funds_promo_title">Adquira suas primeiras criptomoedas.</string>
<string name="main_empty_tokens_list_message">Para começar a rastrear seus criptoativos e transações, adicione tokens.</string>
<string name="main_manage_tokens">Gerenciar tokens</string>
<string name="main_qr_scan_hint">Leia o código QR para enviar fundos ou conectar-se a um aplicativo</string>
@ -1073,7 +1081,7 @@
<string name="onboarding_create_wallet_options_button_options">Outras opções</string>
<string name="onboarding_create_wallet_options_message">Suas chaves serão geradas com segurança dentro do chip. Não há frase mnemônica, o que significa que ninguém pode exportá-las ou roubá-las.</string>
<string name="onboarding_create_wallet_options_title">Gere chaves de forma privada</string>
<string name="onboarding_create_wallet_term_of_conditions_text">Ao continuar, você concorda com os termos. %s</string>
<string name="onboarding_create_wallet_term_of_conditions_text">Ao continuar, você concorda com os termos.\n%s</string>
<string name="onboarding_done_body">Seu cartão está ativado e pronto para uso.</string>
<string name="onboarding_done_header">Sucesso!</string>
<string name="onboarding_done_wallet">Sua carteira está configurada e pronta para uso!</string>
@ -1210,9 +1218,7 @@
<string name="organize_tokens_title">Organizar tokens</string>
<string name="organize_tokens_ungroup">Desagrupar</string>
<string name="provider_name_support">%s suporte</string>
<string name="push_notification_settings_banner_button_grant_permission">Conceder permissão</string>
<string name="push_notification_settings_banner_description">As notificações push estão ativadas, mas só funcionarão depois que você as permitir nas configurações do seu dispositivo.</string>
<string name="push_notification_settings_banner_description_grant_permission">As notificações push estão ativadas, mas não funcionarão até que você conceda permissão.</string>
<string name="push_notification_settings_banner_title">Permitir notificações</string>
<string name="push_notification_settings_offers_updates_subtitle">Novidades sobre produtos, ofertas exclusivas e lembretes de atividades.</string>
<string name="push_notification_settings_offers_updates_title">Ofertas e atualizações</string>
@ -1684,11 +1690,13 @@
<string name="tangem_pay_freeze_card_failed">Não foi possível bloquear o cartão. Tente novamente mais tarde.</string>
<string name="tangem_pay_freeze_card_freeze">Congelar</string>
<string name="tangem_pay_freeze_card_success">Seu cartão está bloqueado.</string>
<string name="tangem_pay_freeze_card_unfreeze">Descongelar</string>
<string name="tangem_pay_get_help">Obtenha ajuda</string>
<string name="tangem_pay_history_item_spend_mc_declined_reason">Razão: %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">Outro</string>
<string name="tangem_pay_pin_code_title">Código PIN</string>
<string name="tangem_pay_rooted_device_subtitle">Não é possível usar em dispositivos com root.</string>
<string name="tangem_pay_status_completed">Concluído</string>
<string name="tangem_pay_status_declined">Recusado</string>
@ -1697,15 +1705,15 @@
<string name="tangem_pay_terms_fees_limits">Termos, taxas e limites</string>
<string name="tangem_pay_terms_limits">Termos e Limites</string>
<string name="tangem_pay_transaction_declined_notification_text">O banco rejeitou esta solicitação de transação.</string>
<string name="tangem_pay_transaction_fee_notification_text">Essa taxa destina-se a cobrir os custos de processamento da sua transferência.</string>
<string name="tangem_pay_transaction_fee_notification_text">Uma taxa é cobrada de acordo com as tarifas de serviço</string>
<string name="tangem_pay_transaction_reversed_notification_text">A transação foi parcial ou totalmente revertida pelo comerciante.</string>
<string name="tangem_pay_unfreeze_card_alert_body">Continue usando seu dinheiro. Você pode congelar a qualquer momento.</string>
<string name="tangem_pay_unfreeze_card_alert_title">Descongelar seu cartão?</string>
<string name="tangem_pay_unfreeze_card_failed">Não foi possível desbloquear o cartão. Tente novamente mais tarde.</string>
<string name="tangem_pay_unfreeze_card_success">Seu cartão foi desbloqueado.</string>
<string name="tangem_pay_withdrawal">Retirada</string>
<string name="tangempay_account_deactivated_message_subtitle">Isso foi feito devido a requisitos regulatórios. No entanto, saques ainda estão disponíveis.</string>
<string name="tangempay_account_deactivated_message_title">Seu cartão foi desativado</string>
<string name="tangempay_account_deactivated_message_subtitle">Para dúvidas sobre sua conta, dados ou histórico de transações, entre em contato com o suporte</string>
<string name="tangempay_account_deactivated_message_title">Sua conta foi encerrada</string>
<string name="tangempay_account_unable_to_use_rooted">Não é possível usar em dispositivos com root.</string>
<string name="tangempay_available_balance">Saldo disponível</string>
<string name="tangempay_cancel_kyc">Ocultar KYC da tela principal</string>
@ -1739,7 +1747,6 @@
<string name="tangempay_card_details_open_wallet_title">Adicionar cartão ao Google Pay</string>
<string name="tangempay_card_details_open_wallet_title_apple">Adicionar cartão ao Apple Pay</string>
<string name="tangempay_card_details_pin_code">Código PIN</string>
<string name="tangempay_card_details_receive_description">Compartilhe seu endereço ou mostre o código QR.</string>
<string name="tangempay_card_details_receive_error_description">Problemas técnicos detectados. Tente novamente mais tarde ou entre em contato com o suporte.</string>
<string name="tangempay_card_details_receive_error_title">Receber indisponível agora</string>
<string name="tangempay_card_details_reissue_card">Substituir cartão</string>
@ -1748,7 +1755,6 @@
<string name="tangempay_card_details_rename_card_placeholder">Nome do cartão</string>
<string name="tangempay_card_details_reveal_text">Revelar</string>
<string name="tangempay_card_details_show_details">Mostrar detalhes</string>
<string name="tangempay_card_details_swap_description">Troque qualquer ativo da sua carteira por um cartão.</string>
<string name="tangempay_card_details_title">Detalhes do cartão</string>
<string name="tangempay_card_details_unable_to_rename_card_description">Por favor, tente novamente mais tarde.</string>
<string name="tangempay_card_details_unfreeze_card">Descongelar cartão</string>
@ -1766,6 +1772,7 @@
<string name="tangempay_card_page_daily_limit_change">Mudar</string>
<string name="tangempay_card_page_daily_limit_current_limit">Limite atual</string>
<string name="tangempay_card_page_daily_limit_error_description">Não foi possível carregar seu limite diário. Tente novamente.</string>
<string name="tangempay_card_page_daily_limit_error_subtitle">Recarregue a página para tentar novamente.</string>
<string name="tangempay_card_page_daily_limit_error_title">Limite diário indisponível</string>
<string name="tangempay_card_page_daily_limit_success_description">Você pode alterar isso novamente quando quiser.</string>
<string name="tangempay_card_page_daily_limit_success_title">O limite diário está definido.</string>
@ -1827,7 +1834,7 @@
<string name="tangempay_onboarding_security_title">Privacidade incomparável</string>
<string name="tangempay_onboarding_setup_wallet_description">E vincule um cartão de pagamento a ele.</string>
<string name="tangempay_onboarding_setup_wallet_title">Vamos configurar uma carteira.</string>
<string name="tangempay_onboarding_title">Obtenha seu cartão Tangem Pay gratuito em minutos.</string>
<string name="tangempay_onboarding_title">Obtenha seu cartão Tangem Pay em minutos</string>
<string name="tangempay_pay_support">Suporte de Pay</string>
<string name="tangempay_payment_account">Conta de pagamento</string>
<string name="tangempay_payment_account_sync_needed">Tangem Pay sessão expirada</string>
@ -1851,7 +1858,6 @@
<string name="tangempay_sync_needed">Use o cartão ou anel para renovar a sessão</string>
<string name="tangempay_sync_needed_body">Use o cartão ou anel para renovar a sessão</string>
<string name="tangempay_sync_needed_button">Restaurar acesso</string>
<string name="tangempay_sync_needed_restore_access">Restaurar acesso</string>
<string name="tangempay_sync_needed_title">Tangem Pay sessão expirada</string>
<string name="tangempay_tangem_visa_card">Use USDC para pagamentos do dia a dia.</string>
<string name="tangempay_temporarily_unavailable">O serviço Tangem Pay está temporariamente inacessível.</string>
@ -1861,7 +1867,6 @@
<string name="tangempay_topup_swap_body">Troque qualquer ativo por USDC Polygon</string>
<string name="tangempay_topup_swap_title">Da sua Tangem Wallet</string>
<string name="tangempay_usdc_on_polygon_network">USDC na rede Polygon</string>
<string name="tangempay_use_tangem_device_to_restore_payment_account">Clique no botão abaixo para restaurar o acesso.</string>
<string name="tangempay_withdrawal_note_description">Os fundos de compras reembolsadas não serão devolvidos ao seu saldo na blockchain nem estarão disponíveis para saque, mas permanecerão no saldo do seu cartão para compras futuras</string>
<string name="tangempay_withdrawal_note_title">Observe</string>
<string name="tangempay_your_pin_code">Seu código PIN</string>

View file

@ -231,7 +231,7 @@
<string name="common_accounts">Аккаунты</string>
<string name="common_activate">Активировать</string>
<string name="common_add">Добавить</string>
<string name="common_add_funds">Добавить средств</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>
@ -410,6 +410,7 @@
<string name="common_select_action">Выберите действие</string>
<string name="common_sell">Продать</string>
<string name="common_send">Отправить</string>
<string name="common_send_colon">Отправка:</string>
<string name="common_send_tx_error">Не удалось отправить транзакцию</string>
<string name="common_server_unavailable">Сервер недоступен, повторите попытку позднее</string>
<string name="common_share">Поделиться</string>
@ -782,6 +783,8 @@
<string name="koinos_mana_level_description">Сеть Koinos использует Ману для оплаты комиссии сети. У вас есть %1$s/%2$s Mana</string>
<string name="koinos_mana_level_title">Уровень маны</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>
@ -1684,15 +1687,15 @@
<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_fee_notification_text">Эта комиссия покрывает стоимость обработки вашего перевода.</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>
<string name="tangem_pay_unfreeze_card_alert_title">Разморозить карту?</string>
<string name="tangem_pay_unfreeze_card_failed">Не удалось разморозить карту, попробуйте еще раз</string>
<string name="tangem_pay_unfreeze_card_success">Карта разморожена</string>
<string name="tangem_pay_withdrawal">Вывод средств</string>
<string name="tangempay_account_deactivated_message_subtitle">Это произошло из-за регуляторных требований. Вывод средств по-прежнему доступен.</string>
<string name="tangempay_account_deactivated_message_title">Карта была деактивирована</string>
<string name="tangempay_account_deactivated_message_subtitle">По вопросам данных или истории транзакций, обратитесь в поддержку</string>
<string name="tangempay_account_deactivated_message_title">Аккаунт закрыт</string>
<string name="tangempay_account_unable_to_use_rooted">Запрещено использовать на root-устройствах</string>
<string name="tangempay_available_balance">Баланс</string>
<string name="tangempay_cancel_kyc">Скрыть KYC с главной</string>
@ -1726,7 +1729,6 @@
<string name="tangempay_card_details_open_wallet_title">Добавьте карту в Google Pay</string>
<string name="tangempay_card_details_open_wallet_title_apple">Добавить карту в Apple Pay</string>
<string name="tangempay_card_details_pin_code">ПИН-код</string>
<string name="tangempay_card_details_receive_description">Скопируйте свой адрес или покажите QR</string>
<string name="tangempay_card_details_receive_error_description">Техническая ошибка. Попробуйте позже или обратитесь в поддержку.</string>
<string name="tangempay_card_details_receive_error_title">Пополнение недоступно</string>
<string name="tangempay_card_details_reissue_card">Перевыпустить карту</string>
@ -1734,7 +1736,6 @@
<string name="tangempay_card_details_rename_card_invalid_title">Недопустимые символы</string>
<string name="tangempay_card_details_reveal_text">Показать</string>
<string name="tangempay_card_details_show_details">Реквизиты</string>
<string name="tangempay_card_details_swap_description">Пополните карту любым активом через обмен </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>
@ -1816,7 +1817,6 @@
<string name="tangempay_sync_needed">Используйте карту или кольцо для обновления сессии</string>
<string name="tangempay_sync_needed_body">Используйте карту или кольцо для обновления сессии</string>
<string name="tangempay_sync_needed_button">Обновить сессию</string>
<string name="tangempay_sync_needed_restore_access">Обновить сессию</string>
<string name="tangempay_sync_needed_title">Tangem Pay · Cессия истекла</string>
<string name="tangempay_tangem_visa_card">Оплачивайте ежедневные покупки в USDC</string>
<string name="tangempay_temporarily_unavailable">Tangem Pay временно недоступен</string>
@ -1826,7 +1826,6 @@
<string name="tangempay_topup_swap_body">Обменяйте любой актив на USDC Polygon</string>
<string name="tangempay_topup_swap_title">Из вашего кошелька Tangem</string>
<string name="tangempay_usdc_on_polygon_network">USDC в сети Polygon</string>
<string name="tangempay_use_tangem_device_to_restore_payment_account">Нажмите на кнопку ниже, чтобы восстановить доступ</string>
<string name="tangempay_withdrawal_note_description">При возвратах покупок средства не возвращаются на ончейн-баланс Polygon и недоступны для вывода, но отображаются на карте и могут быть использованы для покупок</string>
<string name="tangempay_withdrawal_note_title">Обратите внимание</string>
<string name="tangempay_your_pin_code">Ваш PIN-код</string>
@ -2071,6 +2070,7 @@
<string name="warning_express_notification_invalid_reserve_amount_title">Сумма получения не может быть менее %s</string>
<string name="warning_express_pair_unavailable_message">Это может произойти из-за того, что провайдер временно не предоставляет обмен выбранной вами пары. Пожалуйста, подождите некоторое время и попробуйте снова. (Код %s)</string>
<string name="warning_express_pair_unavailable_title">Выбранная пара временно недоступна</string>
<string name="warning_express_providers_fca_warning_description">Для пользователей из Великобритании: некоторые провайдеры не авторизованы FCA Великобритании. Вам следует избегать взаимодействия с ними.</string>
<string name="warning_express_providers_fca_warning_title">Предупреждающий список FCA</string>
<string name="warning_express_refresh_required_title">Сервис временно недоступен</string>
<string name="warning_express_too_maximum_amount_title">Сумма для обмена должна быть не более %s</string>

View file

@ -384,6 +384,7 @@
<string name="common_select_action">Оберіть дію</string>
<string name="common_sell">Продати</string>
<string name="common_send">Надіслати</string>
<string name="common_send_colon">Відправка:</string>
<string name="common_send_tx_error">Не вдалося надіслати транзакцію</string>
<string name="common_server_unavailable">Сервер недоступний, спробуйте пізніше</string>
<string name="common_share">Поширити</string>
@ -1604,15 +1605,15 @@
<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_fee_notification_text">Ця комісія покриває витрати на обробку вашого переказу.</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>
<string name="tangem_pay_unfreeze_card_alert_title">Розморозити картку?</string>
<string name="tangem_pay_unfreeze_card_failed">Не вдалося розморозити картку. Спробуйте пізніше.</string>
<string name="tangem_pay_unfreeze_card_success">Картку розморожено.</string>
<string name="tangem_pay_withdrawal">Виведення коштів</string>
<string name="tangempay_account_deactivated_message_subtitle">Це було зроблено відповідно до регуляторних вимог. Виведення коштів усе ще доступне.</string>
<string name="tangempay_account_deactivated_message_title">Вашу картку було деактивовано</string>
<string name="tangempay_account_deactivated_message_subtitle">З питань щодо даних або історії транзакцій зверніться до служби підтримки</string>
<string name="tangempay_account_deactivated_message_title">Ваш обліковий запис було закрито</string>
<string name="tangempay_account_unable_to_use_rooted">Заборонено використовувати на root-пристроях</string>
<string name="tangempay_available_balance">Баланс</string>
<string name="tangempay_cancel_kyc">Приховати KYC з головного екрана</string>
@ -1644,7 +1645,6 @@
<string name="tangempay_card_details_open_wallet_title">Додайте картку до Google Pay</string>
<string name="tangempay_card_details_open_wallet_title_apple">Додайте свою картку в Apple Pay</string>
<string name="tangempay_card_details_pin_code">ПІН-код</string>
<string name="tangempay_card_details_receive_description">Поділіться своєю адресою або покажіть QR-код</string>
<string name="tangempay_card_details_receive_error_description">Виявлено технічні проблеми. Будь ласка, спробуйте пізніше або зверніться до служби підтримки.</string>
<string name="tangempay_card_details_receive_error_title">Поповнення наразі недоступне</string>
<string name="tangempay_card_details_reissue_card">Перевипустити картку</string>
@ -1652,7 +1652,6 @@
<string name="tangempay_card_details_rename_card_invalid_title">Неприпустимі символи</string>
<string name="tangempay_card_details_reveal_text">Показати</string>
<string name="tangempay_card_details_show_details">Показати деталі</string>
<string name="tangempay_card_details_swap_description">Обміняйте будь-який актив у вашому портфелі на картку</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>
@ -1713,7 +1712,7 @@
<string name="tangempay_onboarding_purchases_title">Платіть стільки, скільки бачите</string>
<string name="tangempay_onboarding_security_description">Буде створено окремий платіжний рахунок без розкриття ваших адрес та активів</string>
<string name="tangempay_onboarding_security_title">Неперевершена конфіденційність</string>
<string name="tangempay_onboarding_title">Отримайте безкоштовну картку Tangem Pay за лічені хвилини</string>
<string name="tangempay_onboarding_title">Отримайте картку Tangem Pay за лічені хвилини</string>
<string name="tangempay_payment_account">Платіжний акаунт</string>
<string name="tangempay_payment_account_sync_needed">Tangem Pay · Сесія закінчилася</string>
<string name="tangempay_pin_validation_error_message">Слабкий ПІН: не використовуйте повторів або послідовностей.</string>
@ -1735,7 +1734,6 @@
<string name="tangempay_sync_needed">Використайте картку або кільце для поновлення сесії</string>
<string name="tangempay_sync_needed_body">Використайте картку або кільце для поновлення сесії</string>
<string name="tangempay_sync_needed_button">Відновити доступ</string>
<string name="tangempay_sync_needed_restore_access">Відновити доступ</string>
<string name="tangempay_sync_needed_title">Tangem Pay · Сесія закінчилася</string>
<string name="tangempay_tangem_visa_card">Використовуйте USDC для щоденних платежів</string>
<string name="tangempay_temporarily_unavailable">Tangem Pay тимчасово недоступний</string>
@ -1745,7 +1743,6 @@
<string name="tangempay_topup_swap_body">Обміняйте будь-який актив на USDC Polygon</string>
<string name="tangempay_topup_swap_title">З вашого Tangem Wallet</string>
<string name="tangempay_usdc_on_polygon_network">USDC у Polygon</string>
<string name="tangempay_use_tangem_device_to_restore_payment_account">Натисніть кнопку нижче, щоб відновити доступ</string>
<string name="tangempay_withdrawal_note_description">Кошти з повернених покупок не будуть повернуті на ваш ончейн-баланс Polygon і не будуть доступні для виведення, але залишаться на балансі картки для покупок.</string>
<string name="tangempay_withdrawal_note_title">Зверніть увагу</string>
<string name="tangempay_your_pin_code">Ваш PIN-код</string>

View file

@ -393,6 +393,7 @@
<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>
@ -1187,9 +1188,7 @@
<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_button_grant_permission">授予权限</string>
<string name="push_notification_settings_banner_description">推送通知已启用,但需要您在设备设置中允许通知才能正常工作。</string>
<string name="push_notification_settings_banner_description_grant_permission">推送通知已启用,但需要您授予权限才能生效。</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>
@ -1671,15 +1670,15 @@
<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_fee_notification_text">这笔费用用于支付您办理转账时的费用。</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>
<string name="tangem_pay_unfreeze_card_alert_title">要解冻您的卡片?</string>
<string name="tangem_pay_unfreeze_card_failed">卡片解冻失败,请稍后再试。</string>
<string name="tangem_pay_unfreeze_card_success">您的卡片已解冻。</string>
<string name="tangem_pay_withdrawal">提款</string>
<string name="tangempay_account_deactivated_message_subtitle">这是根据监管要求执行的。不过,提现仍然可用。</string>
<string name="tangempay_account_deactivated_message_title">您的卡已停用</string>
<string name="tangempay_account_deactivated_message_subtitle">如需咨询账户、数据或交易记录,请联系支持团队</string>
<string name="tangempay_account_deactivated_message_title">您的账户已被关闭</string>
<string name="tangempay_account_unable_to_use_rooted">无法在已root的设备上使用</string>
<string name="tangempay_available_balance">可用余额</string>
<string name="tangempay_cancel_kyc">从主屏幕隐藏 KYC 页面</string>
@ -1713,7 +1712,6 @@
<string name="tangempay_card_details_open_wallet_title">将卡片添加到 Google Pay</string>
<string name="tangempay_card_details_open_wallet_title_apple">将卡片添加到 Apple Pay</string>
<string name="tangempay_card_details_pin_code">PIN码</string>
<string name="tangempay_card_details_receive_description">分享您的地址或出示二维码</string>
<string name="tangempay_card_details_receive_error_description">检测到技术问题。请稍后再试或联系技术支持。</string>
<string name="tangempay_card_details_receive_error_title">目前无法接收</string>
<string name="tangempay_card_details_reissue_card">重新发行卡片</string>
@ -1722,7 +1720,6 @@
<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_swap_description">将您投资组合中的任何资产互换到卡片</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>
@ -1800,7 +1797,7 @@
<string name="tangempay_onboarding_security_title">无与伦比的隐私保护</string>
<string name="tangempay_onboarding_setup_wallet_description">并将其与支付卡关联。</string>
<string name="tangempay_onboarding_setup_wallet_title">我们将设置一个钱包。</string>
<string name="tangempay_onboarding_title">几分钟内即可获得免费的 Tangem Pay 卡</string>
<string name="tangempay_onboarding_title">立即获取你的 Tangem Pay 卡</string>
<string name="tangempay_pay_support">支付支持</string>
<string name="tangempay_payment_account">支付账户</string>
<string name="tangempay_payment_account_sync_needed">Tangem Pay 会话已过期</string>
@ -1824,7 +1821,6 @@
<string name="tangempay_sync_needed">用卡或戒指续期会话</string>
<string name="tangempay_sync_needed_body">用卡或戒指续期会话</string>
<string name="tangempay_sync_needed_button">恢复访问权限</string>
<string name="tangempay_sync_needed_restore_access">恢复访问权限</string>
<string name="tangempay_sync_needed_title">Tangem Pay 会话已过期</string>
<string name="tangempay_tangem_visa_card">使用 USDC 进行日常支付</string>
<string name="tangempay_temporarily_unavailable">Tangem Pay暂时无法使用。</string>
@ -1834,7 +1830,6 @@
<string name="tangempay_topup_swap_body">將任何資產兌換為 USDC Polygon</string>
<string name="tangempay_topup_swap_title">從您的 Tangem 錢包</string>
<string name="tangempay_usdc_on_polygon_network">Polygon网络上的 USDC</string>
<string name="tangempay_use_tangem_device_to_restore_payment_account">点击下方按钮恢复访问权限</string>
<string name="tangempay_withdrawal_note_description">您的Polygon链上 USDC 余额与您的卡片余额不同,并在购买后 2 个工作日内更新。购物退款的资金不会退还至您的链上余额,也不能提现,但会保留在您的卡片余额中用于购物。</string>
<string name="tangempay_withdrawal_note_title">请注意</string>
<string name="tangempay_your_pin_code">您的PIN码</string>

View file

@ -337,15 +337,15 @@
<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_fee_notification_text">此費用用於支付處理您轉帳的成本。</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>
<string name="tangem_pay_unfreeze_card_alert_title">解凍您的卡片?</string>
<string name="tangem_pay_unfreeze_card_failed">無法解凍卡片。請稍後再試。</string>
<string name="tangem_pay_unfreeze_card_success">您的卡片已解凍。</string>
<string name="tangem_pay_withdrawal">提現</string>
<string name="tangempay_account_deactivated_message_subtitle">这是根据监管要求执行的。不过,提现仍然可用。</string>
<string name="tangempay_account_deactivated_message_title">您的卡已停用</string>
<string name="tangempay_account_deactivated_message_subtitle">如需查詢帳戶、資料或交易記錄,請聯絡客服支援</string>
<string name="tangempay_account_deactivated_message_title">您的帳戶已被關閉</string>
<string name="tangempay_cancel_kyc">在主畫面隱藏身份驗證</string>
<string name="tangempay_card_details_add_funds">添加资金</string>
<string name="tangempay_card_details_add_funds_subtitle">充值选项</string>
@ -374,12 +374,10 @@
<string name="tangempay_card_details_open_wallet_step_5">全部完成!您的卡片已準備就緒。</string>
<string name="tangempay_card_details_open_wallet_title">將卡片添加到 Google Pay</string>
<string name="tangempay_card_details_open_wallet_title_apple">添加卡片到 Apple Pay</string>
<string name="tangempay_card_details_receive_description">分享您的地址或显示二维码</string>
<string name="tangempay_card_details_receive_error_title">暫時無法接收</string>
<string name="tangempay_card_details_reissue_card">重新发行卡片</string>
<string name="tangempay_card_details_reveal_text">显示</string>
<string name="tangempay_card_details_show_details">顯示詳情</string>
<string name="tangempay_card_details_swap_description">將您投資組合中的任何資產兌換成卡片</string>
<string name="tangempay_card_details_title">卡片详情</string>
<string name="tangempay_card_details_unfreeze_card">解凍卡片</string>
<string name="tangempay_card_details_withdraw">提现</string>
@ -420,7 +418,7 @@
<string name="tangempay_onboarding_purchases_title">所見即所付</string>
<string name="tangempay_onboarding_security_description">將創建單獨的支付帳戶,且不會透露您的地址和資產</string>
<string name="tangempay_onboarding_security_title">無與倫比的隱私</string>
<string name="tangempay_onboarding_title">在幾分鐘內獲得免費的 Tangem Pay 卡</string>
<string name="tangempay_onboarding_title">立即獲取你的 Tangem Pay 卡</string>
<string name="tangempay_payment_account">付款帳戶</string>
<string name="tangempay_payment_account_sync_needed">Tangem Pay 工作階段已過期</string>
<string name="tangempay_reissue_card_description">這將產生一組新的卡片資料。您的舊資料將停止使用。此操作無法復原。</string>
@ -439,7 +437,6 @@
<string name="tangempay_topup_receive_title">从其他钱包或交易所</string>
<string name="tangempay_topup_swap_body">将任何资产兑换为 USDC Polygon</string>
<string name="tangempay_topup_swap_title">从您的 Tangem 钱包</string>
<string name="tangempay_use_tangem_device_to_restore_payment_account">點擊下方按鈕以恢復存取權限</string>
<string name="tangempay_withdrawal_note_description">您的 USDC Polygon 鏈上餘額與卡片餘額不同,並在購買後 2 個工作日內更新。退款交易的資金不會返回到您的鏈上餘額或可供提現,但會保留在您的卡片餘額中用於購買。</string>
<string name="tangempay_withdrawal_note_title">請注意</string>
<string name="tangempay_your_pin_code">您的PIN码</string>

View file

@ -664,6 +664,11 @@
<string name="feedback_subject_support_tangem">Tangem feedback</string>
<string name="feedback_subject_tx_failed">Can\'t send a transaction</string>
<string name="feedback_token_description_error">Coin description error</string>
<string name="force_update_banner_message">Update the application to the latest version to ensure proper functionality</string>
<string name="force_update_banner_title">Update Needed</string>
<string name="force_update_button">Update</string>
<string name="force_update_warning_message">Please update the application to the latest version to ensure proper functionality.</string>
<string name="force_update_warning_title">Update Required</string>
<string name="gasless_not_enough_funds_to_cover_token_fee">Not enough funds</string>
<string name="gasless_transaction_fee">Transaction fee</string>
<string name="generic_error">An error occurred</string>
@ -789,8 +794,8 @@
<string name="koinos_mana_level_description">The Koinos network requires Mana for network fees. You have %1$s/%2$s Mana</string>
<string name="koinos_mana_level_title">Mana level</string>
<string name="main_add_and_manage_tokens">Add &amp; Manage</string>
<string name="main_add_funds_promo_description">Deposit crypto or buy with card to get started</string>
<string name="main_add_funds_promo_title">Add funds to start earning and trading</string>
<string name="main_add_funds_promo_description">Buy or receive crypto to start using your wallet.</string>
<string name="main_add_funds_promo_title">Get your first crypto</string>
<string name="main_empty_tokens_list_message">To begin tracking your crypto assets and transactions, add tokens</string>
<string name="main_manage_tokens">Manage tokens</string>
<string name="main_qr_scan_hint">Scan QR code to send funds or connect to an app</string>
@ -1672,7 +1677,7 @@
<string name="tangem_pay_card_details_unable_to_rename_card_title">Unable to rename card</string>
<string name="tangem_pay_card_frozen">Card frozen</string>
<string name="tangem_pay_card_payment">Card payment</string>
<string name="tangem_pay_close_card_popup_description">It will disappear from payment account</string>
<string name="tangem_pay_close_card_popup_description">It will disappear from the app</string>
<string name="tangem_pay_close_card_popup_primary_button_title">Close card</string>
<string name="tangem_pay_close_card_popup_secondary_button_title">Go back</string>
<string name="tangem_pay_close_card_popup_title">Close your card?</string>
@ -1693,6 +1698,7 @@
<string name="tangem_pay_history_item_spend_mcc">MCC %s</string>
<string name="tangem_pay_other">Other</string>
<string name="tangem_pay_pin_code_title">PIN-code</string>
<string name="tangem_pay_purchase">Purchase</string>
<string name="tangem_pay_rooted_device_subtitle">Unable to use on rooted devices</string>
<string name="tangem_pay_status_completed">Completed</string>
<string name="tangem_pay_status_declined">Declined</string>
@ -1701,15 +1707,17 @@
<string name="tangem_pay_terms_fees_limits">Terms, Fees &amp; Limits</string>
<string name="tangem_pay_terms_limits">Terms and fees</string>
<string name="tangem_pay_transaction_declined_notification_text">The bank rejected this transaction request.</string>
<string name="tangem_pay_transaction_fee_notification_text">This fee goes to cover the cost of handling your transfer.</string>
<string name="tangem_pay_transaction_details_category">Category</string>
<string name="tangem_pay_transaction_details_mcc">MCC</string>
<string name="tangem_pay_transaction_fee_notification_text">A fee is charged in accordance with the service tariffs</string>
<string name="tangem_pay_transaction_reversed_notification_text">The transaction was partially or fully reversed by the merchant</string>
<string name="tangem_pay_unfreeze_card_alert_body">Keep using your money. You can freeze anytime.</string>
<string name="tangem_pay_unfreeze_card_alert_title">Unfreeze your card?</string>
<string name="tangem_pay_unfreeze_card_failed">Failed to unfreeze the card. Try again later.</string>
<string name="tangem_pay_unfreeze_card_success">Your card is unfrozen.</string>
<string name="tangem_pay_withdrawal">Withdrawal</string>
<string name="tangempay_account_deactivated_message_subtitle">This was done due to regulatory requirements. Anyway withdrawals are still available.</string>
<string name="tangempay_account_deactivated_message_title">Your card was deactivated</string>
<string name="tangempay_account_deactivated_message_subtitle">For questions about account, data or transaction history, please contact support</string>
<string name="tangempay_account_deactivated_message_title">Your account has been closed</string>
<string name="tangempay_account_unable_to_use_rooted">Unable to use on rooted device</string>
<string name="tangempay_available_balance">Available balance</string>
<string name="tangempay_cancel_kyc">Hide KYC from main screen</string>
@ -1819,6 +1827,24 @@
<string name="tangempay_kyc_rejected_description_span">your profile.</string>
<string name="tangempay_maximum_cards_issued_description">You can have up to 3 cards. Delete one to add a new card.</string>
<string name="tangempay_maximum_cards_issued_title">Maximum Cards Issued</string>
<string name="tangempay_newonboard_Q1_body">Yes to operate a regulated Visa card, identity verification is mandatory. KYC is handled by Sumsub (compliance partner).</string>
<string name="tangempay_newonboard_Q1_title">Do I have to share my docs?</string>
<string name="tangempay_newonboard_Q2_body">No. KYC applies only to the Tangem Pay account. Your Tangem Wallet itself remains a separate, self-custodial, non-KYC environment.</string>
<string name="tangempay_newonboard_Q2_title">Does the KYC associate with my wallet?</string>
<string name="tangempay_newonboard_Q3_body">Sumsub a globally regulated KYC provider, trusted by 4,000+ financial institutions verifies your identity and securely stores the results under ISO 27001 and SOC 2 standards.</string>
<string name="tangempay_newonboard_Q3_title">Who stores my personal data and how is it protected?</string>
<string name="tangempay_newonboard_Q4_body">Card balance nominated in USDC on Polygon, but you can use any asset (USDT, SOL, ETH, BTC, XRP etc.) to fund it using Tangem\'s convenient built-in swap mechanisms.</string>
<string name="tangempay_newonboard_Q4_title">What crypto can I spend?</string>
<string name="tangempay_newonboard_body">Spend crypto anywhere — no banks, no middlemen, no exchanges. The power of self-custody meets everyday payments.</string>
<string name="tangempay_newonboard_bottomleft_body">Buy online and via Apple Pay</string>
<string name="tangempay_newonboard_bottomleft_title">Accepted worldwide</string>
<string name="tangempay_newonboard_bottomright_body">Use anywhere in the world</string>
<string name="tangempay_newonboard_bottomright_title">FX fee is just 1%</string>
<string name="tangempay_newonboard_title">Get your Tangem Pay Card</string>
<string name="tangempay_newonboard_topleft_body">Pay what you see</string>
<string name="tangempay_newonboard_topleft_title">No purchase fees, \n1 USDC = 1 USD</string>
<string name="tangempay_newonboard_topright_body">No surprises</string>
<string name="tangempay_newonboard_topright_title">$0 monthly fee\n$0 topup fee</string>
<string name="tangempay_onboarding_banner_description">Get your free Tangem Visa virtual card</string>
<string name="tangempay_onboarding_banner_title">Use USDC for everyday payments</string>
<string name="tangempay_onboarding_get_card_button_text">Get card</string>
@ -1830,7 +1856,7 @@
<string name="tangempay_onboarding_security_title">Unrivaled privacy</string>
<string name="tangempay_onboarding_setup_wallet_description">And link a payment card to it</string>
<string name="tangempay_onboarding_setup_wallet_title">We\'ll set up a wallet</string>
<string name="tangempay_onboarding_title">Get your free Tangem Pay Card in minutes</string>
<string name="tangempay_onboarding_title">Get your Tangem Pay Card in minutes</string>
<string name="tangempay_pay_support">Pay Support</string>
<string name="tangempay_payment_account">Payment account</string>
<string name="tangempay_payment_account_sync_needed">Payment account session expired</string>
@ -1847,16 +1873,16 @@
<string name="tangempay_reissue_card_title">Replace your card?</string>
<string name="tangempay_service_unavailable_description">Were fixing a technical issue. Please try again later.</string>
<string name="tangempay_service_unavailable_title">Service temporarily unavailable</string>
<string name="tangempay_service_unreachable_try_later">Unable to display details. However, card payments are still working.</string>
<string name="tangempay_service_unreachable_try_later">The service is currently unreachable. Please try again later.</string>
<string name="tangempay_set_pin_code">Set \nPIN code</string>
<string name="tangempay_status_deactivated">Card deactivated</string>
<string name="tangempay_status_deactivated">Account closed</string>
<string name="tangempay_status_replacing">Replacing your card</string>
<string name="tangempay_sync_needed">Use your card or ring to renew session</string>
<string name="tangempay_sync_needed_body">Use your card or ring to renew session</string>
<string name="tangempay_sync_needed_button">Renew session</string>
<string name="tangempay_sync_needed_title">Payment account session expired</string>
<string name="tangempay_tangem_visa_card">Use USDC for everyday payments</string>
<string name="tangempay_temporarily_unavailable">Tangem Pay is temporarily unreachable</string>
<string name="tangempay_temporarily_unavailable">Tangem Pay is temporarily unavailable</string>
<string name="tangempay_title">Tangem Pay</string>
<string name="tangempay_topup_receive_body">Send USDC Polygon to your accounts address </string>
<string name="tangempay_topup_receive_title">From another wallet or exchange</string>

View file

@ -1,7 +1,7 @@
package com.tangem.core.ui.components.provider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.tangem.core.ui.R
import com.tangem.domain.express.models.ProviderFilterType
@ -20,31 +20,31 @@ fun ProviderTypeFilterPicker(
onFilterSelect: (ProviderFilterType) -> Unit,
modifier: Modifier = Modifier,
) {
val segments = availableFilters.map { filter ->
TangemSegmentUM(
id = filter.name,
title = when (filter) {
ProviderFilterType.ALL -> resourceReference(R.string.common_all)
ProviderFilterType.CEX -> TextReference.Str("CEX")
ProviderFilterType.DEX -> TextReference.Str("DEX")
},
)
}.toImmutableList()
val selectedSegment = segments.firstOrNull { it.id == selectedFilter.name }
TangemThemeRedesign {
// key() forces recomposition when selectedFilter changes to re-seed initialSelectedItem,
// because TangemSegmentedPicker owns its selection state internally via remember.
key(selectedFilter) {
TangemSegmentedPicker(
items = segments,
initialSelectedItem = selectedSegment,
isFixed = true,
modifier = modifier,
onClick = { segment ->
val filterType = availableFilters.firstOrNull { it.name == segment.id }
if (filterType != null) onFilterSelect(filterType)
val segments = remember(availableFilters) {
availableFilters.map { filter ->
TangemSegmentUM(
id = filter.name,
title = when (filter) {
ProviderFilterType.ALL -> resourceReference(R.string.common_all)
ProviderFilterType.CEX -> TextReference.Str("CEX")
ProviderFilterType.DEX -> TextReference.Str("DEX")
},
)
}
}.toImmutableList()
}
val selectedSegment = remember(segments, selectedFilter) {
segments.firstOrNull { it.id == selectedFilter.name }
}
TangemThemeRedesign {
TangemSegmentedPicker(
items = segments,
initialSelectedItem = selectedSegment,
isFixed = true,
modifier = modifier,
onClick = { segment ->
val filterType = availableFilters.firstOrNull { it.name == segment.id }
if (filterType != null) onFilterSelect(filterType)
},
)
}
}

View file

@ -1,21 +1,14 @@
package com.tangem.core.ui.ds2.surface
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.RippleAlpha
import androidx.compose.material3.LocalRippleConfiguration
import androidx.compose.material3.RippleConfiguration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
@ -94,6 +87,7 @@ fun TangemSurface(
onClick = onClick!!,
)
},
contentAlignment = Alignment.Center,
) {
content()
}

View file

@ -464,6 +464,10 @@ val LocalRedesignEnabled = staticCompositionLocalOf<Boolean> {
false
}
val LocalVisaRedesignEnabled = staticCompositionLocalOf<Boolean> {
false
}
val LocalPowerSavingState = compositionLocalOf<PowerSavingState> {
error("No PowerSavingState provided")
}

View file

@ -1,27 +0,0 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M15.75,9.25C16.164,9.25 16.5,9.586 16.5,10C16.5,13.59 13.59,16.5 10,16.5C7.986,16.5 6.19,15.581 5,14.144V14.791C5,15.205 4.664,15.541 4.25,15.541C3.836,15.541 3.5,15.205 3.5,14.791V12.396C3.5,11.981 3.836,11.646 4.25,11.646H6.646C7.06,11.646 7.395,11.981 7.396,12.396C7.396,12.81 7.06,13.146 6.646,13.146H6.123C7.04,14.276 8.434,15 10,15C12.762,15 15,12.762 15,10C15,9.586 15.336,9.25 15.75,9.25Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M10,3.5C12.014,3.5 13.81,4.419 15,5.855V5.208C15,4.794 15.336,4.458 15.75,4.458C16.164,4.458 16.5,4.794 16.5,5.208V7.604C16.5,8.018 16.164,8.354 15.75,8.354H13.354C12.94,8.353 12.604,8.018 12.604,7.604C12.605,7.19 12.941,6.854 13.354,6.854H13.876C12.96,5.723 11.565,5 10,5C7.238,5 5,7.238 5,10C5,10.414 4.664,10.75 4.25,10.75C3.836,10.75 3.5,10.414 3.5,10C3.5,6.41 6.41,3.5 10,3.5Z"
android:fillColor="#ffffff"/>
</vector>

View file

@ -1,6 +1,7 @@
plugins {
alias(deps.plugins.kotlin.jvm)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.kotlin.serialization)
id("configuration")
}
@ -15,12 +16,13 @@ dependencies {
kapt(deps.hilt.kapt)
// endregion
// region Coroutines
implementation(deps.kotlin.coroutines)
// region Kotlin
api(deps.kotlin.coroutines)
api(deps.kotlin.serialization)
// endregion
// region Time dependencies
implementation(deps.jodatime)
api(deps.jodatime)
// endregion
testImplementation(deps.test.coroutine)

View file

@ -0,0 +1,22 @@
package com.tangem.utils
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
/**
* Extracts all string primitive values from a [JsonElement] tree (recursively into
* objects and arrays). Non-string primitives are ignored.
*/
object JsonStringValuesExtractor {
fun extract(json: JsonElement): List<String> = json.extractStringValues()
private fun JsonElement.extractStringValues(): List<String> = when (this) {
is JsonPrimitive -> if (isString) listOfNotNull(contentOrNull) else emptyList()
is JsonObject -> values.flatMap { it.extractStringValues() }
is JsonArray -> flatMap { it.extractStringValues() }
}
}

View file

@ -0,0 +1,171 @@
package com.tangem.utils
import com.google.common.truth.Truth
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class JsonStringValuesExtractorTest {
@Test
fun `extract returns single value for string primitive`() {
// Arrange
val json = JsonPrimitive("hello")
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).containsExactly("hello")
}
@Test
fun `extract returns empty for numeric primitive`() {
// Arrange
val json = JsonPrimitive(42)
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `extract returns empty for boolean primitive`() {
// Arrange
val json = JsonPrimitive(true)
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `extract returns empty for json null`() {
// Act
val actual = JsonStringValuesExtractor.extract(JsonNull)
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `extract returns all string values from flat object`() {
// Arrange
val json = Json.parseToJsonElement(
"""{"apiKey":"abc","secret":"xyz","count":42,"enabled":true}""",
)
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).containsExactly("abc", "xyz")
}
@Test
fun `extract returns all string values from flat array`() {
// Arrange
val json = Json.parseToJsonElement("""["one","two",3,true,null]""")
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).containsExactly("one", "two").inOrder()
}
@Test
fun `extract recurses into nested objects`() {
// Arrange
val json = Json.parseToJsonElement(
"""{"outer":{"inner":{"key":"deep"}},"top":"shallow"}""",
)
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).containsExactly("deep", "shallow")
}
@Test
fun `extract recurses into nested arrays`() {
// Arrange
val json = Json.parseToJsonElement("""[["a","b"],["c",["d"]]]""")
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).containsExactly("a", "b", "c", "d").inOrder()
}
@Test
fun `extract handles mixed nested objects and arrays`() {
// Arrange
val json = Json.parseToJsonElement(
"""{"keys":["k1","k2"],"nested":{"items":[{"name":"x"},{"name":"y"}]}}""",
)
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).containsExactly("k1", "k2", "x", "y")
}
@Test
fun `extract returns empty for empty object`() {
// Arrange
val json = Json.parseToJsonElement("""{}""")
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `extract returns empty for empty array`() {
// Arrange
val json = Json.parseToJsonElement("""[]""")
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `extract preserves duplicate values`() {
// Arrange — extractor does NOT dedupe; that's the caller's concern
val json = Json.parseToJsonElement("""{"a":"same","b":"same","c":"other"}""")
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).containsExactly("same", "same", "other")
}
@Test
fun `extract returns empty string when string primitive is empty`() {
// Arrange
val json = Json.parseToJsonElement("""{"a":"","b":"x"}""")
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert — extractor returns "" too; filtering is caller's job
Truth.assertThat(actual).containsExactly("", "x")
}
}

View file

@ -26,6 +26,7 @@ dependencies {
// region Project - Domain
implementation(projects.domain.account)
implementation(projects.domain.common)
implementation(projects.domain.dynamicAddresses)
implementation(projects.domain.dynamicAddresses.models)
implementation(projects.domain.models)

View file

@ -1,5 +1,7 @@
package com.tangem.data.dynamicaddresses
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.DynamicAddressesSupportedBlockchains
import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase
@ -7,6 +9,7 @@ import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.firstOrNull
import javax.inject.Inject
@ -21,11 +24,22 @@ class DynamicAddressesInitializer @Inject constructor(
private val dynamicAddressesRepository: DynamicAddressesRepository,
private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
private val getDerivedXpubUseCase: GetDerivedXpubUseCase,
private val userWalletsListRepository: UserWalletsListRepository,
) {
suspend fun getXpubs(userWalletId: UserWalletId, networks: Set<Network>): Map<Network, String> {
if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return emptyMap()
/*
* Dynamic addresses rely on the server-side wallet accounts list, which is populated only for
* multi-currency wallets. Single-currency wallets (Note, s2c, etc.) never populate it, so
* DynamicAddressesRepository.getStatus() backed by WalletAccountsFetcher.get() would never
* emit and firstOrNull() below would suspend forever, hanging the whole balance fetch and leaving
* the currency stuck in Loading. Skip such wallets entirely. ([REDACTED_TASK_KEY])
*/
val userWallet = userWalletsListRepository.getSyncOrNull(userWalletId)
if (userWallet == null || !userWallet.isMultiCurrency) return emptyMap()
val result = mutableMapOf<Network, String>()
for (network in networks) {
if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(network.rawId)) continue

View file

@ -1,55 +0,0 @@
package com.tangem.data.pay
import arrow.core.Either
import arrow.core.Either.Companion.catch
import com.tangem.blockchain.blockchains.ethereum.Chain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.core.error.UniversalError
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.pay.entity.TangemPayCurrencyFactory
import com.tangem.data.pay.util.TangemPayErrorConverter
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
private const val TAG = "TangemPay: DefaultTangemPayCryptoCurrencyFactory"
@Deprecated("Use TangemPayCurrencyFactory instead")
internal class DefaultTangemPayCryptoCurrencyFactory @Inject constructor(
excludedBlockchains: ExcludedBlockchains,
private val errorConverter: TangemPayErrorConverter,
) : TangemPayCryptoCurrencyFactory {
private val cryptoCurrencyFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
CryptoCurrencyFactory(excludedBlockchains)
}
private val networkFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
NetworkFactory(excludedBlockchains)
}
override fun create(userWallet: UserWallet, chainId: Int): Either<UniversalError, CryptoCurrency> {
return catch {
val chain = requireNotNull(Chain.entries.find { it.id == chainId }) { "Can not find chain with $chainId" }
val blockchain = requireNotNull(chain.blockchain)
val network = networkFactory.create(
blockchain = blockchain,
extraDerivationPath = null,
userWallet = userWallet,
)
cryptoCurrencyFactory.createToken(
network = requireNotNull(network),
rawId = CryptoCurrency.RawID(TangemPayCurrencyFactory.TOKEN_ID),
name = TangemPayCurrencyFactory.TOKEN_NAME,
symbol = TangemPayCurrencyFactory.TOKEN_NAME,
contractAddress = TangemPayCurrencyFactory.TOKEN_CONTRACT_ADDRESS,
decimals = TangemPayCurrencyFactory.TOKEN_DECIMALS,
)
}.mapLeft { exception ->
TangemLogger.withTag(TAG).e("Error", exception)
errorConverter.convert(exception)
}
}
}

View file

@ -1,7 +1,6 @@
package com.tangem.data.pay.converter
import arrow.core.getOrElse
import com.tangem.data.pay.entity.TangemPayCurrencyFactory
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.CardDisplayName
@ -11,7 +10,9 @@ import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardLimit
import com.tangem.domain.models.pay.TangemPayCardLimitData
import com.tangem.domain.models.pay.TangemPayCardLimitPeriod
import com.tangem.domain.models.pay.TangemPayCardState
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
import javax.inject.Inject
import javax.inject.Singleton
@ -43,6 +44,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
fiatBalance = value.fiatBalance.toDM(),
cryptoBalance = value.cryptoBalance.toDM(),
availableForWithdrawal = value.availableForWithdrawal,
fiatRate = value.fiatRate,
cards = value.cards.map { card ->
PaymentAccountStatusValueDM.TangemPayCard(
id = card.id,
@ -52,7 +54,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
adminDailyLimit = card.limit?.adminCardLimit?.amount,
frozenState = card.frozenState.toString(),
lastDigits = card.lastDigits,
isReissuing = card.isReissuing,
state = card.state.toString(),
)
},
)
@ -61,6 +63,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
)
is PaymentAccountStatusValue.Empty -> PaymentAccountStatusValueDM.Empty()
is PaymentAccountStatusValue.Deactivated -> PaymentAccountStatusValueDM.DeactivatedAccount(
fiatRate = value.fiatRate,
fiatBalance = value.fiatBalance.toDM(),
cryptoBalance = value.cryptoBalance.toDM(),
)
@ -93,6 +96,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
cryptoBalance = value.cryptoBalance.toDomain(),
availableForWithdrawal = value.availableForWithdrawal,
cryptoCurrency = cryptoCurrency,
fiatRate = value.fiatRate,
cards = value.cards.map { card ->
TangemPayCard(
id = card.id,
@ -108,7 +112,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
),
frozenState = TangemPayCardFrozenState.fromString(card.frozenState),
lastDigits = card.lastDigits,
isReissuing = card.isReissuing,
state = TangemPayCardState.fromString(card.state),
)
},
)
@ -122,6 +126,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
cryptoCurrency = cryptoCurrency,
fiatRate = value.fiatRate,
)
null -> PaymentAccountStatusValue.Error.Unavailable
}

View file

@ -5,9 +5,9 @@ import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.tangem.data.pay.DefaultTangemPayCryptoCurrencyFactory
import com.tangem.data.pay.DefaultTangemPayEligibilityManager
import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter
import com.tangem.data.pay.entity.DefaultTangemPayCurrencyFactory
import com.tangem.data.pay.flow.DefaultPaymentAccountStatusFetcher
import com.tangem.data.pay.flow.DefaultPaymentAccountStatusProducer
import com.tangem.data.pay.repository.*
@ -21,19 +21,13 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory
import com.tangem.domain.pay.TangemPayCurrencyFactory
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.flow.PaymentAccountStatusProducer
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.repository.*
import com.tangem.domain.pay.usecase.ChangeCardFrozenStateUseCase
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.domain.pay.usecase.ReissueTangemPayCardUseCase
import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase
import com.tangem.domain.pay.usecase.StartTangemPayOrderPollingUseCase
import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase
import com.tangem.domain.pay.usecase.*
import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
@ -75,9 +69,11 @@ internal interface TangemPayDataModule {
@Binds
@Singleton
fun bindTangemPayCryptoCurrencyFactory(
factory: DefaultTangemPayCryptoCurrencyFactory,
): TangemPayCryptoCurrencyFactory
fun bindCloseCardRepository(repository: DefaultCloseCardRepository): TangemPayCloseCardRepository
@Binds
@Singleton
fun bindTangemPayCryptoCurrencyFactory(factory: DefaultTangemPayCurrencyFactory): TangemPayCurrencyFactory
@Binds
@Singleton
@ -220,5 +216,20 @@ internal interface TangemPayDataModule {
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
)
}
@Provides
fun provideCloseTangemPayCardUseCase(
closeCardRepository: TangemPayCloseCardRepository,
startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
appCoroutineScope: AppCoroutineScope,
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
): CloseTangemPayCardUseCase {
return CloseTangemPayCardUseCase(
closeCardRepository = closeCardRepository,
startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase,
appCoroutineScope = appCoroutineScope,
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
)
}
}
}

View file

@ -8,20 +8,21 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.requireUserWalletsSync
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class TangemPayCurrencyFactory @Inject constructor(
internal class DefaultTangemPayCurrencyFactory @Inject constructor(
excludedBlockchains: ExcludedBlockchains,
private val userWalletsListRepository: UserWalletsListRepository,
private val networkFactory: NetworkFactory,
) {
) : TangemPayCurrencyFactory {
private val cryptoCurrencyFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
CryptoCurrencyFactory(excludedBlockchains)
}
fun create(userWalletId: UserWalletId): CryptoCurrency.Token {
override fun create(userWalletId: UserWalletId): CryptoCurrency.Token {
val userWallet = userWalletsListRepository.requireUserWalletsSync()
.firstOrNull { it.walletId == userWalletId }
?: error("User wallet with id $userWalletId not found")
@ -32,18 +33,11 @@ internal class TangemPayCurrencyFactory @Inject constructor(
)
return cryptoCurrencyFactory.createToken(
network = requireNotNull(network),
rawId = CryptoCurrency.RawID(TOKEN_ID),
name = TOKEN_NAME,
symbol = TOKEN_NAME,
contractAddress = TOKEN_CONTRACT_ADDRESS,
decimals = TOKEN_DECIMALS,
rawId = TangemPayCurrencyFactory.TOKEN_ID,
name = TangemPayCurrencyFactory.TOKEN_NAME,
symbol = TangemPayCurrencyFactory.TOKEN_NAME,
contractAddress = TangemPayCurrencyFactory.TOKEN_CONTRACT_ADDRESS,
decimals = TangemPayCurrencyFactory.TOKEN_DECIMALS,
)
}
companion object {
internal const val TOKEN_ID = "usd-coin"
internal const val TOKEN_NAME = "USDC"
internal const val TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"
internal const val TOKEN_DECIMALS = 6
}
}

View file

@ -1,7 +1,6 @@
package com.tangem.data.pay.flow
import arrow.core.Either
import com.tangem.data.pay.entity.TangemPayCurrencyFactory
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.models.StatusSource
@ -11,7 +10,9 @@ import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardLimitData
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.model.CustomerInfo
@ -21,9 +22,14 @@ import com.tangem.domain.pay.model.TangemPayEntryPoint
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.repository.TangemPayReissueCardRepository
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardState
import com.tangem.domain.pay.model.isFinalStatus
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.pay.repository.TangemPayCloseCardRepository
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.security.isSecurityExposed
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -31,12 +37,13 @@ import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import java.math.BigDecimal
import javax.inject.Inject
import kotlin.time.Duration.Companion.minutes
private const val TAG = "PaymentAccountStatusFetcher"
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private val paymentAccountStatusesStore: PaymentAccountStatusesStore,
private val onboardingRepository: OnboardingRepository,
@ -46,6 +53,8 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private val tangemPayCurrencyFactory: TangemPayCurrencyFactory,
private val eligibilityManager: TangemPayEligibilityManager,
private val reissueCardRepository: TangemPayReissueCardRepository,
private val singleQuoteSupplier: SingleQuoteStatusSupplier,
private val closeCardRepository: TangemPayCloseCardRepository,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
) : PaymentAccountStatusFetcher {
@ -259,6 +268,9 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
}
private suspend fun CustomerInfo.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue {
val quotesData = singleQuoteSupplier.getSyncOrNull(
params = SingleQuoteStatusProducer.Params(rawCurrencyId = TangemPayCurrencyFactory.TOKEN_ID),
)?.value as? QuoteStatus.Data
val cardInfo = this.cardInfo
val productInstance = this.productInstance
@ -281,12 +293,14 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
fiatRate = quotesData?.fiatRate,
)
}
cardInfo != null && productInstance != null && !customerId.isNullOrEmpty() -> convertToContentState(
userWalletId = userWalletId,
productInstance = productInstance,
cardInfo = cardInfo,
fiatRate = quotesData?.fiatRate,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
)
else -> PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
@ -298,17 +312,11 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
productInstance: CustomerInfo.ProductInstance,
cardInfo: CustomerInfo.CardInfo,
customerId: String,
fiatRate: BigDecimal?,
): PaymentAccountStatusValue {
val reissueOrder = reissueCardRepository.getReissueOrderInfo(
userWalletId = userWalletId,
cardId = productInstance.cardId,
).getOrNull()
val isReissuing = reissueOrder != null &&
reissueOrder.orderStatus != OrderStatus.CANCELED &&
reissueOrder.orderStatus != OrderStatus.COMPLETED
val cardFrozenState = cardDetailsRepository.cardFrozenStateSync(productInstance.cardId)
val cardId = productInstance.cardId
val cardState = getCardState(cardId, userWalletId)
val cardFrozenState = cardDetailsRepository.cardFrozenStateSync(cardId)
val cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId)
return PaymentAccountStatusValue.Loaded(
source = StatusSource.ACTUAL,
@ -319,9 +327,10 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
cryptoBalance = cardInfo.cryptoBalance,
availableForWithdrawal = cardInfo.availableForWithdrawal,
cryptoCurrency = cryptoCurrency,
fiatRate = fiatRate,
cards = listOf(
TangemPayCard(
id = productInstance.cardId,
id = cardId,
hasPinCode = cardInfo.isPinSet,
displayName = productInstance.displayName,
limit = TangemPayCardLimitData(
@ -334,12 +343,35 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
productInstance.frozenState
},
lastDigits = cardInfo.lastFourDigits,
isReissuing = isReissuing,
state = cardState,
),
),
)
}
private suspend fun getCardState(cardId: String, userWalletId: UserWalletId): TangemPayCardState {
val closingOrderId = closeCardRepository.getCloseOrderId(userWalletId, cardId).getOrNull()
val reissueOrderId = reissueCardRepository.getReissueOrderId(userWalletId, cardId).getOrNull()
return if (closingOrderId != null) {
val order = cardDetailsRepository.getOrderInfo(userWalletId, closingOrderId).getOrNull()
if (order != null && order.orderStatus.isFinalStatus) {
closeCardRepository.setCloseOrderId(cardId, null)
TangemPayCardState.Active
} else {
TangemPayCardState.Closing
}
} else if (reissueOrderId != null) {
val order = cardDetailsRepository.getOrderInfo(userWalletId, reissueOrderId).getOrNull()
if (order != null && order.orderStatus.isFinalStatus) {
TangemPayCardState.Active
} else {
TangemPayCardState.Reissuing
}
} else {
TangemPayCardState.Active
}
}
private suspend fun VisaApiError.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue {
return when (this) {
is VisaApiError.RefreshTokenExpired -> PaymentAccountStatusValue.Error.NotSynced

View file

@ -0,0 +1,52 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.right
import com.tangem.core.error.UniversalError
import com.tangem.data.pay.util.OrderStatusConverter
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.CloseCardRequest
import com.tangem.datasource.local.visa.TangemPayCloseCardStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.TangemPayCloseCardRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.coroutines.runSuspendCatching
import javax.inject.Inject
internal class DefaultCloseCardRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
private val tangemPayCloseCardStore: TangemPayCloseCardStore,
) : TangemPayCloseCardRepository {
override suspend fun closeCard(
userWalletId: UserWalletId,
cardId: String,
): Either<VisaApiError, TangemPayOrderInfo> = either {
val response = requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.closeCard(
authHeader = authHeader,
body = CloseCardRequest(cardId = cardId),
)
}.bind()
TangemPayOrderInfo(
orderId = response.result.orderId,
orderStatus = OrderStatusConverter.convert(response.result.status),
)
}
override suspend fun setCloseOrderId(cardId: String, orderId: String?): Either<UniversalError, Unit> =
runSuspendCatching {
tangemPayCloseCardStore.setCloseOrderId(cardId, orderId)
}.fold(
onSuccess = { Unit.right() },
onFailure = { Either.Left(VisaApiError.Unspecified) },
)
override suspend fun getCloseOrderId(userWalletId: UserWalletId, cardId: String): Either<UniversalError, String?> =
either {
runSuspendCatching { tangemPayCloseCardStore.getOrderId(cardId) }.getOrNull()
}
}

View file

@ -11,7 +11,6 @@ import com.tangem.datasource.local.visa.TangemPayReissueCardStore
import com.tangem.domain.models.pay.TangemPayReissueCardFee
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.pay.repository.TangemPayReissueCardRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.coroutines.runSuspendCatching
@ -21,7 +20,6 @@ internal class DefaultReissueCardRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
private val tangemPayReissueCardStore: TangemPayReissueCardStore,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
) : TangemPayReissueCardRepository {
override suspend fun getReissueCardFee(userWalletId: UserWalletId): Either<VisaApiError, TangemPayReissueCardFee> =
@ -74,17 +72,11 @@ internal class DefaultReissueCardRepository @Inject constructor(
onFailure = { Either.Left(VisaApiError.Unspecified) },
)
override suspend fun getReissueOrderInfo(
override suspend fun getReissueOrderId(
userWalletId: UserWalletId,
cardId: String,
): Either<UniversalError, TangemPayOrderInfo?> = either {
val orderId = runSuspendCatching { tangemPayReissueCardStore.getOrderId(cardId) }.getOrNull()
if (orderId == null) {
return null.right()
}
cardDetailsRepository.getOrderInfo(userWalletId, orderId).bind()
): Either<UniversalError, String?> = either {
runSuspendCatching { tangemPayReissueCardStore.getOrderId(cardId) }.getOrNull()
}
private companion object {

View file

@ -1,12 +1,12 @@
package com.tangem.data.pay.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.data.pay.entity.TangemPayCurrencyFactory
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Nested
@ -70,6 +70,7 @@ internal class PaymentAccountStatusValueDMConverterTest {
),
cryptoBalance = cryptoBalance(),
cryptoCurrency = cryptoCurrency,
fiatRate = BigDecimal("1.05"),
)
// WHEN
@ -80,6 +81,7 @@ internal class PaymentAccountStatusValueDMConverterTest {
val dm = result as PaymentAccountStatusValueDM.DeactivatedAccount
assertThat(dm.fiatBalance.availableBalance).isEqualTo(BigDecimal("100"))
assertThat(dm.fiatBalance.currency).isEqualTo("USD")
assertThat(dm.fiatRate).isEqualTo(BigDecimal("1.05"))
}
@Test
@ -144,6 +146,7 @@ internal class PaymentAccountStatusValueDMConverterTest {
currency = "EUR",
),
cryptoBalance = cryptoBalanceDM(),
fiatRate = BigDecimal("0.92"),
)
// WHEN
@ -155,6 +158,7 @@ internal class PaymentAccountStatusValueDMConverterTest {
assertThat(deactivated.source).isEqualTo(StatusSource.CACHE)
assertThat(deactivated.fiatBalance.availableBalance).isEqualTo(BigDecimal("200"))
assertThat(deactivated.fiatBalance.currency).isEqualTo("EUR")
assertThat(deactivated.fiatRate).isEqualTo(BigDecimal("0.92"))
}
@Test

View file

@ -16,43 +16,26 @@ internal object YieldBoostStatusConverter {
private const val REASON_CLOSED = "closed"
fun convert(dto: YieldBoostStatusResponse): YieldBoostStatus = when (dto.promoEnrollmentStatus.lowercase()) {
STATUS_ACTIVE -> dto.toActive() ?: YieldBoostStatus.NotStarted
STATUS_COMPLETED -> dto.toCompleted() ?: YieldBoostStatus.NotStarted
STATUS_ACTIVE, STATUS_COMPLETED -> dto.toEnrolled()
STATUS_DISQUALIFIED -> YieldBoostStatus.Disqualified(reason = dto.disqualificationReason.toReason())
STATUS_NOT_STARTED -> YieldBoostStatus.NotStarted
else -> YieldBoostStatus.NotStarted // forward-compat: unknown status → treat as NotStarted
}
/** Backend `"active"` → [YieldBoostStatus.Active]. Returns `null` if mandatory dates can't be parsed. */
private fun YieldBoostStatusResponse.toActive(): YieldBoostStatus.Active? {
val activation = activationDate?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return null
val qualificationEnd =
qualificationEndDate?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return null
return YieldBoostStatus.Active(
tokenName = tokenName.orEmpty(),
networkId = networkId.orEmpty(),
moduleAddress = moduleAddress.orEmpty(),
userAddress = userAddress.orEmpty(),
contractAddress = contractAddress.orEmpty(),
activationDate = activation,
qualificationEndDate = qualificationEnd,
)
}
private fun YieldBoostStatusResponse.toCompleted(): YieldBoostStatus.Completed? {
val activation = activationDate?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return null
val qualificationEnd =
qualificationEndDate?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return null
return YieldBoostStatus.Completed(
tokenName = tokenName.orEmpty(),
networkId = networkId.orEmpty(),
moduleAddress = moduleAddress.orEmpty(),
userAddress = userAddress.orEmpty(),
contractAddress = contractAddress.orEmpty(),
activationDate = activation,
qualificationEndDate = qualificationEnd,
)
}
/**
* Backend `"active"` / `"completed"` [YieldBoostStatus.Enrolled].
*
* An unparseable / missing `qualificationEndDate` is kept as `null` (block hidden) never downgraded to
* [YieldBoostStatus.NotStarted], which would re-prompt an already-enrolled user to join.
*/
private fun YieldBoostStatusResponse.toEnrolled(): YieldBoostStatus.Enrolled = YieldBoostStatus.Enrolled(
tokenName = tokenName.orEmpty(),
networkId = networkId.orEmpty(),
moduleAddress = moduleAddress.orEmpty(),
userAddress = userAddress.orEmpty(),
contractAddress = contractAddress.orEmpty(),
qualificationEndDate = qualificationEndDate?.let { runCatching { Instant.parse(it) }.getOrNull() },
)
private fun String?.toReason(): YieldBoostStatus.Disqualified.Reason = when (this?.lowercase()) {
REASON_FROD -> YieldBoostStatus.Disqualified.Reason.FROD

View file

@ -3,11 +3,11 @@ package com.tangem.data.yield.supply.promo.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse
import com.tangem.domain.yield.supply.models.YieldBoostStatus
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Test
class YieldBoostStatusConverterTest {
private val activation = "2026-05-01T00:00:00Z"
private val qualificationEnd = "2026-06-01T00:00:00Z"
@Test
@ -20,7 +20,7 @@ class YieldBoostStatusConverterTest {
}
@Test
fun `GIVEN active backend status with valid dates WHEN convert THEN returns Active`() {
fun `GIVEN active backend status with valid date WHEN convert THEN returns Enrolled`() {
val dto = dto(
promoEnrollmentStatus = "active",
tokenName = "USD Coin",
@ -28,47 +28,49 @@ class YieldBoostStatusConverterTest {
moduleAddress = "0xmodule",
userAddress = "0xuser",
contractAddress = "0xcontract",
activationDate = activation,
qualificationEndDate = qualificationEnd,
)
val result = YieldBoostStatusConverter.convert(dto)
assertThat(result).isInstanceOf(YieldBoostStatus.Active::class.java)
val active = result as YieldBoostStatus.Active
assertThat(active.tokenName).isEqualTo("USD Coin")
assertThat(active.networkId).isEqualTo("ethereum")
assertThat(active.contractAddress).isEqualTo("0xcontract")
assertThat(result).isInstanceOf(YieldBoostStatus.Enrolled::class.java)
val enrolled = result as YieldBoostStatus.Enrolled
assertThat(enrolled.tokenName).isEqualTo("USD Coin")
assertThat(enrolled.networkId).isEqualTo("ethereum")
assertThat(enrolled.contractAddress).isEqualTo("0xcontract")
assertThat(enrolled.qualificationEndDate).isEqualTo(Instant.parse(qualificationEnd))
}
@Test
fun `GIVEN active status missing activationDate WHEN convert THEN falls back to NotStarted`() {
fun `GIVEN active status missing qualificationEndDate WHEN convert THEN returns Enrolled with null date`() {
val dto = dto(
promoEnrollmentStatus = "active",
activationDate = null,
qualificationEndDate = qualificationEnd,
contractAddress = "0xcontract",
qualificationEndDate = null,
)
val result = YieldBoostStatusConverter.convert(dto)
assertThat(result).isEqualTo(YieldBoostStatus.NotStarted)
assertThat(result).isInstanceOf(YieldBoostStatus.Enrolled::class.java)
assertThat((result as YieldBoostStatus.Enrolled).qualificationEndDate).isNull()
}
@Test
fun `GIVEN active status with malformed activationDate WHEN convert THEN falls back to NotStarted`() {
fun `GIVEN active status with malformed qualificationEndDate WHEN convert THEN returns Enrolled with null date`() {
val dto = dto(
promoEnrollmentStatus = "active",
activationDate = "not-an-iso",
qualificationEndDate = qualificationEnd,
contractAddress = "0xcontract",
qualificationEndDate = "not-an-iso",
)
val result = YieldBoostStatusConverter.convert(dto)
assertThat(result).isEqualTo(YieldBoostStatus.NotStarted)
assertThat(result).isInstanceOf(YieldBoostStatus.Enrolled::class.java)
assertThat((result as YieldBoostStatus.Enrolled).qualificationEndDate).isNull()
}
@Test
fun `GIVEN completed status with valid dates WHEN convert THEN returns Completed`() {
fun `GIVEN completed status with valid date WHEN convert THEN returns Enrolled`() {
val dto = dto(
promoEnrollmentStatus = "completed",
tokenName = "USDT",
@ -76,13 +78,14 @@ class YieldBoostStatusConverterTest {
moduleAddress = "0xmodule",
userAddress = "0xuser",
contractAddress = "0xcontract",
activationDate = "2026-04-01T00:00:00Z",
qualificationEndDate = "2026-05-01T00:00:00Z",
)
val result = YieldBoostStatusConverter.convert(dto)
assertThat(result).isInstanceOf(YieldBoostStatus.Completed::class.java)
assertThat(result).isInstanceOf(YieldBoostStatus.Enrolled::class.java)
assertThat((result as YieldBoostStatus.Enrolled).qualificationEndDate)
.isEqualTo(Instant.parse("2026-05-01T00:00:00Z"))
}
@Test
@ -153,13 +156,12 @@ class YieldBoostStatusConverterTest {
moduleAddress = "0xmodule",
userAddress = "0xuser",
contractAddress = "0xcontract",
activationDate = activation,
qualificationEndDate = qualificationEnd,
)
val result = YieldBoostStatusConverter.convert(dto)
assertThat(result).isInstanceOf(YieldBoostStatus.Active::class.java)
assertThat(result).isInstanceOf(YieldBoostStatus.Enrolled::class.java)
}
private fun dto(
@ -169,7 +171,6 @@ class YieldBoostStatusConverterTest {
moduleAddress: String? = null,
userAddress: String? = null,
contractAddress: String? = null,
activationDate: String? = null,
qualificationEndDate: String? = null,
disqualificationReason: String? = null,
) = YieldBoostStatusResponse(
@ -179,7 +180,6 @@ class YieldBoostStatusConverterTest {
userAddress = userAddress,
contractAddress = contractAddress,
promoEnrollmentStatus = promoEnrollmentStatus,
activationDate = activationDate,
qualificationEndDate = qualificationEndDate,
disqualificationReason = disqualificationReason,
)

View file

@ -31,8 +31,14 @@ sealed class PaymentAccountStatusValue {
is UnderReview,
-> TotalFiatBalance.Loaded(amount = SerializedBigDecimal.ZERO, source = source)
is Loading -> TotalFiatBalance.Loading
is Loaded -> TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance, source = source)
is Deactivated -> TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance, source = source)
is Loaded -> {
val rate = this.fiatRate ?: return TotalFiatBalance.Failed
TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance.multiply(rate), source = source)
}
is Deactivated -> {
val rate = this.fiatRate ?: return TotalFiatBalance.Failed
TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance.multiply(rate), source = source)
}
}
/**
@ -99,6 +105,11 @@ sealed class PaymentAccountStatusValue {
*
* @property source The source of the status information.
* @property fiatBalance The fiat balance details.
* @property cryptoBalance The crypto balance details.
* @property cryptoCurrency The crypto currency held by the deactivated account.
* @property fiatRate Exchange rate of [cryptoCurrency] to the account's fiat currency,
* or `null` if the quote is not yet available. When `null`,
* [totalFiatBalance] resolves to [TotalFiatBalance.Failed].
*/
@Serializable
data class Deactivated(
@ -106,25 +117,15 @@ sealed class PaymentAccountStatusValue {
val fiatBalance: FiatBalance,
val cryptoBalance: CryptoBalance,
val cryptoCurrency: CryptoCurrency.Token,
val fiatRate: SerializedBigDecimal?,
) : PaymentAccountStatusValue() {
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = cryptoCurrency,
value = CryptoCurrencyStatus.Loaded(
value = buildCryptoCurrencyStatusValue(
amount = cryptoBalance.balance,
fiatAmount = fiatBalance.availableBalance,
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ZERO,
networkAddress = NetworkAddress.Single(
defaultAddress = NetworkAddress.Address(
type = NetworkAddress.Address.Type.Primary,
value = cryptoBalance.depositAddress,
),
),
sources = CryptoCurrencyStatus.Sources(),
pendingTransactions = emptySet(),
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
fiatRate = fiatRate,
depositAddress = cryptoBalance.depositAddress,
),
)
}
@ -139,7 +140,11 @@ sealed class PaymentAccountStatusValue {
* @property fiatBalance The fiat balance details.
* @property cryptoBalance The crypto balance details.
* @property availableForWithdrawal The crypto amount currently available for withdrawal/swap (excludes pending/locked funds).
* @property cryptoCurrency The crypto currency held by the account.
* @property cards The list of user's cards.
* @property fiatRate Exchange rate of [cryptoCurrency] to the account's fiat currency,
* or `null` if the quote is not yet available. When `null`,
* [totalFiatBalance] resolves to [TotalFiatBalance.Failed].
*/
@Serializable
data class Loaded(
@ -152,25 +157,15 @@ sealed class PaymentAccountStatusValue {
val availableForWithdrawal: SerializedBigDecimal,
val cryptoCurrency: CryptoCurrency.Token,
val cards: List<TangemPayCard>,
val fiatRate: SerializedBigDecimal?,
) : PaymentAccountStatusValue() {
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = cryptoCurrency,
value = CryptoCurrencyStatus.Loaded(
value = buildCryptoCurrencyStatusValue(
amount = availableForWithdrawal,
fiatAmount = fiatBalance.availableBalance,
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ZERO,
networkAddress = NetworkAddress.Single(
defaultAddress = NetworkAddress.Address(
type = NetworkAddress.Address.Type.Primary,
value = cryptoBalance.depositAddress,
),
),
sources = CryptoCurrencyStatus.Sources(),
pendingTransactions = emptySet(),
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
fiatRate = fiatRate,
depositAddress = cryptoBalance.depositAddress,
),
)
}
@ -235,6 +230,44 @@ sealed class PaymentAccountStatusValue {
)
}
private fun buildCryptoCurrencyStatusValue(
amount: SerializedBigDecimal,
fiatAmount: SerializedBigDecimal,
fiatRate: SerializedBigDecimal?,
depositAddress: String,
): CryptoCurrencyStatus.Value {
val networkAddress = NetworkAddress.Single(
defaultAddress = NetworkAddress.Address(
type = NetworkAddress.Address.Type.Primary,
value = depositAddress,
),
)
return if (fiatRate != null) {
CryptoCurrencyStatus.Loaded(
amount = amount,
fiatAmount = fiatAmount,
fiatRate = fiatRate,
priceChange = BigDecimal.ZERO,
networkAddress = networkAddress,
sources = CryptoCurrencyStatus.Sources(),
pendingTransactions = emptySet(),
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
)
} else {
CryptoCurrencyStatus.NoQuote(
amount = amount,
networkAddress = networkAddress,
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
sources = CryptoCurrencyStatus.Sources(),
)
}
}
fun Loaded.hasCardWithId(cardId: String): Boolean = cards.any { it.id == cardId }
fun Loaded.findCardWithId(cardId: String): TangemPayCard? = cards.firstOrNull { it.id == cardId }

View file

@ -13,6 +13,7 @@ import kotlinx.serialization.Serializable
* @property limit spending limit configuration for the card; `null` if not configured or not yet loaded.
* @property frozenState whether the card is currently frozen (blocked for payments).
* @property lastDigits The last four digits of the card number.
* @property state current lifecycle state of the card.
*/
@Serializable
data class TangemPayCard(
@ -22,7 +23,7 @@ data class TangemPayCard(
@SerialName("limit") val limit: TangemPayCardLimitData?,
@SerialName("frozen_state") val frozenState: TangemPayCardFrozenState,
@SerialName("last_digits") val lastDigits: String,
@SerialName("is_reissuing") val isReissuing: Boolean,
@SerialName("state") val state: TangemPayCardState,
)
val TangemPayCard.isFrozen

View file

@ -0,0 +1,38 @@
package com.tangem.domain.models.pay
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.util.Locale
/**
* Lifecycle state of a Tangem Pay card.
*/
@Serializable
enum class TangemPayCardState {
/** Card is operational and ready to use. */
@SerialName("Active")
Active,
/** A reissue order is in progress; the card is being replaced. */
@SerialName("Reissuing")
Reissuing,
/** A close order is in progress; the card is being closed. */
@SerialName("Closing")
Closing,
;
override fun toString() = when (this) {
Active -> "Active"
Reissuing -> "Reissuing"
Closing -> "Closing"
}
companion object {
fun fromString(value: String) = when (value.lowercase(Locale.US)) {
"reissuing" -> Reissuing
"closing" -> Closing
else -> Active
}
}
}

View file

@ -51,7 +51,9 @@ class BalanceFetchingOperations(
async {
val result = when (source) {
FetchingSource.NETWORK -> fetchNetworks(userWalletId, currencies)
FetchingSource.QUOTE -> fetchQuotes(currencies)
FetchingSource.QUOTE -> fetchQuotes(
currencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId },
)
FetchingSource.STAKING -> fetchStaking(userWalletId, currencies)
}
source to result
@ -85,17 +87,14 @@ class BalanceFetchingOperations(
}
/**
* Fetches quotes for the given currencies.
* Fetches quotes for the given raw currency ids.
*
* @param currencies the cryptocurrencies to fetch quotes for
* @param rawCurrencyIds the raw currency ids to fetch quotes for
* @return Either with Unit on success or Throwable on failure
*/
suspend fun fetchQuotes(currencies: Collection<CryptoCurrency>): Either<Throwable, Unit> {
suspend fun fetchQuotes(rawCurrencyIds: Set<CryptoCurrency.RawID>): Either<Throwable, Unit> {
return multiQuoteStatusFetcher(
params = MultiQuoteStatusFetcher.Params(
currenciesIds = currencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId },
appCurrencyId = null,
),
params = MultiQuoteStatusFetcher.Params(currenciesIds = rawCurrencyIds, appCurrencyId = null),
)
}

View file

@ -13,6 +13,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.pay.TangemPayCurrencyFactory
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.staking.StakingIdFactory
@ -173,6 +174,7 @@ class WalletBalanceFetcher internal constructor(
// Fetch TangemPay separately — may run long-polling, so it must not block balance error checking
if (fetchingSources.any { it is WalletFetchingSource.TangemPay }) {
balanceFetchingOperations.fetchQuotes(rawCurrencyIds = setOf(TangemPayCurrencyFactory.TOKEN_ID))
paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWalletId))
}
}

View file

@ -1,12 +0,0 @@
package com.tangem.domain.pay
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
@Deprecated("TangemPayCurrencyFactory")
interface TangemPayCryptoCurrencyFactory {
fun create(userWallet: UserWallet, chainId: Int): Either<UniversalError, CryptoCurrency>
}

View file

@ -0,0 +1,29 @@
package com.tangem.domain.pay
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
/**
* Factory that builds the [CryptoCurrency.Token] used by Tangem Pay (USDC on Polygon) for a given user wallet.
*
* Replaces the deprecated `TangemPayCryptoCurrencyFactory`: callers no longer pass the chain id explicitly
* the underlying network is resolved from the wallet.
*/
interface TangemPayCurrencyFactory {
/**
* Builds the Tangem Pay token bound to the network of the wallet identified by [userWalletId].
*
* @throws IllegalStateException if no wallet with [userWalletId] is currently loaded.
*/
fun create(userWalletId: UserWalletId): CryptoCurrency.Token
/** Hardcoded token metadata for the Tangem Pay currency (USDC on Polygon). */
companion object {
/** CoinGecko-style raw id used to query quotes for the Tangem Pay token. */
val TOKEN_ID = CryptoCurrency.RawID("usd-coin")
const val TOKEN_NAME = "USDC"
const val TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"
const val TOKEN_DECIMALS = 6
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.domain.pay.repository
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.visa.error.VisaApiError
interface TangemPayCloseCardRepository {
suspend fun closeCard(userWalletId: UserWalletId, cardId: String): Either<VisaApiError, TangemPayOrderInfo>
suspend fun setCloseOrderId(cardId: String, orderId: String?): Either<UniversalError, Unit>
suspend fun getCloseOrderId(userWalletId: UserWalletId, cardId: String): Either<UniversalError, String?>
}

View file

@ -15,8 +15,5 @@ interface TangemPayReissueCardRepository {
suspend fun storeReissueOrderId(cardId: String, orderId: String): Either<UniversalError, Unit>
suspend fun getReissueOrderInfo(
userWalletId: UserWalletId,
cardId: String,
): Either<UniversalError, TangemPayOrderInfo?>
suspend fun getReissueOrderId(userWalletId: UserWalletId, cardId: String): Either<UniversalError, String?>
}

View file

@ -0,0 +1,33 @@
package com.tangem.domain.pay.usecase
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.repository.TangemPayCloseCardRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.coroutines.AppCoroutineScope
import kotlinx.coroutines.launch
class CloseTangemPayCardUseCase(
private val closeCardRepository: TangemPayCloseCardRepository,
private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
private val appCoroutineScope: AppCoroutineScope,
) {
suspend operator fun invoke(userWalletId: UserWalletId, cardId: String): Either<VisaApiError, Unit> = either {
val order = closeCardRepository.closeCard(userWalletId, cardId).bind()
if (order.orderStatus == OrderStatus.CANCELED) {
raise(VisaApiError.Unspecified)
}
closeCardRepository.setCloseOrderId(cardId, order.orderId)
paymentAccountStatusFetcher.invoke(userWalletId)
appCoroutineScope.launch {
startTangemPayOrderPollingUseCase(order, userWalletId)
}
}
}

View file

@ -0,0 +1,104 @@
package com.tangem.domain.pay.usecase
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.TangemPayCloseCardRepository
import com.tangem.domain.visa.error.VisaApiError
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
internal class CloseTangemPayCardUseCaseTest {
private val closeCardRepository: TangemPayCloseCardRepository = mockk(relaxUnitFun = true)
private val startPollingUseCase: StartTangemPayOrderPollingUseCase = mockk()
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk()
@Test
fun `GIVEN closeCard fails WHEN invoke THEN returns Left and skips store, fetch and polling`() = runTest {
val useCase = createUseCase()
coEvery { closeCardRepository.closeCard(USER_WALLET_ID, CARD_ID) } returns VisaApiError.Unspecified.left()
val result = useCase(USER_WALLET_ID, CARD_ID)
assertThat(result.isLeft()).isTrue()
coVerify(exactly = 0) { closeCardRepository.setCloseOrderId(any(), any()) }
coVerify(exactly = 0) { paymentAccountStatusFetcher.invoke(any<UserWalletId>()) }
coVerify(exactly = 0) { startPollingUseCase(any(), any()) }
}
@Test
fun `GIVEN closeCard returns CANCELED order WHEN invoke THEN returns Left and skips store, fetch and polling`() =
runTest {
val useCase = createUseCase()
val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.CANCELED)
coEvery { closeCardRepository.closeCard(USER_WALLET_ID, CARD_ID) } returns order.right()
val result = useCase(USER_WALLET_ID, CARD_ID)
assertThat(result.isLeft()).isTrue()
coVerify(exactly = 0) { closeCardRepository.setCloseOrderId(any(), any()) }
coVerify(exactly = 0) { paymentAccountStatusFetcher.invoke(any<UserWalletId>()) }
coVerify(exactly = 0) { startPollingUseCase(any(), any()) }
}
@Test
fun `GIVEN closeCard returns PROCESSING order WHEN invoke THEN stores order id, fetches status and starts polling`() =
runTest {
val useCase = createUseCase()
val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING)
coEvery { closeCardRepository.closeCard(USER_WALLET_ID, CARD_ID) } returns order.right()
coEvery { closeCardRepository.setCloseOrderId(CARD_ID, ORDER_ID) } returns Unit.right()
coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right()
coEvery { startPollingUseCase(order, USER_WALLET_ID) } returns true
val result = useCase(USER_WALLET_ID, CARD_ID)
assertThat(result.isRight()).isTrue()
coVerifyOrder {
closeCardRepository.setCloseOrderId(CARD_ID, ORDER_ID)
paymentAccountStatusFetcher.invoke(USER_WALLET_ID)
startPollingUseCase(order, USER_WALLET_ID)
}
}
@Test
fun `GIVEN closeCard returns COMPLETED order WHEN invoke THEN stores order id, fetches status and starts polling`() =
runTest {
val useCase = createUseCase()
val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED)
coEvery { closeCardRepository.closeCard(USER_WALLET_ID, CARD_ID) } returns order.right()
coEvery { closeCardRepository.setCloseOrderId(CARD_ID, ORDER_ID) } returns Unit.right()
coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right()
coEvery { startPollingUseCase(order, USER_WALLET_ID) } returns true
val result = useCase(USER_WALLET_ID, CARD_ID)
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) { closeCardRepository.setCloseOrderId(CARD_ID, ORDER_ID) }
coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) }
coVerify(exactly = 1) { startPollingUseCase(order, USER_WALLET_ID) }
}
private fun createUseCase() = CloseTangemPayCardUseCase(
closeCardRepository = closeCardRepository,
startTangemPayOrderPollingUseCase = startPollingUseCase,
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
appCoroutineScope = TestAppCoroutineScope(),
)
private companion object {
val USER_WALLET_ID = UserWalletId("aabbcc112233")
const val CARD_ID = "card-test-id"
const val ORDER_ID = "order-test-1"
}
}

View file

@ -6,26 +6,22 @@ sealed interface YieldBoostStatus {
data object NotStarted : YieldBoostStatus
/** User entered boost, qualification period is still running. */
data class Active(
/**
* User is enrolled in the boost (backend `active` or `completed`).
*
* The boost block on the active screen is driven entirely by [qualificationEndDate], which the backend
* computes as the end of the bonus-accrual period:
* - `null` nothing is shown;
* - in the future days left until the date;
* - reached / passed awaiting payout.
*/
data class Enrolled(
val tokenName: String,
val networkId: String,
val moduleAddress: String,
val userAddress: String,
val contractAddress: String,
val activationDate: Instant,
val qualificationEndDate: Instant,
) : YieldBoostStatus
/** Boost has finished (backend `completed`). */
data class Completed(
val tokenName: String,
val networkId: String,
val moduleAddress: String,
val userAddress: String,
val contractAddress: String,
val activationDate: Instant,
val qualificationEndDate: Instant,
val qualificationEndDate: Instant?,
) : YieldBoostStatus
data class Disqualified(val reason: Reason) : YieldBoostStatus {

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 = 120.toBigInteger() // 20% increase
val INCREASE_GAS_LIMIT_FOR_SUPPLY = 140.toBigInteger() // 20% increase
fun Fee.fixFee(cryptoCurrency: CryptoCurrency, gasLimit: BigInteger): Fee = when (this) {
is Fee.Ethereum.Legacy -> copy(

View file

@ -316,9 +316,9 @@ class YieldSupplyEstimateEnterFeeUseCaseTest {
val deployFee = txs.first().fee as Fee.Ethereum.EIP1559
val approveFee = txs[1].fee as Fee.Ethereum.EIP1559
val enterFee = txs.last().fee as Fee.Ethereum.EIP1559
Truth.assertThat(deployFee.gasLimit).isEqualTo(BigInteger.valueOf(1_200))
Truth.assertThat(approveFee.gasLimit).isEqualTo(BigInteger.valueOf(2_400))
Truth.assertThat(enterFee.gasLimit).isEqualTo(BigInteger.valueOf(3_600))
Truth.assertThat(deployFee.gasLimit).isEqualTo(BigInteger.valueOf(1_400))
Truth.assertThat(approveFee.gasLimit).isEqualTo(BigInteger.valueOf(2_800))
Truth.assertThat(enterFee.gasLimit).isEqualTo(BigInteger.valueOf(4_200))
}
@Test
@ -355,9 +355,9 @@ class YieldSupplyEstimateEnterFeeUseCaseTest {
val deployFee = txs.first().fee as Fee.Ethereum.Legacy
val approveFee = txs[1].fee as Fee.Ethereum.Legacy
val enterFee = txs.last().fee as Fee.Ethereum.Legacy
Truth.assertThat(deployFee.gasLimit).isEqualTo(BigInteger.valueOf(1_200))
Truth.assertThat(approveFee.gasLimit).isEqualTo(BigInteger.valueOf(2_400))
Truth.assertThat(enterFee.gasLimit).isEqualTo(BigInteger.valueOf(3_600))
Truth.assertThat(deployFee.gasLimit).isEqualTo(BigInteger.valueOf(1_400))
Truth.assertThat(approveFee.gasLimit).isEqualTo(BigInteger.valueOf(2_800))
Truth.assertThat(enterFee.gasLimit).isEqualTo(BigInteger.valueOf(4_200))
}
private fun getDeployTx() = uncompiled(

View file

@ -91,21 +91,10 @@ class IsYieldBoostPromoEnabledForTokenUseCaseTest {
}
@Test
fun `GIVEN status is Active WHEN invoke THEN returns Right(false)`() = runTest {
fun `GIVEN status is Enrolled WHEN invoke THEN returns Right(false)`() = runTest {
val token = createToken()
coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo()
coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns activeStatus()
val result = useCase(userWalletId, token)
assertThat(result.getOrNull()).isFalse()
}
@Test
fun `GIVEN status is Completed WHEN invoke THEN returns Right(false)`() = runTest {
val token = createToken()
coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo()
coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns completedStatus()
coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns enrolledStatus()
val result = useCase(userWalletId, token)
@ -162,26 +151,15 @@ class IsYieldBoostPromoEnabledForTokenUseCaseTest {
link = null,
)
private fun activeStatus() = YieldBoostStatus.Active(
private fun enrolledStatus() = YieldBoostStatus.Enrolled(
tokenName = "USD Coin",
networkId = networkRawId,
moduleAddress = "0xmodule",
userAddress = "0xuser",
contractAddress = contractAddress,
activationDate = Instant.parse("2026-05-01T00:00:00Z"),
qualificationEndDate = Instant.parse("2026-06-01T00:00:00Z"),
)
private fun completedStatus() = YieldBoostStatus.Completed(
tokenName = "USD Coin",
networkId = networkRawId,
moduleAddress = "0xmodule",
userAddress = "0xuser",
contractAddress = contractAddress,
activationDate = Instant.parse("2026-04-01T00:00:00Z"),
qualificationEndDate = Instant.parse("2026-05-01T00:00:00Z"),
)
private fun createToken(
contractAddress: String = this.contractAddress,
networkRawId: String = this.networkRawId,

View file

@ -57,9 +57,9 @@ class ShouldShowYieldBoostMainBannerUseCaseTest {
}
@Test
fun `GIVEN status is Active WHEN invoke THEN returns Right(false)`() = runTest {
fun `GIVEN status is Enrolled WHEN invoke THEN returns Right(false)`() = runTest {
coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo()
coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns activeStatus()
coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns enrolledStatus()
val result = useCase(userWalletId)
@ -92,13 +92,12 @@ class ShouldShowYieldBoostMainBannerUseCaseTest {
link = null,
)
private fun activeStatus() = YieldBoostStatus.Active(
private fun enrolledStatus() = YieldBoostStatus.Enrolled(
tokenName = "USD Coin",
networkId = networkRawId,
moduleAddress = "0xmodule",
userAddress = "0xuser",
contractAddress = contractAddress,
activationDate = Instant.parse("2026-05-01T00:00:00Z"),
qualificationEndDate = Instant.parse("2026-06-01T00:00:00Z"),
)
}

View file

@ -25,11 +25,12 @@ interface SelectApprovalTypeComponent : ComposableBottomSheetComponent {
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val amountFooter: TextReference,
val initialApproveType: ApproveType = ApproveType.LIMITED,
val spenderAddress: String,
val callback: Callback,
)
interface Callback {
fun onApproveTypeSelected(approveType: ApproveType)
fun onApproveTypeSelected(spenderAddress: String, approveType: ApproveType)
fun onCancelClick()
}

View file

@ -43,7 +43,7 @@ internal class SelectApprovalTypeModel @Inject constructor(
}
fun onConfirmClick() {
params.callback.onApproveTypeSelected(uiState.value.approveType)
params.callback.onApproveTypeSelected(params.spenderAddress, uiState.value.approveType)
}
fun onCancelClick() {

View file

@ -1,14 +1,29 @@
package com.tangem.features.commonfeatures.api.addfunds
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
interface AddFundsComponent : ComposableContentComponent {
interface AddFundsComponent : ComposableBottomSheetComponent {
data class Params(
val userWalletId: UserWalletId,
val launchMode: LaunchMode,
val onDismiss: () -> Unit,
)
sealed interface LaunchMode {
data class ChooseToken(val userWalletId: UserWalletId) : LaunchMode
data class TokenActionsOnly(
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
) : LaunchMode
data class FilteredByRawId(
val rawCurrencyId: CryptoCurrency.RawID,
) : LaunchMode
}
interface Factory : ComponentFactory<Params, AddFundsComponent>
}

View file

@ -8,6 +8,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.AnalyticsParams
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher
import com.tangem.features.commonfeatures.api.tokenactions.BottomAction
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.SharedFlow
@ -94,7 +95,14 @@ interface AddToPortfolioManager : AddToPortfolioManagerInternal {
val wallet: UserWallet,
val account: AccountStatus.CryptoPortfolio,
val addedCurrency: CryptoCurrencyStatus,
val meta: FinishMeta = FinishMeta.None,
)
sealed interface FinishMeta {
data object None : FinishMeta
data object OnQuickAction : FinishMeta
data class OnBottomAction(val action: BottomAction) : FinishMeta
}
}
/**

View file

@ -29,6 +29,7 @@ interface ChooseTokenBridge : ChooseTokenBridgeInternal {
val title: TextReference,
val isShowMarketBlock: Boolean,
val isShowPaymentAccount: Boolean,
val isAppBarShown: Boolean = true,
) {
companion object {
val SwapFrom = Settings(
@ -45,6 +46,7 @@ interface ChooseTokenBridge : ChooseTokenBridgeInternal {
title = resourceReference(R.string.swapping_to_title),
isShowMarketBlock = true,
isShowPaymentAccount = false,
isAppBarShown = false,
)
}
}
@ -90,6 +92,11 @@ data class ChooseTokenResult(
val analyticsPayload: Set<ChooseTokenAnalyticsPayload> = emptySet(),
) {
val walletId get() = wallet.walletId
val wasJustAdded: Boolean
get() = analyticsPayload
.filterIsInstance<ChooseTokenAnalyticsPayload.IsMarketTokenSelected>()
.any { it.value }
}
sealed interface ChooseTokenAnalyticsPayload {

View file

@ -0,0 +1,3 @@
package com.tangem.features.commonfeatures.api.tokenactions
enum class BottomAction { GoToToken, None }

View file

@ -1,93 +1,232 @@
package com.tangem.features.commonfeatures.impl.addfunds
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.LocalTangemBottomSheetContentBottomInset
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemeRedesign
import com.tangem.features.commonfeatures.api.addfunds.AddFundsComponent
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addfunds.model.AddFundsModel
import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.addfunds.model.uiSpec
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.userportfolio.UserPortfolioComponent
import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import com.tangem.core.ui.R as CoreR
@Suppress("LongParameterList")
internal class DefaultAddFundsComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: AddFundsComponent.Params,
chooseTokenComponentFactory: ChooseTokenComponent.Factory,
tokenActionsComponentFactory: TokenActionsComponent.Factory,
userPortfolioComponentFactory: UserPortfolioComponent.Factory,
walletFeatureToggles: WalletFeatureToggles,
) : AppComponentContext by appComponentContext, AddFundsComponent {
private val model: AddFundsModel = getOrCreateModel(params)
private val chooseTokenComponent: ChooseTokenComponent = chooseTokenComponentFactory.create(
context = child(key = "addFundsChooseToken"),
params = ChooseTokenComponent.Params(bridge = model.chooseTokenBridge),
)
private val isCompactTokenActions: Boolean = params.launchMode is AddFundsComponent.LaunchMode.TokenActionsOnly
private val tokenActionsComponent: TokenActionsComponent = tokenActionsComponentFactory.create(
context = child(key = "addFundsTokenActions"),
params = TokenActionsComponent.Params(
data = model.tokenActionsData,
callbacks = model,
bottomAction = TokenActionsComponent.BottomAction.GoToToken,
isRedesignForced = true,
),
)
private val isAddFundsStage1Enabled: Boolean = walletFeatureToggles.isAddFundsStage1Enabled
private val tokenActionsComponent: TokenActionsComponent by lazy {
tokenActionsComponentFactory.create(
context = child(key = "addFundsTokenActions"),
params = TokenActionsComponent.Params(
data = model.tokenActionsData,
callbacks = model,
bottomAction = model.currentBottomAction,
isRedesignForced = true,
isCompact = isCompactTokenActions,
),
)
}
private val chooseTokenComponent: ChooseTokenComponent? by lazy {
(params.launchMode as? AddFundsComponent.LaunchMode.ChooseToken)?.let {
chooseTokenComponentFactory.create(
context = child(key = "addFundsChooseToken"),
params = ChooseTokenComponent.Params(
bridge = model.chooseTokenBridge,
),
)
}
}
private val userPortfolioComponent: UserPortfolioComponent by lazy {
userPortfolioComponentFactory.create(
context = child(key = "addFundsUserPortfolio"),
params = UserPortfolioComponent.Params(
uiState = model.userPortfolioStateController.uiState,
callbacks = object : UserPortfolioComponent.Callbacks {
override fun onContinueFromUserPortfolio() = Unit
},
),
)
}
override fun dismiss() = model.onDismiss()
@Composable
override fun Content(modifier: Modifier) {
chooseTokenComponent.Content(modifier)
val isTokenActionsShown by model.isTokenActionsShown.collectAsStateWithLifecycle()
if (isTokenActionsShown) {
// force use redesign theme here according to the task requirements, will be reworked in the next release
TangemThemeRedesign {
TangemModalBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = model::onTokenActionsDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
containerColor = TangemTheme.colors2.surface.level2,
scrollableContent = true,
title = {
TangemModalBottomSheetTitle(
modifier = Modifier.fillMaxWidth(),
title = resourceReference(R.string.common_get_token),
endIconRes = R.drawable.ic_close_24,
onEndClick = model::onTokenActionsDismiss,
)
},
content = { _ ->
Column(
modifier = Modifier.padding(
start = TangemTheme.dimens2.x4,
top = TangemTheme.dimens2.x2,
end = TangemTheme.dimens2.x4,
bottom = TangemTheme.dimens2.x4,
),
) {
tokenActionsComponent.Content(Modifier)
}
},
)
}
override fun BottomSheet() {
val route by model.uiRoute.collectAsStateWithLifecycle()
val canGoBack by model.canGoBack.collectAsStateWithLifecycle()
LaunchedEffect(route) {
if (route != AddFundsModel.UiRoute.UserPortfolio) return@LaunchedEffect
val mode = params.launchMode as? AddFundsComponent.LaunchMode.FilteredByRawId ?: return@LaunchedEffect
model.userPortfolioStateController.updateAndWaitNotNullState(
allAvailableData = model.buildAvailableToAddDataForChooser(),
rawCurrencyId = mode.rawCurrencyId,
)
}
WithOptionalRedesignTheme(isEnabled = isAddFundsStage1Enabled) {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
onBack = if (canGoBack) model::onBack else ::dismiss,
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
),
type = when (params.launchMode) {
is AddFundsComponent.LaunchMode.TokenActionsOnly -> TangemBottomSheetType.Modal
is AddFundsComponent.LaunchMode.ChooseToken -> TangemBottomSheetType.Default
is AddFundsComponent.LaunchMode.FilteredByRawId ->
if (route is AddFundsModel.UiRoute.TokenActions) {
TangemBottomSheetType.Default
} else {
TangemBottomSheetType.Modal
}
},
containerColor = TangemTheme.colors2.surface.level2,
title = {
AddFundsBottomSheetTitle(
route = route,
canGoBack = canGoBack,
onBackClick = model::onBack,
onCloseClick = ::dismiss,
)
},
content = {
val animatedContentModifier =
if (params.launchMode is AddFundsComponent.LaunchMode.ChooseToken) {
Modifier.fillMaxSize()
} else {
Modifier
}
AnimatedContent(
targetState = route,
modifier = animatedContentModifier,
label = "AddFundsContentAnimation",
) { animatedRoute ->
AddFundsRouteContent(
route = animatedRoute,
shouldFillHeight = !isCompactTokenActions && animatedRoute.uiSpec().shouldFillHeight,
)
}
},
)
}
}
@Composable
private fun AddFundsRouteContent(route: AddFundsModel.UiRoute, shouldFillHeight: Boolean) {
val spec = route.uiSpec()
val horizontalPadding = if (spec.shouldApplyHorizontalPadding) {
Modifier.padding(horizontal = TangemTheme.dimens2.x4)
} else {
Modifier
}
val sizeModifier = if (shouldFillHeight) Modifier.fillMaxSize() else Modifier.fillMaxWidth()
RenderRoute(route, horizontalPadding.then(sizeModifier))
}
@Composable
private fun RenderRoute(route: AddFundsModel.UiRoute, modifier: Modifier = Modifier) {
when (route) {
AddFundsModel.UiRoute.Loading -> Unit
AddFundsModel.UiRoute.ChooseToken -> chooseTokenComponent?.Content(modifier)
AddFundsModel.UiRoute.UserPortfolio -> CompositionLocalProvider(
LocalTangemBottomSheetContentBottomInset provides TangemTheme.dimens2.x4,
) {
userPortfolioComponent.Content(modifier)
}
AddFundsModel.UiRoute.TokenActions -> tokenActionsComponent.Content(modifier)
}
}
@Composable
private fun AddFundsBottomSheetTitle(
route: AddFundsModel.UiRoute,
canGoBack: Boolean,
onBackClick: () -> Unit,
onCloseClick: () -> Unit,
) {
TangemTopBar(
title = route.uiSpec().title,
type = TangemTopBarType.BottomSheet,
startContent = if (canGoBack) {
{ CircleIconButton(iconRes = CoreR.drawable.ic_arrow_back_28, onClick = onBackClick) }
} else {
null
},
endContent = {
CircleIconButton(iconRes = R.drawable.ic_close_24, onClick = onCloseClick)
},
)
}
@Composable
private fun WithOptionalRedesignTheme(isEnabled: Boolean, content: @Composable () -> Unit) {
if (isEnabled) {
TangemThemeRedesign(content = content)
} else {
content()
}
}
@Composable
private fun CircleIconButton(iconRes: Int, onClick: () -> Unit) {
Icon(
imageVector = ImageVector.vectorResource(id = iconRes),
contentDescription = null,
tint = TangemTheme.colors2.graphic.neutral.primary,
modifier = Modifier
.size(TangemTheme.dimens2.x11)
.background(
color = TangemTheme.colors2.button.backgroundSecondary,
shape = CircleShape,
)
.clickableSingle(onClick = onClick)
.padding(TangemTheme.dimens2.x2),
)
}
@AssistedFactory

View file

@ -19,8 +19,6 @@ internal sealed class AddFundsAnalyticsEvent(
class ButtonReceive : AddFundsAnalyticsEvent(event = "Button - Receive")
class ButtonGoToToken : AddFundsAnalyticsEvent(event = "Button - Go to Token")
companion object {
private const val CATEGORY = "Add Funds"
const val SOURCE_MAIN_SCREEN = "Main Screen"

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