Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-28 01:18:48 +04:00
commit 02e65a84ef
72 changed files with 2922 additions and 267 deletions

View file

@ -74,6 +74,10 @@ When the user asks to **port** an iOS test to Android:
strings inside `step(...)`.
- **Each click is its own** `step("Click on '$x' button")`. Combining clicks into one step hides which
click failed in the Allure report.
- **No reusable step-helpers as private functions in the test class.** A sequence reused across tests
(e.g. `enterAmount`, `assertReady`) goes in a `scenarios/` file as a `BaseTestCase` extension, not as a
private method on the test class — reviewers reject the latter. The test body then calls it wrapped in a
`step(...)` like any scenario.
- **Every scenario call in the test body is wrapped in its own `step("…")`**, even though the scenario
itself contains inner `step(...)`s — the outer step names the flow in the Allure tree, the inner ones
detail it (nested steps are expected). `step(...)` (Allure) is callable anywhere, including inside
@ -121,13 +125,20 @@ Scenario files orchestrate flows; they must not define page objects or duplicate
### Waits and synchronization
- **Manual polls are banned** (`onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()` in a loop). Use:
- `composeTestRule.waitUntilAtLeastOneExists(matcher, timeoutMillis)` — wait for one thing to appear.
- `composeTestRule.waitUntil(timeout) { runCatching { someAssertion() }.isSuccess }` — wait until an
action no longer throws.
- `composeTestRule.waitUntil(timeout) { matcherA exists || matcherB exists }` — the either/or case.
- **`flakySafely(timeout)`** (Kaspresso) is reachable only from `TestCase` subclasses, NOT from
extension functions on `BaseTestCase`. In extension code use the `waitUntil` variants above.
- **Manual polls are banned** (`onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()` in a loop) — even
if a bot reviewer suggests one.
- **Default in the test body: `flakySafely(TIMEOUT) { assertion }`** — the codebase idiom (hundreds of
uses); reviewers prefer it over `composeTestRule.waitUntil { runCatching { … }.isSuccess }`.
- **`ComposeNotIdleException` / `AppNotIdleException` ("busy for ~60s") is usually a sick emulator, not
your test.** After many back-to-back local runs the emulator degrades (you may even see a "System UI
isn't responding" ANR), and idle-synced ops (`flakySafely`, `waitForIdle()`, Kakao actions) start
timing out *anywhere* data is loading — different test each run. Before concluding a test is flaky or
that a screen "never idles", **cold-boot a fresh emulator** (`emulator -avd … -no-snapshot -wipe-data
-memory 4096 -cores 2`) and re-run. A suite that flaked across runs on a tired emulator can be a clean
10/10 on a fresh one (verified on this exact suite). Don't rewrite waits to work around emulator rot.
- **In scenario / `BaseTestCase`-extension code, `flakySafely` is NOT available** regardless — use the
same `composeTestRule.waitUntil` fallback (or `waitUntilAtLeastOneExists(matcher, timeout)` to wait for
appearance, `{ a exists || b exists }` for either/or).
### Comment hygiene
@ -145,4 +156,5 @@ Delete anything explaining WHAT a step does.
look like passing tests.
- **`reference/running-and-debugging.md`** — read when building, installing, running tests (orchestrator
vs. raw `am instrument`), running against a local WireMock, interpreting CLI/Allure output, using
`@Ignore`, or driving WireMock scenarios.
`@Ignore`, or driving WireMock scenarios. Includes how to find app-side root causes when the UI fails
silently (the app log in `files/log.txt`, and the WireMock journal).

View file

@ -154,6 +154,34 @@ Non-obvious points that bite:
Reference: `AddFundsBottomSheetPageObject.trendingTokenWithTitle` and `MainScreenPageObject` (`lazyList`).
## Touch auto-scroll gets hijacked by a nested-scroll container (e.g. a bottom sheet)
When a screen hosts a nested-scroll container (a Material3 bottom sheet, `PullToRefreshBox`), Kakao's
**touch-based** auto-scroll toward a below-the-fold target can be consumed by that container instead —
expanding the sheet over the content, so the next click lands on the wrong element.
- **Scroll with semantics, not touch:** `onNode(CONTAINER).performScrollToNode(matcher)` issues a
`ScrollToIndex` action that does NOT engage nested scroll.
- **Don't `device.pressBack()` to collapse the sheet** on a root screen — its `BackHandler` only fires
when already expanded, races the press, and back often falls through and quits the app.
## A perpetually animating screen keeps Compose non-idle → idle-synced actions flake
Kakao/Compose-test actions block on Compose reaching *idle* first. A screen that animates forever — an
auto-advancing stories/onboarding carousel, a looping shimmer, a never-ending spinner — never idles, so
`clickWithAssertion()` / `assertIsDisplayed()` on it flake (`… is not displayed`, or
`ComposeNotIdleException`). **First rule out a degraded emulator** (see running-and-debugging) — a
slow-*loading* screen on a tired emulator throws the identical exception but is fixed by a cold-boot, not
by changing the test. Only treat it as a *truly* infinite animation if it reproduces on a fresh emulator.
For a genuinely infinite animation, **remove the screen at its source rather than out-waiting it:** most
are gated by a feature toggle or a mock response — flip it off so the screen never renders. If it's
server-driven, set the toggle **before app launch** (config is fetched at startup), not mid-test.
(Example: the swap first-time stories are disabled via their WireMock scenario, then opened with
`storiesExist = false`.) Note that `waitUntilAtLeastOneExists(hasTestTag(TAG))` polls the **merged** tree
(no `useUnmergedTree` option), so a `clickable` node inside a `mergeDescendants` container — which exists
only in the *unmerged* tree — will never match it; poll through the page object instead.
## Decompose model lifecycle vs. data refresh
Models (e.g. `TangemPayDetailsModel`) call data fetches from `init {}`, NOT on `ON_RESUME`. Returning

View file

@ -100,6 +100,29 @@ curl -s http://localhost:8081/__admin/requests/unmatched | jq '.requests[] | "\(
(harness/emulator) for the hang. A non-empty list names exactly which mapping (or scenario state) the
local instance is missing.
## When the UI fails silently, the cause is usually app-side — two places to look
A screen failing silently with correct locators (fee shows "—", a banner never appears, a button stays
disabled) is usually missing mock *data* or an app-side gate, not a test bug. Two diagnostics find it:
- **The app's own log is in `files/log.txt`, not logcat** — the mocked build routes `TangemLogger` to a
file, so `adb logcat` shows nothing. Fastest path to a root cause (e.g. it surfaced
`IllegalStateException: No native currency found` → a native coin missing from the mock):
```bash
adb exec-out run-as <pkg> cat files/log.txt | grep -iE "Error|Exception|<feature>"
```
- **The WireMock journal separates "mock missing" from "app never asked"**
`/__admin/requests/unmatched` finds missing mappings, but if `unmatched=0` *and* the expected request
is also absent from the full log (`/__admin/requests`), the app never issued it (a data/state gate) →
fix the mock data or the app, not the mappings.
## "UiAutomationService already registered" — retry, it's not a failure
Back-to-back `am instrument` runs sometimes fail instantly with `UiAutomationService … already
registered!` — a teardown race between runs, not a test failure. Retry. (The orchestrator avoids it by
spacing runs — another reason to confirm a flaky-looking suite via the orchestrator, not raw
`am instrument`.)
## Classify the result — Allure noise vs. real failure
After `pm clear`, `/data/user/0/<pkg>/files/original_screenshots` doesn't exist →

View file

@ -140,12 +140,15 @@ fun BaseTestCase.openMainScreenWithExistingHotWallet(seedPhrase: String, accessC
fun BaseTestCase.synchronizeAddresses(
balance: String? = null,
isBalanceAvailable: Boolean = true
isBalanceAvailable: Boolean = true,
assertBalance: Boolean = true,
) {
step("Click on 'Synchronize addresses' button") {
onMainScreen { synchronizeAddressesButton.clickWithAssertion() }
}
if (!assertBalance) return
when {
!isBalanceAvailable -> step("Assert wallet balance = '$DASH_SIGN'") {
onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) }

View file

@ -11,13 +11,16 @@ import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG
import com.tangem.common.extensions.assertVisibility
import com.tangem.common.extensions.clickAndWaitFor
import com.tangem.common.extensions.clickWhenEnabled
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.extractText
import com.tangem.common.extensions.isDisplayedSafely
import com.tangem.core.ui.R as CoreUiR
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.HotWalletAccessCodeTestTags
import com.tangem.screens.*
import com.tangem.tap.domain.sdk.mocks.MockContent
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.Allure.step
import com.tangem.common.ui.R as CommonUiR
@ -260,6 +263,112 @@ fun BaseTestCase.checkSwapWarning(
}
}
/** Scans a card wallet and opens Swap for [tokenName] in [fromAccountName] without choosing the receive token yet. */
fun BaseTestCase.openSwapForTokenInAccount(
tokenName: String,
fromAccountName: String = "Account 1",
mockContent: MockContent? = null,
) {
step("Open 'Main' screen") {
openMainScreen(mockContent = mockContent)
}
step("Synchronize addresses") {
synchronizeAddresses(assertBalance = false)
}
step("Wait for addresses to be generated") {
waitForAddressesGenerated()
}
navigateToSwapForToken(tokenName, fromAccountName)
}
/** Opens Swap for [tokenName] in [fromAccountName] and picks it again in [toAccountName] to enter Transfer mode; needs a two-accounts-same-token mock. */
fun BaseTestCase.openSwapInTransferMode(
tokenName: String,
fromAccountName: String = "Account 1",
toAccountName: String = "Account 2",
mockContent: MockContent? = null,
) {
openSwapForTokenInAccount(tokenName, fromAccountName, mockContent)
step("Choose identical receive token '$tokenName' from '$toAccountName'") {
chooseIdenticalReceiveToken(tokenName = tokenName, receiveAccountName = toAccountName)
}
}
/** Like [openSwapInTransferMode] but imports a hot wallet first — required for broadcasting flows (the mock card can't sign). */
fun BaseTestCase.openSwapInTransferModeWithHotWallet(
tokenName: String,
seedPhrase: String,
fromAccountName: String = "Account 1",
toAccountName: String = "Account 2",
) {
step("Open 'Main' screen with existing hot wallet") {
openMainScreenWithExistingHotWallet(seedPhrase)
}
step("Generate missing addresses") {
generateMissingHotWalletAddresses()
}
step("Wait for addresses to be generated") {
waitForAddressesGenerated()
}
navigateToSwapForToken(tokenName, fromAccountName)
step("Choose identical receive token '$tokenName' from '$toAccountName'") {
chooseIdenticalReceiveToken(tokenName = tokenName, receiveAccountName = toAccountName)
}
}
private fun BaseTestCase.navigateToSwapForToken(tokenName: String, fromAccountName: String) {
step("Scroll '$fromAccountName' into view (semantics, not touch — avoids the Markets sheet)") {
onMainScreen { scrollToAccount(fromAccountName) }
}
step("Expand account '$fromAccountName' and reveal token '$tokenName'") {
onMainScreen {
findAccountSectionByName(fromAccountName).clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onMainScreen { findTokenInAnyAccountByName(tokenName).assertIsDisplayed() }
},
)
}
}
step("Click on token with name: '$tokenName'") {
onMainScreen { findTokenInAnyAccountByName(tokenName).clickWithAssertion() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
}
}
// Hot wallets derive locally, so the second account's missing addresses are generated without a card scan when prompted.
fun BaseTestCase.generateMissingHotWalletAddresses() {
var notificationShown = false
onMainScreen { notificationShown = synchronizeAddressesButton.isDisplayedSafely() }
if (notificationShown) {
onMainScreen { synchronizeAddressesButton.performClick() }
}
}
// The receive selector shows "No address" until the second account's derivation lands; the prompt disappears when it does.
fun BaseTestCase.waitForAddressesGenerated() {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
var generated = false
onMainScreen { generated = !synchronizeAddressesButton.isDisplayedSafely() }
generated
}
}
/** Picks the identical [tokenName] in [receiveAccountName]; the receive list collapses the other account, so its header is expanded first. */
fun BaseTestCase.chooseIdenticalReceiveToken(tokenName: String, receiveAccountName: String) {
step("Click on 'Choose token' button") {
onSwapTokenScreen { chooseTokenButton.performClick() }
}
step("Expand account '$receiveAccountName' in receive selector") {
onSwapSelectTokenScreen { tokenWithName(receiveAccountName).performClick() }
}
step("Click on token with name '$tokenName'") {
onSwapSelectTokenScreen { tokenWithName(tokenName).performClick() }
}
}
fun BaseTestCase.chooseReceiveToken(tokenName: String) {
step("Click on 'Choose token' button") {
onSwapTokenScreen { chooseTokenButton.performClick() }
@ -269,6 +378,16 @@ fun BaseTestCase.chooseReceiveToken(tokenName: String) {
}
}
/** Reopens the receive selector via the receive-card icon and picks [tokenName] directly — the reopened selector keeps the account expanded. */
fun BaseTestCase.changeReceiveToken(tokenName: String) {
step("Open receive token selector") {
onSwapTokenScreen { receiveSelectTokenIcon.performClick() }
}
step("Click on token with name '$tokenName'") {
onSwapSelectTokenScreen { tokenWithName(tokenName).performClick() }
}
}
/**
* From a clean start: open the main screen (cold by default, or an existing hot wallet when
* [seedPhrase] is given), open Swap for [fromTokenName], choose [receiveTokenName] to receive and
@ -371,6 +490,14 @@ fun BaseTestCase.confirmSwapByHolding(accessCode: String? = null) {
}
}
/** Holds the last BASE_BUTTON to confirm a transfer; the caller asserts the outcome (transfer mode has no in-progress marker to wait on). */
fun BaseTestCase.holdToConfirmTransfer() {
val buttons = composeTestRule.onAllNodes(hasTestTag(BaseButtonTestTags.BUTTON))
val confirmButton = buttons[buttons.fetchSemanticsNodes().lastIndex]
confirmButton.performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) }
waitForIdle()
}
sealed class SwapEntryPoint {
object MainScreen : SwapEntryPoint()
object TokenDetails : SwapEntryPoint()
@ -383,4 +510,35 @@ enum class FeeType {
Fast
}
fun BaseTestCase.inputAmount(amount: String) {
// No waitForIdle(): the transfer screen recalculates the fee continuously and never reaches idle.
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSwapTokenScreen { textInput.assertIsDisplayed() } }.isSuccess
}
onSwapTokenScreen {
textInput.clickWithAssertion()
textInput.performTextReplacement(amount)
}
}
// composeTestRule.waitUntil rather than flakySafely — the latter is unavailable in extensions on BaseTestCase.
fun BaseTestCase.assertTransferReady() {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSwapTokenScreen { transferButton.assertIsDisplayed() } }.isSuccess
}
onSwapTokenScreen { providersBlock.assertIsNotDisplayed() }
}
fun BaseTestCase.waitForFeeDisplayed() {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSwapTokenScreen { feeAmount.assertIsDisplayed() } }.isSuccess
}
}
fun BaseTestCase.swapFeeDiffersFrom(previousFee: String): Boolean {
var current = ""
onSwapTokenScreen { current = feeAmount.extractText() }
return current.isNotEmpty() && current != previousFee
}

View file

@ -102,6 +102,15 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
}
}
/** Scrolls to [accountName] via ScrollToIndex semantics, not a touch swipe — a bottom-edge drag is stolen by the Markets sheet's nested scroll. */
@OptIn(ExperimentalTestApi::class)
fun scrollToAccount(accountName: String) {
semanticsProvider.onNode(withTestTag(MainScreenTestTags.SCREEN_CONTAINER))
.performScrollToNode(
withTestTag(MainScreenTestTags.ACCOUNT_LIST_ITEM) and hasAnyDescendant(withText(accountName)),
)
}
val restoringProgressText: KNode = child {
hasTestTag(MainScreenTestTags.SYNC_PROGRESS_TEXT)
useUnmergedTree = true

View file

@ -20,6 +20,13 @@ class SwapSuccessPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
useUnmergedTree = true
}
// App Transfers reuses the swap success screen; in transfer mode its title is "Transfer in progress".
val transferInProgressTitle: KNode = child {
hasTestTag(TransactionSuccessScreenTestTags.TITLE)
hasText(getResourceString(R.string.transfer_in_progress_title))
useUnmergedTree = true
}
val closeButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasAnyDescendant(withText(getResourceString(R.string.common_close)))

View file

