diff --git a/.claude/skills/write-ui-test/SKILL.md b/.claude/skills/write-ui-test/SKILL.md index acb6f6d5da..7508749a1f 100644 --- a/.claude/skills/write-ui-test/SKILL.md +++ b/.claude/skills/write-ui-test/SKILL.md @@ -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. \ No newline at end of file + `@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). \ No newline at end of file diff --git a/.claude/skills/write-ui-test/reference/compose-traps.md b/.claude/skills/write-ui-test/reference/compose-traps.md index 0458caa898..96e51a4384 100644 --- a/.claude/skills/write-ui-test/reference/compose-traps.md +++ b/.claude/skills/write-ui-test/reference/compose-traps.md @@ -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 diff --git a/.claude/skills/write-ui-test/reference/running-and-debugging.md b/.claude/skills/write-ui-test/reference/running-and-debugging.md index 5698b4e87f..8f73aaa83c 100644 --- a/.claude/skills/write-ui-test/reference/running-and-debugging.md +++ b/.claude/skills/write-ui-test/reference/running-and-debugging.md @@ -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 cat files/log.txt | grep -iE "Error|Exception|" + ``` +- **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//files/original_screenshots` doesn't exist → diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt index 08641bc0d6..31b41c8bea 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -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) } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt index cd9cfb4dd6..ad101a5545 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt @@ -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 +} + diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index 7ce81ac85b..8e83fc81ea 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -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 diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapSuccessPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapSuccessPageObject.kt index 3ba2a38384..0dace29264 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapSuccessPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapSuccessPageObject.kt @@ -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))) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt index a0374130c3..c123dbb27c 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt @@ -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) = diff --git a/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt new file mode 100644 index 0000000000..14e9fb1557 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt @@ -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() } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt index e6d872be54..aa7881a6db 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt @@ -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, + ), ), ), ), diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt index c341a7949c..cbfae4ad27 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt @@ -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), + ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/image/DeviceIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/image/DeviceIcon.kt index 6d6d6f046d..0ab1068bb0 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/image/DeviceIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/image/DeviceIcon.kt @@ -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 { diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemTab.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemTab.kt index 15dccb3a1d..fb3522efef 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemTab.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemTab.kt @@ -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(), diff --git a/core/ui/src/main/res/drawable-hdpi/img_tangem_pay_visa_reissuing.webp b/core/ui/src/main/res/drawable-hdpi/img_tangem_pay_visa_reissuing.webp new file mode 100644 index 0000000000..1fbd34056c Binary files /dev/null and b/core/ui/src/main/res/drawable-hdpi/img_tangem_pay_visa_reissuing.webp differ diff --git a/core/ui/src/main/res/drawable-xhdpi/img_tangem_pay_visa_reissuing.webp b/core/ui/src/main/res/drawable-xhdpi/img_tangem_pay_visa_reissuing.webp new file mode 100644 index 0000000000..10ab621d27 Binary files /dev/null and b/core/ui/src/main/res/drawable-xhdpi/img_tangem_pay_visa_reissuing.webp differ diff --git a/core/ui/src/main/res/drawable-xxhdpi/img_tangem_pay_visa_reissuing.webp b/core/ui/src/main/res/drawable-xxhdpi/img_tangem_pay_visa_reissuing.webp new file mode 100644 index 0000000000..15b0b2b725 Binary files /dev/null and b/core/ui/src/main/res/drawable-xxhdpi/img_tangem_pay_visa_reissuing.webp differ diff --git a/core/ui/src/main/res/drawable-xxxhdpi/img_tangem_pay_visa_reissuing.webp b/core/ui/src/main/res/drawable-xxxhdpi/img_tangem_pay_visa_reissuing.webp new file mode 100644 index 0000000000..b310786441 Binary files /dev/null and b/core/ui/src/main/res/drawable-xxxhdpi/img_tangem_pay_visa_reissuing.webp differ diff --git a/data/visa/src/test/kotlin/com/tangem/data/pay/repository/DefaultCustomerOffersRepositoryTest.kt b/data/visa/src/test/kotlin/com/tangem/data/pay/repository/DefaultCustomerOffersRepositoryTest.kt index c4a97ce566..0513c84f3c 100644 --- a/data/visa/src/test/kotlin/com/tangem/data/pay/repository/DefaultCustomerOffersRepositoryTest.kt +++ b/data/visa/src/test/kotlin/com/tangem/data/pay/repository/DefaultCustomerOffersRepositoryTest.kt @@ -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", ), ), ), diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt index cba4635b01..83e74f5040 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt @@ -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 diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt index 7357341b16..7f337a069c 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt @@ -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 Result.logErrorUnlessCancellation(): Result = onFailure { error -> + val cancellation = error as? CancellationException ?: error.cause as? CancellationException + if (cancellation != null) throw cancellation + TangemLogger.e("Error", error) } private fun createDeployTransaction( diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderType.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderType.kt index c5cf104ae8..432d0ea5e0 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderType.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderType.kt @@ -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, diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/model/OrderConflictRulesTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/model/OrderConflictRulesTest.kt index d0a4c4895a..1ed00d3f7b 100644 --- a/domain/visa/src/test/kotlin/com/tangem/domain/pay/model/OrderConflictRulesTest.kt +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/model/OrderConflictRulesTest.kt @@ -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) diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CheckOrderConflictUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CheckOrderConflictUseCaseTest.kt index 270eda776c..d36c36616e 100644 --- a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CheckOrderConflictUseCaseTest.kt +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/CheckOrderConflictUseCaseTest.kt @@ -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() diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCaseTest.kt index 5200c2b329..e64a03b9d1 100644 --- a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCaseTest.kt +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCaseTest.kt @@ -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().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(), ) diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/RestoreActiveIssueOrdersUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/RestoreActiveIssueOrdersUseCaseTest.kt index bfbfecfd1a..a6d1b46934 100644 --- a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/RestoreActiveIssueOrdersUseCaseTest.kt +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/RestoreActiveIssueOrdersUseCaseTest.kt @@ -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() diff --git a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt index aa21b96b61..fc7e661cfd 100644 --- a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt +++ b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt @@ -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]. diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/MarketsListBatchFlowManager.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/MarketsListBatchFlowManager.kt index bff9bfb159..d7099f3159 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/MarketsListBatchFlowManager.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/MarketsListBatchFlowManager.kt @@ -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, @Assisted private val currentSearchText: Provider, @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, currentSearchText: Provider, modelScope: CoroutineScope, ): MarketsListBatchFlowManager diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketCategory.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketCategory.kt new file mode 100644 index 0000000000..5e480142ac --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketCategory.kt @@ -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, + val selected: SwapMarketCategory, + val onCategoryClick: (SwapMarketCategory) -> Unit, +) \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketState.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketState.kt index dd11a69c73..98505e6f2c 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketState.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketState.kt @@ -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, val total: Int, @@ -21,17 +23,20 @@ internal sealed class SwapMarketState { val visibleIdsChanged: (List) -> 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), diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt index 5703c8d4a0..15fffa4ab4 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt @@ -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>(emptyList()) private val visibleDefaultMarketItemIds = MutableStateFlow>(emptyList()) + private val selectedCategoryFlow = MutableStateFlow(SwapMarketCategory.Trending) + val addToPortfolioSlot: SlotNavigation = 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 { - 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 { val marketsTitle = TextReference.Res(R.string.markets_common_title) return combine( diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt index 179376d9d2..18fbcd8716 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt @@ -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( diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/SwapMarketsListLazyColumn.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/SwapMarketsListLazyColumn.kt index adf96a1463..2368913a12 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/SwapMarketsListLazyColumn.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/SwapMarketsListLazyColumn.kt @@ -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( diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/DefaultManageFundsComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/DefaultManageFundsComponent.kt index acd951b2b2..355331a109 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/DefaultManageFundsComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/managefunds/DefaultManageFundsComponent.kt @@ -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, ) }, ) diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index 13530ac93f..d8f2337725 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -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) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index 830560dcc8..09b5ce82af 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -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, ): List + suspend fun getUnfulfilledReceiveRequirement(toSwapCurrencyStatus: SwapCurrencyStatus): AssetRequirementsCondition? + fun findProvidersForPair( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 9464e5f5b1..f301453ef5 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -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(), ) + private val yieldSwapAllowedRouters = newSetFromMap(ConcurrentHashMap()) + 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, ): List { - 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 { + ): Pair? { 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 diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index a1759b9e86..209108a216 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -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, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt index fc42b91521..8c4476d24c 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt @@ -53,4 +53,6 @@ interface SwapTransferInteractor { cryptoAmount: BigDecimal, toSwapCurrencyStatus: SwapCurrencyStatus, ): Either + + suspend fun incrementTronTokenFeeShowCount(cryptoCurrencyStatus: CryptoCurrencyStatus?) } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt index 03f6d275be..ac06572551 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt @@ -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) + } + } } \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt index de553ebb28..dd3da80960 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt @@ -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`. diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindProvidersForPairTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindProvidersForPairTest.kt index 1f7e5c8d85..234f50b740 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindProvidersForPairTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindProvidersForPairTest.kt @@ -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() + } } } \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadIntegratedApprovalDataTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadIntegratedApprovalDataTest.kt index f2ec1512df..25d50a9077 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadIntegratedApprovalDataTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadIntegratedApprovalDataTest.kt @@ -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() + // 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() + // 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()).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() + 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.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 arrow.core.Either.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" diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt index cdecb5a391..b140e07270 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt @@ -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) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 4dd33ac7bf..d46ad20a15 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -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, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 86595797c2..1d2ca68bb9 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -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 { + 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, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index 65ae024ca4..ad200c6f48 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -49,6 +49,7 @@ internal data class SwapStateHolder( val onShowPermissionBottomSheet: () -> Unit = {}, val onSwapUIModeChange: (SwapUIMode) -> Unit = {}, val onSwapTypeMenuOpened: () -> Unit = {}, + val onTronBannerShown: () -> Unit = {}, ) @Immutable diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt index 97fe358356..1e01c3a4aa 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt @@ -34,4 +34,5 @@ internal data class UiActions( val onReceiveCardWarningClick: () -> Unit, val onSwapUIModeChange: (SwapUIMode) -> Unit, val onSwapTypeMenuOpened: () -> Unit, + val onTronBannerShown: () -> Unit, ) \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt index 0769f7b5b7..5e89b084af 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt @@ -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), + ) } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 362c8b1bea..1875936424 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -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, ): 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, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index de4b81df32..4f15adb4cd 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -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) { +private fun SwapNotifications(notifications: List, 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) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt index 5ae7859709..9508fd1e4b 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt @@ -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 { 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.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.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 + } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt index 10a023634f..b7f14dbffd 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt @@ -37,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, diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/SwapAmountScreenClickIntentsTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/SwapAmountScreenClickIntentsTest.kt index 90da2f7079..ffaf87dce5 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/SwapAmountScreenClickIntentsTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/SwapAmountScreenClickIntentsTest.kt @@ -37,6 +37,7 @@ internal class SwapAmountScreenClickIntentsTest { onReceiveCardWarningClick = {}, onSwapUIModeChange = {}, onSwapTypeMenuOpened = {}, + onTronBannerShown = {}, ) private val sut = SwapAmountScreenClickIntents(actions) diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt index f541df7896..f415d9db37 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt @@ -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()).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()).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()).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()).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()).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() @@ -204,9 +201,8 @@ internal class SwapTransferNotificationsFactoryTest { transferState = transferState, feeCryptoCurrencyStatus = null, fee = null, - onReduceByAmount = { _, _ -> }, - onReduceToAmount = {}, - onBuyClick = {}, + actions = actions, + getFeeError = null, ) assertThat(result.filterIsInstance()).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()).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()).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()).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()).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()).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() + 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()).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()).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(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 + } } \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt index a5d4203e98..64861e80a5 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt @@ -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, ) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt index 7e6ba3c8d0..51ffdfc7f1 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt @@ -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, ) } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index 541ff3efb8..b9ef7fc608 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -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 diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt index 8163372405..d529fcd2fd 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt @@ -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))) + } } } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt index b9d33268ec..f0419494f2 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt @@ -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() { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/controller/TangemPayCardDetailsController.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/controller/TangemPayCardDetailsController.kt index 9f742a1ab7..7f089402ee 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/controller/TangemPayCardDetailsController.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/controller/TangemPayCardDetailsController.kt @@ -77,6 +77,7 @@ internal class TangemPayCardDetailsController @AssistedInject constructor( onReveal = ::requestReveal, onCopy = ::copyData, shouldShowCardDetailsButtonOnCard = config.shouldShowCardDetailsButtonOnCard, + cardState = card.state, ) val uiState: StateFlow @@ -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) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt index 7cea9f32f3..c6a024f59e 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt @@ -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, ) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt index 3be9ae9f4d..230a6ac5cb 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt @@ -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) } } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueBlock.kt new file mode 100644 index 0000000000..f764bfc445 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueBlock.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReplacingCardBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReplacingCardBlock.kt index 8ea8355a62..7b1c3675d9 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReplacingCardBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReplacingCardBlock.kt @@ -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 diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt index 3ac13a5110..ba1ecb9e4c 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt @@ -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, diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModelTest.kt new file mode 100644 index 0000000000..9cf134e92b --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModelTest.kt @@ -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()) } 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()) } + 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()) } + } + + @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, + ) +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactory.kt index ac71135be8..82610ab8c7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactory.kt @@ -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> { + 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, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt index 6909483f63..5085257879 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt @@ -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( diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactoryTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactoryTest.kt index fb3b3d3ad7..5ce92507a5 100644 --- a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactoryTest.kt +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactoryTest.kt @@ -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()) + } 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()) + } 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") } diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt index 5c08e72030..9a8b774a5c 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainFeeUtils.kt @@ -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]") + } + } } \ No newline at end of file