@ -130,6 +130,12 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
useUnmergedTree = true
}
fun insufficientFeeForTransferNotificationTitle(feeCoinName: String): KNode = child {
hasTestTag(NotificationTestTags.TITLE)
hasText(getResourceString(R.string.warning_send_blocked_funds_for_fee_title, feeCoinName))
useUnmergedTree = true
}
fun warningTitle(title: String): KNode = child {
hasTestTag(NotificationTestTags.TITLE)
hasText(title)
@ -158,6 +164,23 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
hasText(getResourceString(R.string.common_swap))
}
val transferButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.swapping_transfer_action))
}
val transferTitle: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
hasText(getResourceString(R.string.common_transfer))
useUnmergedTree = true
}
// PercentPill testTag is the PredefinedPercentAmount enum name; MAX == "MAX".
val maxAmountButton: KNode = child {
hasTestTag("MAX")
useUnmergedTree = true
}
val youSwapBlock: KNode = child {
hasTestTag(SwapTokenScreenTestTags.SWAP_BLOCK_HEADER)
hasAnyDescendant(withText(getResourceString(R.string.swapping_from_title_v2)))
@ -222,6 +245,12 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
hasText(getResourceString(R.string.common_choose_token))
useUnmergedTree = true
}
// Transfer mode auto-fills the memo/destination tag — the manual Send-address field must never render here.
val destinationTagField: KNode = child {
hasTestTag(SendAddressScreenTestTags.DESTINATION_TAG_TEXT_FIELD)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onSwapTokenScreen(function: SwapTokenPageObject.() -> Unit) =

View file

@ -0,0 +1,910 @@
package com.tangem.tests.transfer
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.extractText
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.ui.R as CoreUiR
import com.tangem.scenarios.*
import com.tangem.screens.*
import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithDerivationsMockContent
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.Allure.step
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Ignore
import org.junit.Test
@HiltAndroidTest
class AppTransfersTest : BaseTestCase() {
private val ethCallScenario = "eth_call_api"
private val ethBalanceScenario = "eth_network_balance"
private val started = "Started"
// Disable the first-time-swap stories (500 → not shown); their auto-advancing animation keeps Compose non-idle and flakes the close.
private val storiesScenario = "stories_first_time_swap_v2"
private val storiesErrorState = "Error"
@AllureId("9838")
@DisplayName("App transfers: identical pair switches to Transfer mode")
@Test
fun identicalPairSwitchesToTransferModeTest() {
val token = "Ethereum"
val amount = "0.001"
val userTokensState = "TwoAccountsSameToken"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(ethCallScenario)
resetWireMockScenarioState(ethBalanceScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
}
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Enter amount '$amount'") { inputAmount(amount) }
step("Assert Transfer mode is ready") { assertTransferReady() }
step("Assert network fee is displayed") { waitForFeeDisplayed() }
}
}
@AllureId("9843")
@DisplayName("App transfers: zero amount keeps Transfer button disabled")
@Test
fun zeroAmountKeepsTransferButtonDisabledTest() {
val token = "Ethereum"
val userTokensState = "TwoAccountsSameToken"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(ethCallScenario)
resetWireMockScenarioState(ethBalanceScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
}
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Assert provider block is not displayed") {
onSwapTokenScreen { providersBlock.assertIsNotDisplayed() }
}
step("Assert 'Transfer' button is disabled") {
onSwapTokenScreen { transferButton.assertIsNotEnabled() }
}
}
}
@AllureId("9992")
@DisplayName("App transfers: reversing tokens keeps Transfer mode")
@Test
fun reversingTokensKeepsTransferModeTest() {
val token = "Ethereum"
val amount = "0.001"
val userTokensState = "TwoAccountsSameToken"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(ethCallScenario)
resetWireMockScenarioState(ethBalanceScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
}
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Enter amount '$amount'") { inputAmount(amount) }
step("Assert network fee is displayed") { waitForFeeDisplayed() }
step("Click on 'Swap tokens' (reverse) button") {
onSwapTokenScreen { replaceTokensButton.performClick() }
}
step("Assert Transfer mode is ready") { assertTransferReady() }
}
}
@AllureId("9847")
@DisplayName("App transfers: Max amount keeps Transfer enabled and subtracts fee")
@Test
fun maxAmountFractionSubtractsFeeTest() {
val token = "Ethereum"
val userTokensState = "TwoAccountsSameToken"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(ethCallScenario)
resetWireMockScenarioState(ethBalanceScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
}
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Focus amount field to reveal predefined amount buttons") {
waitForIdle()
onSwapTokenScreen { textInput.clickWithAssertion() }
}
step("Click on 'Max' amount button") {
onSwapTokenScreen { maxAmountButton.performClick() }
}
step("Assert network fee is displayed") { waitForFeeDisplayed() }
step("Assert Transfer mode is ready") { assertTransferReady() }
step("Assert 'Transfer' button is enabled") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen { transferButton.assertIsEnabled() }
}
}
}
}
@AllureId("9844")
@DisplayName("App transfers: amount above balance disables Transfer")
@Test
fun amountAboveBalanceDisablesTransferTest() {
val token = "Ethereum"
val aboveBalanceAmount = "100"
val userTokensState = "TwoAccountsSameToken"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(ethCallScenario)
resetWireMockScenarioState(ethBalanceScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
}
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Enter amount '$aboveBalanceAmount'") { inputAmount(aboveBalanceAmount) }
// Above-balance recalculates the fee forever (Compose never idles), so assert the "Insufficient funds" title, not button state.
step("Assert 'Insufficient funds' is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen { insufficientFundsErrorTitle.assertIsDisplayed() }
}
}
}
}
@AllureId("10003")
@DisplayName("App transfers: EVM network fee speed options")
@Test
fun evmNetworkFeeSpeedOptionsTest() {
val token = "Ethereum"
val amount = "0.001"
val userTokensState = "TwoAccountsSameToken"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(ethCallScenario)
resetWireMockScenarioState(ethBalanceScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
}
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Enter amount '$amount'") { inputAmount(amount) }
step("Assert network fee is displayed") { waitForFeeDisplayed() }
step("Assert Transfer mode is ready") { assertTransferReady() }
var marketFee = ""
step("Read displayed 'Market' fee amount") {
onSwapTokenScreen { marketFee = feeAmount.extractText() }
}
step("Open 'Network fee' selector via 'Select fee' icon") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen { selectFeeIcon.performClick() }
onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() }
}
}
step("Click on 'Fast' fee option") {
onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.clickWithAssertion() }
}
step("Assert fee amount changed from Market fee") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen { feeAmount.assertIsDisplayed() }
check(swapFeeDiffersFrom(marketFee)) { "Network fee did not change from '$marketFee'" }
}
}
}
}
@AllureId("10002")
@DisplayName("App transfers: UTXO network fee")
@Test
fun utxoNetworkFeeTest() {
val token = "Bitcoin"
val amount = "0.001"
val userTokensState = "TwoAccountsSameBitcoin"
val bitcoinUtxoScenario = "bitcoin_utxo"
val bitcoinUtxoState = "BalanceAnyAddress"
val assetsScenario = "express_api_assets"
val assetsBitcoinState = "BitcoinExchangeEnabled"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(bitcoinUtxoScenario)
resetWireMockScenarioState(assetsScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$bitcoinUtxoScenario' to state: '$bitcoinUtxoState'") {
setWireMockScenarioState(scenarioName = bitcoinUtxoScenario, state = bitcoinUtxoState)
}
// Bitcoin swap must be exchange-enabled or the token-details Swap button stays disabled.
step("Set WireMock scenario: '$assetsScenario' to state: '$assetsBitcoinState'") {
setWireMockScenarioState(scenarioName = assetsScenario, state = assetsBitcoinState)
}
// V3 card: Bitcoin's default path is m/84' (matches the stub) so the coin isn't custom — else the Swap button stays disabled.
step("Open Swap in Transfer mode for '$token'") {
openSwapInTransferMode(token, mockContent = Wallet2WithDerivationsMockContent)
}
step("Enter amount '$amount'") { inputAmount(amount) }
step("Assert Transfer mode is ready") { assertTransferReady() }
step("Assert network fee is displayed") { waitForFeeDisplayed() }
}
}
@AllureId("10004")
@DisplayName("App transfers: Solana network fee")
@Test
fun solanaNetworkFeeTest() {
val token = "Solana"
val amount = "0.001"
val userTokensState = "TwoAccountsSameSolana"
val solanaBalanceScenario = "solana_balance"
val quotesSolanaState = "Solana"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(solanaBalanceScenario)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$solanaBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = solanaBalanceScenario, state = started)
}
// Non-zero SOL price keeps total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet.
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesSolanaState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesSolanaState)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Enter amount '$amount'") { inputAmount(amount) }
step("Assert Transfer mode is ready") { assertTransferReady() }
step("Assert network fee is displayed") { waitForFeeDisplayed() }
}
}
@AllureId("9845")
@DisplayName("App transfers: insufficient native coin for fee disables Transfer")
@Test
fun insufficientNativeCoinForFeeDisablesTransferTest() {
val token = "Tether"
val feeCoinName = "Ethereum"
val amount = "0.001"
val userTokensState = "TwoAccountsSameUsdt"
// Zero native ETH (coin present in the mock so the fee still estimates) → fee exceeds balance.
val ethBalanceState = "EmptyAnyId"
val quotesUsdtState = "USDTHotWalletSvS"
val feeHistoryScenario = "eth_fee_history"
val estimateGasScenario = "eth_estimate_gas"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(ethCallScenario)
resetWireMockScenarioState(ethBalanceScenario)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(feeHistoryScenario)
resetWireMockScenarioState(estimateGasScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
}
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$ethBalanceState'") {
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = ethBalanceState)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesUsdtState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesUsdtState)
}
step("Set WireMock scenario: '$feeHistoryScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = feeHistoryScenario, state = started)
}
step("Set WireMock scenario: '$estimateGasScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = estimateGasScenario, state = started)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Enter amount '$amount'") { inputAmount(amount) }
step("Assert 'Insufficient $feeCoinName to cover network fee' notification is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen {
insufficientFeeForTransferNotificationTitle(feeCoinName).assertIsDisplayed()
}
}
}
}
}
@AllureId("9990")
@DisplayName("App transfers: search filters receive token list")
@Test
fun searchFiltersReceiveTokenListTest() {
val sourceToken = "Polygon"
val ethereumToken = "Ethereum"
val polygonReceiveName = "POL (ex-MATIC)"
val noMatchQuery = "f"
val polygonQuery = "pol"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = { resetWireMockScenarioState(storiesScenario) },
).run {
step("Open 'Main' screen") { openMainScreen() }
step("Synchronize addresses") { synchronizeAddresses() }
step("Click on token with name: '$sourceToken'") {
onMainScreen { tokenWithTitleAndAddress(sourceToken).clickWithAssertion() }
}
step("Open 'Swap' screen") { openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) }
step("Open receive token selector") {
onSwapTokenScreen { chooseTokenButton.performClick() }
}
step("Type '$noMatchQuery' in search field") {
onSwapSelectTokenScreen {
searchBarBlock.performClick()
searchBarBlock.performTextInput(noMatchQuery)
}
}
step("Assert '$ethereumToken' is not displayed") {
onSwapSelectTokenScreen { tokenWithName(ethereumToken).assertIsNotDisplayed() }
}
step("Assert '$polygonReceiveName' is not displayed") {
onSwapSelectTokenScreen { tokenWithName(polygonReceiveName).assertIsNotDisplayed() }
}
step("Replace search text with '$polygonQuery'") {
onSwapSelectTokenScreen { searchBarBlock.performTextReplacement(polygonQuery) }
}
step("Assert '$polygonReceiveName' is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapSelectTokenScreen { tokenWithName(polygonReceiveName).assertIsDisplayed() }
}
}
step("Assert '$ethereumToken' is not displayed") {
onSwapSelectTokenScreen { tokenWithName(ethereumToken).assertIsNotDisplayed() }
}
}
}
@AllureId("9841")
@DisplayName("App transfers: full transfer reaches 'Transfer in progress' screen")
@Test
fun fullTransferReachesTransferInProgressScreenTest() {
val token = "Ethereum"
val amount = "0.001"
val userTokensState = "TwoAccountsSameToken"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(ethCallScenario)
resetWireMockScenarioState(ethBalanceScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
}
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started)
}
step("Open Swap in Transfer mode for '$token' with existing hot wallet") {
openSwapInTransferModeWithHotWallet(tokenName = token, seedPhrase = SVS_SEED_PHRASE_12)
}
step("Enter amount '$amount'") { inputAmount(amount) }
step("Assert Transfer mode is ready") { assertTransferReady() }
step("Assert network fee is displayed") { waitForFeeDisplayed() }
step("Hold to confirm the transfer") { holdToConfirmTransfer() }
step("Assert 'Transfer in progress' screen is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapSuccessScreen { transferInProgressTitle.assertIsDisplayed() }
}
}
}
}
@AllureId("9999")
@DisplayName("App transfers: broadcast error shows alert without finish screen")
@Test
fun broadcastErrorShowsAlertWithoutFinishScreenTest() {
val token = "Ethereum"
val amount = "0.001"
val userTokensState = "TwoAccountsSameToken"
val sendRawTransactionScenario = "eth_sendRawTransaction"
val broadcastErrorState = "BroadcastError"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(ethCallScenario)
resetWireMockScenarioState(ethBalanceScenario)
resetWireMockScenarioState(sendRawTransactionScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
}
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started)
}
step("Set WireMock scenario: '$sendRawTransactionScenario' to state: '$broadcastErrorState'") {
setWireMockScenarioState(scenarioName = sendRawTransactionScenario, state = broadcastErrorState)
}
step("Open Swap in Transfer mode for '$token' with existing hot wallet") {
openSwapInTransferModeWithHotWallet(tokenName = token, seedPhrase = SVS_SEED_PHRASE_12)
}
step("Enter amount '$amount'") { inputAmount(amount) }
step("Assert Transfer mode is ready") { assertTransferReady() }
step("Assert network fee is displayed") { waitForFeeDisplayed() }
step("Hold to confirm the transfer") { holdToConfirmTransfer() }
step("Assert 'Transaction failed' dialog is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onFailedTransactionDialog { dialogContainer.assertIsDisplayed() }
}
}
step("Assert 'Transfer in progress' screen is not displayed") {
onSwapSuccessScreen { transferInProgressTitle.assertDoesNotExist() }
}
}
}
@AllureId("9989")
@DisplayName("App transfers: receive list allows identical token on another account")
@Test
fun receiveListAllowsIdenticalTokenOnAnotherAccountTest() {
val token = "Ethereum"
val receiveAccountName = "Account 2"
val userTokensState = "TwoAccountsSameToken"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(ethCallScenario)
resetWireMockScenarioState(ethBalanceScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
}
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started)
}
step("Open Swap for '$token' in 'Account 1'") { openSwapForTokenInAccount(token) }
step("Open receive token selector") {
onSwapTokenScreen { chooseTokenButton.performClick() }
}
step("Expand account '$receiveAccountName' in receive selector") {
onSwapSelectTokenScreen { tokenWithName(receiveAccountName).performClick() }
}
step("Assert token '$token' is displayed in receive selector") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapSelectTokenScreen { tokenWithName(token).assertIsDisplayed() }
}
}
}
}
@AllureId("9998")
@DisplayName("App transfers: fee calculation error disables Transfer")
@Test
fun feeCalculationErrorDisablesTransferTest() {
val token = "Ethereum"
val amount = "0.001"
val userTokensState = "TwoAccountsSameToken"
val feeHistoryScenario = "eth_fee_history"
val estimateGasScenario = "eth_estimate_gas"
val unreachable = "Unreachable"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(ethCallScenario)
resetWireMockScenarioState(ethBalanceScenario)
resetWireMockScenarioState(feeHistoryScenario)
resetWireMockScenarioState(estimateGasScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
}
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started)
}
step("Set WireMock scenario: '$feeHistoryScenario' to state: '$unreachable'") {
setWireMockScenarioState(scenarioName = feeHistoryScenario, state = unreachable)
}
step("Set WireMock scenario: '$estimateGasScenario' to state: '$unreachable'") {
setWireMockScenarioState(scenarioName = estimateGasScenario, state = unreachable)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Enter amount '$amount'") { inputAmount(amount) }
// Unreachable fee endpoints leave the fee unresolved (shown as '—'); the transfer stays blocked.
step("Assert 'Transfer' button is disabled") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen { transferButton.assertIsNotEnabled() }
}
}
}
}
@AllureId("9994")
@DisplayName("App transfers: mode switches reactively without screen reload")
@Test
fun modeSwitchesReactivelyWithoutScreenReloadTest() {
val token = "Solana"
val swapReceiveToken = "USDC"
val userTokensState = "TwoAccountsSameSolanaWithUsdc"
val solanaBalanceScenario = "solana_balance"
val assetsScenario = "express_api_assets"
val fromPairsScenario = "solana_from_pairs"
val dexProviderState = "DexProvider"
val quotesSolanaState = "Solana"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(solanaBalanceScenario)
resetWireMockScenarioState(assetsScenario)
resetWireMockScenarioState(fromPairsScenario)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$solanaBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = solanaBalanceScenario, state = started)
}
step("Set WireMock scenario: '$assetsScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = assetsScenario, state = started)
}
step("Set WireMock scenario: '$fromPairsScenario' to state: '$dexProviderState'") {
setWireMockScenarioState(scenarioName = fromPairsScenario, state = dexProviderState)
}
// Non-zero SOL price keeps total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet.
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesSolanaState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesSolanaState)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Assert Transfer mode is ready") { assertTransferReady() }
step("Change receive token to '$swapReceiveToken' to switch to Swap mode") {
changeReceiveToken(swapReceiveToken)
}
step("Assert 'Swap' button is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen { swapButton.assertIsDisplayed() }
}
}
step("Change receive token back to identical '$token' to switch to Transfer mode") {
changeReceiveToken(token)
}
step("Assert Transfer mode is ready") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { assertTransferReady() }
}
}
}
@AllureId("10001")
@DisplayName("App transfers: memo field is not entered manually in Transfer mode")
@Test
fun memoFieldIsNotEnteredManuallyInTransferModeTest() {
val token = "XRP Ledger"
val amount = "0.001"
val userTokensState = "TwoAccountsSameXRP"
val rippleAccountInfoScenario = "ripple_account_info"
val quotesRippleState = "Ripple"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(rippleAccountInfoScenario)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$rippleAccountInfoScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = rippleAccountInfoScenario, state = started)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesRippleState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesRippleState)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Enter amount '$amount'") { inputAmount(amount) }
step("Assert Transfer mode is ready") { assertTransferReady() }
step("Assert manual 'Destination tag' field is not displayed") {
onSwapTokenScreen { destinationTagField.assertDoesNotExist() }
}
}
}
@AllureId("10009")
@DisplayName("App transfers: XRP network fee")
@Test
fun xrpNetworkFeeTest() {
val token = "XRP Ledger"
val amount = "0.001"
val userTokensState = "TwoAccountsSameXRP"
val rippleAccountInfoScenario = "ripple_account_info"
val quotesRippleState = "Ripple"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(rippleAccountInfoScenario)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$rippleAccountInfoScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = rippleAccountInfoScenario, state = started)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesRippleState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesRippleState)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Enter amount '$amount'") { inputAmount(amount) }
step("Assert Transfer mode is ready") { assertTransferReady() }
step("Assert network fee is displayed") { waitForFeeDisplayed() }
}
}
@AllureId("10011")
@DisplayName("App transfers: Stellar network fee")
@Test
fun stellarNetworkFeeTest() {
val token = "Stellar"
val amount = "0.001"
val userTokensState = "TwoAccountsSameXLM"
val quotesXlmState = "XLM"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesXlmState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesXlmState)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Enter amount '$amount'") { inputAmount(amount) }
step("Assert Transfer mode is ready") { assertTransferReady() }
step("Assert network fee is displayed") { waitForFeeDisplayed() }
}
}
// [REDACTED_TASK_KEY]: transfer mode never runs tx validation, so the destination rent-exemption notification never shows.
@Ignore("[REDACTED_JIRA]")
@AllureId("9852")
@DisplayName("App transfers: amount below destination reserve disables Transfer")
@Test
fun amountBelowDestinationReserveDisablesTransferTest() {
val token = "Solana"
val belowReserveAmount = "0.0001"
val userTokensState = "TwoAccountsSameSolana"
val solanaBalanceScenario = "solana_balance"
val recipientAccountScenario = "solana_recipient_account"
val notExistState = "NotExist"
val quotesSolanaState = "Solana"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(solanaBalanceScenario)
resetWireMockScenarioState(recipientAccountScenario)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$solanaBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = solanaBalanceScenario, state = started)
}
step("Set WireMock scenario: '$recipientAccountScenario' to state: '$notExistState'") {
setWireMockScenarioState(scenarioName = recipientAccountScenario, state = notExistState)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesSolanaState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesSolanaState)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Enter amount '$belowReserveAmount'") { inputAmount(belowReserveAmount) }
step("Assert error notification is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen { errorNotificationTitle.assertIsDisplayed() }
}
}
step("Assert 'Transfer' button is disabled") {
onSwapTokenScreen { transferButton.assertIsNotEnabled() }
}
}
}
@AllureId("9997")
@DisplayName("App transfers: amount below minimum disables Transfer")
@Test
fun amountBelowMinimumDisablesTransferTest() {
val token = "Kaspa"
val belowMinimumAmount = "0.00000001"
val userTokensState = "TwoAccountsSameKaspa"
val kaspaUtxoScenario = "kaspa_utxo"
// Android-specific UTXO body — addresses differ from the iOS fixture (see kaspa-utxo.json).
val kaspaUtxoState = "more_than_84_android"
val quotesKaspaState = "Kaspa"
val invalidAmountTitle = getResourceString(CoreUiR.string.send_notification_invalid_amount_title)
val minimumAmountMessagePrefix =
getResourceString(CoreUiR.string.send_notification_invalid_minimum_amount_text).substringBefore("%1")
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(kaspaUtxoScenario)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$kaspaUtxoScenario' to state: '$kaspaUtxoState'") {
setWireMockScenarioState(scenarioName = kaspaUtxoScenario, state = kaspaUtxoState)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesKaspaState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesKaspaState)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Enter amount '$belowMinimumAmount'") { inputAmount(belowMinimumAmount) }
step("Assert '$invalidAmountTitle' notification title is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen { warningTitle(invalidAmountTitle).assertIsDisplayed() }
}
}
step("Assert notification message contains the minimum-amount text") {
onSwapTokenScreen { errorNotificationText.assertTextContains(minimumAmountMessagePrefix, substring = true) }
}
step("Assert 'Transfer' button is disabled") {
onSwapTokenScreen { transferButton.assertIsNotEnabled() }
}
}
}
}

View file

@ -172,7 +172,16 @@ object WalletMockContent : MockContent {
remainingSignatures = null,
index = 1,
hasBackup = false,
derivedKeys = emptyMap(),
derivedKeys = mapOf(
DerivationPath("m/44'/501'/0'") to ExtendedPublicKey( // Solana (account 1)
publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109),
chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6),
),
DerivationPath("m/44'/501'/1'") to ExtendedPublicKey( // Solana (account 2)
publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109),
chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6),
),
),
extendedPublicKey = ExtendedPublicKey(
publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109),
chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6),
@ -218,6 +227,13 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/84'/0'/1'/0/0") to ExtendedPublicKey( // btc (account 2)
publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52),
chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth
publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67),
chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109),
@ -281,6 +297,13 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/144'/1'/0/0") to ExtendedPublicKey( // XRP (account 2)
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/1729'/0'/0'") to ExtendedPublicKey( // Tezos
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
@ -380,6 +403,13 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/148'/1'") to ExtendedPublicKey( // Stellar (account 2)
publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82),
chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/330'/0'/0/0") to ExtendedPublicKey( // Terra
publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82),
chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109),
@ -548,6 +578,13 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/148'/1'") to ExtendedPublicKey( // Stellar (account 2)
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
),
),
),

View file

@ -25,7 +25,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.focus.FocusRequester
@ -201,6 +200,7 @@ private fun DecorationBox(
Field(
state = state,
innerTextField = innerTextField,
modifier = if (state.query.isEmpty()) Modifier else Modifier.weight(1f),
)
ClearButton(
state = state,
@ -217,58 +217,45 @@ private fun DecorationBox(
}
@Composable
private fun Field(state: SearchBarUM, innerTextField: @Composable () -> Unit) {
private fun Field(state: SearchBarUM, modifier: Modifier = Modifier, innerTextField: @Composable () -> Unit) {
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier
modifier = modifier
.heightIn(min = TangemTheme.dimens2.x5)
.width(IntrinsicSize.Max),
.then(if (state.query.isEmpty()) Modifier.width(IntrinsicSize.Max) else Modifier),
) {
innerTextField()
val placeholderOpacity by remember(state.query) {
derivedStateOf {
if (state.query.isNotEmpty()) {
0f
} else {
1f
}
}
if (state.query.isEmpty()) {
Text(
text = state.placeholderText.resolveReference(),
color = TangemTheme.colors2.text.neutral.tertiary,
style = TangemTheme.typography2.bodySemibold16,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag(SearchBarTestTags.PLACEHOLDER_TEXT),
)
}
Text(
text = state.placeholderText.resolveReference(),
color = TangemTheme.colors2.text.neutral.tertiary,
style = TangemTheme.typography2.bodySemibold16,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.testTag(SearchBarTestTags.PLACEHOLDER_TEXT)
.alpha(placeholderOpacity),
)
}
}
@Composable
private fun ClearButton(state: SearchBarUM) {
if (state.query.isNotEmpty()) {
Box(modifier = Modifier.fillMaxWidth()) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_close_new_20),
tint = TangemTheme.colors2.graphic.neutral.tertiary,
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterEnd)
.size(TangemTheme.dimens2.x5)
.clip(CircleShape)
.clickable(
onClick = state.onClearClick,
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false),
)
.testTag(SearchBarTestTags.CLEAR_BUTTON),
)
}
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_close_new_20),
tint = TangemTheme.colors2.graphic.neutral.tertiary,
contentDescription = null,
modifier = Modifier
.size(TangemTheme.dimens2.x5)
.clip(CircleShape)
.clickable(
onClick = state.onClearClick,
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false),
)
.testTag(SearchBarTestTags.CLEAR_BUTTON),
)
}
}

View file

@ -11,13 +11,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_cloud_24_filled
private const val DEFAULT_DEVICE_ICON_COLOR = 0xFF2C2C2C
private const val DEFAULT_BORDER_COLOR = 0x1A1E1E1E
/**
* Composable function for displaying a wallet icon based on the provided [DeviceIconUM] state.
@ -57,9 +59,9 @@ fun TangemDeviceIcon(state: DeviceIconUM, modifier: Modifier = Modifier) {
)
DeviceIconUM.Mobile -> Icon(
modifier = modifier,
imageVector = ImageVector.vectorResource(R.drawable.ic_shield_24),
imageVector = Icons.ic_cloud_24_filled,
contentDescription = null,
tint = TangemTheme.colors2.graphic.status.attention,
tint = Color(DEFAULT_DEVICE_ICON_COLOR),
)
}
}
@ -73,10 +75,10 @@ private fun DeviceIcon(
tColor: Color?,
modifier: Modifier = Modifier,
) {
val main = mainColor.takeOrElse { TangemTheme.colors2.graphic.neutral.tertiaryConstant }
val second = secondColor?.takeOrElse { TangemTheme.colors2.graphic.neutral.tertiaryConstant }
val third = thirdColor?.takeOrElse { TangemTheme.colors2.graphic.neutral.tertiaryConstant }
val borderColor = TangemTheme.colors2.border.walletIcon
val main = mainColor.takeOrElse { Color(DEFAULT_DEVICE_ICON_COLOR) }
val second = secondColor?.takeOrElse { Color(DEFAULT_DEVICE_ICON_COLOR) }
val third = thirdColor?.takeOrElse { Color(DEFAULT_DEVICE_ICON_COLOR) }
val borderColor = Color(DEFAULT_BORDER_COLOR)
val imageVector = remember(isRing, main, second, third, borderColor, tColor) {
when {

View file

@ -67,7 +67,10 @@ fun TangemTab(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
)
.padding(all = TangemTheme.dimens2.x3),
.padding(
vertical = TangemTheme.dimens2.x2,
horizontal = TangemTheme.dimens2.x3,
),
) {
Text(
text = text.resolveReference(),

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

View file

@ -32,7 +32,7 @@ internal class DefaultCustomerOffersRepositoryTest {
fee = CustomerOffersResponse.Fee(amount = BigDecimal("1.00"), currency = "USD"),
data = CustomerOffersResponse.Data(
specificationName = "SP_000004",
orderType = "CARD_ISSUE_ADDITIONAL",
orderType = "CARD_ISSUE_VIRTUAL_RAIN_KYC",
),
),
),

View file

@ -476,6 +476,16 @@ internal class DefaultWalletManagersFacade @Inject constructor(
}
}
override suspend fun isSwapSpenderAllowed(
userWalletId: UserWalletId,
network: Network,
spenderAddress: String,
): Boolean = withContext(dispatchers.io) {
val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network)
?: return@withContext false
walletManager.isSwapSpenderAllowed(spenderAddress)
}
override suspend fun getDynamicAddressesReceiveAddress(userWalletId: UserWalletId, network: Network): String? {
val dynamicAddressesManager = getEnabledDynamicAddressesManagerOrNull(userWalletId, network) ?: return null
return dynamicAddressesManager.findFirstUnusedReceiveAddress()?.address

View file

@ -19,8 +19,10 @@ import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.extensions.orZero
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withContext
import java.math.BigDecimal
@ -98,7 +100,7 @@ internal class DefaultYieldSupplyTransactionRepository(
cryptoCurrency: CryptoCurrency,
): BigDecimal? = withContext(dispatchers.io) {
require(cryptoCurrency is CryptoCurrency.Token)
runCatching {
runSuspendCatching {
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = cryptoCurrency.network.toBlockchain(),
@ -111,7 +113,7 @@ internal class DefaultYieldSupplyTransactionRepository(
decimals = cryptoCurrency.decimals,
),
)
}.onFailure { TangemLogger.e("Error", it) }.getOrThrow()
}.logErrorUnlessCancellation().getOrThrow()
}
@Suppress("LongParameterList")
@ -202,27 +204,27 @@ internal class DefaultYieldSupplyTransactionRepository(
cryptoCurrency: CryptoCurrency,
): String? = withContext(dispatchers.io) {
require(cryptoCurrency is CryptoCurrency.Token)
runCatching {
runSuspendCatching {
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = cryptoCurrency.network.toBlockchain(),
derivationPath = cryptoCurrency.network.derivationPath.value,
) ?: error("Wallet manager not found")
walletManager.calculateYieldModuleAddress()
}.onFailure { TangemLogger.e("Error", it) }.getOrThrow()
}.logErrorUnlessCancellation().getOrThrow()
}
override suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String? =
withContext(dispatchers.io) {
require(cryptoCurrency is CryptoCurrency.Token)
runCatching {
runSuspendCatching {
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = cryptoCurrency.network.toBlockchain(),
derivationPath = cryptoCurrency.network.derivationPath.value,
) ?: error("Wallet manager not found")
walletManager.getYieldModuleAddress()
}.onFailure { TangemLogger.e("Error", it) }.getOrThrow()
}.logErrorUnlessCancellation().getOrThrow()
}
override suspend fun wrapYieldSwapCallDataWithUpgradeIfNeeded(
@ -274,7 +276,7 @@ internal class DefaultYieldSupplyTransactionRepository(
walletManager: WalletManager,
cryptoCurrency: CryptoCurrency.Token,
): YieldSupplyStatus? = withContext(dispatchers.io) {
runCatching {
runSuspendCatching {
val sdkSupplyStatus = walletManager.getYieldSupplyStatus(cryptoCurrency.contractAddress)
val protocolBalance = if (sdkSupplyStatus?.isActive == true) {
walletManager.getEffectiveProtocolBalance(
@ -301,7 +303,20 @@ internal class DefaultYieldSupplyTransactionRepository(
isAllowedToSpend = isAllowedToSpend,
effectiveProtocolBalance = protocolBalance,
)
}.onFailure { TangemLogger.e("Error", it) }.getOrNull()
}.logErrorUnlessCancellation().getOrThrow()
}
/**
* Logs failures via [TangemLogger] but rethrows coroutine cancellation so a cancelled scope
* (e.g. leaving the yield-supply screen) cancels cleanly instead of being reported as a real
* error and swallowed to `null`. Covers both a raw [CancellationException] and one wrapped by
* the blockchain SDK as [BlockchainSdkError.WrappedThrowable] (carried in [Throwable.cause]),
* which is what `EthereumLikeJsonRpcProvider.post` throws when an in-flight RPC is cancelled.
*/
private fun <T> Result<T>.logErrorUnlessCancellation(): Result<T> = onFailure { error ->
val cancellation = error as? CancellationException ?: error.cause as? CancellationException
if (cancellation != null) throw cancellation
TangemLogger.e("Error", error)
}
private fun createDeployTransaction(

View file

@ -33,12 +33,10 @@ enum class OrderType(val wireValue: String) {
companion object {
/**
* All order types that represent issuing a card: the first virtual card (and its KYC
* variants) and an additional card. Used both to filter `findOrders` and to detect
* issue-card conflicts.
* All order types that represent issuing a card: the virtual card and its KYC variants.
* Used both to filter `findOrders` and to detect issue-card conflicts.
*/
val issueCardTypes = setOf(
CARD_ISSUE_ADDITIONAL,
CARD_ISSUE_VIRTUAL_RAIN,
CARD_ISSUE_VIRTUAL_RAIN_KYC,
CARD_ISSUE_VIRTUAL_RAIN_KYC_V2,

View file

@ -18,8 +18,8 @@ internal class OrderConflictRulesTest {
}
@Test
fun `IssueCard is blocked by an active additional-issue order`() {
val active = listOf(order(type = OrderType.CARD_ISSUE_ADDITIONAL, status = OrderStatus.NEW))
fun `IssueCard is blocked by an active KYC v2 issue order`() {
val active = listOf(order(type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2, status = OrderStatus.NEW))
val resolution = OrderConflictRules.resolve(OrderIntent.IssueCard, active)

View file

@ -30,7 +30,7 @@ internal class CheckOrderConflictUseCaseTest {
@Test
fun `WHEN active issue order exists AND intent is IssueCard THEN returns Blocked`() = runTest {
val activeIssue = order(type = OrderType.CARD_ISSUE_ADDITIONAL, status = OrderStatus.PROCESSING)
val activeIssue = order(type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.PROCESSING)
coEvery { repository.findOrders(userWalletId, types = emptySet(), statuses = ACTIVE_STATUSES) } returns
listOf(activeIssue).right()

View file

@ -40,7 +40,7 @@ internal class IssueAdditionalCardUseCaseTest {
private val offer = Offer(
type = Offer.Type.CARD_ISSUE_VIRTUAL_RAIN,
fee = Offer.Fee(amount = BigDecimal("1.00"), currency = Currency.getInstance("USD")),
data = Offer.Data(specificationName = spec, orderType = OrderType.CARD_ISSUE_ADDITIONAL),
data = Offer.Data(specificationName = spec, orderType = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC),
)
@Test
@ -58,7 +58,7 @@ internal class IssueAdditionalCardUseCaseTest {
fun `WHEN active issue order exists THEN reuses it without calling createOrder`() = runTest {
val existing = order(
id = "existing",
type = OrderType.CARD_ISSUE_ADDITIONAL,
type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
status = OrderStatus.PROCESSING,
)
coEvery { offersRepository.getOffers(userWalletId) } returns listOf(offer).right()
@ -66,7 +66,6 @@ internal class IssueAdditionalCardUseCaseTest {
orderRepository.findOrders(
userWalletId,
types = setOf(
OrderType.CARD_ISSUE_ADDITIONAL,
OrderType.CARD_ISSUE_VIRTUAL_RAIN,
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2,
@ -89,7 +88,6 @@ internal class IssueAdditionalCardUseCaseTest {
orderRepository.findOrders(
userWalletId,
types = setOf(
OrderType.CARD_ISSUE_ADDITIONAL,
OrderType.CARD_ISSUE_VIRTUAL_RAIN,
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2,
@ -100,7 +98,7 @@ internal class IssueAdditionalCardUseCaseTest {
coEvery {
orderRepository.createOrder(
userWalletId = userWalletId,
type = OrderType.CARD_ISSUE_ADDITIONAL,
type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
specificationName = spec,
idempotencyKey = any(),
)
@ -118,7 +116,6 @@ internal class IssueAdditionalCardUseCaseTest {
orderRepository.findOrders(
userWalletId,
types = setOf(
OrderType.CARD_ISSUE_ADDITIONAL,
OrderType.CARD_ISSUE_VIRTUAL_RAIN,
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2,
@ -128,13 +125,13 @@ internal class IssueAdditionalCardUseCaseTest {
} returns emptyList<Order>().right()
val newOrder = order(
id = "new",
type = OrderType.CARD_ISSUE_ADDITIONAL,
type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
status = OrderStatus.NEW,
)
coEvery {
orderRepository.createOrder(
userWalletId = userWalletId,
type = OrderType.CARD_ISSUE_ADDITIONAL,
type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
specificationName = spec,
idempotencyKey = any(),
)

View file

@ -34,7 +34,7 @@ internal class RestoreActiveIssueOrdersUseCaseTest {
@Test
fun `GIVEN active issue orders WHEN invoke THEN each order is stored and polled`() = runTest {
// Arrange
val first = order(id = "first", type = OrderType.CARD_ISSUE_ADDITIONAL, status = OrderStatus.NEW)
val first = order(id = "first", type = OrderType.CARD_ISSUE_VIRTUAL_RAIN, status = OrderStatus.NEW)
val second = order(id = "second", type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.PROCESSING)
coEvery {
orderRepository.findOrders(
@ -78,7 +78,7 @@ internal class RestoreActiveIssueOrdersUseCaseTest {
@Test
fun `GIVEN a terminal order leaks through WHEN invoke THEN it is filtered out`() = runTest {
// Arrange
val completed = order(id = "done", type = OrderType.CARD_ISSUE_ADDITIONAL, status = OrderStatus.COMPLETED)
val completed = order(id = "done", type = OrderType.CARD_ISSUE_VIRTUAL_RAIN, status = OrderStatus.COMPLETED)
coEvery {
orderRepository.findOrders(userWalletId, types = ISSUE_ORDER_TYPES, statuses = ACTIVE_STATUSES)
} returns listOf(completed).right()

View file

@ -246,6 +246,8 @@ interface WalletManagersFacade {
*/
suspend fun getPsbtFee(userWalletId: UserWalletId, network: Network, psbtBase64: String): BigDecimal?
suspend fun isSwapSpenderAllowed(userWalletId: UserWalletId, network: Network, spenderAddress: String): Boolean
/**
* Get requirements for asset(currency)
* @return null if there's no requirement, otherwise [AssetRequirementsCondition].

View file

@ -28,7 +28,7 @@ internal class MarketsListBatchFlowManager @AssistedInject constructor(
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val dispatchers: CoroutineDispatcherProvider,
@Assisted private val batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType,
@Assisted private val order: TokenMarketListConfig.Order,
@Assisted private val currentOrder: Provider<TokenMarketListConfig.Order>,
@Assisted private val currentSearchText: Provider<String?>,
@Assisted private val modelScope: CoroutineScope,
) {
@ -192,7 +192,7 @@ internal class MarketsListBatchFlowManager @AssistedInject constructor(
searchText ?: currentSearchText()
},
priceChangeInterval = TokenMarketListConfig.Interval.H24,
order = order,
order = currentOrder(),
shouldNetworks = true,
),
),
@ -262,7 +262,7 @@ internal class MarketsListBatchFlowManager @AssistedInject constructor(
interface Factory {
fun create(
batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType,
order: TokenMarketListConfig.Order,
currentOrder: Provider<TokenMarketListConfig.Order>,
currentSearchText: Provider<String?>,
modelScope: CoroutineScope,
): MarketsListBatchFlowManager

View file

@ -0,0 +1,49 @@
package com.tangem.features.commonfeatures.impl.choosetoken.market.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.markets.TokenMarketListConfig
import kotlinx.collections.immutable.ImmutableList
/**
* Category of the "Market Pulse" block on the Choose asset screen.
*
* Each category maps to a [TokenMarketListConfig.Order] used to fetch the markets list.
*/
internal enum class SwapMarketCategory(
val title: TextReference,
val order: TokenMarketListConfig.Order,
) {
Trending(
title = resourceReference(R.string.markets_sort_by_trending_title),
order = TokenMarketListConfig.Order.Trending,
),
ExperiencedBuyers(
title = resourceReference(R.string.markets_sort_by_experienced_buyers_title),
order = TokenMarketListConfig.Order.Buyers,
),
TopGainers(
title = resourceReference(R.string.markets_sort_by_top_gainers_title),
order = TokenMarketListConfig.Order.TopGainers,
),
TopLosers(
title = resourceReference(R.string.markets_sort_by_top_losers_title),
order = TokenMarketListConfig.Order.TopLosers,
),
}
/**
* UI model for the selectable category tabs of the "Market Pulse" block.
*
* @property items all available categories in display order.
* @property selected currently selected category.
* @property onCategoryClick invoked when a category tab is tapped.
*/
@Immutable
internal data class SwapMarketCategoriesUM(
val items: ImmutableList<SwapMarketCategory>,
val selected: SwapMarketCategory,
val onCategoryClick: (SwapMarketCategory) -> Unit,
)

View file

@ -13,6 +13,8 @@ internal sealed class SwapMarketState {
abstract val marketsTitle: TextReference
abstract val shouldAssetsCount: Boolean
open val categories: SwapMarketCategoriesUM? = null
data class Content(
val items: ImmutableList<MarketsListItemUM>,
val total: Int,
@ -21,17 +23,20 @@ internal sealed class SwapMarketState {
val visibleIdsChanged: (List<CryptoCurrency.RawID>) -> Unit,
override val marketsTitle: TextReference,
override val shouldAssetsCount: Boolean,
override val categories: SwapMarketCategoriesUM? = null,
) : SwapMarketState()
data class Loading(
override val marketsTitle: TextReference,
override val shouldAssetsCount: Boolean,
override val categories: SwapMarketCategoriesUM? = null,
) : SwapMarketState()
data class LoadingError(
val onRetryClicked: () -> Unit,
override val marketsTitle: TextReference,
override val shouldAssetsCount: Boolean,
override val categories: SwapMarketCategoriesUM? = null,
) : SwapMarketState()
data object SearchNothingFound : SwapMarketState() {
@ -40,11 +45,6 @@ internal sealed class SwapMarketState {
}
companion object {
val DefaultLoading
get() = Loading(
marketsTitle = TextReference.Res(R.string.feed_trending_now),
shouldAssetsCount = false,
)
val SearchLoading
get() = Loading(
marketsTitle = TextReference.Res(R.string.markets_common_title),

View file

@ -22,6 +22,8 @@ import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInter
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
import com.tangem.features.commonfeatures.impl.choosetoken.AddToPortfolioRoute
import com.tangem.features.commonfeatures.impl.choosetoken.market.MarketsListBatchFlowManager
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketCategoriesUM
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketCategory
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.Provider
@ -49,6 +51,8 @@ internal class MarketBlockDelegate @AssistedInject constructor(
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
private val visibleDefaultMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
private val selectedCategoryFlow = MutableStateFlow(SwapMarketCategory.Trending)
val addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute> = SlotNavigation()
val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create(
scope = modelScope,
@ -91,7 +95,7 @@ internal class MarketBlockDelegate @AssistedInject constructor(
private val defaultMarketsListManager by lazy {
marketsListBatchFlowManagerFactory.create(
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main,
order = TokenMarketListConfig.Order.Trending,
currentOrder = Provider { selectedCategoryFlow.value.order },
currentSearchText = Provider { null },
modelScope = modelScope,
)
@ -100,7 +104,7 @@ internal class MarketBlockDelegate @AssistedInject constructor(
private val searchMarketsListManager by lazy {
marketsListBatchFlowManagerFactory.create(
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search,
order = TokenMarketListConfig.Order.ByRating,
currentOrder = Provider { TokenMarketListConfig.Order.ByRating },
currentSearchText = Provider { searchQueryState.value.value },
modelScope = modelScope,
)
@ -148,19 +152,26 @@ internal class MarketBlockDelegate @AssistedInject constructor(
}
private fun createDefaultMarketsFlow(): Flow<SwapMarketState> {
val marketsTitle = TextReference.Res(R.string.feed_trending_now)
val marketsTitle = TextReference.Res(R.string.markets_pulse_common_title)
return combine(
defaultMarketsListManager.uiItems,
defaultMarketsListManager.isInInitialLoadingErrorState,
defaultMarketsListManager.totalCount,
) { uiItems, isError, total ->
flow = defaultMarketsListManager.uiItems,
flow2 = defaultMarketsListManager.isInInitialLoadingErrorState,
flow3 = defaultMarketsListManager.totalCount,
flow4 = selectedCategoryFlow,
) { uiItems, isError, total, selectedCategory ->
val categories = buildCategoriesUM(selectedCategory)
when {
isError -> SwapMarketState.LoadingError(
onRetryClicked = { defaultMarketsListManager.reload() },
marketsTitle = marketsTitle,
shouldAssetsCount = false,
categories = categories,
)
uiItems.isEmpty() -> SwapMarketState.Loading(
marketsTitle = marketsTitle,
shouldAssetsCount = false,
categories = categories,
)
uiItems.isEmpty() -> SwapMarketState.DefaultLoading
else -> SwapMarketState.Content(
items = uiItems,
loadMore = { defaultMarketsListManager.loadMore() },
@ -169,11 +180,24 @@ internal class MarketBlockDelegate @AssistedInject constructor(
total = total ?: uiItems.size,
marketsTitle = marketsTitle,
shouldAssetsCount = false,
categories = categories,
)
}
}
}
private fun buildCategoriesUM(selected: SwapMarketCategory): SwapMarketCategoriesUM = SwapMarketCategoriesUM(
items = SwapMarketCategory.entries.toImmutableList(),
selected = selected,
onCategoryClick = ::onCategorySelected,
)
private fun onCategorySelected(category: SwapMarketCategory) {
if (selectedCategoryFlow.value == category) return
selectedCategoryFlow.value = category
defaultMarketsListManager.reload()
}
private fun createSearchMarketsFlow(): Flow<SwapMarketState> {
val marketsTitle = TextReference.Res(R.string.markets_common_title)
return combine(

View file

@ -153,7 +153,7 @@ private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) {
)
if (state.marketsBlock != null) {
item("markets_title_spacer") { SpacerH(height = 20.dp) }
item("markets_title_spacer") { SpacerH(height = 40.dp) }
swapMarketsListItems(state.marketsBlock)
}
}
@ -206,9 +206,9 @@ private fun VisibleItemsTracker(lazyListState: LazyListState, marketState: SwapM
private fun LazyListScope.assetsTitle() {
item(key = "assets_title") {
Text(
text = stringResourceSafe(R.string.swap_your_assets_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
text = stringResourceSafe(R.string.markets_portfolio_block_subtitle),
style = TangemTheme.typography2.headingSemibold20,
color = TangemTheme.colors2.text.neutral.primary,
modifier = Modifier
.fillMaxWidth()
.padding(
@ -224,7 +224,7 @@ private fun LazyListScope.walletListItem(walletList: WalletListUM) {
if (walletList.items.isEmpty()) return
item("wallet_list") {
LazyRow(
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing8),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
@ -249,7 +249,7 @@ private fun WalletTabItem(state: WalletTabUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.clip(RoundedCornerShape(percent = 50))
.background(backgroundColor)
.clickable(onClick = state.onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
@ -259,7 +259,7 @@ private fun WalletTabItem(state: WalletTabUM, modifier: Modifier = Modifier) {
Text(
text = state.text.resolveReference(),
color = buttonTextColor,
style = TangemTheme.typography.button,
style = TangemTheme.typography2.bodySemibold16,
)
val count = state.count
@ -331,7 +331,13 @@ private fun LazyListScope.emptyTokensList(modifier: Modifier = Modifier) {
item("EmptyTokensList") {
Box(
modifier = modifier
.background(TangemTheme.colors.background.secondary)
.background(
color = if (LocalRedesignEnabled.current) {
TangemTheme.colors2.surface.level2
} else {
TangemTheme.colors.background.secondary
},
)
.fillParentMaxSize(),
) {
Column(modifier = Modifier.align(Alignment.Center)) {
@ -362,7 +368,13 @@ private fun LazyListScope.tokensNotFound(modifier: Modifier = Modifier) {
item("TokensNotFound") {
Box(
modifier = modifier
.background(TangemTheme.colors.background.secondary)
.background(
color = if (LocalRedesignEnabled.current) {
TangemTheme.colors2.surface.level2
} else {
TangemTheme.colors.background.secondary
},
)
.fillParentMaxSize(),
) {
Text(

View file

@ -2,6 +2,8 @@ package com.tangem.features.commonfeatures.impl.choosetoken.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -14,10 +16,12 @@ import com.tangem.common.ui.markets.MarketsListItem
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.ds.tabs.TangemTab
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketCategoriesUM
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) {
@ -26,17 +30,22 @@ internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) {
Text(
text = buildAnnotatedString {
append(state.marketsTitle.resolveReference())
if (totalCount != null) {
if (totalCount != null && state.shouldAssetsCount) {
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
append(" $totalCount")
}
}
},
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography2.headingSemibold20,
color = TangemTheme.colors2.text.neutral.primary,
modifier = Modifier.fillMaxWidth().padding(horizontal = TangemTheme.dimens.spacing16),
)
}
state.categories?.let { categories ->
item(key = "market_categories") {
MarketCategoriesRow(categories = categories)
}
}
when (state) {
is SwapMarketState.Loading -> {
items(count = 100, key = { "market_placeholder_$it" }) {
@ -69,7 +78,7 @@ internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) {
modifier = Modifier.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = state.items.lastIndex,
backgroundColor = TangemTheme.colors.background.action,
backgroundColor = TangemTheme.colors.background.primary,
),
)
}
@ -77,6 +86,31 @@ internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) {
}
}
@Composable
private fun MarketCategoriesRow(categories: SwapMarketCategoriesUM, modifier: Modifier = Modifier) {
LazyRow(
modifier = modifier
.fillMaxWidth()
.padding(
top = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing8,
),
contentPadding = PaddingValues(horizontal = TangemTheme.dimens.spacing16),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),
) {
items(
items = categories.items,
key = { category -> category.name },
) { category ->
TangemTab(
text = category.title,
isChecked = category == categories.selected,
onCheckedChange = { categories.onCategoryClick(category) },
)
}
}
}
@Composable
private fun LoadingErrorItem(onTryAgain: () -> Unit, modifier: Modifier = Modifier) {
Box(

View file

@ -199,7 +199,7 @@ internal class DefaultManageFundsComponent @AssistedInject constructor(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_back_28),
onClick = onBackClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
variant = TangemButton.Variant.Secondary,
)
}
} else {
@ -211,7 +211,7 @@ internal class DefaultManageFundsComponent @AssistedInject constructor(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_close_24),
onClick = onCloseClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
variant = TangemButton.Variant.Secondary,
)
},
)

View file

@ -47,6 +47,7 @@ dependencies {
implementation(projects.domain.visa.models)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.yieldSupply)
implementation(projects.domain.notifications)
/** Common modules */
implementation(projects.common.ui)

View file

@ -7,6 +7,7 @@ import com.tangem.domain.express.models.ExpressOperationType
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.IntegratedApprovalData
@ -29,6 +30,8 @@ interface SwapInteractor {
pairs: List<SwapPairLeast>,
): List<SwapProvider>
suspend fun getUnfulfilledReceiveRequirement(toSwapCurrencyStatus: SwapCurrencyStatus): AssetRequirementsCondition?
fun findProvidersForPair(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,

View file

@ -46,6 +46,7 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.AllowanceInfo
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.utils.convertToSdkAmount
@ -62,6 +63,7 @@ import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.toStringWithRightOffset
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.lib.crypto.BlockchainFeeUtils.patchIntegratedApprovalPriorityFee
import com.tangem.lib.crypto.BlockchainUtils.isBitcoin
import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.utils.coroutines.runSuspendCatching
@ -129,11 +131,29 @@ internal class SwapInteractorImpl @Inject constructor(
ConcurrentHashMap<IntegratedApprovalFallbackKey, Boolean>(),
)
private val yieldSwapAllowedRouters = newSetFromMap(ConcurrentHashMap<String, Boolean>())
private fun hasIntegratedApprovalFallenBack(fromSwapCurrencyStatus: SwapCurrencyStatus, spenderAddress: String?) =
integratedApprovalFallbackContexts.contains(
IntegratedApprovalFallbackKey.of(fromSwapCurrencyStatus, spenderAddress),
)
private suspend fun isYieldSwapRouterAllowed(
fromSwapCurrencyStatus: SwapCurrencyStatus,
routerAddress: String,
): Boolean {
val network = fromSwapCurrencyStatus.currency.network
val key = "${network.rawId}:${routerAddress.lowercase()}"
if (yieldSwapAllowedRouters.contains(key)) return true
val isAllowed = walletManagersFacade.isSwapSpenderAllowed(
userWalletId = fromSwapCurrencyStatus.userWalletId,
network = network,
spenderAddress = routerAddress,
)
if (isAllowed) yieldSwapAllowedRouters.add(key)
return isAllowed
}
override suspend fun getPair(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
@ -207,12 +227,14 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus: SwapCurrencyStatus,
pairs: List<SwapPairLeast>,
): List<SwapProvider> {
val requirements = getAssetRequirementsUseCase.invoke(
val fromRequirements = getAssetRequirementsUseCase.invoke(
fromSwapCurrencyStatus.userWalletId,
fromSwapCurrencyStatus.currency,
).getOrNull()
if (!rampStateManager.checkAssetRequirements(requirements)) {
val isToFulfilled = getUnfulfilledReceiveRequirement(toSwapCurrencyStatus) == null
if (!rampStateManager.checkAssetRequirements(fromRequirements) || !isToFulfilled) {
return emptyList()
}
@ -223,6 +245,17 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
override suspend fun getUnfulfilledReceiveRequirement(
toSwapCurrencyStatus: SwapCurrencyStatus,
): AssetRequirementsCondition? {
val requirements = getAssetRequirementsUseCase.invoke(
toSwapCurrencyStatus.userWalletId,
toSwapCurrencyStatus.currency,
).getOrNull()
return requirements?.takeUnless { rampStateManager.checkAssetRequirements(it) }
}
override suspend fun findBestQuote(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
@ -289,7 +322,7 @@ internal class SwapInteractorImpl @Inject constructor(
}
}
}
}.awaitAll().toMap()
}.awaitAll().filterNotNull().toMap()
}
}
@ -301,7 +334,7 @@ internal class SwapInteractorImpl @Inject constructor(
amount: SwapAmount,
reduceBalanceBy: BigDecimal,
expressOperationType: ExpressOperationType,
): Pair<SwapProvider, SwapState> {
): Pair<SwapProvider, SwapState>? {
if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true &&
!swapFeatureToggles.isYieldSwapEnabled
) {
@ -351,6 +384,12 @@ internal class SwapInteractorImpl @Inject constructor(
val dexRouterSpenderAddress = maybeQuote.getOrNull()?.allowanceContract
if (isYieldSwap && dexRouterSpenderAddress != null &&
!isYieldSwapRouterAllowed(fromSwapCurrencyStatus, dexRouterSpenderAddress)
) {
return null
}
val allowanceInfo = spenderAddress?.let { allowanceContract ->
getAllowanceInfoUseCase(
userWalletId = fromSwapCurrencyStatus.userWalletId,
@ -1446,11 +1485,13 @@ internal class SwapInteractorImpl @Inject constructor(
raise(GetFeeError.DataError(error))
}
val approvalFee = getFeeUseCase(
transactionData = approvalTx,
userWallet = fromStatus.userWallet,
network = fromStatus.currency.network,
).bind()
val approvalFee = runSuspendCatching {
getFeeUseCase(
transactionData = approvalTx,
userWallet = fromStatus.userWallet,
network = fromStatus.currency.network,
).bind().patchIntegratedApprovalPriorityFee(INCREASE_GAS_PRICE_FOR_INTEGRATED_APPROVAL)
}.getOrElse { error -> raise(GetFeeError.DataError(error)) }
IntegratedApprovalData(
approvalTransaction = approvalTx,
@ -2431,6 +2472,8 @@ internal class SwapInteractorImpl @Inject constructor(
}
companion object {
private const val INCREASE_GAS_PRICE_FOR_INTEGRATED_APPROVAL = 115 // 15% increase
private val PRICE_IMPACT_AMOUNT_MIN_THRESHOLD = 25.toBigDecimal() // in USD
private val PRICE_IMPACT_AMOUNT_MAX_THRESHOLD = 5000.toBigDecimal() // in USD
private val PRICE_IMPACT_AMOUNT_LOW_THRESHOLD = 100_000.toBigDecimal() // in USD

View file

@ -54,6 +54,7 @@ sealed interface SwapState {
val isAccountsMode: Boolean,
val isFeeCoverage: Boolean,
val sendingAmount: BigDecimal,
val tronFeeNotificationShowCount: Int,
val isSendingAmountLoading: Boolean = false,
val currencyCheck: CryptoCurrencyCheck? = null,
val validationResult: Throwable? = null,

View file

@ -53,4 +53,6 @@ interface SwapTransferInteractor {
cryptoAmount: BigDecimal,
toSwapCurrencyStatus: SwapCurrencyStatus,
): Either<SendTransactionError, WithdrawalResult>
suspend fun incrementTronTokenFeeShowCount(cryptoCurrencyStatus: CryptoCurrencyStatus?)
}

View file

@ -19,6 +19,8 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.notifications.GetTronFeeNotificationShowCountUseCase
import com.tangem.domain.notifications.IncrementNotificationsShowCountUseCase
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
@ -62,6 +64,8 @@ class SwapTransferInteractorImpl @Inject constructor(
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase,
private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase,
private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase,
private val getTronFeeNotificationShowCountUseCase: GetTronFeeNotificationShowCountUseCase,
private val incrementNotificationsShowCountUseCase: IncrementNotificationsShowCountUseCase,
) : SwapTransferInteractor {
override suspend fun updateTransfer(
@ -120,6 +124,7 @@ class SwapTransferInteractorImpl @Inject constructor(
feeStatus = feeStatus,
)
}
val tronFeeNotificationShowCount = getTronFeeNotificationShowCountUseCase()
return SwapState.Transfer(
userWallet = userWallet,
fromTokenInfo = fromTokenInfo,
@ -131,6 +136,7 @@ class SwapTransferInteractorImpl @Inject constructor(
isAccountsMode = isAccountsMode,
isFeeCoverage = coverageState.isFeeCoverage,
sendingAmount = coverageState.sendingAmount,
tronFeeNotificationShowCount = tronFeeNotificationShowCount,
isSendingAmountLoading = coverageState.isSendingAmountLoading,
currencyCheck = currencyCheck,
)
@ -390,4 +396,10 @@ class SwapTransferInteractorImpl @Inject constructor(
private fun SwapCurrencyStatus.destinationAddress(): String? {
return status.value.networkAddress?.defaultAddress?.value
}
override suspend fun incrementTronTokenFeeShowCount(cryptoCurrencyStatus: CryptoCurrencyStatus?) {
cryptoCurrencyStatus?.currency?.let { cryptoCurrency ->
incrementNotificationsShowCountUseCase(cryptoCurrency)
}
}
}

View file

@ -888,6 +888,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
coEvery {
yieldModuleAddressProvider.getOrFetch(any(), any())
} returns yieldProxyAddress
coEvery { walletManagersFacade.isSwapSpenderAllowed(any(), any(), any()) } returns true
}
@Test
@ -1131,6 +1132,144 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class YieldSwapRouterAllowlist {
private val yieldProxyAddress = "0xYieldModuleProxy"
private val yieldTokenContract = "0xTokenContract"
private val notAllowedRouter = "0xMoonPayRouter"
private val allowedRouter = "0xOneInchRouter"
@BeforeEach
fun enableYieldSwap() {
every { swapFeatureToggles.isYieldSwapEnabled } returns true
coEvery { yieldModuleAddressProvider.getOrFetch(any(), any()) } returns yieldProxyAddress
}
private fun yieldTokenStatus(yieldActive: Boolean = true) = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = yieldTokenContract,
isCoin = false,
amount = BigDecimal("10"),
yieldSupplyActive = yieldActive,
yieldSupplyAllowedToSpend = true,
)
private fun stubDexQuote(providerId: String, router: String) {
coEvery {
repository.findBestQuote(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
fromDecimals = any(), toDecimals = any(),
providerId = providerId, rateType = any(),
)
} returns buildQuoteModel(allowanceContract = router).right()
}
private fun stubExchangeData(providerId: String) {
coEvery {
repository.getExchangeData(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), fromAddress = any(), toNetwork = any(),
fromAmount = any(), fromDecimals = any(), toDecimals = any(),
providerId = providerId, rateType = any(), toAddress = any(),
expressOperationType = any(), refundAddress = any(),
)
} returns buildSwapDataModelDex().right()
}
@Test
fun `should hide yield-swap DEX provider whose router is not allowed by the registry`() = runTest {
// Given — yield active, router NOT in the SwapExecutionRegistry (MoonPay/swaps.xyz)
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
stubDexQuote(dexProvider.providerId, notAllowedRouter)
coEvery { walletManagersFacade.isSwapSpenderAllowed(any(), any(), notAllowedRouter) } returns false
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = yieldTokenStatus(),
toSwapCurrencyStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork),
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — provider is absent from the list
assertThat(result.containsKey(dexProvider)).isFalse()
assertThat(result).isEmpty()
}
@Test
fun `should keep yield-swap DEX provider whose router is allowed by the registry`() = runTest {
// Given — yield active, router whitelisted (1inch)
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
stubDexQuote(dexProvider.providerId, allowedRouter)
stubExchangeData(dexProvider.providerId)
coEvery { walletManagersFacade.isSwapSpenderAllowed(any(), any(), allowedRouter) } returns true
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = yieldTokenStatus(),
toSwapCurrencyStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork),
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then
assertThat(result.containsKey(dexProvider)).isTrue()
assertThat(result[dexProvider]).isNotNull()
}
@Test
fun `should hide only the not-allowed router and keep the allowed one for yield swaps`() = runTest {
// Given — two DEX providers, only one router whitelisted
val allowedProvider = buildSwapProvider(ExchangeProviderType.DEX, "dex-allowed")
val blockedProvider = buildSwapProvider(ExchangeProviderType.DEX, "dex-blocked")
stubDexQuote(allowedProvider.providerId, allowedRouter)
stubDexQuote(blockedProvider.providerId, notAllowedRouter)
stubExchangeData(allowedProvider.providerId)
coEvery { walletManagersFacade.isSwapSpenderAllowed(any(), any(), allowedRouter) } returns true
coEvery { walletManagersFacade.isSwapSpenderAllowed(any(), any(), notAllowedRouter) } returns false
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = yieldTokenStatus(),
toSwapCurrencyStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork),
providers = listOf(allowedProvider, blockedProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then
assertThat(result.containsKey(allowedProvider)).isTrue()
assertThat(result.containsKey(blockedProvider)).isFalse()
}
@Test
fun `should not apply the registry filter to regular non-yield swaps`() = runTest {
// Given — yield NOT active; the registry verdict must be irrelevant for plain DEX swaps
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
stubDexQuote(dexProvider.providerId, notAllowedRouter)
stubExchangeData(dexProvider.providerId)
coEvery { walletManagersFacade.isSwapSpenderAllowed(any(), any(), any()) } returns false
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = yieldTokenStatus(yieldActive = false),
toSwapCurrencyStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork),
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — provider stays; the on-chain allowlist does not gate non-yield swaps
assertThat(result.containsKey(dexProvider)).isTrue()
coVerify(exactly = 0) { walletManagersFacade.isSwapSpenderAllowed(any(), any(), any()) }
}
}
/**
* Regular (non-yield) DEX swap with the integrated-approve toggle ON: the
* `isAllowanceSatisfied` matrix in `manageDex`.

View file

@ -4,6 +4,7 @@ import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import io.mockk.coEvery
import io.mockk.every
@ -156,5 +157,94 @@ internal class SwapInteractorImplFindProvidersForPairTest : SwapInteractorImplTe
// Then
assertThat(result).containsExactly(providerA, providerB)
}
@Test
fun `should return empty list when destination asset requires association even if source is fulfilled`() =
runTest {
// Arrange — source has no requirements, but the destination (e.g. unassociated Hedera HTS token)
// requires an on-chain opt-in. Without this check the swap would proceed and the payout would
// get stuck (AND-Hedera ERC20/HTS association).
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true)
val toStatus = buildSwapCurrencyStatus(
networkRawId = btcNetwork,
contractAddress = "0xAbc",
isCoin = false,
)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0xAbc",
providers = listOf(buildSwapProvider(ExchangeProviderType.CEX, "A")),
)
val toRequirement = AssetRequirementsCondition.PaidTransaction
coEvery {
getAssetRequirementsUseCase.invoke(any(), fromStatus.currency)
} returns null.right()
coEvery {
getAssetRequirementsUseCase.invoke(any(), toStatus.currency)
} returns toRequirement.right()
every { rampStateManager.checkAssetRequirements(null) } returns true
every { rampStateManager.checkAssetRequirements(toRequirement) } returns false
// Act
val result = sut.findProvidersForPairWithCheck(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
pairs = listOf(pair),
)
// Assert
assertThat(result).isEmpty()
}
}
@Nested
inner class GetUnfulfilledReceiveRequirement {
@Test
fun `should return requirement when destination asset requirement is not fulfilled`() = runTest {
// Arrange
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0xAbc", isCoin = false)
val requirement = AssetRequirementsCondition.PaidTransaction
coEvery { getAssetRequirementsUseCase.invoke(any(), any()) } returns requirement.right()
every { rampStateManager.checkAssetRequirements(requirement) } returns false
// Act
val result = sut.getUnfulfilledReceiveRequirement(toStatus)
// Assert
assertThat(result).isEqualTo(requirement)
}
@Test
fun `should return null when destination asset requirement is fulfilled`() = runTest {
// Arrange
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0xAbc", isCoin = false)
val requirement = AssetRequirementsCondition.PaidTransaction
coEvery { getAssetRequirementsUseCase.invoke(any(), any()) } returns requirement.right()
every { rampStateManager.checkAssetRequirements(requirement) } returns true
// Act
val result = sut.getUnfulfilledReceiveRequirement(toStatus)
// Assert
assertThat(result).isNull()
}
@Test
fun `should return null when there is no destination asset requirement`() = runTest {
// Arrange
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0xAbc", isCoin = false)
coEvery { getAssetRequirementsUseCase.invoke(any(), any()) } returns null.right()
every { rampStateManager.checkAssetRequirements(null) } returns true
// Act
val result = sut.getUnfulfilledReceiveRequirement(toStatus)
// Assert
assertThat(result).isNull()
}
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.feature.swap.domain
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
@ -17,6 +18,7 @@ import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
import java.math.BigInteger
/**
* Tests for [SwapInteractorImpl.loadIntegratedApprovalData].
@ -185,6 +187,211 @@ internal class SwapInteractorImplLoadIntegratedApprovalDataTest : SwapInteractor
}
}
// region patchIntegratedApprovalPriorityFee — INCREASE_GAS_PRICE_FOR_INTEGRATED_APPROVAL (115 = +15% gas-price)
/**
* The loaded approval fee is patched via
* [com.tangem.lib.crypto.BlockchainFeeUtils.patchIntegratedApprovalPriorityFee] before being
* returned. Scales the **gas-price** fields (Legacy `gasPrice`; EIP1559
* `maxFeePerGas` and `priorityFee`) and the derived `amount` for [Fee.Ethereum] legs by 15%;
* The new `amount` is recomputed from `gasLimit * newGasPrice` shifted left by `decimals`,
* independent of the input amount value.
*/
@Test
fun `GIVEN Ethereum Legacy Single fee WHEN loaded THEN gasPrice and amount bumped by 15 percent`() = runTest {
// Arrange
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
val initialFee = Fee.Ethereum.Legacy(
amount = ethAmount(BigDecimal("0.002")), // gasLimit * gasPrice / 1e18 = 100_000 * 20e9 / 1e18
gasLimit = BigInteger.valueOf(100_000),
gasPrice = BigInteger.valueOf(20_000_000_000),
)
coEvery {
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
} returns TransactionFee.Single(normal = initialFee).right()
// Act
val result = loadLimited(fromStatus)
// Assert
val patched = result.singleNormal<Fee.Ethereum.Legacy>()
// gasLimit is NOT changed by this patch (it bumps gas-price only)
assertThat(patched.gasLimit).isEqualTo(BigInteger.valueOf(100_000))
// 20_000_000_000 * 115 / 100 = 23_000_000_000
assertThat(patched.gasPrice).isEqualTo(BigInteger.valueOf(23_000_000_000))
// amount recomputed from gasLimit * newGasPrice: 100_000 * 23e9 / 1e18 = 0.0023
assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.0023"))
// amount decimals preserved
assertThat(patched.amount.decimals).isEqualTo(18)
}
@Test
fun `GIVEN Ethereum EIP1559 Single fee WHEN loaded THEN gas-price fields bumped AND gasLimit untouched`() =
runTest {
// Arrange
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
val initialFee = Fee.Ethereum.EIP1559(
amount = ethAmount(BigDecimal("0.0032")), // gasLimit * maxFeePerGas / 1e18 = 80_000 * 40e9 / 1e18
gasLimit = BigInteger.valueOf(80_000),
maxFeePerGas = BigInteger.valueOf(40_000_000_000),
priorityFee = BigInteger.valueOf(2_000_000_000),
)
coEvery {
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
} returns TransactionFee.Single(normal = initialFee).right()
// Act
val result = loadLimited(fromStatus)
// Assert
val patched = result.singleNormal<Fee.Ethereum.EIP1559>()
// gasLimit is NOT changed by this patch (it bumps gas-price only)
assertThat(patched.gasLimit).isEqualTo(BigInteger.valueOf(80_000))
// EIP1559 gas-price fields scaled by 115 / 100
assertThat(patched.maxFeePerGas).isEqualTo(BigInteger.valueOf(46_000_000_000))
assertThat(patched.priorityFee).isEqualTo(BigInteger.valueOf(2_300_000_000))
// amount recomputed from gasLimit * newMaxFeePerGas: 80_000 * 46e9 / 1e18 = 0.00368
assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.00368"))
}
@Test
fun `GIVEN Choosable Ethereum fee WHEN loaded THEN all three legs gasPrice bumped by 15 percent`() = runTest {
// Arrange
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
val gasPrice = BigInteger.valueOf(20_000_000_000)
val choosable = TransactionFee.Choosable(
minimum = Fee.Ethereum.Legacy(
amount = ethAmount(BigDecimal("0.0008")), // 40_000 * 20e9 / 1e18
gasLimit = BigInteger.valueOf(40_000),
gasPrice = gasPrice,
),
normal = Fee.Ethereum.Legacy(
amount = ethAmount(BigDecimal("0.0016")), // 80_000 * 20e9 / 1e18
gasLimit = BigInteger.valueOf(80_000),
gasPrice = gasPrice,
),
priority = Fee.Ethereum.Legacy(
amount = ethAmount(BigDecimal("0.0024")), // 120_000 * 20e9 / 1e18
gasLimit = BigInteger.valueOf(120_000),
gasPrice = gasPrice,
),
)
coEvery {
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
} returns choosable.right()
// Act
val patched = (loadLimited(fromStatus).feeOrFail() as TransactionFee.Choosable)
// Assert — every leg's gas-price scaled (gasPrice * 115 / 100 = 23e9), gasLimit unchanged
val newGasPrice = BigInteger.valueOf(23_000_000_000)
assertThat((patched.minimum as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(40_000))
assertThat((patched.minimum as Fee.Ethereum.Legacy).gasPrice).isEqualTo(newGasPrice)
assertThat((patched.normal as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(80_000))
assertThat((patched.normal as Fee.Ethereum.Legacy).gasPrice).isEqualTo(newGasPrice)
assertThat((patched.priority as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(120_000))
assertThat((patched.priority as Fee.Ethereum.Legacy).gasPrice).isEqualTo(newGasPrice)
}
@Test
fun `GIVEN non-Ethereum approval fee WHEN loaded THEN fee is returned unchanged`() = runTest {
// Arrange
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
val commonFee = Fee.Common(amount = ethAmount(BigDecimal("0.5"), decimals = 8))
coEvery {
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
} returns TransactionFee.Single(normal = commonFee).right()
// Act
val result = loadLimited(fromStatus)
// Assert — non-Ethereum legs pass through untouched (same instance)
assertThat(result.singleNormal<Fee.Common>()).isSameInstanceAs(commonFee)
}
@Test
fun `GIVEN Ethereum Legacy fee with zero gasLimit WHEN loaded THEN gasPrice bumped and amount is zero`() =
runTest {
// Arrange
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
val zeroGasFee = Fee.Ethereum.Legacy(
amount = ethAmount(BigDecimal("0.000002")),
gasLimit = BigInteger.ZERO,
gasPrice = BigInteger.valueOf(20_000_000_000),
)
coEvery {
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
} returns TransactionFee.Single(normal = zeroGasFee).right()
// Act
val result = loadLimited(fromStatus)
// Assert — the gas-price path does NOT short-circuit on zero gasLimit (unlike the
// gas-limit path); gasPrice is still bumped and amount recomputes to gasLimit(0) * price = 0
val patched = result.singleNormal<Fee.Ethereum.Legacy>()
assertThat(patched.gasLimit).isEqualTo(BigInteger.ZERO)
assertThat(patched.gasPrice).isEqualTo(BigInteger.valueOf(23_000_000_000))
assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal.ZERO)
}
@Test
fun `GIVEN Ethereum TokenCurrency approval fee WHEN loaded THEN returns Left DataError wrapping [REDACTED_TASK_KEY]`() =
runTest {
// Arrange
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
val tokenFee = Fee.Ethereum.TokenCurrency(
amount = ethAmount(BigDecimal("0.001")),
gasLimit = BigInteger.valueOf(100_000),
coinPriceInToken = BigInteger.ONE,
feeTransferGasLimit = BigInteger.valueOf(50_000),
baseGas = BigInteger.valueOf(21_000),
)
coEvery {
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
} returns TransactionFee.Single(normal = tokenFee).right()
// Act — the patch throws IllegalStateException, but the fee load is wrapped in
// runSuspendCatching ([REDACTED_TASK_KEY]) so it is caught and converted to Left(DataError)
// instead of crashing the DEX swap flow.
val result = loadLimited(fromStatus)
// Assert
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.DataError::class.java)
val cause = (error as GetFeeError.DataError).cause
assertThat(cause).isInstanceOf(IllegalStateException::class.java)
assertThat(cause?.message).contains("[REDACTED_TASK_KEY]")
}
}
// endregion
private suspend fun loadLimited(fromStatus: com.tangem.domain.swap.models.SwapCurrencyStatus) =
sut.loadIntegratedApprovalData(
fromStatus = fromStatus,
spenderAddress = SPENDER,
approveType = ApproveType.LIMITED,
approvalAmount = SWAP_AMOUNT,
)
/** Unwraps a Right result into its [TransactionFee], failing the test on Left. */
private fun arrow.core.Either<GetFeeError, IntegratedApprovalData>.feeOrFail(): TransactionFee {
assertThat(isRight()).isTrue()
return getOrNull()!!.approvalFee
}
/** Unwraps a Right result into the `normal` leg of a [TransactionFee.Single], cast to [T]. */
private inline fun <reified T : Fee> arrow.core.Either<GetFeeError, IntegratedApprovalData>.singleNormal(): T {
return (feeOrFail() as TransactionFee.Single).normal as T
}
private fun ethAmount(value: BigDecimal, decimals: Int = 18): Amount = Amount(
currencySymbol = "ETH",
value = value,
decimals = decimals,
)
private companion object {
const val SPENDER = "0xSpender"
const val CONTRACT = "0xContract"

View file

@ -16,6 +16,8 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.notifications.GetTronFeeNotificationShowCountUseCase
import com.tangem.domain.notifications.IncrementNotificationsShowCountUseCase
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
@ -61,6 +63,8 @@ internal class SwapTransferInteractorImplTest {
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase = mockk()
private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase = mockk()
private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase = mockk(relaxed = true)
private val getTronFeeNotificationShowCountUseCase: GetTronFeeNotificationShowCountUseCase = mockk(relaxed = true)
private val incrementNotificationsShowCountUseCase: IncrementNotificationsShowCountUseCase = mockk(relaxed = true)
private val sut = SwapTransferInteractorImpl(
swapFeatureToggles = swapFeatureToggles,
@ -76,6 +80,8 @@ internal class SwapTransferInteractorImplTest {
isAmountSubtractAvailableUseCase = isAmountSubtractAvailableUseCase,
tangemPayWithdrawUseCase = tangemPayWithdrawUseCase,
getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase,
getTronFeeNotificationShowCountUseCase = getTronFeeNotificationShowCountUseCase,
incrementNotificationsShowCountUseCase = incrementNotificationsShowCountUseCase,
)
@AfterEach
@ -180,6 +186,7 @@ internal class SwapTransferInteractorImplTest {
isAccountsMode = true,
isFeeCoverage = false,
sendingAmount = expectedAmount,
tronFeeNotificationShowCount = 0,
currencyCheck = currencyCheck,
)
assertThat(result).isEqualTo(expected)
@ -251,6 +258,7 @@ internal class SwapTransferInteractorImplTest {
isAccountsMode = true,
isFeeCoverage = false,
sendingAmount = expectedAmount,
tronFeeNotificationShowCount = 0,
currencyCheck = currencyCheck,
)
assertThat(result).isEqualTo(expected)

View file

@ -78,6 +78,7 @@ import com.tangem.domain.tangempay.TangemPayWithdrawWithSwapUseCase
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
@ -650,7 +651,7 @@ internal class SwapModel @Inject constructor(
pairs = dataState.pairs,
)
if (toProvidersList.isEmpty()) {
handleSwapNotSupported(
handlePairUnavailable(
fromSwapCurrencyStatus = newFromSwapCurrencyStatus,
toSwapCurrencyStatus = newToSwapCurrencyStatus,
)
@ -714,7 +715,7 @@ internal class SwapModel @Inject constructor(
pairs = pairs,
)
if (providerList.isEmpty()) {
handleSwapNotSupported(
handlePairUnavailable(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
)
@ -822,6 +823,7 @@ internal class SwapModel @Inject constructor(
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = feePaidCryptoCurrency,
fee = selectedFee,
feeError = feeSelectorRepository.state.value as? FeeSelectorUM.Error,
)
when {
uiState.successState != null -> Unit
@ -866,9 +868,10 @@ internal class SwapModel @Inject constructor(
transferState = refreshed,
actions = actions,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
feePaidCryptoCurrencyStatus = feePaidCryptoCurrencyStatus ?: dataState.feePaidCryptoCurrency,
fee = fee,
isTangemPayWithdrawal = isTangemPayWithdrawal(),
feeError = feeSelectorRepository.state.value as? FeeSelectorUM.Error,
)
}
}
@ -927,6 +930,16 @@ internal class SwapModel @Inject constructor(
)
return
}
if (toProvidersList.isEmpty()) {
modelScope.launch {
handlePairUnavailable(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
)
}
return
}
if (!isSilent) {
uiState = stateBuilder.createQuotesLoadingState(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
@ -1318,6 +1331,15 @@ internal class SwapModel @Inject constructor(
return
}
modelScope.launch(dispatchers.main) {
val toRequirement = swapInteractor.getUnfulfilledReceiveRequirement(toSwapCurrencyStatus)
if (toRequirement != null) {
handleDestinationRequirementBlocked(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
requirement = toRequirement,
)
return@launch
}
runCatching(dispatchers.io) {
swapInteractor.onSwap(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
@ -2105,6 +2127,7 @@ internal class SwapModel @Inject constructor(
},
onSwapUIModeChange = ::onSwapUIModeChange,
onSwapTypeMenuOpened = ::onSwapTypeMenuOpened,
onTronBannerShown = ::incrementTronTokenFeeShowCount,
)
}
@ -2132,6 +2155,16 @@ internal class SwapModel @Inject constructor(
)
}
private fun incrementTronTokenFeeShowCount() {
// Fired once per banner appearance from the UI (tied to the banner's composition lifecycle),
// so the show-count advances per appearance rather than on every transfer-state rebuild.
modelScope.launch {
swapTransferInteractor.incrementTronTokenFeeShowCount(
cryptoCurrencyStatus = dataState.fromSwapCurrencyStatus?.status,
)
}
}
private fun selectWalletInSelector(
fromSwapCurrencyStatus: SwapCurrencyStatus?,
toSwapCurrencyStatus: SwapCurrencyStatus?,
@ -2238,6 +2271,50 @@ internal class SwapModel @Inject constructor(
)
}
private suspend fun handlePairUnavailable(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
) {
val toRequirement = swapInteractor.getUnfulfilledReceiveRequirement(toSwapCurrencyStatus)
if (toRequirement != null) {
handleDestinationRequirementBlocked(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
requirement = toRequirement,
)
} else {
handleSwapNotSupported(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
)
}
}
private fun handleDestinationRequirementBlocked(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
requirement: AssetRequirementsCondition,
) {
singleTaskScheduler.cancelTask()
lastReducedBalanceBy.value = BigDecimal.ZERO
lastAmount.value = INITIAL_AMOUNT
isFiatInput.value = false
uiState = stateBuilder.createDestinationRequirementBlockedState(
uiStateHolder = uiState,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
requirement = requirement,
onAssociateClick = {
appRouter.push(
AppRoute.CurrencyDetails(
userWalletId = toSwapCurrencyStatus.userWalletId,
currency = toSwapCurrencyStatus.currency,
),
)
},
)
}
private fun handleSwapNotSupported(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,

View file

@ -20,6 +20,7 @@ import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
@ -78,6 +79,19 @@ internal class SwapNotificationsFactory(
)
}
fun getDestinationRequirementNotifications(
requirement: AssetRequirementsCondition,
onAssociateClick: () -> Unit,
): ImmutableList<NotificationUM> {
val notification = when (requirement) {
is AssetRequirementsCondition.RequiredTrustline ->
SwapNotificationUM.Warning.TokenTrustlineRequired(onAssociateClick)
else ->
SwapNotificationUM.Warning.TokenAssociationRequired(onAssociateClick)
}
return persistentListOf(notification)
}
fun getQuotesErrorStateNotifications(
expressDataError: ExpressDataError,
fromToken: CryptoCurrency,

View file

@ -49,6 +49,7 @@ internal data class SwapStateHolder(
val onShowPermissionBottomSheet: () -> Unit = {},
val onSwapUIModeChange: (SwapUIMode) -> Unit = {},
val onSwapTypeMenuOpened: () -> Unit = {},
val onTronBannerShown: () -> Unit = {},
)
@Immutable

View file

@ -34,4 +34,5 @@ internal data class UiActions(
val onReceiveCardWarningClick: () -> Unit,
val onSwapUIModeChange: (SwapUIMode) -> Unit,
val onSwapTypeMenuOpened: () -> Unit,
val onTronBannerShown: () -> Unit,
)

View file

@ -149,6 +149,28 @@ internal object SwapNotificationUM {
),
)
data class TokenAssociationRequired(
val onAssociateClick: () -> Unit,
) : Warning(
title = resourceReference(R.string.warning_hedera_missing_token_association_title),
subtitle = resourceReference(R.string.warning_receive_blocked_hedera_token_association_required_message),
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.warning_hedera_missing_token_association_button_title),
onClick = onAssociateClick,
),
)
data class TokenTrustlineRequired(
val onAssociateClick: () -> Unit,
) : Warning(
title = resourceReference(R.string.warning_token_trustline_title),
subtitle = resourceReference(R.string.warning_receive_blocked_token_trustline_required_message),
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.warning_token_trustline_button_title),
onClick = onAssociateClick,
),
)
data class NeedReserveToCreateAccount(
val receiveAmount: String,
val receiveToken: String,
@ -251,5 +273,10 @@ internal object SwapNotificationUM {
onClick = onApproveClick,
),
)
data object TronTokenFee : Info(
title = resourceReference(R.string.tron_will_be_send_token_fee_title),
subtitle = resourceReference(R.string.tron_will_be_send_token_fee_description),
)
}
}

View file

@ -35,6 +35,7 @@ import com.tangem.domain.swap.models.PredefinedPercentAmount
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.AssetRequirementsCondition
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.converters.SwapProviderResolver
import com.tangem.feature.swap.converters.SwapProviderStateBuilder
@ -122,6 +123,7 @@ internal class StateBuilder(
swapUIMode = swapUIMode,
onSwapUIModeChange = actions.onSwapUIModeChange,
onSwapTypeMenuOpened = actions.onSwapTypeMenuOpened,
onTronBannerShown = actions.onTronBannerShown,
shouldShowAbMenu = swapFeatureToggles.isSwapAbEnabled,
)
}
@ -455,6 +457,34 @@ internal class StateBuilder(
uiStateHolder: SwapStateHolder,
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
): SwapStateHolder = createBlockedSwapState(
uiStateHolder = uiStateHolder,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
notifications = notificationsFactory.getSwapNotSupportedNotifications(),
)
fun createDestinationRequirementBlockedState(
uiStateHolder: SwapStateHolder,
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
requirement: AssetRequirementsCondition,
onAssociateClick: () -> Unit,
): SwapStateHolder = createBlockedSwapState(
uiStateHolder = uiStateHolder,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
notifications = notificationsFactory.getDestinationRequirementNotifications(
requirement = requirement,
onAssociateClick = onAssociateClick,
),
)
private fun createBlockedSwapState(
uiStateHolder: SwapStateHolder,
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
notifications: ImmutableList<NotificationUM>,
): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
return uiStateHolder.copy(
@ -482,7 +512,7 @@ internal class StateBuilder(
isBalanceHidden = isBalanceHiddenProvider(),
appCurrency = appCurrencyProvider(),
),
notifications = notificationsFactory.getSwapNotSupportedNotifications(),
notifications = notifications,
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet),
isEnabled = false,

View file

@ -94,7 +94,12 @@ internal fun SwapScreenContent(
feeBlock?.invoke(Modifier.fillMaxWidth())
if (state.notifications.isNotEmpty()) SwapNotifications(notifications = state.notifications)
if (state.notifications.isNotEmpty()) {
SwapNotifications(
notifications = state.notifications,
onTronBannerShown = state.onTronBannerShown,
)
}
SpacerHMax()
@ -342,7 +347,13 @@ private fun SwapButton(state: SwapStateHolder, modifier: Modifier = Modifier) {
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
private fun SwapNotifications(notifications: List<NotificationUM>) {
private fun SwapNotifications(notifications: List<NotificationUM>, onTronBannerShown: () -> Unit) {
// The Tron token-fee banner's show-count is an "impression": tied to actual on-screen visibility.
// LaunchedEffect re-arms only when the boolean flips, so it fires once per hidden -> shown appearance.
val isTronBannerShown = notifications.any { it is SwapNotificationUM.Info.TronTokenFee }
LaunchedEffect(isTronBannerShown) {
if (isTronBannerShown) onTronBannerShown()
}
Column(
modifier = Modifier
.background(color = TangemTheme.colors.background.secondary)

View file

@ -6,18 +6,22 @@ import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNot
import com.tangem.common.ui.notifications.NotificationsFactory.addExceedsBalanceNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeCoverageNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeUnreachableNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.lib.crypto.BlockchainUtils.getTezosThreshold
import com.tangem.lib.crypto.BlockchainUtils.isTezos
import com.tangem.lib.crypto.BlockchainUtils.isTron
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
@ -31,9 +35,8 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
transferState: SwapState.Transfer,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
onReduceByAmount: (SwapAmount, BigDecimal) -> Unit,
onReduceToAmount: (SwapAmount) -> Unit,
onBuyClick: (CryptoCurrency) -> Unit,
actions: UiActions,
getFeeError: GetFeeError?,
): ImmutableList<NotificationUM> {
return buildList {
maybeAddRentExemptionError(transferState)
@ -41,11 +44,21 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
state = transferState,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
fee = fee,
onReduceByAmount = onReduceByAmount,
onReduceToAmount = onReduceToAmount,
onReduceByAmount = actions.onReduceByAmount,
onReduceToAmount = actions.onReduceToAmount,
)
maybeAddNeedReserveToCreateAccountWarning(transferState)
maybeAddExceedsBalanceNotification(transferState, onBuyClick)
maybeAddExceedsBalanceNotification(transferState, onBuyClick = actions.openTokenDetailsScreen)
addTronNetworkFeesNotification(
cryptoCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus.status,
transferState = transferState,
)
maybeAddFeeUnreachableNotification(
transferState = transferState,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
feeError = getFeeError,
actions = actions,
)
}.toPersistentList()
}
@ -194,4 +207,39 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
onResetAnalyticsEvent = {},
)
}
private fun MutableList<NotificationUM>.addTronNetworkFeesNotification(
cryptoCurrencyStatus: CryptoCurrencyStatus,
transferState: SwapState.Transfer,
) {
val cryptoCurrency = cryptoCurrencyStatus.currency
val isTronToken = cryptoCurrency is CryptoCurrency.Token && isTron(cryptoCurrency.network.rawId)
val isVisible = isTronToken &&
transferState.tronFeeNotificationShowCount <= TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT
if (isVisible) {
add(SwapNotificationUM.Info.TronTokenFee)
}
}
private fun MutableList<NotificationUM>.maybeAddFeeUnreachableNotification(
transferState: SwapState.Transfer,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
feeError: GetFeeError?,
actions: UiActions,
) {
feeCryptoCurrencyStatus ?: return
addFeeUnreachableNotification(
tokenStatus = transferState.fromTokenInfo.swapCurrencyStatus.status,
coinStatus = feeCryptoCurrencyStatus,
feeError = feeError,
dustValue = transferState.currencyCheck?.dustValue,
onReload = actions.onRetryClick,
onClick = actions.openTokenDetailsScreen,
)
}
companion object {
private const val TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT = 3
}
}

View file

@ -37,6 +37,7 @@ import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.ui.SwapAmountScreenClickIntents
import com.tangem.feature.swap.ui.swapSuccessNavigation
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.utils.formatFooterFiatFee
import com.tangem.features.send.api.utils.getTronTokenFeeSendingText
import com.tangem.utils.extensions.orZero
@ -52,12 +53,14 @@ internal class SwapTransferStateBuilder @Inject constructor(
private val iconConverter by lazy(::CryptoCurrencyToIconStateConverter)
@Suppress("LongParameterList")
fun createTransferState(
actions: UiActions,
transferState: SwapState.Transfer,
uiStateHolder: SwapStateHolder,
feePaidCryptoCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
feeError: FeeSelectorUM.Error?,
): SwapStateHolder {
val fromTokenSwapInfo = transferState.fromTokenInfo
val isInsufficientBalance = transferState.isInsufficientBalance
@ -67,9 +70,8 @@ internal class SwapTransferStateBuilder @Inject constructor(
transferState = transferState,
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
onBuyClick = actions.openTokenDetailsScreen,
onReduceByAmount = actions.onReduceByAmount,
onReduceToAmount = actions.onReduceToAmount,
actions = actions,
getFeeError = feeError?.error,
)
return uiStateHolder.copy(
sendCardData = createSendSwapCardState(
@ -342,14 +344,14 @@ internal class SwapTransferStateBuilder @Inject constructor(
feePaidCryptoCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
isTangemPayWithdrawal: Boolean,
feeError: FeeSelectorUM.Error?,
): SwapStateHolder {
val notifications = notificationsFactory.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
onBuyClick = actions.openTokenDetailsScreen,
onReduceByAmount = actions.onReduceByAmount,
onReduceToAmount = actions.onReduceToAmount,
actions = actions,
getFeeError = feeError?.error,
)
return uiStateHolder.copy(
notifications = notifications,
@ -395,7 +397,8 @@ internal class SwapTransferStateBuilder @Inject constructor(
val fiatAmountValue = tokenSwapInfo.amountFiat
val status = dataState.fromSwapCurrencyStatus?.status ?: return null
val fiatFeeValue = fee.amount.value
val value = dataState.feePaidCryptoCurrency?.value
val fiatFeeValue = value?.fiatRate?.multiply(fee.amount.value)
val isFeeConvertibleToFiat = status.currency.network.hasFiatFeeRate
val fiatSendingValue = if (isFeeConvertibleToFiat) {
@ -412,8 +415,11 @@ internal class SwapTransferStateBuilder @Inject constructor(
}
val networkId = status.currency.network.id
// When the fee is convertible to fiat, show the fiat-converted value; otherwise keep the raw
// crypto fee amount — formatFooterFiatFee renders amount.value as crypto in the non-fiat case.
val feeAmount = if (isFeeConvertibleToFiat) fee.amount.copy(value = fiatFeeValue) else fee.amount
val fiatFee = formatFooterFiatFee(
amount = fee.amount.copy(value = fiatFeeValue),
amount = feeAmount,
isFeeConvertibleToFiat = isFeeConvertibleToFiat,
isFeeApproximate = isFeeApproximateUseCase(networkId = networkId, amountType = fee.amount.type),
appCurrency = appCurrency,

View file

@ -37,6 +37,7 @@ internal class SwapAmountScreenClickIntentsTest {
onReceiveCardWarningClick = {},
onSwapUIModeChange = {},
onSwapTypeMenuOpened = {},
onTronBannerShown = {},
)
private val sut = SwapAmountScreenClickIntents(actions)

View file

@ -12,9 +12,11 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.models.states.SwapNotificationUM
import io.mockk.every
import io.mockk.mockk
@ -28,6 +30,8 @@ internal class SwapTransferNotificationsFactoryTest {
private val sut = SwapTransferNotificationsFactory()
private val actions: UiActions = mockk(relaxed = true)
private val userWalletId = UserWalletId(stringValue = "deadbeef")
private val coldWallet: UserWallet.Cold = mockk(relaxed = true) {
every { walletId } returns userWalletId
@ -41,9 +45,8 @@ internal class SwapTransferNotificationsFactoryTest {
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
actions = actions,
getFeeError = null,
)
assertThat(result).isEmpty()
@ -64,9 +67,8 @@ internal class SwapTransferNotificationsFactoryTest {
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<NotificationUM.Solana.RentInfo>()).hasSize(1)
@ -91,9 +93,8 @@ internal class SwapTransferNotificationsFactoryTest {
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = fee,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<NotificationUM.Error.ExistentialDeposit>()).hasSize(1)
@ -114,9 +115,8 @@ internal class SwapTransferNotificationsFactoryTest {
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<NotificationUM.Error.MinimumAmountError>()).hasSize(1)
@ -133,9 +133,8 @@ internal class SwapTransferNotificationsFactoryTest {
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<NotificationUM.Cardano.MinAdaValueCharged>()).hasSize(1)
@ -157,9 +156,8 @@ internal class SwapTransferNotificationsFactoryTest {
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<NotificationUM.Warning.FeeCoverageNotification>()).hasSize(1)
@ -180,9 +178,8 @@ internal class SwapTransferNotificationsFactoryTest {
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
actions = actions,
getFeeError = null,
)
val reserve = result.filterIsInstance<SwapNotificationUM.Warning.NeedReserveToCreateAccount>()
@ -204,9 +201,8 @@ internal class SwapTransferNotificationsFactoryTest {
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<SwapNotificationUM.Warning.ReduceAmount>()).hasSize(1)
@ -226,14 +222,146 @@ internal class SwapTransferNotificationsFactoryTest {
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<NotificationUM.Error.TokenExceedsBalance>()).hasSize(1)
}
@Test
fun `GIVEN Tron token and show count within limit WHEN getNotifications THEN Tron network fees Info is added`() =
runTest {
val transferState = buildTransferState(
fromTokenInfo = buildTokenInfo(
swapCurrencyStatus = buildTronTokenStatus(),
amount = BigDecimal("10"),
),
tronFeeNotificationShowCount = TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT,
)
val result = sut.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<SwapNotificationUM.Info.TronTokenFee>()).hasSize(1)
}
@Test
fun `GIVEN Tron token but show count exceeds limit WHEN getNotifications THEN no Tron network fees Info`() =
runTest {
val transferState = buildTransferState(
fromTokenInfo = buildTokenInfo(
swapCurrencyStatus = buildTronTokenStatus(),
amount = BigDecimal("10"),
),
tronFeeNotificationShowCount = TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT + 1,
)
val result = sut.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<SwapNotificationUM.Info.TronTokenFee>()).isEmpty()
}
@Test
fun `GIVEN Tron coin (not token) WHEN getNotifications THEN no Tron network fees Info`() = runTest {
val transferState = buildTransferState(
fromTokenInfo = buildTokenInfo(
swapCurrencyStatus = buildCoinStatus(rawNetworkId = "tron"),
amount = BigDecimal("10"),
),
tronFeeNotificationShowCount = 0,
)
val result = sut.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<SwapNotificationUM.Info.TronTokenFee>()).isEmpty()
}
@Test
fun `GIVEN UnknownError fee error and fee currency status WHEN getNotifications THEN NetworkFeeUnreachable is added`() =
runTest {
val transferState = buildTransferState()
val feeCryptoCurrencyStatus = buildCoinStatus().status
val result = sut.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
fee = null,
actions = actions,
getFeeError = GetFeeError.UnknownError,
)
assertThat(result.filterIsInstance<NotificationUM.Warning.NetworkFeeUnreachable>()).hasSize(1)
}
@Test
fun `GIVEN TronActivationError fee error and fee currency status WHEN getNotifications THEN TronAccountNotActivated is added`() =
runTest {
val transferState = buildTransferState()
val feeCryptoCurrencyStatus = buildCoinStatus().status
val result = sut.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
fee = null,
actions = actions,
getFeeError = GetFeeError.BlockchainErrors.TronActivationError,
)
val notifications = result.filterIsInstance<NotificationUM.Warning.TronAccountNotActivated>()
assertThat(notifications).hasSize(1)
assertThat(notifications.first().tokenName).isEqualTo(feeCryptoCurrencyStatus.currency.name)
}
@Test
fun `GIVEN fee error but null fee currency status WHEN getNotifications THEN no fee unreachable notification`() =
runTest {
val transferState = buildTransferState()
val result = sut.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
actions = actions,
getFeeError = GetFeeError.UnknownError,
)
assertThat(result.filterIsInstance<NotificationUM.Warning.NetworkFeeUnreachable>()).isEmpty()
}
@Test
fun `GIVEN null fee error and fee currency status WHEN getNotifications THEN no fee unreachable notification`() =
runTest {
val transferState = buildTransferState()
val result = sut.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = buildCoinStatus().status,
fee = null,
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<NotificationUM.Warning.NetworkFeeUnreachable>()).isEmpty()
}
@Suppress("LongParameterList")
private fun buildTransferState(
fromTokenInfo: TokenSwapInfo = buildTokenInfo(buildCoinStatus()),
@ -244,6 +372,7 @@ internal class SwapTransferNotificationsFactoryTest {
minAdaValue: BigDecimal? = null,
isFeeCoverage: Boolean = false,
sendingAmount: BigDecimal = fromTokenInfo.tokenAmount.value,
tronFeeNotificationShowCount: Int = 0,
): SwapState.Transfer = SwapState.Transfer(
userWallet = coldWallet,
fromTokenInfo = fromTokenInfo,
@ -255,6 +384,7 @@ internal class SwapTransferNotificationsFactoryTest {
isAccountsMode = false,
isFeeCoverage = isFeeCoverage,
sendingAmount = sendingAmount,
tronFeeNotificationShowCount = tronFeeNotificationShowCount,
currencyCheck = currencyCheck,
validationResult = validationResult,
minAdaValue = minAdaValue,
@ -326,4 +456,31 @@ internal class SwapTransferNotificationsFactoryTest {
every { decimals } returns 18
}
}
private fun buildTronTokenStatus(): SwapCurrencyStatus {
val token = mockk<CryptoCurrency.Token>(relaxed = true) {
every { id } returns mockk(relaxed = true)
every { network } returns mockk(relaxed = true) {
every { rawId } returns "tron"
every { name } returns "Tron"
}
every { name } returns "Tether"
every { symbol } returns "USDT"
every { decimals } returns 6
}
val statusValue: CryptoCurrencyStatus.Loaded = mockk(relaxed = true) {
every { amount } returns BigDecimal("100")
every { fiatRate } returns BigDecimal.ONE
every { fiatAmount } returns BigDecimal("100")
}
return SwapCurrencyStatus(
userWallet = coldWallet,
status = CryptoCurrencyStatus(currency = token, value = statusValue),
account = Account.CryptoPortfolio.createMainAccount(userWalletId),
)
}
private companion object {
const val TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT = 3
}
}

View file

@ -59,9 +59,8 @@ internal class SwapTransferStateBuilderTest {
transferState = any(),
feeCryptoCurrencyStatus = any(),
fee = any(),
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
actions = any(),
getFeeError = any(),
)
} returns persistentListOf()
}
@ -125,6 +124,7 @@ internal class SwapTransferStateBuilderTest {
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = null,
feeError = null,
)
val portfolioAccount = fromCurrencyStatus.account as Account.CryptoPortfolio
@ -156,9 +156,8 @@ internal class SwapTransferStateBuilderTest {
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
actions = any(),
getFeeError = any(),
)
}
}
@ -179,6 +178,7 @@ internal class SwapTransferStateBuilderTest {
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = null,
feeError = null,
)
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
@ -199,9 +199,8 @@ internal class SwapTransferStateBuilderTest {
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
actions = any(),
getFeeError = any(),
)
}
}
@ -223,6 +222,7 @@ internal class SwapTransferStateBuilderTest {
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = null,
feeError = null,
)
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
@ -243,9 +243,8 @@ internal class SwapTransferStateBuilderTest {
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
actions = any(),
getFeeError = any(),
)
}
}
@ -267,6 +266,7 @@ internal class SwapTransferStateBuilderTest {
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = null,
feeError = null,
)
val portfolioAccount = toCurrencyStatus.account as Account.CryptoPortfolio
@ -293,9 +293,8 @@ internal class SwapTransferStateBuilderTest {
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
actions = any(),
getFeeError = any(),
)
}
}
@ -341,9 +340,8 @@ internal class SwapTransferStateBuilderTest {
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = fee,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
actions = any(),
getFeeError = any(),
)
} returns persistentListOf()
@ -355,6 +353,7 @@ internal class SwapTransferStateBuilderTest {
feePaidCryptoCurrencyStatus = null,
fee = fee,
isTangemPayWithdrawal = false,
feeError = null,
)
assertThat(result.swapButton.isEnabled).isTrue()
@ -365,9 +364,8 @@ internal class SwapTransferStateBuilderTest {
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = fee,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
actions = any(),
getFeeError = any(),
)
}
}
@ -390,6 +388,7 @@ internal class SwapTransferStateBuilderTest {
uiStateHolder = baseStateHolder(),
feePaidCryptoCurrencyStatus = null,
fee = mockk(relaxed = true),
feeError = null,
)
val sendCard = result.sendCardData as SwapCardState.SwapCardData
@ -424,6 +423,7 @@ internal class SwapTransferStateBuilderTest {
uiStateHolder = baseStateHolder(),
feePaidCryptoCurrencyStatus = null,
fee = null,
feeError = null,
)
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
@ -451,6 +451,7 @@ internal class SwapTransferStateBuilderTest {
uiStateHolder = baseStateHolder(),
feePaidCryptoCurrencyStatus = null,
fee = null,
feeError = null,
)
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
@ -481,6 +482,7 @@ internal class SwapTransferStateBuilderTest {
feePaidCryptoCurrencyStatus = null,
fee = mockk(relaxed = true),
isTangemPayWithdrawal = false,
feeError = null,
)
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
@ -515,6 +517,7 @@ internal class SwapTransferStateBuilderTest {
feePaidCryptoCurrencyStatus = null,
fee = fee,
isTangemPayWithdrawal = false,
feeError = null,
)
assertThat(result.transferFooter).isInstanceOf(TextReference.Combined::class.java)
@ -538,20 +541,27 @@ internal class SwapTransferStateBuilderTest {
isAccountsMode = false,
)
val statusWithNetwork = buildStatusWithNetwork(hasFiatFeeRate = true)
val dataState = SwapProcessDataState(fromSwapCurrencyStatus = statusWithNetwork)
// The fee's fiat value is derived from the fee-paid currency's fiat rate, not the raw crypto fee.
val feePaidStatus = buildSwapCurrencyStatus(coldWallet)
val feePaidRate = feePaidStatus.status.value.fiatRate!!
val dataState = SwapProcessDataState(
fromSwapCurrencyStatus = statusWithNetwork,
feePaidCryptoCurrency = feePaidStatus.status,
)
val feeValue = BigDecimal("0.001")
val fee = Fee.Common(
amount = Amount(currencySymbol = "ETH", value = feeValue, decimals = 18),
)
val uiState = baseStateHolder()
val appCurrency = transferState.appCurrency
val expectedFiatSending = (fromAmount * QUOTE).plus(feeValue).format {
val fiatFeeValue = feePaidRate.multiply(feeValue)
val expectedFiatSending = (fromAmount * QUOTE).plus(fiatFeeValue).format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
}
val expectedFiatFee = feeValue.format {
val expectedFiatFee = fiatFeeValue.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
@ -566,6 +576,7 @@ internal class SwapTransferStateBuilderTest {
feePaidCryptoCurrencyStatus = null,
fee = fee,
isTangemPayWithdrawal = false,
feeError = null,
)
assertThat(result.transferFooter).isEqualTo(
@ -611,6 +622,7 @@ internal class SwapTransferStateBuilderTest {
feePaidCryptoCurrencyStatus = null,
fee = fee,
isTangemPayWithdrawal = false,
feeError = null,
)
assertThat(result.transferFooter).isEqualTo(
@ -741,6 +753,7 @@ internal class SwapTransferStateBuilderTest {
feePaidCryptoCurrencyStatus = null,
fee = null,
isTangemPayWithdrawal = true,
feeError = null,
)
assertThat(result.swapButton.isEnabled).isTrue()
@ -776,6 +789,7 @@ internal class SwapTransferStateBuilderTest {
feePaidCryptoCurrencyStatus = null,
fee = null,
isTangemPayWithdrawal = false,
feeError = null,
)
assertThat(result.swapButton.isEnabled).isFalse()
@ -924,6 +938,7 @@ internal class SwapTransferStateBuilderTest {
isAccountsMode = isAccountsMode,
isFeeCoverage = isFeeCoverage,
sendingAmount = toAmount,
tronFeeNotificationShowCount = 0,
isSendingAmountLoading = isSendingAmountLoading,
)
}

View file

@ -3,6 +3,7 @@ package com.tangem.features.tangempay.entity
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardState
import com.tangem.features.tangempay.details.impl.R
import com.tangem.utils.StringsSigns
@ -11,6 +12,7 @@ internal class TangemPayCardDetailsBlockStateFactory(
private val cardNumberEnd: String,
private val displayName: CardDisplayName?,
private val isEditingNameEnabled: Boolean,
private val cardState: TangemPayCardState,
private val onEditNameClick: () -> Unit,
private val onReveal: () -> Unit,
private val onCopy: (String, CardDataType) -> Unit,
@ -38,6 +40,7 @@ internal class TangemPayCardDetailsBlockStateFactory(
null
},
shouldShowCardDetailsButtonOnCard = shouldShowCardDetailsButtonOnCard,
cardState = cardState,
)
}
}

View file

@ -38,6 +38,7 @@ internal data class TangemPayCardDetailsUM(
val displayNameState: DisplayNameState?,
val isActionsAvailable: Boolean = false,
val shouldShowCardDetailsButtonOnCard: Boolean = false,
val cardState: TangemPayCardState = TangemPayCardState.Active,
)
@Immutable

View file

@ -77,9 +77,9 @@ internal class TangemPayChangePinModel @Inject constructor(
uiMessageSender.send(message = ToastMessage(resourceReference(R.string.common_unknown_error)))
return@launch
}
uiState.update { it.copy(submitButtonLoading = false) }
when (result) {
SetPinResult.PIN_TOO_WEAK -> {
uiState.update { it.copy(submitButtonLoading = false) }
uiMessageSender.send(
message = ToastMessage(resourceReference(R.string.tangempay_pin_validation_error_message)),
)
@ -92,7 +92,10 @@ internal class TangemPayChangePinModel @Inject constructor(
SetPinResult.DECRYPTION_ERROR,
SetPinResult.UNKNOWN_ERROR,
null,
-> uiMessageSender.send(message = ToastMessage(resourceReference(R.string.common_unknown_error)))
-> {
uiState.update { it.copy(submitButtonLoading = false) }
uiMessageSender.send(message = ToastMessage(resourceReference(R.string.common_unknown_error)))
}
}
}
}

View file

@ -107,9 +107,8 @@ internal class TangemPayEditDisplayNameModel @Inject constructor(
}
private fun onValueChanged(value: TextFieldValue) {
val displayName = CardDisplayName(value.text)
val isAvailableForConfirm = displayName.isRight()
uiState.update { it.copy(editingValue = value, isDoneEnabled = isAvailableForConfirm) }
if (value.text.length > CardDisplayName.MAX_LENGTH) return
uiState.update { it.copy(editingValue = value, isDoneEnabled = value.text.isNotBlank()) }
}
private fun onDoneClick() {

View file

@ -77,6 +77,7 @@ internal class TangemPayCardDetailsController @AssistedInject constructor(
onReveal = ::requestReveal,
onCopy = ::copyData,
shouldShowCardDetailsButtonOnCard = config.shouldShowCardDetailsButtonOnCard,
cardState = card.state,
)
val uiState: StateFlow<TangemPayCardDetailsUM>
@ -117,6 +118,7 @@ internal class TangemPayCardDetailsController @AssistedInject constructor(
numberShort = "${StringsSigns.ASTERISK}${card.lastDigits}",
cardFrozenState = card.frozenState,
isActionsAvailable = card.state == TangemPayCardState.Active,
cardState = card.state,
)
}
subscribeToCardFrozenState(card.id)

View file

@ -60,6 +60,7 @@ import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.*
import com.tangem.core.ui.test.TangemPayTestTags
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardState
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.CardDataType
import com.tangem.features.tangempay.entity.DisplayNameState
@ -120,6 +121,7 @@ private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modif
.matchParentSize()
.zIndex(0f),
cardFrozenState = state.cardFrozenState,
cardState = state.cardState,
)
Box(
@ -209,7 +211,11 @@ private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modif
}
@Composable
private fun TangemPayCardBackground(cardFrozenState: TangemPayCardFrozenState, modifier: Modifier = Modifier) {
private fun TangemPayCardBackground(
cardState: TangemPayCardState,
cardFrozenState: TangemPayCardFrozenState,
modifier: Modifier = Modifier,
) {
val isFrozen = cardFrozenState == TangemPayCardFrozenState.Frozen
val freezeProgress by animateFloatAsState(
targetValue = if (isFrozen) 1f else 0f,
@ -223,7 +229,14 @@ private fun TangemPayCardBackground(cardFrozenState: TangemPayCardFrozenState, m
Box(modifier = modifier.fillMaxSize()) {
Image(
modifier = Modifier.fillMaxSize(),
painter = painterResource(R.drawable.img_tangem_pay_visa),
painter = when (cardState) {
TangemPayCardState.Active,
-> painterResource(R.drawable.img_tangem_pay_visa)
TangemPayCardState.Reissuing,
TangemPayCardState.Closing,
TangemPayCardState.Issuing,
-> painterResource(R.drawable.img_tangem_pay_visa_reissuing)
},
contentDescription = null,
contentScale = ContentScale.FillBounds,
)

View file

@ -32,9 +32,7 @@ import com.tangem.core.ui.ds.TangemPagerIndicator
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.*
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardState
@ -92,29 +90,93 @@ private fun TangemPayCardPageScreen(
},
) { scaffoldPaddings ->
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
LazyColumn(
val contentBottomPadding = TangemTheme.dimens.spacing16 + bottomBarHeight
val reissueTitle = reissueTitleOrNull(isRedesignEnabled = isRedesignEnabled, cardState = state.cardState)
if (reissueTitle != null) {
ReissueCardLayout(
title = reissueTitle,
cardSection = cardSection,
modifier = Modifier
.fillMaxSize()
.padding(scaffoldPaddings)
.padding(bottom = contentBottomPadding),
)
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(scaffoldPaddings),
contentPadding = PaddingValues(bottom = contentBottomPadding),
verticalArrangement = Arrangement.spacedBy(
if (isRedesignEnabled) 0.dp else TangemTheme.dimens.spacing16,
),
) {
item(key = "Card") {
Box(modifier = Modifier.padding(top = TangemTheme.dimens.spacing8)) {
cardSection()
}
}
if (
isRedesignEnabled &&
state.settingsV2.isNotEmpty() &&
state.cardState == TangemPayCardState.Active
) {
cardPageItem("Settings buttons") {
TangemPayCardPageSettingsButtonsBlock(
modifier = Modifier.fillMaxWidth(),
settings = state.settingsV2,
)
}
}
cardState(state = state)
}
}
}
}
private fun reissueTitleOrNull(isRedesignEnabled: Boolean, cardState: TangemPayCardState): TextReference? {
if (!isRedesignEnabled) return null
return when (cardState) {
TangemPayCardState.Reissuing -> combinedReference(
resourceReference(R.string.tangempay_reissue_card_in_progress),
stringReference(". "),
resourceReference(R.string.tangempay_reissue_card_in_progress_description),
)
TangemPayCardState.Issuing -> combinedReference(
resourceReference(R.string.tangempay_issuing_new_digital_card_title),
stringReference(". "),
resourceReference(R.string.tangempay_reissue_card_in_progress_description),
)
TangemPayCardState.Closing -> combinedReference(
resourceReference(R.string.tangempay_card_page_closing_banner_title),
stringReference(". "),
resourceReference(R.string.tangempay_card_page_closing_banner_description),
)
TangemPayCardState.Active -> null
}
}
@Composable
private fun ReissueCardLayout(
title: TextReference,
modifier: Modifier = Modifier,
cardSection: @Composable () -> Unit,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(modifier = Modifier.padding(top = TangemTheme.dimens.spacing8)) {
cardSection()
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(scaffoldPaddings),
contentPadding = PaddingValues(
bottom = TangemTheme.dimens.spacing16 + bottomBarHeight,
),
verticalArrangement = Arrangement.spacedBy(if (isRedesignEnabled) 0.dp else TangemTheme.dimens.spacing16),
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center,
) {
item(key = "Card") {
Box(modifier = Modifier.padding(top = TangemTheme.dimens.spacing8)) {
cardSection()
}
}
if (isRedesignEnabled && state.settingsV2.isNotEmpty() && state.cardState == TangemPayCardState.Active) {
cardPageItem("Settings buttons") {
TangemPayCardPageSettingsButtonsBlock(
modifier = Modifier.fillMaxWidth(),
settings = state.settingsV2,
)
}
}
cardState(state)
TangemPayReissueBlock(title = title)
}
}
}

View file

@ -0,0 +1,52 @@
package com.tangem.features.tangempay.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_clock_20
@Composable
internal fun TangemPayReissueBlock(title: TextReference, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 48.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(40.dp)
.background(
color = TangemTheme.colors3.bg.opaque.primary,
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = Icons.ic_clock_20,
tint = TangemTheme.colors3.icon.secondary,
contentDescription = null,
)
}
Text(
modifier = Modifier.fillMaxWidth(),
text = title.resolveReference(),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.caption.medium,
textAlign = TextAlign.Center,
)
}
}

View file

@ -2,20 +2,13 @@ package com.tangem.features.tangempay.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH24
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.ds.message.TangemMessage
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.LocalVisaRedesignEnabled
@ -31,14 +24,14 @@ internal fun TangemPayReplacingCardBlock(
subtitle: TextReference = resourceReference(R.string.tangempay_reissue_card_in_progress_description),
) {
if (LocalVisaRedesignEnabled.current) {
BlockV2(title = title, subtitle = subtitle, modifier = modifier)
return
} else {
BlockV1(title = title, subtitle = subtitle, modifier = modifier)
Block(title = title, subtitle = subtitle, modifier = modifier)
}
}
@Composable
private fun BlockV1(
private fun Block(
modifier: Modifier = Modifier,
title: TextReference? = resourceReference(R.string.tangempay_reissue_card_in_progress),
subtitle: TextReference = resourceReference(R.string.tangempay_reissue_card_in_progress_description),
@ -55,27 +48,6 @@ private fun BlockV1(
)
}
@Composable
private fun BlockV2(
modifier: Modifier = Modifier,
title: TextReference? = resourceReference(R.string.tangempay_reissue_card_in_progress),
subtitle: TextReference = resourceReference(R.string.tangempay_reissue_card_in_progress_description),
) {
TangemMessage(
modifier = modifier.padding(top = TangemTheme.dimens2.x2),
title = title,
subtitle = subtitle,
leadingContent = {
Icon(
modifier = Modifier.size(20.dp),
imageVector = ImageVector.vectorResource(R.drawable.ic_clock_24),
contentDescription = null,
tint = TangemTheme.colors3.icon.primary,
)
},
)
}
@Preview(showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable

View file

@ -21,6 +21,9 @@ internal val AccountStatus.Payment.cryptoCurrency: CryptoCurrency.Token
internal val AccountStatus.Payment.isDeactivated: Boolean
get() = value is PaymentAccountStatusValue.Deactivated
internal val PaymentAccountStatusValue.Loaded.isFresh: Boolean
get() = source == StatusSource.ACTUAL && error == null
internal fun AccountStatus.Payment.requireLoaded(): PaymentAccountStatusValue.Loaded =
value as? PaymentAccountStatusValue.Loaded
?: error("Card-detail subflow requires Loaded status, got ${value::class.simpleName}")
@ -45,9 +48,6 @@ internal fun AccountStatus.Payment.balanceOrNull(): PaymentAccountStatusValue.Ba
internal val PaymentAccountStatusValue.Balance.hasWithdrawableAmount: Boolean
get() = availableForWithdrawal.signum() > 0
internal val PaymentAccountStatusValue.Loaded.isFresh: Boolean
get() = source == StatusSource.ACTUAL && error == null
internal fun AccountStatus.Payment.findCard(
initialCardId: String,
initialStatus: AccountStatus.Payment,

View file

@ -0,0 +1,167 @@
package com.tangem.features.tangempay.model
import androidx.compose.ui.text.input.TextFieldValue
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardState
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.tangempay.components.TangemPayEditDisplayNameComponent
import com.tangem.features.tangempay.model.controller.TangemPayCardDetailsController
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.emptyFlow
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
internal class TangemPayEditDisplayNameModelTest {
private val cardId = "card_1"
private val userWalletId = UserWalletId("123")
private val router: Router = mockk(relaxed = true)
private val updateCardNameUseCase: UpdateTangemPayCardNameUseCase = mockk()
private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk()
private val featureToggles: TangemPayFeatureToggles = mockk(relaxed = true)
private val cardDetailsControllerFactory: TangemPayCardDetailsController.Factory = mockk(relaxed = true)
init {
every { paymentAccountStatusSupplier.invoke(any<UserWalletId>()) } returns emptyFlow()
}
private fun createModel(displayName: CardDisplayName? = null) = TangemPayEditDisplayNameModel(
paramsContainer = MutableParamsContainer(
TangemPayEditDisplayNameComponent.Params(
card = card(displayName = displayName),
userWalletId = userWalletId,
),
),
dispatchers = TestingCoroutineDispatcherProvider(),
router = router,
updateCardNameUseCase = updateCardNameUseCase,
uiMessageSender = uiMessageSender,
paymentAccountStatusSupplier = paymentAccountStatusSupplier,
featureToggles = featureToggles,
cardDetailsControllerFactory = cardDetailsControllerFactory,
)
@Nested
inner class OnValueChanged {
// [REDACTED_TASK_KEY]: invalid-but-present input must NOT disable the button, otherwise the
// user can never press Done to see the "Invalid characters" alert.
@Test
fun `GIVEN emoji input WHEN onValueChanged THEN button stays enabled`() {
val model = createModel()
model.uiState.value.onValueChanged(TextFieldValue("Card 😀"))
assertThat(model.uiState.value.isDoneEnabled).isTrue()
}
@Test
fun `GIVEN special characters input WHEN onValueChanged THEN button stays enabled`() {
val model = createModel()
model.uiState.value.onValueChanged(TextFieldValue("Card #1!"))
assertThat(model.uiState.value.isDoneEnabled).isTrue()
}
@Test
fun `GIVEN valid input WHEN onValueChanged THEN button enabled`() {
val model = createModel()
model.uiState.value.onValueChanged(TextFieldValue("My Card"))
assertThat(model.uiState.value.isDoneEnabled).isTrue()
}
@Test
fun `GIVEN blank input WHEN onValueChanged THEN button disabled`() {
val model = createModel()
model.uiState.value.onValueChanged(TextFieldValue(" "))
assertThat(model.uiState.value.isDoneEnabled).isFalse()
}
@Test
fun `GIVEN input longer than max length WHEN onValueChanged THEN change is ignored`() {
val model = createModel()
val maxLengthText = "a".repeat(CardDisplayName.MAX_LENGTH)
model.uiState.value.onValueChanged(TextFieldValue(maxLengthText))
model.uiState.value.onValueChanged(TextFieldValue(maxLengthText + "b"))
assertThat(model.uiState.value.editingValue.text).isEqualTo(maxLengthText)
}
}
@Nested
inner class OnDoneClick {
// [REDACTED_TASK_KEY]: pressing Done on an invalid name surfaces the "Invalid characters" alert
// and does not persist the name.
@Test
fun `GIVEN invalid name WHEN onDoneClick THEN error dialog shown and name not updated`() {
val model = createModel()
model.uiState.value.onValueChanged(TextFieldValue("Card 😀"))
model.uiState.value.onDoneClick()
verify(exactly = 1) { uiMessageSender.send(any<DialogMessage>()) }
coVerify(exactly = 0) { updateCardNameUseCase(any(), any(), any()) }
}
@Test
fun `GIVEN valid changed name WHEN onDoneClick THEN name updated`() {
coEvery { updateCardNameUseCase(any(), any(), any()) } returns Unit.right()
val model = createModel()
model.uiState.value.onValueChanged(TextFieldValue("New Name"))
model.uiState.value.onDoneClick()
coVerify(exactly = 1) {
updateCardNameUseCase(cardId, userWalletId, CardDisplayName("New Name").getOrNull()!!)
}
verify(exactly = 0) { uiMessageSender.send(any<DialogMessage>()) }
}
@Test
fun `GIVEN unchanged name WHEN onDoneClick THEN screen closed without update`() {
val model = createModel(displayName = CardDisplayName("My Card").getOrNull())
model.uiState.value.onDoneClick()
verify(exactly = 1) { router.pop() }
coVerify(exactly = 0) { updateCardNameUseCase(any(), any(), any()) }
}
}
private fun card(displayName: CardDisplayName? = null) = TangemPayCard(
id = cardId,
productInstanceId = "product",
cardStatus = TangemPayCard.Status.ACTIVE,
hasPinCode = true,
displayName = displayName,
limit = null,
frozenState = TangemPayCardFrozenState.Unfrozen,
lastDigits = "1234",
state = TangemPayCardState.Active,
)
}

View file

@ -3,7 +3,10 @@ package com.tangem.feature.wallet.presentation.wallet.domain
import com.tangem.common.TangemSiteUrlBuilder
import com.tangem.common.ui.notifications.NotificationId
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.settings.IsReadyToShowRateAppUseCase
@ -20,12 +23,14 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import javax.inject.Inject
/**
* Factory for creating a list of notifications that can be shown on the wallet screen.
* These notifications are not critical and can be stacked with each other.
*/
@Suppress("LongParameterList")
@ModelScoped
internal class GetWalletNotificationsCarouselFactory @Inject constructor(
private val isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase,
@ -34,8 +39,15 @@ internal class GetWalletNotificationsCarouselFactory @Inject constructor(
private val shouldShowYieldBoostMainBannerUseCase: ShouldShowYieldBoostMainBannerUseCase,
private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase,
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
) {
fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow<ImmutableList<WalletNotificationUM>> {
val isBalanceResolvedFlow = singleAccountStatusListSupplier(
SingleAccountStatusListProducer.Params(userWallet.walletId),
)
.map { it.totalFiatBalance !is TotalFiatBalance.Loading }
.distinctUntilChanged()
return combine(
flow = notificationsRepository.getShouldShowNotification(
NotificationId.EnablePushesReminderNotification.key,
@ -43,11 +55,15 @@ internal class GetWalletNotificationsCarouselFactory @Inject constructor(
flow2 = isReadyToShowRateAppUseCase().distinctUntilChanged(),
flow3 = getWalletsUseCase().conflate(),
flow4 = yieldSupplyGetShouldShowMainPromoUseCase().distinctUntilChanged(),
) { showPushesNotification, showRateAppPromo, wallets, shouldShowYieldPromoLocal ->
flow5 = isBalanceResolvedFlow,
) { showPushesNotification, showRateAppPromo, wallets, shouldShowYieldPromoLocal, isBalanceResolved ->
buildList {
addNoteMigrationNotification(userWallet, wallets, clickIntents)
addRateAppNotification(showRateAppPromo, clickIntents)
// isBalanceResolved gates Rate App on the balance leaving the loading state, so it does not
// flash during loading and then get replaced once balance-dependent banners are resolved.
addRateAppNotification(showRateAppPromo && isBalanceResolved, clickIntents)
addPushNotification(
shouldShow = showPushesNotification,

View file

@ -338,20 +338,22 @@ internal class GetWalletNotificationsFactory @Inject constructor(
) {
if (userWallet !is UserWallet.Hot) return
if (totalFiatBalance is TotalFiatBalance.Loading) return
val isBackupExists = userWallet.backedUp
val isAccessCodeRequired = userWallet.hotWalletId.authType == HotWalletId.AuthType.NoPassword &&
!shouldAccessCodeSkipped
val shouldShowFinishActivation = !isBackupExists || isAccessCodeRequired
val messageEffect = when (totalFiatBalance) {
TotalFiatBalance.Failed,
TotalFiatBalance.Loading,
-> TangemMessageEffect.None
is TotalFiatBalance.Loaded -> if (totalFiatBalance.amount.orZero().isPositive()) {
TangemMessageEffect.Warning
} else {
TangemMessageEffect.None
}
TotalFiatBalance.Loading,
TotalFiatBalance.Failed,
-> TangemMessageEffect.None
}
addIf(

View file

@ -2,6 +2,13 @@ package com.tangem.feature.wallet.presentation.wallet.domain
import arrow.core.Either
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.notifications.repository.NotificationsRepository
@ -25,6 +32,7 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class GetWalletNotificationsCarouselFactoryTest {
@ -35,6 +43,7 @@ internal class GetWalletNotificationsCarouselFactoryTest {
private val shouldShowYieldBoostMainBannerUseCase: ShouldShowYieldBoostMainBannerUseCase = mockk()
private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase = mockk()
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles = mockk()
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk(relaxed = true)
private val clickIntents: WalletClickIntents = mockk(relaxed = true)
private val userWallet: UserWallet.Hot = mockk(relaxed = true)
@ -45,6 +54,7 @@ internal class GetWalletNotificationsCarouselFactoryTest {
shouldShowYieldBoostMainBannerUseCase = shouldShowYieldBoostMainBannerUseCase,
yieldSupplyGetShouldShowMainPromoUseCase = yieldSupplyGetShouldShowMainPromoUseCase,
yieldSupplyFeatureToggles = yieldSupplyFeatureToggles,
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
)
@BeforeEach
@ -56,6 +66,7 @@ internal class GetWalletNotificationsCarouselFactoryTest {
shouldShowYieldBoostMainBannerUseCase,
yieldSupplyGetShouldShowMainPromoUseCase,
yieldSupplyFeatureToggles,
singleAccountStatusListSupplier,
clickIntents,
userWallet,
)
@ -68,6 +79,10 @@ internal class GetWalletNotificationsCarouselFactoryTest {
every { yieldSupplyGetShouldShowMainPromoUseCase() } returns flowOf(true)
every { yieldSupplyFeatureToggles.isYieldPromoEnabled } returns true
coEvery { shouldShowYieldBoostMainBannerUseCase(any()) } returns Either.Right(true)
// Balance is loaded by default, so banners gated on balance are not suppressed.
every {
singleAccountStatusListSupplier(any<SingleAccountStatusListProducer.Params>())
} returns flowOf(accountStatusList(TotalFiatBalance.Loaded(BigDecimal.ZERO, StatusSource.ACTUAL)))
}
@ParameterizedTest
@ -85,6 +100,24 @@ internal class GetWalletNotificationsCarouselFactoryTest {
assertThat(result.any { it is WalletNotificationUM.YieldBoostPromo }).isEqualTo(model.expectedShown)
}
@ParameterizedTest
@MethodSource("provideRateAppTestModels")
fun `GIVEN ready to show rate app and balance state WHEN create THEN rate app banner visibility matches`(
model: RateAppModel,
) = runTest {
// Arrange
every { isReadyToShowRateAppUseCase() } returns flowOf(model.isReadyToShow)
every {
singleAccountStatusListSupplier(any<SingleAccountStatusListProducer.Params>())
} returns flowOf(accountStatusList(model.balance))
// Act
val result = factory.create(userWallet, clickIntents).first()
// Assert
assertThat(result.any { it is WalletNotificationUM.RateApp }).isEqualTo(model.expectedShown)
}
@Test
fun `GIVEN banner shown WHEN buttons clicked THEN routes to click intents`() = runTest {
// Arrange
@ -101,6 +134,16 @@ internal class GetWalletNotificationsCarouselFactoryTest {
verify { clickIntents.onDismissYieldBoostBanner(WALLET_ID) }
}
private fun accountStatusList(balance: TotalFiatBalance) = AccountStatusList(
userWalletId = WALLET_ID,
accountStatuses = emptyList(),
totalAccounts = 0,
totalArchivedAccounts = 0,
totalFiatBalance = balance,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
)
internal data class Model(
val toggleEnabled: Boolean,
val shouldShowLocal: Boolean,
@ -121,6 +164,31 @@ internal class GetWalletNotificationsCarouselFactoryTest {
),
)
internal data class RateAppModel(
val isReadyToShow: Boolean,
val balance: TotalFiatBalance,
val expectedShown: Boolean,
)
private fun provideRateAppTestModels() = listOf(
// Ready to show, but the balance is still loading — don't flash before Add Funds may appear.
RateAppModel(isReadyToShow = true, balance = TotalFiatBalance.Loading, expectedShown = false),
// Ready to show and the balance is loaded — the banner can appear.
RateAppModel(
isReadyToShow = true,
balance = TotalFiatBalance.Loaded(BigDecimal.ZERO, StatusSource.ACTUAL),
expectedShown = true,
),
// Ready to show and the balance failed — terminal state, only loading suppresses the banner.
RateAppModel(isReadyToShow = true, balance = TotalFiatBalance.Failed, expectedShown = true),
// Not ready to show — the banner stays hidden regardless of the balance state.
RateAppModel(
isReadyToShow = false,
balance = TotalFiatBalance.Loaded(BigDecimal.ZERO, StatusSource.ACTUAL),
expectedShown = false,
),
)
private companion object {
val WALLET_ID = UserWalletId("01")
}

View file

@ -39,6 +39,22 @@ object BlockchainFeeUtils {
}
}
fun TransactionFee.patchIntegratedApprovalPriorityFee(increaseBy: Int): TransactionFee {
val patchedFee = when (this) {
is TransactionFee.Choosable -> {
copy(
normal = normal.increaseGasPrice(increaseBy),
minimum = minimum.increaseGasPrice(increaseBy),
priority = priority.increaseGasPrice(increaseBy),
)
}
is TransactionFee.Single -> copy(
normal = normal.increaseGasPrice(increaseBy),
)
}
return patchedFee
}
private fun Fee.increaseEthGasLimitInNeeded(increaseBy: Int): Fee {
return when (this) {
is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]")
@ -81,4 +97,40 @@ object BlockchainFeeUtils {
is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]")
}
}
/**
* Increase gasPrice/maxFeePerGas for Fee.Ethereum
*/
private fun Fee.increaseGasPrice(percent: Int): Fee {
if (this !is Fee.Ethereum) return this
return when (this) {
is Fee.Ethereum.EIP1559 -> {
val increasedGasPrice = maxFeePerGas.multiply(percent.toBigInteger()).divide(HUNDRED_PERCENT)
val increasedAmount = amount.copy(
value = gasLimit.toBigDecimal()
.multiply(increasedGasPrice.toBigDecimal())
.movePointLeft(amount.decimals),
)
copy(
amount = increasedAmount,
maxFeePerGas = increasedGasPrice,
priorityFee = priorityFee.multiply(percent.toBigInteger()).divide(HUNDRED_PERCENT),
)
}
is Fee.Ethereum.Legacy -> {
val increasedGasPrice = gasPrice.multiply(percent.toBigInteger()).divide(HUNDRED_PERCENT)
val increasedAmount = amount.copy(
value = gasLimit.toBigDecimal()
.multiply(increasedGasPrice.toBigDecimal())
.movePointLeft(amount.decimals),
)
copy(
amount = increasedAmount,
gasPrice = increasedGasPrice,
)
}
is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]")
}
}
}